Compare commits

...
Author SHA1 Message Date
Yuang PengandSameer Kadam b92fed2d0a feat(explorer): add Markdown editor write path (#1349)
* feat(explorer): add Markdown editing write path

* fix(explorer): address markdown editor review findings

* feat(explorer): add Markdown editor write path

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-04 01:38:17 +05:30
Mohd Kaif 8910d2c949 docs(ontology): document quality gate threshold semantics (#1450)
* docs(ontology): document quality gate threshold semantics

The Ontology Quality Gate section (#1397) showed a thresholds={...}
example but never explained what min_coverage, max_errors,
max_warnings, or fail_on_warnings actually mean, or that
fail_on_warnings is a separate parameter rather than a thresholds
key. Add a concise defaults/semantics table, verified against
OntologyQualityGate.DEFAULT_THRESHOLDS and __init__ in quality_gate.py.

* docs(ontology): explain thresholds as prose instead of a table

A four-row table was heavier than this needed; each threshold's
meaning reads faster as two connected sentences.
2026-09-03 23:40:00 +05:30
43 changed files with 4893 additions and 212 deletions
+17 -1
View File
@@ -1461,6 +1461,22 @@ semantica-explorer --graph my_graph.json
For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](explorer/README.md)**
The CLI exposes the loaded `ContextGraph`. To also browse and edit an existing
`AgentMemory`, create the ASGI app programmatically with both live objects:
```python
from semantica.context import AgentMemory, ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
graph = ContextGraph()
memory = AgentMemory()
app = create_app(session=GraphSession(graph), agent_memory=memory)
```
The Memories workspace is shown only when `agent_memory` is provided. Apply
updates the supplied runtime object; it does not add disk persistence.
---
## What's New in v0.6.7
@@ -1469,7 +1485,7 @@ For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](ex
- **First-class LangChain integration** (`semantica[langchain]`): a `BaseRetriever` and `VectorStore` over `HybridSearch`, plus graph/decision-query tools
- **SAP OData ingestor** (`semantica[ingest-sap]`): OAuth2/Basic-auth, SSRF-guarded ingestion for Business Partners and Sales Orders, following the existing Snowflake/Databricks connector pattern
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and the Explorer graph inspector gains a read-only Markdown content viewer
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and Explorer can validate and apply Markdown edits to individual graph nodes and AgentMemory items supplied by the hosting application
- **`reasoning` gains a structured Action layer**: rule-driven `Assert`/`Retract`/`Call`/`EmitEvent` actions with optional provenance, turning the reasoner into a production-rule system
- **`run_shacl_validation` is now a public, documented API**, and a dozen ontology/RDF export correctness fixes land: OWL property/class export, SHACL target-namespace resolution, one canonical confidence datatype across all four RDF formats, reachable OWL-Time reification, JSON-LD default-graph and content-derived document identity, and full metadata passthrough on every RDF serializer
- **Security**: Agno's `AgnoKnowledgeGraph.load_urls()` and OpenClaw's MCP tool now route outbound requests through the shared SSRF guard
+4
View File
@@ -110,6 +110,10 @@ range references, and unresolved KG relationship endpoints. It includes
machine-readable issue codes, severity, counts, metrics, and threshold
failures. The first version reports findings only; it does not auto-fix data.
### Thresholds
`min_coverage` (default `0.0`) sets the minimum required `coverage` score, the average of class and property coverage from `0.0` to `1.0`; the gate fails below it. `max_errors` (default `0.0`) caps how many `error`/`critical` issues are allowed before the gate fails. `max_warnings` (default `None`) caps `warning` issues the same way, and `None` means warnings alone never fail the gate. `fail_on_warnings` is a separate parameter, not a `thresholds` key, passed to `OntologyQualityGate(...)` or `.check(...)` directly; when `True`, a single warning fails the gate regardless of `max_warnings`.
## OntologyGenerator (5-Stage Pipeline)
**`OntologyGenerator`** auto-generates a formal ontology from your knowledge graph entities and relationships:
+653
View File
@@ -33,7 +33,9 @@
"devDependencies": {
"@babel/core": "^7.29.6",
"@eslint/js": "^9.39.4",
"@testing-library/react": "^16.3.3",
"@types/babel__core": "^7.20.5",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -43,12 +45,34 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^26.1.0",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^6.4.2"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -340,6 +364,121 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
@@ -1473,6 +1612,63 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.3",
"resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1614,6 +1810,18 @@
"@types/unist": "*"
}
},
"node_modules/@types/jsdom": {
"version": "21.1.7",
"resolved": "https://registry.npmmirror.com/@types/jsdom/-/jsdom-21.1.7.tgz",
"integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/tough-cookie": "*",
"parse5": "^7.0.0"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1665,6 +1873,13 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2010,6 +2225,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2027,6 +2252,42 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
@@ -2259,6 +2520,20 @@
"license": "MIT",
"peer": true
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2370,6 +2645,20 @@
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2387,6 +2676,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -2449,6 +2745,14 @@
"@babel/runtime": "^7.9.2"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
@@ -2466,6 +2770,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -3058,6 +3375,19 @@
"react-is": "^16.7.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -3068,6 +3398,47 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3173,6 +3544,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3186,6 +3564,46 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3321,6 +3739,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -4283,6 +4712,13 @@
"node": ">=18"
}
},
"node_modules/nwsapi": {
"version": "2.2.27",
"resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.27.tgz",
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4382,6 +4818,19 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4505,6 +4954,30 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -4817,6 +5290,33 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4924,6 +5424,13 @@
"inline-style-parser": "0.2.7"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -4941,6 +5448,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -5364,6 +5917,67 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5390,6 +6004,45 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
+4
View File
@@ -9,6 +9,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts",
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
@@ -39,7 +40,9 @@
"devDependencies": {
"@babel/core": "^7.29.6",
"@eslint/js": "^9.39.4",
"@testing-library/react": "^16.3.3",
"@types/babel__core": "^7.20.5",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -49,6 +52,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^26.1.0",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
+45 -14
View File
@@ -17,10 +17,13 @@ import {
type LucideIcon,
} from 'lucide-react';
import { ErrorBoundary } from './ErrorBoundary';
import { ExploreWorkspaceTabs, type ExploreView } from './ExploreWorkspaceTabs';
import { fetchAgentMemoryAvailability } from './explorerCapabilities';
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
const GraphWorkspace = lazy(() => import('./workspaces/GraphWorkspace/GraphWorkspace').then((module) => ({ default: module.GraphWorkspace })));
const MemoryWorkspace = lazy(() => import('./workspaces/MemoryWorkspace').then((module) => ({ default: module.MemoryWorkspace })));
const ImportExportWorkspace = lazy(() => import('./workspaces/ImportExportWorkspace/ImportExportWorkspace').then((module) => ({ default: module.ImportExportWorkspace })));
const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageDiagram').then((module) => ({ default: module.LineageDiagram })));
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
@@ -33,7 +36,6 @@ const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/Ontol
const OntologyWorkspace = lazy(() => import('./workspaces/OntologyWorkspace').then((module) => ({ default: module.OntologyWorkspace })));
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage' | 'ontology-hub';
type ExploreView = 'graph' | 'vocabulary';
type AnalyzeView = 'sparql' | 'reasoning';
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
@@ -1791,6 +1793,37 @@ export default function App() {
const [enrichView, setEnrichView] = useState<EnrichView>('import');
const [manageView, setManageView] = useState<ManageView>('lineage');
const [graphFocusRequest, setGraphFocusRequest] = useState<{ nodeId: string; token: number } | null>(null);
const [exploreDraftDirty, setExploreDraftDirty] = useState(false);
const [agentMemoryAvailable, setAgentMemoryAvailable] = useState(false);
useEffect(() => {
let active = true;
void fetchAgentMemoryAvailability().then((available) => {
if (active) setAgentMemoryAvailable(available);
});
return () => {
active = false;
};
}, []);
const confirmDiscardExploreDraft = () => (
!exploreDraftDirty
|| window.confirm("Discard the unapplied Markdown draft and leave this resource?")
);
const switchExploreView = (nextView: ExploreView) => {
if (nextView === exploreView) return;
if (!confirmDiscardExploreDraft()) return;
setExploreDraftDirty(false);
setExploreView(nextView);
};
const switchWorkspace = (nextWorkspace: WorkspaceId) => {
if (nextWorkspace === activeWorkspace) return;
if (activeWorkspace === "explore" && !confirmDiscardExploreDraft()) return;
setExploreDraftDirty(false);
setActiveWorkspace(nextWorkspace);
};
const renderWorkspace = () => {
@@ -1823,18 +1856,15 @@ export default function App() {
return (
<WorkspaceShell
title="Explore"
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
subtitle={exploreView === 'graph' ? undefined : exploreView === 'memories' ? "Browse and edit canonical AgentMemory documents." : "Browse the graph and switch views without leaving the workspace."}
kicker={exploreView === 'graph' ? 'Graph Studio' : exploreView === 'memories' ? 'Memory Browser' : 'Vocabulary Browser'}
compact
tabs={
<>
<button className="workspace-tab" data-active={exploreView === 'graph'} onClick={() => setExploreView('graph')}>
Semantica Explorer
</button>
<button className="workspace-tab" data-active={exploreView === 'vocabulary'} onClick={() => setExploreView('vocabulary')}>
Vocabulary Browser
</button>
</>
<ExploreWorkspaceTabs
activeView={exploreView}
agentMemoryAvailable={agentMemoryAvailable}
onSelect={switchExploreView}
/>
}
>
<ErrorBoundary key={`explore-${exploreView}`}>
@@ -1843,8 +1873,9 @@ export default function App() {
<GraphWorkspace
externalFocusNodeId={graphFocusRequest?.nodeId}
externalFocusToken={graphFocusRequest?.token}
onDirtyChange={setExploreDraftDirty}
/>
) : <VocabularyWorkspace />}
) : exploreView === 'memories' ? <MemoryWorkspace onDirtyChange={setExploreDraftDirty} /> : <VocabularyWorkspace />}
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
@@ -1989,13 +2020,13 @@ export default function App() {
<style>{shellStyles}</style>
<div className="app-shell">
<aside className="app-rail">
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => switchWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
{navItems.map(({ id, label, hint, icon: Icon }) => (
<button
key={id}
className="nav-button"
data-active={activeWorkspace === id}
onClick={() => setActiveWorkspace(id)}
onClick={() => switchWorkspace(id)}
title={hint}
>
<Icon size={20} />
+29
View File
@@ -0,0 +1,29 @@
export type ExploreView = 'graph' | 'memories' | 'vocabulary';
type ExploreWorkspaceTabsProps = {
activeView: ExploreView;
agentMemoryAvailable: boolean;
onSelect: (view: ExploreView) => void;
};
export function ExploreWorkspaceTabs({
activeView,
agentMemoryAvailable,
onSelect,
}: ExploreWorkspaceTabsProps) {
return (
<>
<button className="workspace-tab" data-active={activeView === 'graph'} onClick={() => onSelect('graph')}>
Semantica Explorer
</button>
{agentMemoryAvailable ? (
<button className="workspace-tab" data-active={activeView === 'memories'} onClick={() => onSelect('memories')}>
Memories
</button>
) : null}
<button className="workspace-tab" data-active={activeView === 'vocabulary'} onClick={() => onSelect('vocabulary')}>
Vocabulary Browser
</button>
</>
);
}
+24
View File
@@ -0,0 +1,24 @@
type Fetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
type ExplorerInfo = {
capabilities?: {
agent_memory?: boolean;
};
};
export async function fetchAgentMemoryAvailability(
fetcher: Fetcher = fetch,
): Promise<boolean> {
try {
const response = await fetcher('/api/info');
if (!response.ok) return false;
const info = await response.json() as ExplorerInfo;
return info.capabilities?.agent_memory === true;
} catch {
return false;
}
}
+1
View File
@@ -15,6 +15,7 @@ export type RegistryEntryOp =
| "export"
| "merge"
| "add-node"
| "update-node"
| "add-edge"
| "delete"
| "infer"
@@ -16,6 +16,7 @@ const OP_META: Record<
export: { label: "EXPORT", color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" },
merge: { label: "MERGE", color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" },
"add-node": { label: "ADD NODE", color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" },
"update-node": { label: "UPDATE NODE", color: "#79c0ff", bg: "rgba(121,192,255,0.10)", border: "rgba(121,192,255,0.24)" },
"add-edge": { label: "ADD EDGE", color: "#4cc38a", bg: "rgba(76,195,138,0.10)", border: "rgba(76,195,138,0.22)" },
delete: { label: "DELETE", color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" },
infer: { label: "INFER", color: "#d2a8ff", bg: "rgba(210,168,255,0.12)", border: "rgba(210,168,255,0.28)" },
@@ -23,7 +24,7 @@ const OP_META: Record<
};
const ALL_OPS: (RegistryEntryOp | "all")[] = [
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
"all", "import", "export", "merge", "add-node", "update-node", "add-edge", "infer", "delete", "vocab-import",
];
function formatTimestamp(date: Date): string {
@@ -4,6 +4,7 @@ import { graph } from "../../store/graphStore";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import type { GraphSelectedNodeKind } from "./types";
import { MarkdownContentViewer } from "./MarkdownContentViewer";
import type { MarkdownApplyResult } from "./markdownResourceClient";
export type LinkPrediction = {
target: string;
@@ -44,6 +45,8 @@ export interface GraphInspectorPanelProps {
pathResult: PathResponse | null;
onDownloadProvenance: (format: "json" | "markdown") => void;
onFocusNode?: (nodeId: string) => void;
onMarkdownApplied?: (result: MarkdownApplyResult) => void;
onMarkdownDirtyChange?: (dirty: boolean) => void;
}
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
@@ -304,6 +307,8 @@ export function GraphInspectorPanel({
pathResult,
onDownloadProvenance,
onFocusNode,
onMarkdownApplied,
onMarkdownDirtyChange,
}: GraphInspectorPanelProps) {
if (!nodeId) {
return (
@@ -414,19 +419,18 @@ export function GraphInspectorPanel({
</div>
) : null}
{/* Content Section — only rendered when the node carries actual content.
This matches the existing inspector convention: sections that have no
data for the current node are either hidden (temporal bounds) or closed
by default (Source Attribution, Properties). Always showing an open
empty panel would add noise for every relationship/predicate node. */}
{nodeContent && (
<details className="node-panel-collapse" open>
<summary className="node-panel-summary">Content</summary>
<div className="node-panel-body" style={{ marginTop: 8 }}>
<MarkdownContentViewer content={nodeContent} />
</div>
</details>
)}
{/* Canonical nodes remain editable even when their current body is empty. */}
<details className="node-panel-collapse" open>
<summary className="node-panel-summary">Content</summary>
<div className="node-panel-body" style={{ marginTop: 8 }}>
<MarkdownContentViewer
content={nodeContent}
resource={{ kind: "context-node", id: effectiveNodeId }}
onApplied={onMarkdownApplied}
onDirtyChange={onMarkdownDirtyChange}
/>
</div>
</details>
{/* Actions */}
<section style={sectionStyle}>
@@ -44,6 +44,12 @@ import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./t
import { SMALL_GRAPH_MAX_NODES } from "./smallGraphLayout";
import { buildRealtimeEdgeAttributes } from "./realtimeGraphAttributes";
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { MarkdownApplyResult } from "./markdownResourceClient";
import {
NodeMarkdownRefreshGuard,
buildNodeMarkdownAttributeUpdate,
readNodeMarkdownAttributeUpdate,
} from "./nodeMarkdownSync";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
GraphAnalyticsSnapshot,
@@ -1241,9 +1247,10 @@ function collectPluginOverlays(
interface GraphWorkspaceProps {
externalFocusNodeId?: string;
externalFocusToken?: number;
onDirtyChange?: (dirty: boolean) => void;
}
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) {
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirtyChange }: GraphWorkspaceProps = {}) {
const [selectedNodeId, setSelectedNodeId] = useState("");
const [focusedNodeId, setFocusedNodeId] = useState("");
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
@@ -1251,6 +1258,12 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
const [graphReady, setGraphReady] = useState(false);
const [graphVersion, setGraphVersion] = useState(0);
const [markdownDraftDirty, setMarkdownDraftDirty] = useState(false);
const markdownRefreshGuard = useMemo(() => new NodeMarkdownRefreshGuard(), []);
const handleMarkdownDirtyChange = useCallback((dirty: boolean) => {
setMarkdownDraftDirty(dirty);
onDirtyChange?.(dirty);
}, [onDirtyChange]);
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
const [aggregationEnabled] = useState(true);
const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
@@ -1628,8 +1641,17 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
: null,
[viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion],
);
const confirmDiscardMarkdownDraft = useCallback(() => {
if (!markdownDraftDirty) return true;
const discard = window.confirm(
"Discard the unapplied Markdown draft and leave this node?",
);
return discard;
}, [markdownDraftDirty]);
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
if (nextViewMode !== viewMode && !confirmDiscardMarkdownDraft()) return;
if (nextViewMode === "focused") {
const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph);
if (!resolution.resolvedNodeId) {
@@ -1682,6 +1704,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
));
setViewMode("full");
}, [
confirmDiscardMarkdownDraft,
aggregationEnabled,
collapsedNeighborhoodNodeIds,
focusedNodeId,
@@ -1692,9 +1715,11 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
lastGroupedSelectedNodeId,
resolveNodeIdForFocusedMode,
selectedNodeId,
viewMode,
]);
const focusNode = useCallback((nodeId: string) => {
if (nodeId !== selectedNodeId && !confirmDiscardMarkdownDraft()) return;
if (!nodeId) {
setSelectedNodeId("");
setSelectedEdgeId("");
@@ -1720,14 +1745,18 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
setFocusedNodeId(nextSelectedNodeId);
setIsLayoutRunning(false);
}
}, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
}, [confirmDiscardMarkdownDraft, selectedNodeId, viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
useEffect(() => {
if (!externalFocusNodeId || externalFocusToken == null) return;
if (lastExternalFocusTokenRef.current === externalFocusToken) return;
if (!graphReady || !graph.hasNode(externalFocusNodeId)) return;
if (
externalFocusNodeId !== selectedNodeId
&& !confirmDiscardMarkdownDraft()
) return;
lastExternalFocusTokenRef.current = externalFocusToken;
// Set state directly instead of going through focusNode(), which captures
// a stale viewMode in its closure. setViewMode is called first so the node
// is visible in the full graph before the scene pans to it.
@@ -1737,7 +1766,13 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
window.setTimeout(() => {
sceneRef.current?.focusNode(externalFocusNodeId);
}, 0);
}, [externalFocusNodeId, externalFocusToken, graphReady]);
}, [
confirmDiscardMarkdownDraft,
externalFocusNodeId,
externalFocusToken,
graphReady,
selectedNodeId,
]);
const handleEdgeSelect = useCallback((edgeId: string) => {
setSelectedEdgeId(edgeId);
@@ -1843,6 +1878,37 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
document.body.removeChild(anchor);
}, [inspectableNodeId]);
const handleMarkdownApplied = useCallback((result: MarkdownApplyResult) => {
if (result.resource.kind !== "context-node") return;
if (!graph.hasNode(result.resource.id)) return;
const syncGeneration = markdownRefreshGuard.begin(result.resource.id);
const attributes = graph.getNodeAttributes(result.resource.id) as NodeAttributes;
graph.mergeNodeAttributes(
result.resource.id,
buildNodeMarkdownAttributeUpdate(
result.resource.id,
result.body,
attributes.properties ?? {},
),
);
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
void readNodeMarkdownAttributeUpdate(result.resource.id)
.then((savedAttributes) => {
if (
!markdownRefreshGuard.isCurrent(result.resource.id, syncGeneration)
|| !graph.hasNode(result.resource.id)
) return;
graph.mergeNodeAttributes(result.resource.id, savedAttributes);
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
})
.catch((syncError) => {
console.error("[GraphWorkspace] applied node refresh failed", syncError);
});
}, [markdownRefreshGuard]);
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/graph-updates`);
@@ -1873,6 +1939,25 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
}
if (eventType === "UPDATE_NODE" && payload?.id && graph.hasNode(payload.id)) {
markdownRefreshGuard.invalidate(payload.id);
const properties = payload.properties ?? {};
const current = graph.getNodeAttributes(payload.id) as NodeAttributes;
const content = typeof properties.content === "string" ? properties.content : "";
graph.mergeNodeAttributes(payload.id, {
...buildNodeMarkdownAttributeUpdate(payload.id, content, properties),
nodeType: payload.type ?? current.nodeType,
valid_from: properties.valid_from ?? null,
valid_until: properties.valid_until ?? null,
});
logEvent(
"update-node",
`Updated node ${payload.id} via realtime ws`,
{ nodeId: payload.id, nodeType: payload.type },
);
setGraphVersion((version) => version + 1);
sceneRef.current?.getRuntime()?.requestRender();
}
if (eventType === "ADD_EDGE") {
const isSmallGraph = smallGraphModeRef.current;
batchMergeEdges([
@@ -1899,7 +1984,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
return () => {
socket.close();
};
}, []);
}, [markdownRefreshGuard]);
useEffect(() => {
setCollapsedNeighborhoodNodeIds([]);
@@ -2172,28 +2257,29 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
const displayResult = useMemo(
() => (
viewMode === "grouped"
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
}))
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
})
),
[
aggregationEnabled,
collapsedNeighborhoodNodeIds,
groupedDisplayCandidate,
structuralActivePath,
structuralActivePathEdgeIds,
structuralSelectedNodeId,
viewMode,
],
);
const displayResult = useMemo(() => {
// The displayed graph is an aggregated clone. Rebuild it after domain
// mutations so applied Markdown labels do not remain stale on the canvas.
void graphVersion;
return viewMode === "grouped"
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
}))
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
});
}, [
aggregationEnabled,
collapsedNeighborhoodNodeIds,
graphVersion,
groupedDisplayCandidate,
structuralActivePath,
structuralActivePathEdgeIds,
structuralSelectedNodeId,
viewMode,
]);
const displayState = useMemo(
() => (
viewMode === "grouped"
@@ -3295,6 +3381,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
pathResult={pathResult}
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
onFocusNode={focusNode}
onMarkdownApplied={handleMarkdownApplied}
onMarkdownDirtyChange={handleMarkdownDirtyChange}
/>
</Suspense>
</div>
@@ -1,125 +1,243 @@
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
import {
Check,
Code2,
Copy,
Eye,
ExternalLink,
Image as ImageIcon,
Loader2,
Pencil,
RefreshCw,
X,
} from "lucide-react";
import { GRAPH_THEME } from "./graphTheme";
import type { MarkdownApplyResult } from "./markdownResourceClient";
import type { MarkdownResourceRef } from "./markdownEditorState";
import { isSafeUrl } from "./markdownUrlSafety";
import { useMarkdownEditor } from "./useMarkdownEditor";
export interface MarkdownContentViewerProps {
content?: string | null;
resource?: MarkdownResourceRef;
onApplied?: (result: MarkdownApplyResult) => void;
onDirtyChange?: (dirty: boolean) => void;
className?: string;
defaultMode?: "preview" | "source";
}
export function MarkdownContentViewer({
content,
resource,
onApplied,
onDirtyChange,
className,
defaultMode = "preview",
}: MarkdownContentViewerProps) {
const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode);
const [copied, setCopied] = useState(false);
const modeBeforeEditRef = useRef<"preview" | "source">(defaultMode);
const resourceKey = resource ? `${resource.kind}:${resource.id}` : "";
const [activeResourceKey, setActiveResourceKey] = useState(resourceKey);
const editor = useMarkdownEditor({ resource, onApplied, onDirtyChange });
const {
session,
error,
dirty,
editing,
saving,
loading,
} = editor;
if (activeResourceKey !== resourceKey) {
setActiveResourceKey(resourceKey);
setCopied(false);
setActiveMode(defaultMode);
}
// Track the content value for which the copied indicator is valid.
// When content changes (i.e. the user selects a different node), reset the
// copied indicator inline during render rather than in a useEffect — this
// avoids a cascading-render lint error and is the React-recommended pattern
// for resetting derived visual state on prop changes.
const [copiedForContent, setCopiedForContent] = useState<string | null | undefined>(content);
if (copiedForContent !== content) {
setCopiedForContent(content);
if (copied) {
// Clear the stale indicator synchronously so the new node's copy button
// never shows "Copied" from the previous selection.
setCopied(false);
}
if (copied) setCopied(false);
}
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clean up any outstanding timeout on unmount.
const copyTimeoutRef = useRef<number | undefined>(undefined);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
clearTimeout(copyTimeoutRef.current);
};
}, []);
const rawContent = typeof content === "string" ? content : "";
const rawContent = editor.editing
? editor.session?.draft ?? ""
: (typeof content === "string" ? content : "");
const previewContent = useMemo(() => {
if (!editor.editing) return rawContent;
const lines = rawContent.split(/\r?\n/);
if (lines[0] !== "---") return rawContent;
const closingIndex = lines.findIndex((line, index) => index > 0 && line === "---");
return closingIndex < 0 ? rawContent : lines.slice(closingIndex + 1).join("\n").replace(/^\n/, "");
}, [editor.editing, rawContent]);
const hasContent = rawContent.trim().length > 0;
// react-markdown runs the whole remark pipeline synchronously inside its own
// render, so without this memo every unrelated re-render of this component --
// clicking Copy, toggling Preview/Source -- re-parses the entire document.
// Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118).
// Keyed on rawContent so a genuine node change still re-parses exactly once.
const renderedMarkdown = useMemo(
() => (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{rawContent}
{previewContent}
</ReactMarkdown>
),
[rawContent],
[previewContent],
);
const handleCopy = async () => {
if (!hasContent) return;
try {
await navigator.clipboard.writeText(rawContent);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
clearTimeout(copyTimeoutRef.current);
setCopied(true);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard write unavailable
// Clipboard write unavailable.
}
};
const handleEdit = async () => {
modeBeforeEditRef.current = activeMode;
setActiveMode("source");
if (!await editor.beginEdit()) {
setActiveMode(modeBeforeEditRef.current);
}
};
const handleCancel = () => {
editor.discard();
setActiveMode(modeBeforeEditRef.current);
};
const handleApply = async () => {
if (await editor.save()) {
setActiveMode("preview");
}
};
return (
<div className={className} style={viewerContainerStyle}>
<div style={viewerHeaderStyle}>
<div style={{ display: "flex", gap: 4 }} role="tablist">
<div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Markdown view">
<button
type="button"
role="tab"
aria-selected={activeMode === "preview"}
aria-controls="markdown-viewer-panel"
onClick={() => setActiveMode("preview")}
style={{ ...tabBtnStyle, ...(activeMode === "preview" ? activeTabBtnStyle : {}) }}
>
<Eye size={12} style={{ marginRight: 5 }} />
<Eye size={12} style={{ marginRight: 5 }} aria-hidden="true" />
Preview
</button>
<button
type="button"
role="tab"
aria-selected={activeMode === "source"}
aria-controls="markdown-viewer-panel"
onClick={() => setActiveMode("source")}
style={{ ...tabBtnStyle, ...(activeMode === "source" ? activeTabBtnStyle : {}) }}
>
<Code2 size={12} style={{ marginRight: 5 }} />
<Code2 size={12} style={{ marginRight: 5 }} aria-hidden="true" />
Source
</button>
</div>
{hasContent && (
<button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
{copied ? (
<>
<Check size={12} color="#3fb950" style={{ marginRight: 4 }} />
<span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
</>
) : (
<>
<Copy size={12} style={{ marginRight: 4 }} />
<span style={{ fontSize: 11 }}>Copy</span>
</>
)}
</button>
)}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{hasContent && (
<button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
{copied ? (
<>
<Check size={12} color="#3fb950" style={{ marginRight: 4 }} aria-hidden="true" />
<span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
</>
) : (
<>
<Copy size={12} style={{ marginRight: 4 }} aria-hidden="true" />
<span style={{ fontSize: 11 }}>Copy</span>
</>
)}
</button>
)}
{resource && !editing && !loading ? (
<button type="button" onClick={() => void handleEdit()} style={copyBtnStyle}>
<Pencil size={12} style={{ marginRight: 4 }} aria-hidden="true" />
Edit
</button>
) : null}
{loading ? (
<button type="button" disabled style={{ ...copyBtnStyle, opacity: 0.65 }}>
<Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
Loading
</button>
) : null}
{editing ? (
<>
<button type="button" onClick={handleCancel} disabled={saving} style={copyBtnStyle}>
<X size={12} style={{ marginRight: 4 }} aria-hidden="true" />
Cancel
</button>
<button
type="button"
onClick={() => void handleApply()}
disabled={saving || !dirty}
title={!dirty ? "Make a change before applying" : undefined}
style={{ ...saveBtnStyle, opacity: saving || !dirty ? 0.55 : 1 }}
>
{saving ? (
<Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
) : (
<Check size={12} style={{ marginRight: 4 }} aria-hidden="true" />
)}
{saving ? "Applying…" : "Apply"}
</button>
</>
) : null}
</div>
</div>
<div style={viewerBodyStyle}>
{!hasContent ? (
{error ? (
<div id="markdown-editor-error" role="alert" style={errorStyle}>
<span>{error.message}</span>
{error.kind === "conflict" ? (
<button type="button" onClick={() => void editor.reloadLatest()} style={errorActionStyle}>
<RefreshCw size={12} style={{ marginRight: 4 }} aria-hidden="true" />
Reload latest
</button>
) : null}
</div>
) : null}
<div
id="markdown-viewer-panel"
role="tabpanel"
aria-busy={saving || loading}
style={viewerBodyStyle}
>
{activeMode === "source" && editing ? (
<textarea
aria-label="Markdown source"
aria-describedby={error ? "markdown-editor-error" : undefined}
aria-invalid={error?.kind === "validation" || undefined}
value={session?.draft ?? ""}
onChange={(event) => editor.changeDraft(event.target.value)}
disabled={saving}
spellCheck={false}
style={editorStyle}
/>
) : !hasContent ? (
<div style={emptyTextStyle}>No content available for this node.</div>
) : activeMode === "source" ? (
<pre style={sourcePreStyle}>
@@ -238,6 +356,8 @@ const viewerHeaderStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
flexWrap: "wrap",
padding: "6px 10px",
background: "rgba(0, 0, 0, 0.2)",
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
@@ -275,12 +395,56 @@ const copyBtnStyle: CSSProperties = {
cursor: "pointer",
};
const saveBtnStyle: CSSProperties = {
...copyBtnStyle,
background: GRAPH_THEME.ui.control.primaryBg,
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
color: GRAPH_THEME.ui.control.primaryText,
fontWeight: 700,
};
const errorStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: "8px 12px",
color: "#ffb4ad",
background: "rgba(248, 81, 73, 0.1)",
borderBottom: "1px solid rgba(248, 81, 73, 0.25)",
fontSize: 12,
lineHeight: 1.5,
};
const errorActionStyle: CSSProperties = {
...copyBtnStyle,
flexShrink: 0,
color: "#ffb4ad",
border: "1px solid rgba(248, 81, 73, 0.32)",
};
const viewerBodyStyle: CSSProperties = {
padding: 12,
maxHeight: 380,
overflowY: "auto",
};
const editorStyle: CSSProperties = {
display: "block",
boxSizing: "border-box",
width: "100%",
minHeight: 280,
resize: "vertical",
padding: 10,
borderRadius: 8,
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
background: "rgba(0, 0, 0, 0.3)",
color: GRAPH_THEME.ui.text.strong,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 12,
lineHeight: 1.6,
};
const emptyTextStyle: CSSProperties = {
color: GRAPH_THEME.ui.text.muted,
fontSize: 12,
@@ -0,0 +1,114 @@
export type MarkdownResourceRef =
| { kind: "context-node"; id: string }
| { kind: "agent-memory"; id: string };
export type EditorStatus =
| "viewing"
| "loading-document"
| "editing"
| "saving"
| "validation-error"
| "save-error"
| "conflict";
export interface MarkdownEditorError {
kind: "validation" | "conflict" | "save" | "network";
message: string;
field?: string;
currentRevision?: string;
}
export interface MarkdownEditSession {
resource: MarkdownResourceRef;
baseSource: string;
baseRevision: string;
draft: string;
status: EditorStatus;
error: MarkdownEditorError | null;
}
export interface MarkdownSavedDocument {
source: string;
revision: string;
}
export function createLoadingSession(resource: MarkdownResourceRef): MarkdownEditSession {
return {
resource,
baseSource: "",
baseRevision: "",
draft: "",
status: "loading-document",
error: null,
};
}
export function createEditSession(
resource: MarkdownResourceRef,
document: MarkdownSavedDocument,
): MarkdownEditSession {
return {
resource,
baseSource: document.source,
baseRevision: document.revision,
draft: document.source,
status: "editing",
error: null,
};
}
export function updateDraft(
session: MarkdownEditSession,
draft: string,
): MarkdownEditSession {
return {
...session,
draft,
status: "editing",
error: null,
};
}
export function isDirty(session: MarkdownEditSession | null): boolean {
return session !== null && session.draft !== session.baseSource;
}
export function saveStarted(session: MarkdownEditSession): MarkdownEditSession {
if (!isDirty(session)) return session;
return { ...session, status: "saving", error: null };
}
export function saveSucceeded(
session: MarkdownEditSession,
document: MarkdownSavedDocument,
): MarkdownEditSession {
return {
...session,
baseSource: document.source,
baseRevision: document.revision,
draft: document.source,
status: "viewing",
error: null,
};
}
export function saveFailed(
session: MarkdownEditSession,
error: MarkdownEditorError,
): MarkdownEditSession {
const status: EditorStatus =
error.kind === "validation"
? "validation-error"
: error.kind === "conflict"
? "conflict"
: "save-error";
return { ...session, status, error };
}
export function cancelEdit(): null {
return null;
}
export function shouldConfirmDiscard(session: MarkdownEditSession | null): boolean {
return isDirty(session) && session?.status !== "saving";
}
@@ -0,0 +1,103 @@
import type {
MarkdownEditorError,
MarkdownResourceRef,
} from "./markdownEditorState";
export interface MarkdownDocument {
resource: MarkdownResourceRef;
source: string;
body: string;
revision: string;
editable: boolean;
}
export interface MarkdownApplyResult extends MarkdownDocument {
changed: boolean;
}
type ErrorDetail = {
code?: string;
message?: string;
field?: string;
current_revision?: string;
};
export class MarkdownClientError extends Error implements MarkdownEditorError {
readonly kind: MarkdownEditorError["kind"];
readonly field?: string;
readonly currentRevision?: string;
constructor(error: MarkdownEditorError) {
super(error.message);
this.name = "MarkdownClientError";
this.kind = error.kind;
this.field = error.field;
this.currentRevision = error.currentRevision;
}
}
function resourceUrl(ref: MarkdownResourceRef): string {
return `/api/markdown/${ref.kind}/${encodeURIComponent(ref.id)}`;
}
async function responseError(response: Response): Promise<MarkdownClientError> {
let detail: ErrorDetail = {};
try {
const payload = (await response.json()) as { detail?: ErrorDetail };
if (payload.detail && typeof payload.detail === "object") {
detail = payload.detail;
}
} catch {
// A non-JSON response is mapped from its status below.
}
const kind: MarkdownEditorError["kind"] =
response.status === 422
? "validation"
: response.status === 409
? "conflict"
: "save";
return new MarkdownClientError({
kind,
message: detail.message || `Markdown request failed (${response.status}).`,
field: detail.field,
currentRevision: detail.current_revision,
});
}
async function request<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
try {
const response = await fetch(input, init);
if (!response.ok) {
throw await responseError(response);
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof MarkdownClientError) throw error;
throw new MarkdownClientError({
kind: "network",
message: "The Markdown service could not be reached. Your draft was kept.",
});
}
}
export function readMarkdownResource(
ref: MarkdownResourceRef,
): Promise<MarkdownDocument> {
return request<MarkdownDocument>(resourceUrl(ref));
}
export function applyMarkdownResource(
ref: MarkdownResourceRef,
markdown: string,
expectedRevision: string,
): Promise<MarkdownApplyResult> {
return request<MarkdownApplyResult>(resourceUrl(ref), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
markdown,
expected_revision: expectedRevision,
}),
});
}
@@ -0,0 +1,81 @@
export interface NodeMarkdownAttributeUpdate {
label: string;
content: string;
properties: Record<string, unknown>;
}
export interface GraphNodeMarkdownSnapshot {
id: string;
type: string;
content: string;
properties: Record<string, unknown>;
valid_from: string | null;
valid_until: string | null;
}
export interface SavedNodeMarkdownAttributeUpdate extends NodeMarkdownAttributeUpdate {
nodeType: string;
valid_from: string | null;
valid_until: string | null;
}
export class NodeMarkdownRefreshGuard {
private readonly generations = new Map<string, number>();
begin(nodeId: string): number {
const generation = (this.generations.get(nodeId) ?? 0) + 1;
this.generations.set(nodeId, generation);
return generation;
}
invalidate(nodeId: string): void {
this.begin(nodeId);
}
isCurrent(nodeId: string, generation: number): boolean {
return this.generations.get(nodeId) === generation;
}
}
export function buildNodeMarkdownAttributeUpdate(
nodeId: string,
content: string,
properties: Record<string, unknown>,
): NodeMarkdownAttributeUpdate {
return {
label: content || nodeId,
content,
properties: {
...properties,
content,
},
};
}
export async function readNodeMarkdownAttributeUpdate(
nodeId: string,
fetcher: typeof fetch = fetch,
): Promise<SavedNodeMarkdownAttributeUpdate> {
const response = await fetcher(
`/api/graph/node?node_id=${encodeURIComponent(nodeId)}`,
);
if (!response.ok) {
throw new Error(`Graph node refresh failed (${response.status}).`);
}
const node = await response.json() as GraphNodeMarkdownSnapshot;
if (node.id !== nodeId) {
throw new Error("Graph node refresh returned a different resource.");
}
return {
...buildNodeMarkdownAttributeUpdate(
node.id,
node.content,
node.properties ?? {},
),
nodeType: node.type,
valid_from: node.valid_from ?? null,
valid_until: node.valid_until ?? null,
};
}
@@ -0,0 +1,177 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
MarkdownClientError,
applyMarkdownResource,
readMarkdownResource,
type MarkdownApplyResult,
} from "./markdownResourceClient";
import {
cancelEdit,
createEditSession,
createLoadingSession,
isDirty,
saveFailed,
saveStarted,
saveSucceeded,
updateDraft,
type MarkdownEditorError,
type MarkdownEditSession,
type MarkdownResourceRef,
} from "./markdownEditorState";
interface MarkdownEditorOptions {
resource?: MarkdownResourceRef;
onApplied?: (result: MarkdownApplyResult) => void;
onDirtyChange?: (dirty: boolean) => void;
}
interface KeyedError {
resourceKey: string;
error: MarkdownEditorError;
}
function keyOf(resource?: MarkdownResourceRef): string {
return resource ? `${resource.kind}:${resource.id}` : "";
}
function normalizeError(failure: unknown): MarkdownEditorError {
if (failure instanceof MarkdownClientError) return failure;
return {
kind: "network",
message: "The Markdown service could not be reached. Your draft was kept.",
};
}
export function useMarkdownEditor({
resource,
onApplied,
onDirtyChange,
}: MarkdownEditorOptions) {
const resourceKey = keyOf(resource);
const [session, setSession] = useState<MarkdownEditSession | null>(null);
const [viewError, setViewError] = useState<KeyedError | null>(null);
const [renderedResourceKey, setRenderedResourceKey] = useState(resourceKey);
const loadGenerationRef = useRef(0);
if (renderedResourceKey !== resourceKey) {
setRenderedResourceKey(resourceKey);
setSession(null);
setViewError(null);
}
useLayoutEffect(() => {
loadGenerationRef.current += 1;
}, [resourceKey]);
const activeSession = session && keyOf(session.resource) === resourceKey
? session
: null;
const dirty = isDirty(activeSession);
const editing = activeSession !== null
&& activeSession.status !== "viewing"
&& activeSession.status !== "loading-document";
const saving = activeSession?.status === "saving";
const loading = activeSession?.status === "loading-document";
const error = activeSession?.error
?? (viewError?.resourceKey === resourceKey ? viewError.error : null);
useEffect(() => {
onDirtyChange?.(dirty);
return () => {
if (dirty) onDirtyChange?.(false);
};
}, [dirty, onDirtyChange]);
useEffect(() => {
if (!dirty) return;
const protectDraft = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", protectDraft);
return () => window.removeEventListener("beforeunload", protectDraft);
}, [dirty]);
const beginEdit = useCallback(async () => {
if (!resource) return false;
const loadGeneration = ++loadGenerationRef.current;
setViewError(null);
setSession(createLoadingSession(resource));
try {
const document = await readMarkdownResource(resource);
if (loadGeneration !== loadGenerationRef.current) return false;
setSession(createEditSession(resource, document));
return true;
} catch (failure) {
if (loadGeneration !== loadGenerationRef.current) return false;
setSession(null);
setViewError({ resourceKey, error: normalizeError(failure) });
return false;
}
}, [resource, resourceKey]);
const discard = useCallback(() => {
if (!activeSession || saving) return;
setSession(cancelEdit());
setViewError(null);
}, [activeSession, saving]);
const save = useCallback(async () => {
if (!activeSession || saving || !dirty) return false;
const resourceGeneration = loadGenerationRef.current;
const pending = saveStarted(activeSession);
setSession(pending);
try {
const result = await applyMarkdownResource(
pending.resource,
pending.draft,
pending.baseRevision,
);
if (resourceGeneration !== loadGenerationRef.current) return false;
setSession(saveSucceeded(pending, result));
onApplied?.(result);
return true;
} catch (failure) {
if (resourceGeneration !== loadGenerationRef.current) return false;
setSession(saveFailed(pending, normalizeError(failure)));
return false;
}
}, [activeSession, dirty, onApplied, saving]);
const reloadLatest = useCallback(async () => {
if (!resource || saving) return;
if (
dirty
&& !window.confirm("Discard this draft and reload the latest applied version?")
) return;
await beginEdit();
}, [beginEdit, dirty, resource, saving]);
const changeDraft = useCallback((draft: string) => {
setSession((current) => (
current && keyOf(current.resource) === resourceKey
? updateDraft(current, draft)
: current
));
}, [resourceKey]);
return {
session: activeSession,
error,
dirty,
editing,
saving,
loading,
beginEdit,
discard,
save,
reloadLatest,
changeDraft,
};
}
+464
View File
@@ -0,0 +1,464 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { Brain, RefreshCw } from "lucide-react";
import { MarkdownContentViewer } from "./GraphWorkspace/MarkdownContentViewer";
import {
readMarkdownResource,
type MarkdownApplyResult,
} from "./GraphWorkspace/markdownResourceClient";
import { GRAPH_THEME } from "./GraphWorkspace/graphTheme";
interface MemorySummary {
id: string;
type: string;
excerpt: string;
updated_at: string | null;
}
interface MemoryListResponse {
items: MemorySummary[];
total: number;
skip: number;
limit: number;
}
interface MemoryWorkspaceProps {
onDirtyChange?: (dirty: boolean) => void;
}
const MEMORY_PAGE_SIZE = 100;
function responseMessage(payload: unknown, fallback: string): string {
if (!payload || typeof payload !== "object" || !("detail" in payload)) {
return fallback;
}
return typeof payload.detail === "string" ? payload.detail : fallback;
}
async function fetchMemoryList(skip = 0): Promise<MemoryListResponse> {
const response = await fetch(`/api/memories?skip=${skip}&limit=${MEMORY_PAGE_SIZE}`);
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(responseMessage(payload, `Memory list failed (${response.status}).`));
}
return response.json() as Promise<MemoryListResponse>;
}
async function fetchLoadedMemoryPages(endOffset: number): Promise<{
items: MemorySummary[];
total: number;
nextOffset: number;
}> {
const items: MemorySummary[] = [];
let total = 0;
let nextOffset = 0;
const targetOffset = Math.max(endOffset, MEMORY_PAGE_SIZE);
while (nextOffset < targetOffset) {
const payload = await fetchMemoryList(nextOffset);
items.push(...payload.items);
total = payload.total;
nextOffset = payload.skip + payload.items.length;
if (payload.items.length === 0 || nextOffset >= total) break;
}
return { items, total, nextOffset };
}
export function MemoryWorkspace({ onDirtyChange }: MemoryWorkspaceProps = {}) {
const [items, setItems] = useState<MemorySummary[]>([]);
const [total, setTotal] = useState(0);
const [nextOffset, setNextOffset] = useState(0);
const [selectedId, setSelectedId] = useState("");
const [selectedBody, setSelectedBody] = useState("");
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [dirty, setDirty] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const listGenerationRef = useRef(0);
const selectionGenerationRef = useRef(0);
const handleDirtyChange = useCallback((nextDirty: boolean) => {
setDirty(nextDirty);
onDirtyChange?.(nextDirty);
}, [onDirtyChange]);
useEffect(() => {
let cancelled = false;
const listGeneration = ++listGenerationRef.current;
const selectionGeneration = ++selectionGenerationRef.current;
const isCurrent = () => (
!cancelled
&& listGeneration === listGenerationRef.current
&& selectionGeneration === selectionGenerationRef.current
);
const load = async () => {
setLoading(true);
setLoadingMore(false);
setError("");
try {
const payload = await fetchMemoryList();
if (!isCurrent()) return;
setItems(payload.items);
setTotal(payload.total);
setNextOffset(payload.skip + payload.items.length);
const first = payload.items[0];
if (!first) {
setSelectedId("");
setSelectedBody("");
return;
}
const document = await readMarkdownResource({
kind: "agent-memory",
id: first.id,
});
if (!isCurrent()) return;
setSelectedId(first.id);
setSelectedBody(document.body);
} catch (failure) {
if (isCurrent()) {
setError(failure instanceof Error ? failure.message : "Memories could not be loaded.");
}
} finally {
if (isCurrent()) setLoading(false);
}
};
void load();
return () => {
cancelled = true;
listGenerationRef.current += 1;
selectionGenerationRef.current += 1;
};
}, [reloadToken]);
const selectMemory = async (memoryId: string) => {
if (memoryId === selectedId) return;
if (dirty && !window.confirm("Discard the unapplied Markdown draft and open another memory?")) return;
const selectionGeneration = ++selectionGenerationRef.current;
// Do NOT call handleDirtyChange(false) here: the editor's own onDirtyChange
// callback fires automatically when MarkdownContentViewer re-renders with
// the new resource prop and its session is cleared.
setLoading(true);
setError("");
try {
const document = await readMarkdownResource({
kind: "agent-memory",
id: memoryId,
});
if (selectionGeneration !== selectionGenerationRef.current) return;
setSelectedId(memoryId);
setSelectedBody(document.body);
} catch (failure) {
if (selectionGeneration === selectionGenerationRef.current) {
setError(failure instanceof Error ? failure.message : "The memory could not be loaded.");
}
} finally {
if (selectionGeneration === selectionGenerationRef.current) {
setLoading(false);
}
}
};
const loadMoreMemories = useCallback(async () => {
if (loadingMore || nextOffset >= total) return;
const listGeneration = ++listGenerationRef.current;
setLoadingMore(true);
setError("");
try {
const payload = await fetchMemoryList(nextOffset);
if (listGeneration !== listGenerationRef.current) return;
setItems((current) => {
const knownIds = new Set(current.map((item) => item.id));
return [
...current,
...payload.items.filter((item) => !knownIds.has(item.id)),
];
});
setTotal(payload.total);
setNextOffset(payload.skip + payload.items.length);
} catch (failure) {
if (listGeneration === listGenerationRef.current) {
setError(failure instanceof Error ? failure.message : "More memories could not be loaded.");
}
} finally {
if (listGeneration === listGenerationRef.current) {
setLoadingMore(false);
}
}
}, [loadingMore, nextOffset, total]);
const refreshMemorySummaries = useCallback(async () => {
const listGeneration = ++listGenerationRef.current;
try {
const payload = await fetchLoadedMemoryPages(nextOffset);
if (listGeneration !== listGenerationRef.current) return;
setItems(payload.items);
setTotal(payload.total);
setNextOffset(payload.nextOffset);
} catch {
if (listGeneration === listGenerationRef.current) {
setError("Memory was applied, but its summary could not be refreshed.");
}
} finally {
if (listGeneration === listGenerationRef.current) {
setLoadingMore(false);
}
}
}, [nextOffset]);
const applyMemory = useCallback((result: MarkdownApplyResult) => {
setSelectedBody(result.body);
handleDirtyChange(false);
setItems((current) => current.map((item) => (
item.id === result.resource.id
? { ...item, excerpt: result.body.replace(/\s+/g, " ").slice(0, 160) }
: item
)));
void refreshMemorySummaries();
}, [handleDirtyChange, refreshMemorySummaries]);
return (
<div style={workspaceStyle}>
<aside style={listPanelStyle} aria-label="Agent memories">
<div style={listHeaderStyle}>
<div>
<div style={listTitleStyle}>AgentMemory</div>
<div style={listCountStyle}>{items.length} of {total} loaded</div>
</div>
<button
type="button"
aria-label="Refresh memories"
onClick={() => setReloadToken((value) => value + 1)}
disabled={loading || loadingMore || dirty}
title={dirty ? "Apply or cancel the current draft before refreshing" : "Refresh memories"}
style={{ ...iconButtonStyle, opacity: loading || loadingMore || dirty ? 0.55 : 1 }}
>
<RefreshCw size={14} aria-hidden="true" />
</button>
</div>
<div style={memoryListStyle}>
{items.map((item) => (
<button
type="button"
key={item.id}
onClick={() => void selectMemory(item.id)}
aria-current={item.id === selectedId ? "true" : undefined}
style={{
...memoryButtonStyle,
...(item.id === selectedId ? selectedMemoryButtonStyle : {}),
}}
>
<span style={memoryTypeStyle}>{item.type}</span>
<span style={memoryIdStyle}>{item.id}</span>
<span style={memoryExcerptStyle}>{item.excerpt || "Empty memory"}</span>
</button>
))}
{nextOffset < total ? (
<button
type="button"
aria-label="Load more memories"
onClick={() => void loadMoreMemories()}
disabled={loading || loadingMore}
style={{ ...retryButtonStyle, opacity: loading || loadingMore ? 0.55 : 1 }}
>
{loadingMore ? "Loading…" : "Load more"}
</button>
) : null}
{!loading && items.length === 0 ? (
<div style={emptyStyle}>
<Brain size={22} aria-hidden="true" />
<span>No AgentMemory items are available.</span>
<button type="button" onClick={() => setReloadToken((value) => value + 1)} style={retryButtonStyle}>
Refresh
</button>
</div>
) : null}
</div>
</aside>
<main style={editorPanelStyle}>
{error ? <div role="alert" style={alertStyle}>{error}</div> : null}
{loading ? (
<div role="status" style={emptyStyle}>Loading memories</div>
) : selectedId ? (
<>
<div style={selectionHeaderStyle}>
<span style={selectionLabelStyle}>Selected memory</span>
<strong style={selectionIdStyle}>{selectedId}</strong>
</div>
<MarkdownContentViewer
content={selectedBody}
resource={{ kind: "agent-memory", id: selectedId }}
onApplied={applyMemory}
onDirtyChange={handleDirtyChange}
/>
</>
) : null}
</main>
</div>
);
}
const workspaceStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(220px, 300px) minmax(0, 1fr)",
height: "100%",
minHeight: 0,
background: GRAPH_THEME.ui.surface.stage,
};
const listPanelStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
minHeight: 0,
borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
background: GRAPH_THEME.ui.surface.panel,
};
const listHeaderStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: 16,
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
};
const listTitleStyle: CSSProperties = {
color: GRAPH_THEME.ui.text.strong,
fontSize: 14,
fontWeight: 700,
};
const listCountStyle: CSSProperties = {
color: GRAPH_THEME.ui.text.muted,
fontSize: 11,
marginTop: 3,
};
const iconButtonStyle: CSSProperties = {
display: "inline-grid",
placeItems: "center",
width: 30,
height: 30,
borderRadius: 8,
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
background: "rgba(255, 255, 255, 0.04)",
color: GRAPH_THEME.ui.text.body,
cursor: "pointer",
};
const memoryListStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
minHeight: 0,
padding: 10,
overflowY: "auto",
};
const memoryButtonStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 5,
padding: 10,
borderRadius: 9,
border: "1px solid transparent",
background: "transparent",
color: GRAPH_THEME.ui.text.body,
textAlign: "left",
cursor: "pointer",
};
const selectedMemoryButtonStyle: CSSProperties = {
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
background: GRAPH_THEME.ui.timeline.playheadSoft,
};
const memoryTypeStyle: CSSProperties = {
color: GRAPH_THEME.ui.timeline.playhead,
fontSize: 10,
fontWeight: 700,
textTransform: "uppercase",
};
const memoryIdStyle: CSSProperties = {
maxWidth: "100%",
overflow: "hidden",
color: GRAPH_THEME.ui.text.strong,
fontFamily: "monospace",
fontSize: 12,
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const memoryExcerptStyle: CSSProperties = {
display: "-webkit-box",
overflow: "hidden",
color: GRAPH_THEME.ui.text.muted,
fontSize: 11,
lineHeight: 1.45,
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
};
const editorPanelStyle: CSSProperties = {
minWidth: 0,
minHeight: 0,
padding: 20,
overflowY: "auto",
};
const selectionHeaderStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 4,
marginBottom: 12,
};
const selectionLabelStyle: CSSProperties = {
color: GRAPH_THEME.ui.text.muted,
fontSize: 11,
};
const selectionIdStyle: CSSProperties = {
color: GRAPH_THEME.ui.text.strong,
fontFamily: "monospace",
fontSize: 14,
wordBreak: "break-all",
};
const emptyStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 10,
minHeight: 160,
padding: 20,
color: GRAPH_THEME.ui.text.muted,
fontSize: 12,
textAlign: "center",
};
const retryButtonStyle: CSSProperties = {
padding: "6px 10px",
borderRadius: 7,
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
background: GRAPH_THEME.ui.timeline.playheadSoft,
color: GRAPH_THEME.ui.timeline.playhead,
cursor: "pointer",
};
const alertStyle: CSSProperties = {
marginBottom: 12,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(248, 81, 73, 0.28)",
background: "rgba(248, 81, 73, 0.1)",
color: "#ffb4ad",
fontSize: 12,
};
@@ -42,6 +42,9 @@ async function startVite(): Promise<void> {
}
async function installApiFixture(page: Page): Promise<void> {
await page.route("**/api/info", async (route) => {
await route.fulfill({ json: { capabilities: { agent_memory: false } } });
});
await page.route("**/api/graph/**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === "/api/graph/stats") {
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import React from "react";
import { fetchAgentMemoryAvailability } from "../src/explorerCapabilities.ts";
(globalThis as typeof globalThis & { React: typeof React }).React = React;
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
Node: dom.window.Node,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
const { cleanup, render } = await import("@testing-library/react");
const { ExploreWorkspaceTabs } = await import("../src/ExploreWorkspaceTabs.tsx");
test.afterEach(cleanup);
test("reports AgentMemory when the Explorer host provides it", async () => {
const available = await fetchAgentMemoryAvailability(async () => (
new Response(
JSON.stringify({ capabilities: { agent_memory: true } }),
{ status: 200, headers: { "content-type": "application/json" } },
)
));
assert.equal(available, true);
});
test("keeps AgentMemory hidden when the capability is absent or unavailable", async () => {
const absent = await fetchAgentMemoryAvailability(async () => (
new Response(JSON.stringify({ status: "active" }), { status: 200 })
));
const unavailable = await fetchAgentMemoryAvailability(async () => {
throw new Error("network unavailable");
});
assert.equal(absent, false);
assert.equal(unavailable, false);
});
test("shows the Memories tab only when the host provides AgentMemory", () => {
const availableView = render(
<ExploreWorkspaceTabs
activeView="graph"
agentMemoryAvailable
onSelect={() => undefined}
/>,
);
assert.ok(availableView.getByRole("button", { name: "Memories" }));
cleanup();
const unavailableView = render(
<ExploreWorkspaceTabs
activeView="graph"
agentMemoryAvailable={false}
onSelect={() => undefined}
/>,
);
assert.equal(
unavailableView.queryByRole("button", { name: "Memories" }),
null,
);
});
+24 -1
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import React from "react";
import { renderToString } from "react-dom/server";
(globalThis as any).React = React;
(globalThis as typeof globalThis & { React: typeof React }).React = React;
import { MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/markdownUrlSafety.ts";
@@ -60,6 +60,29 @@ test("renders Preview mode with formatted Markdown elements and tabs", () => {
assert.equal(html.includes("Item B"), true);
});
test("stays read-only without a resource and exposes Edit for canonical resources", () => {
const readOnly = renderToString(React.createElement(MarkdownContentViewer, {
content: "Read-only body",
}));
const editable = renderToString(React.createElement(MarkdownContentViewer, {
content: "Editable body",
resource: { kind: "context-node", id: "node-1" },
}));
assert.equal(readOnly.includes(">Edit</button>"), false);
assert.equal(editable.includes(">Edit</button>"), true);
});
test("empty canonical resources still expose Edit", () => {
const html = renderToString(React.createElement(MarkdownContentViewer, {
content: "",
resource: { kind: "context-node", id: "empty-node" },
}));
assert.equal(html.includes("No content available for this node."), true);
assert.equal(html.includes(">Edit</button>"), true);
});
test("renders Source mode with exact unmodified text inside pre/code", () => {
const markdown = `# Title 🚀\n\n * Indented item\n\n\`\`\`python\ndef test():\n return "α + β"\n\`\`\``;
const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "source" }));
@@ -0,0 +1,639 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import React from "react";
(globalThis as typeof globalThis & { React: typeof React }).React = React;
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
Node: dom.window.Node,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
dom.window.confirm = () => true;
// Testing Library and the components must load after the jsdom globals above.
const { act, cleanup, fireEvent, render, waitFor } = await import(
"@testing-library/react"
);
const { MarkdownContentViewer } = await import(
"../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx"
);
const { MemoryWorkspace } = await import(
"../src/workspaces/MemoryWorkspace.tsx"
);
test.afterEach(() => {
cleanup();
dom.window.confirm = () => true;
});
const resource = { kind: "context-node" as const, id: "node-1" };
const originalSource = "---\nid: node-1\ntype: Note\n---\n\nOriginal";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
test("Edit and Apply send canonical Markdown and publish the applied result", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
let appliedBody = "";
globalThis.fetch = async (input, init) => {
requests.push({ url: String(input), init });
if (init?.method === "PUT") {
return jsonResponse({
resource,
source: originalSource.replace("Original", "Updated"),
body: "Updated",
revision: "sha256:updated",
editable: true,
changed: true,
});
}
return jsonResponse({
resource,
source: originalSource,
body: "Original",
revision: "sha256:original",
editable: true,
});
};
const view = render(
<MarkdownContentViewer
content="Original"
resource={resource}
onApplied={(result) => { appliedBody = result.body; }}
/>,
);
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
fireEvent.input(textarea, {
target: { value: originalSource.replace("Original", "Updated") },
});
fireEvent.click(view.getByRole("button", { name: "Apply" }));
await waitFor(() => assert.equal(appliedBody, "Updated"));
assert.deepEqual(requests.map(({ init }) => init?.method ?? "GET"), ["GET", "PUT"]);
assert.equal(
JSON.parse(String(requests[1].init?.body)).expected_revision,
"sha256:original",
);
assert.equal(
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
"true",
);
});
test("Cancel restores the previous view and never sends a PUT", async () => {
const methods: string[] = [];
globalThis.fetch = async (_input, init) => {
methods.push(init?.method ?? "GET");
return jsonResponse({
resource,
source: originalSource,
body: "Original",
revision: "sha256:original",
editable: true,
});
};
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
fireEvent.input(textarea, {
target: { value: originalSource.replace("Original", "Draft") },
});
fireEvent.click(view.getByRole("button", { name: "Cancel" }));
assert.deepEqual(methods, ["GET"]);
assert.equal(view.queryByRole("textbox", { name: "Markdown source" }), null);
assert.equal(
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
"true",
);
});
test("validation failures keep the draft visible for correction", async () => {
globalThis.fetch = async (_input, init) => {
if (init?.method === "PUT") {
return jsonResponse({
detail: {
code: "invalid_markdown_frontmatter",
message: "Markdown frontmatter contains invalid YAML.",
},
}, 422);
}
return jsonResponse({
resource,
source: originalSource,
body: "Original",
revision: "sha256:original",
editable: true,
});
};
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
const invalidDraft = "---\nid: [\n---\n\nDraft";
fireEvent.input(textarea, { target: { value: invalidDraft } });
fireEvent.click(view.getByRole("button", { name: "Apply" }));
const alert = await view.findByRole("alert");
assert.match(alert.textContent ?? "", /invalid YAML/);
assert.equal(
(view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement)
.value,
invalidDraft,
);
});
test("resource changes discard the previous editor session", async () => {
globalThis.fetch = async (input) => {
const id = String(input).endsWith("node-2") ? "node-2" : "node-1";
return jsonResponse({
resource: { kind: "context-node", id },
source: `---\nid: ${id}\ntype: Note\n---\n\n${id}`,
body: id,
revision: `sha256:${id}`,
editable: true,
});
};
const view = render(
<MarkdownContentViewer content="node-1" resource={resource} />,
);
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
fireEvent.input(textarea, {
target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` },
});
view.rerender(
<MarkdownContentViewer
content="node-2"
resource={{ kind: "context-node", id: "node-2" }}
/>,
);
await waitFor(() => assert.equal(
view.queryByRole("textbox", { name: "Markdown source" }),
null,
));
view.rerender(
<MarkdownContentViewer content="node-1" resource={resource} />,
);
await waitFor(() => assert.equal(
view.queryByRole("textbox", { name: "Markdown source" }),
null,
));
assert.ok(view.getByRole("button", { name: "Edit" }));
});
test("unmounting a dirty editor clears the parent dirty guard", async () => {
const dirtyStates: boolean[] = [];
globalThis.fetch = async () => jsonResponse({
resource,
source: originalSource,
body: "Original",
revision: "sha256:original",
editable: true,
});
const view = render(
<MarkdownContentViewer
content="Original"
resource={resource}
onDirtyChange={(dirty) => dirtyStates.push(dirty)}
/>,
);
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
fireEvent.input(textarea, {
target: { value: originalSource.replace("Original", "Draft") },
});
await waitFor(() => assert.equal(dirtyStates.at(-1), true));
view.unmount();
assert.equal(dirtyStates.at(-1), false);
});
test("MemoryWorkspace protects a dirty memory draft when selection changes", async () => {
const requestedUrls: string[] = [];
globalThis.fetch = async (input) => {
const url = String(input);
requestedUrls.push(url);
if (url.startsWith("/api/memories")) {
return jsonResponse({
items: [
{ id: "mem-1", type: "note", excerpt: "First", updated_at: null },
{ id: "mem-2", type: "note", excerpt: "Second", updated_at: null },
],
total: 2,
skip: 0,
limit: 100,
});
}
const id = url.endsWith("mem-2") ? "mem-2" : "mem-1";
return jsonResponse({
resource: { kind: "agent-memory", id },
source: `---\nid: ${id}\ntype: note\n---\n\n${id}`,
body: id,
revision: `sha256:${id}`,
editable: true,
});
};
const view = render(<MemoryWorkspace />);
await view.findByText("Selected memory");
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
fireEvent.input(textarea, {
target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` },
});
dom.window.confirm = () => false;
fireEvent.click(view.getByRole("button", { name: /mem-2/ }));
assert.equal(requestedUrls.some((url) => url.endsWith("mem-2")), false);
assert.equal(view.getByText("mem-1", { selector: "strong" }).textContent, "mem-1");
});
test("MemoryWorkspace loads memories beyond the first server page", async () => {
const requestedUrls: string[] = [];
const firstPage = Array.from({ length: 100 }, (_, index) => ({
id: `mem-${index + 1}`,
type: "note",
excerpt: `Memory ${index + 1}`,
updated_at: null,
}));
globalThis.fetch = async (input) => {
const url = String(input);
requestedUrls.push(url);
if (url === "/api/memories?skip=0&limit=100") {
return jsonResponse({
items: firstPage,
total: 101,
skip: 0,
limit: 100,
});
}
if (url === "/api/memories?skip=100&limit=100") {
return jsonResponse({
items: [{
id: "mem-101",
type: "note",
excerpt: "Memory 101",
updated_at: null,
}],
total: 101,
skip: 100,
limit: 100,
});
}
return jsonResponse({
resource: { kind: "agent-memory", id: "mem-1" },
source: "---\nid: mem-1\ntype: note\n---\n\nmem-1",
body: "mem-1",
revision: "sha256:mem-1",
editable: true,
});
};
const view = render(<MemoryWorkspace />);
await view.findByText("Selected memory");
fireEvent.click(view.getByRole("button", { name: "Load more memories" }));
await view.findByRole("button", { name: /mem-101/ });
assert.ok(requestedUrls.includes("/api/memories?skip=100&limit=100"));
assert.equal(view.getByText("101 of 101 loaded").textContent, "101 of 101 loaded");
});
test("MemoryWorkspace ignores stale selection responses", async () => {
let resolveMem2: ((response: Response) => void) | undefined;
let resolveMem3: ((response: Response) => void) | undefined;
const mem2Response = new Promise<Response>((resolve) => {
resolveMem2 = resolve;
});
const mem3Response = new Promise<Response>((resolve) => {
resolveMem3 = resolve;
});
globalThis.fetch = async (input) => {
const url = String(input);
if (url.startsWith("/api/memories")) {
return jsonResponse({
items: [
{ id: "mem-1", type: "note", excerpt: "First", updated_at: null },
{ id: "mem-2", type: "note", excerpt: "Second", updated_at: null },
{ id: "mem-3", type: "note", excerpt: "Third", updated_at: null },
],
total: 3,
skip: 0,
limit: 100,
});
}
if (url.endsWith("mem-2")) return mem2Response;
if (url.endsWith("mem-3")) return mem3Response;
return jsonResponse({
resource: { kind: "agent-memory", id: "mem-1" },
source: "---\nid: mem-1\ntype: note\n---\n\nmem-1",
body: "mem-1",
revision: "sha256:mem-1",
editable: true,
});
};
const view = render(<MemoryWorkspace />);
await view.findByText("Selected memory");
fireEvent.click(view.getByRole("button", { name: /mem-2/ }));
fireEvent.click(view.getByRole("button", { name: /mem-3/ }));
await act(async () => {
resolveMem3?.(jsonResponse({
resource: { kind: "agent-memory", id: "mem-3" },
source: "---\nid: mem-3\ntype: note\n---\n\nmem-3",
body: "mem-3",
revision: "sha256:mem-3",
editable: true,
}));
await mem3Response;
});
await waitFor(() => assert.equal(
view.getByText("mem-3", { selector: "strong" }).textContent,
"mem-3",
));
await act(async () => {
resolveMem2?.(jsonResponse({
resource: { kind: "agent-memory", id: "mem-2" },
source: "---\nid: mem-2\ntype: note\n---\n\nmem-2",
body: "mem-2",
revision: "sha256:mem-2",
editable: true,
}));
await mem2Response;
});
assert.equal(
view.getByText("mem-3", { selector: "strong" }).textContent,
"mem-3",
);
});
test("MemoryWorkspace refreshes frontmatter summaries after apply", async () => {
let listRequests = 0;
globalThis.fetch = async (input, init) => {
const url = String(input);
if (url.startsWith("/api/memories")) {
listRequests += 1;
const saved = listRequests > 1;
return jsonResponse({
items: [{
id: "mem-1",
type: saved ? "decision" : "note",
excerpt: saved ? "Updated memory" : "Original memory",
updated_at: saved ? "2026-09-01T12:00:00+00:00" : null,
}],
total: 1,
skip: 0,
limit: 100,
});
}
if (init?.method === "PUT") {
return jsonResponse({
resource: { kind: "agent-memory", id: "mem-1" },
source: "---\nid: mem-1\ntype: decision\n---\n\nUpdated memory",
body: "Updated memory",
revision: "sha256:updated",
editable: true,
changed: true,
});
}
return jsonResponse({
resource: { kind: "agent-memory", id: "mem-1" },
source: "---\nid: mem-1\ntype: note\n---\n\nOriginal memory",
body: "Original memory",
revision: "sha256:original",
editable: true,
});
};
const view = render(<MemoryWorkspace />);
await view.findByText("Selected memory");
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
fireEvent.input(textarea, {
target: {
value: (textarea as HTMLTextAreaElement).value
.replace("type: note", "type: decision")
.replace("Original memory", "Updated memory"),
},
});
fireEvent.click(view.getByRole("button", { name: "Apply" }));
await view.findByText("decision");
assert.equal(listRequests, 2);
assert.equal(view.getAllByText("Updated memory").length, 2);
});
test("HTTP 409 conflict preserves draft and shows conflict error with reload option", async () => {
// After a 409, the user's draft must be kept and a recovery path available.
const requests: Array<{ method: string; body?: unknown }> = [];
let fetchCount = 0;
globalThis.fetch = async (input, init) => {
fetchCount += 1;
const method = init?.method ?? "GET";
let parsedBody: unknown = undefined;
if (init?.body) {
try { parsedBody = JSON.parse(String(init.body)); } catch { /* ignore */ }
}
requests.push({ method, body: parsedBody });
if (method === "PUT") {
// First PUT returns 409 with current_revision
return jsonResponse({
detail: {
code: "markdown_revision_conflict",
message: "This item changed after editing began. Reload the latest version before applying.",
current_revision: "sha256:newer",
},
}, 409);
}
// All GETs return the canonical document
return jsonResponse({
resource,
source: originalSource,
body: "Original",
revision: "sha256:original",
editable: true,
});
};
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
// Enter edit mode
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
const draftValue = originalSource.replace("Original", "My draft");
fireEvent.input(textarea, { target: { value: draftValue } });
// Apply → receives 409
fireEvent.click(view.getByRole("button", { name: "Apply" }));
// Conflict error must appear
const alert = await view.findByRole("alert");
assert.match(
alert.textContent ?? "",
/changed after editing|Reload/i,
"conflict error message must be shown",
);
// Draft must be preserved in the textarea
const textareaAfterConflict = view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement;
assert.equal(textareaAfterConflict.value, draftValue, "draft must be preserved after 409");
// A reload / recovery action must be available
const reloadButton = view.queryByRole("button", { name: /reload latest/i });
assert.ok(reloadButton !== null, "a 'Reload latest' recovery button must be shown");
// Click reload — should re-fetch the latest canonical document
await act(async () => {
fireEvent.click(reloadButton!);
});
// After reload the editor is re-initialized with the server's canonical source
await waitFor(() => {
const refreshedTextarea = view.queryByRole("textbox", { name: "Markdown source" });
assert.ok(refreshedTextarea !== null, "editor must still be open after reload");
assert.equal(
(refreshedTextarea as HTMLTextAreaElement).value,
originalSource,
"editor must show the server canonical source after reload",
);
});
// Reload must have triggered exactly one more GET
const getCount = requests.filter((r) => r.method === "GET").length;
assert.ok(getCount >= 2, "reload must issue a new GET to fetch the latest canonical document");
});
test("successful retry after 422 uses the original revision and persists changes", async () => {
// After a 422 (validation failure), the baseRevision must remain valid so that
// correcting the draft and re-applying succeeds without re-fetching the document.
let putCallCount = 0;
globalThis.fetch = async (_input, init) => {
const method = init?.method ?? "GET";
if (method === "PUT") {
putCallCount += 1;
if (putCallCount === 1) {
// First PUT: validation failure — resource is unchanged
return jsonResponse({
detail: {
code: "invalid_markdown_frontmatter",
message: "Markdown frontmatter contains invalid YAML.",
},
}, 422);
}
// Second PUT: success with the corrected Markdown
const body = JSON.parse(String(init?.body ?? "{}")) as { markdown: string };
const correctedBody = body.markdown.includes("Corrected") ? "Corrected body" : "body";
return jsonResponse({
resource,
source: originalSource.replace("Original", "Corrected"),
body: correctedBody,
revision: "sha256:after-retry",
editable: true,
changed: true,
});
}
return jsonResponse({
resource,
source: originalSource,
body: "Original",
revision: "sha256:original",
editable: true,
});
};
let appliedRevision = "";
const view = render(
<MarkdownContentViewer
content="Original"
resource={resource}
onApplied={(result) => { appliedRevision = result.revision; }}
/>,
);
// Enter edit mode
fireEvent.click(view.getByRole("button", { name: "Edit" }));
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
// First attempt: create an invalid draft
const invalidDraft = "---\nid: [\n---\n\nInvalid body";
fireEvent.input(textarea, { target: { value: invalidDraft } });
fireEvent.click(view.getByRole("button", { name: "Apply" }));
// 422 error appears, draft is preserved
const alert = await view.findByRole("alert");
assert.match(alert.textContent ?? "", /invalid YAML/i);
assert.equal(
(view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement).value,
invalidDraft,
"invalid draft must be preserved after 422",
);
// Correct the draft
const correctedDraft = originalSource.replace("Original", "Corrected");
fireEvent.input(view.getByRole("textbox", { name: "Markdown source" }), {
target: { value: correctedDraft },
});
// Apply is re-enabled (still dirty)
const applyButton = view.getByRole("button", { name: "Apply" });
assert.equal(
(applyButton as HTMLButtonElement).disabled,
false,
"Apply must be re-enabled after correcting the draft",
);
// Second attempt: apply corrected draft
fireEvent.click(applyButton);
// Must succeed — server returns new revision
await waitFor(() => assert.equal(appliedRevision, "sha256:after-retry"));
// Editor returns to preview mode after successful save
assert.equal(
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
"true",
"editor must return to preview after successful retry",
);
// Error is cleared
assert.equal(view.queryByRole("alert"), null, "error banner must be cleared after success");
// Both PUT attempts were made — retry used original revision (no extra GET between attempts)
assert.equal(putCallCount, 2, "exactly two PUT requests must be made (failed + successful retry)");
});
+132
View File
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cancelEdit,
createEditSession,
createLoadingSession,
isDirty,
saveFailed,
saveStarted,
saveSucceeded,
shouldConfirmDiscard,
updateDraft,
} from "../src/workspaces/GraphWorkspace/markdownEditorState.ts";
const resource = { kind: "context-node" as const, id: "node-1" };
test("enters loading and editing with the canonical source", () => {
const loading = createLoadingSession(resource);
const editing = createEditSession(resource, {
source: "---\nid: node-1\n---\n\nBody",
revision: "sha256:one",
});
assert.equal(loading.status, "loading-document");
assert.equal(editing.status, "editing");
assert.equal(editing.draft, editing.baseSource);
assert.equal(isDirty(editing), false);
});
test("draft changes derive dirty state and clear prior errors", () => {
const session = createEditSession(resource, {
source: "base",
revision: "sha256:one",
});
const failed = saveFailed(session, {
kind: "validation",
message: "Invalid",
});
const edited = updateDraft(failed, "draft");
assert.equal(edited.status, "editing");
assert.equal(edited.error, null);
assert.equal(isDirty(edited), true);
assert.equal(shouldConfirmDiscard(edited), true);
});
test("cancel discards the edit session without saving", () => {
const session = updateDraft(
createEditSession(resource, { source: "base", revision: "sha256:one" }),
"draft",
);
assert.equal(isDirty(session), true);
assert.equal(cancelEdit(), null);
});
test("no-op save never enters saving state", () => {
const session = createEditSession(resource, {
source: "base",
revision: "sha256:one",
});
assert.equal(saveStarted(session), session);
assert.equal(isDirty(session), false);
});
test("save success replaces the base source and revision", () => {
const session = saveStarted(
updateDraft(
createEditSession(resource, { source: "base", revision: "sha256:one" }),
"draft",
),
);
const saved = saveSucceeded(session, {
source: "canonical saved",
revision: "sha256:two",
});
assert.equal(saved.status, "viewing");
assert.equal(saved.baseSource, "canonical saved");
assert.equal(saved.draft, "canonical saved");
assert.equal(saved.baseRevision, "sha256:two");
assert.equal(isDirty(saved), false);
});
test("validation, conflict, and save failures retain the draft for retry", () => {
const draft = updateDraft(
createEditSession(resource, { source: "base", revision: "sha256:one" }),
"draft",
);
const validation = saveFailed(draft, {
kind: "validation",
message: "Invalid",
});
const conflict = saveFailed(draft, {
kind: "conflict",
message: "Stale",
currentRevision: "sha256:two",
});
const network = saveFailed(draft, {
kind: "network",
message: "Offline",
});
assert.equal(validation.status, "validation-error");
assert.equal(conflict.status, "conflict");
assert.equal(network.status, "save-error");
assert.equal(validation.draft, "draft");
assert.equal(conflict.draft, "draft");
assert.equal(network.draft, "draft");
assert.equal(saveStarted(network).status, "saving");
});
test("saving sessions do not allow a competing discard confirmation", () => {
const saving = saveStarted(
updateDraft(
createEditSession(resource, { source: "base", revision: "sha256:one" }),
"draft",
),
);
assert.equal(shouldConfirmDiscard(saving), false);
});
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
NodeMarkdownRefreshGuard,
buildNodeMarkdownAttributeUpdate,
readNodeMarkdownAttributeUpdate,
} from "../src/workspaces/GraphWorkspace/nodeMarkdownSync.ts";
test("saved Markdown updates graph content and its visible label", () => {
const update = buildNodeMarkdownAttributeUpdate(
"issue-1327",
"# Issue 1328888\n\nUpdated body",
{ status: "implemented" },
);
assert.equal(update.content, "# Issue 1328888\n\nUpdated body");
assert.equal(update.label, "# Issue 1328888\n\nUpdated body");
assert.deepEqual(update.properties, {
status: "implemented",
content: "# Issue 1328888\n\nUpdated body",
});
});
test("empty Markdown falls back to the stable node id label", () => {
const update = buildNodeMarkdownAttributeUpdate("issue-1327", "", {});
assert.equal(update.label, "issue-1327");
assert.equal(update.content, "");
});
test("saved frontmatter is refreshed from the canonical graph node", async () => {
const update = await readNodeMarkdownAttributeUpdate(
"node/1",
async (input) => {
assert.equal(String(input), "/api/graph/node?node_id=node%2F1");
return new Response(JSON.stringify({
id: "node/1",
type: "Decision",
content: "Updated body",
properties: {
content: "Updated body",
status: "accepted",
valid_from: "2026-09-01T00:00:00Z",
},
valid_from: "2026-09-01T00:00:00Z",
valid_until: null,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
);
assert.deepEqual(update, {
label: "Updated body",
content: "Updated body",
properties: {
content: "Updated body",
status: "accepted",
valid_from: "2026-09-01T00:00:00Z",
},
nodeType: "Decision",
valid_from: "2026-09-01T00:00:00Z",
valid_until: null,
});
});
test("realtime updates invalidate an older local-save refresh", () => {
const guard = new NodeMarkdownRefreshGuard();
const localSaveRefresh = guard.begin("node-1");
guard.invalidate("node-1");
assert.equal(guard.isCurrent("node-1", localSaveRefresh), false);
});
test("refresh invalidation is scoped to one node", () => {
const guard = new NodeMarkdownRefreshGuard();
const firstNodeRefresh = guard.begin("node-1");
const secondNodeRefresh = guard.begin("node-2");
guard.invalidate("node-1");
assert.equal(guard.isCurrent("node-1", firstNodeRefresh), false);
assert.equal(guard.isCurrent("node-2", secondNodeRefresh), true);
});
+115
View File
@@ -65,9 +65,11 @@ import os
import re
import stat
import tempfile
import threading
from collections import deque
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
@@ -78,6 +80,12 @@ from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from ..utils.types import EntityDict, RelationshipDict
from ._markdown_filesystem import find_filesystem_link
from .markdown import (
MarkdownIdentityError,
MarkdownResourceNotFoundError,
MarkdownRevisionConflictError,
markdown_document_revision,
)
class _UniqueKeySafeLoader(yaml.SafeLoader):
@@ -158,6 +166,17 @@ class MemoryItem:
)
def _with_memory_lock(method):
"""Serialize AgentMemory state mutations and Markdown revision checks."""
@wraps(method)
def locked(self, *args, **kwargs):
with self._memory_lock:
return method(self, *args, **kwargs)
return locked
class AgentMemory:
"""
Agent memory manager with RAG integration and Hierarchical Memory.
@@ -197,6 +216,7 @@ class AgentMemory:
self.logger = get_logger("agent_memory")
self.config = config or {}
self.config.update(kwargs)
self._memory_lock = threading.RLock()
self.vector_store = self.config.get("vector_store")
self.knowledge_graph = self.config.get("knowledge_graph")
@@ -249,6 +269,7 @@ class AgentMemory:
self.logger.info(f"Saved agent memory to {path}")
@_with_memory_lock
def load(self, path: str) -> None:
"""
Load memory state from disk.
@@ -298,6 +319,7 @@ class AgentMemory:
self.logger.info(f"Loaded agent memory from {path}")
@_with_memory_lock
def store(
self,
content: str,
@@ -574,6 +596,7 @@ class AgentMemory:
"relationships": memory_item.relationships,
}
@_with_memory_lock
def delete_memory(self, memory_id: str, *, skip_vector: bool = False) -> bool:
"""
Delete memory item.
@@ -625,6 +648,7 @@ class AgentMemory:
self.logger.debug(f"Deleted memory item: {memory_id}")
return True
@_with_memory_lock
def vector_ids_for(self, memory_id: str) -> List[str]:
"""Return the vector-store ids owned by a memory item.
@@ -1136,6 +1160,7 @@ class AgentMemory:
"""
return self.get_memory(memory_id)
@_with_memory_lock
def update(
self,
memory_id: str,
@@ -1416,6 +1441,13 @@ class AgentMemory:
return results
@_with_memory_lock
def list_snapshot(
self, *, limit: int = 100, offset: int = 0
) -> Tuple[List[Dict[str, Any]], int]:
"""Return one memory page and its total from the same locked state."""
return self.list(limit=limit, offset=offset), len(self.memory_items)
def get_by_conversation(
self, conversation_id: str, limit: int = 100
) -> List[Dict[str, Any]]:
@@ -1631,6 +1663,88 @@ class AgentMemory:
updated += 1
return updated
@_with_memory_lock
def export_item_markdown(self, memory_id: str) -> str:
"""Return one existing memory item as canonical Markdown.
Args:
memory_id: Stable identifier of the memory item to export.
Returns:
Canonical Markdown containing the memory frontmatter and body.
Raises:
MarkdownResourceNotFoundError: If ``memory_id`` does not exist.
"""
memory = self.get(memory_id)
if memory is None:
raise MarkdownResourceNotFoundError(
f"AgentMemory item {memory_id!r} was not found."
)
return self._memory_to_markdown(memory)
@_with_memory_lock
def apply_item_markdown(
self,
memory_id: str,
document: str,
*,
expected_revision: Optional[str] = None,
) -> bool:
"""Validate and atomically replace one existing memory item.
Args:
memory_id: Stable identifier of the memory item to update.
document: Canonical Markdown containing the replacement item.
expected_revision: Optional revision returned by
:meth:`export_item_markdown`. A mismatch rejects stale edits.
Returns:
``True`` when the item changed, otherwise ``False``.
Raises:
ValueError: If the Markdown or frontmatter is invalid.
MarkdownIdentityError: If the frontmatter changes the memory ID.
MarkdownResourceNotFoundError: If ``memory_id`` does not exist.
MarkdownRevisionConflictError: If ``expected_revision`` is stale.
RuntimeError: If the validated item cannot be persisted.
"""
memory = self._markdown_to_memory_dict(document, source=f"memory {memory_id!r}")
document_id = memory["memory_id"]
if document_id != memory_id:
raise MarkdownIdentityError(
f"Frontmatter id {document_id!r} does not match resource id "
f"{memory_id!r}."
)
if not self.exists(memory_id):
raise MarkdownResourceNotFoundError(
f"AgentMemory item {memory_id!r} was not found."
)
if expected_revision is not None:
# _memory_lock is an RLock; this re-entrant call into
# export_item_markdown (also @_with_memory_lock) is intentional
# and safe because RLock allows the same thread to re-acquire.
current_revision = markdown_document_revision(
self.export_item_markdown(memory_id)
)
if current_revision != expected_revision:
raise MarkdownRevisionConflictError(current_revision)
if self._markdown_record_matches(memory_id, memory):
return False
success = self._replace_memory_item(
memory_id,
memory["content"],
metadata=memory["metadata"],
entities=memory["entities"],
relationships=memory["relationships"],
timestamp=memory["timestamp"],
skip_graph=True,
)
if not success:
raise RuntimeError(f"AgentMemory item {memory_id!r} could not be replaced.")
return True
# Export/Import
def export(
self,
@@ -1679,6 +1793,7 @@ class AgentMemory:
return self._export_markdown(memories, destination=destination)
return export_data
@_with_memory_lock
def import_data(
self, data: Union[str, Path, Dict[str, Any]], format: str = "json"
) -> int:
+181 -14
View File
@@ -131,6 +131,12 @@ from ..utils.progress_tracker import get_progress_tracker
from ..utils.skos import is_skos_hierarchy_edge, validate_skos_hierarchy
from ._markdown_filesystem import find_filesystem_link
from .entity_linker import EntityLinker
from .markdown import (
MarkdownIdentityError,
MarkdownResourceNotFoundError,
MarkdownRevisionConflictError,
markdown_document_revision,
)
class _UniqueKeySafeLoader(yaml.SafeLoader):
@@ -1167,6 +1173,157 @@ class ContextGraph:
)
)
def export_node_markdown(self, node_id: str) -> str:
"""Return one existing node as canonical Markdown.
Args:
node_id: Stable identifier of the node to export.
Returns:
Canonical Markdown containing the node frontmatter and body.
Raises:
MarkdownResourceNotFoundError: If ``node_id`` does not exist.
"""
with self._lock:
node = self.nodes.get(node_id)
if node is None:
raise MarkdownResourceNotFoundError(
f"ContextGraph node {node_id!r} was not found."
)
return self._node_markdown_source(node)
def _node_markdown_source(self, node: ContextNode) -> str:
frontmatter = {
"id": node.node_id,
"type": node.node_type,
"properties": copy.deepcopy(node.properties),
"metadata": copy.deepcopy(node.metadata),
"valid_from": node.valid_from,
"valid_until": node.valid_until,
}
return self._render_markdown_document(
frontmatter, node.content, f"node {node.node_id!r}"
)
def apply_node_markdown(
self,
node_id: str,
document: str,
*,
expected_revision: Optional[str] = None,
) -> bool:
"""Validate and atomically replace one existing node.
Args:
node_id: Stable identifier of the node to update.
document: Canonical Markdown containing the replacement node.
expected_revision: Optional revision returned by
:meth:`export_node_markdown`. A mismatch rejects stale edits.
Returns:
``True`` when the node changed, otherwise ``False``.
Raises:
ValueError: If the Markdown or frontmatter is invalid.
MarkdownIdentityError: If the frontmatter changes the node ID.
MarkdownResourceNotFoundError: If ``node_id`` does not exist.
MarkdownRevisionConflictError: If ``expected_revision`` is stale.
"""
source = f"node {node_id!r}"
frontmatter, body = self._parse_markdown_document(document, source)
candidate = self._parse_markdown_node(frontmatter, body, source)
if candidate.node_id != node_id:
raise MarkdownIdentityError(
f"Frontmatter id {candidate.node_id!r} does not match resource id "
f"{node_id!r}."
)
with self._lock:
existing = self.nodes.get(node_id)
if existing is None:
raise MarkdownResourceNotFoundError(
f"ContextGraph node {node_id!r} was not found."
)
if expected_revision is not None:
current_revision = markdown_document_revision(
self._node_markdown_source(existing)
)
if current_revision != expected_revision:
raise MarkdownRevisionConflictError(current_revision)
if existing == candidate:
return False
# Decision index rebuilding can still reject YAML-valid property
# shapes, so retain every affected structure until commit succeeds.
decision_state_before = {}
if (
existing.node_type.lower() == "decision"
or candidate.node_type.lower() == "decision"
):
for attribute in (
"_decisions",
"_decision_index",
"_entity_index",
"_temporal_index",
):
if not hasattr(self, attribute):
decision_state_before[attribute] = None
elif attribute in {"_decision_index", "_entity_index"}:
decision_state_before[attribute] = {
key: set(values)
for key, values in getattr(self, attribute).items()
}
elif attribute == "_temporal_index":
decision_state_before[attribute] = list(
getattr(self, attribute)
)
else:
decision_state_before[attribute] = dict(
getattr(self, attribute)
)
old_type = existing.node_type
try:
old_bucket = self.node_type_index.get(old_type)
if old_bucket is not None:
old_bucket.discard(node_id)
if not old_bucket:
del self.node_type_index[old_type]
self.nodes[node_id] = candidate
self.node_type_index[candidate.node_type].add(node_id)
if (
old_type.lower() == "decision"
or candidate.node_type.lower() == "decision"
):
self._sync_decision_from_node(node_id)
payload = candidate.to_dict()
self._analytics_cache.clear()
except Exception:
self.nodes[node_id] = existing
candidate_bucket = self.node_type_index.get(candidate.node_type)
if candidate_bucket is not None:
candidate_bucket.discard(node_id)
if not candidate_bucket:
del self.node_type_index[candidate.node_type]
self.node_type_index[old_type].add(node_id)
for attribute, state in decision_state_before.items():
if state is None:
if hasattr(self, attribute):
delattr(self, attribute)
else:
restored = (
defaultdict(set, state)
if attribute in {"_decision_index", "_entity_index"}
else state
)
setattr(self, attribute, restored)
raise
self._emit_mutation("UPDATE_NODE", node_id, payload)
return True
def save_to_file(
self, path: Union[str, Path], format: str = "json"
) -> None:
@@ -4947,32 +5104,42 @@ class ContextGraph:
def _sync_decision_from_node(self, node_id: str) -> None:
"""Synchronise a single decision index entry from the node store.
Called after ``add_node_attribute`` mutates a decision node so that
``_decisions`` and the derived indexes stay consistent without
requiring a full rebuild of all decisions.
Called after ``add_node_attribute`` mutates a decision node and after
``apply_node_markdown`` replaces a node whose old or new type is
``"decision"``, so that ``_decisions`` and the derived indexes stay
consistent without requiring a full rebuild of all decisions.
Temporal index cleanup (``_temporal_index``) runs unconditionally
before the node-type guard so that stale entries are removed even when
transitioning a decision node to a non-decision type. Callers are
expected to ensure this is only invoked when at least one of the
current or previous node types is ``"decision"``; callers that bypass
that invariant will have ``node_id`` silently removed from
``_temporal_index`` even if it was never a decision node.
"""
node = self.nodes.get(node_id)
if node is None:
return
if (getattr(node, "node_type", None) or "").lower() != "decision":
return
if not hasattr(self, "_decisions"):
# Indexes don't exist yet — a full rebuild is safer.
self._rebuild_decision_indexes()
return
# Remove stale index entries for this decision ID.
old = self._decisions.get(node_id)
# Remove stale index entries before deciding whether the current node
# still belongs in the decision indexes.
old = self._decisions.pop(node_id, None)
if old:
old_cat = old.get("category", "")
if old_cat and node_id in self._decision_index.get(old_cat, set()):
if old_cat:
self._decision_index[old_cat].discard(node_id)
for ent in old.get("entities", []):
self._entity_index[ent].discard(node_id)
self._temporal_index = [
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
]
self._temporal_index = [
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
]
if node is None:
return
if (getattr(node, "node_type", None) or "").lower() != "decision":
return
# Rebuild the entry for this node and re-insert index entries.
meta: Dict[str, Any] = {}
+25
View File
@@ -0,0 +1,25 @@
"""Shared revision helpers and errors for single-resource Markdown operations."""
import hashlib
class MarkdownResourceNotFoundError(KeyError):
"""Raised when a Markdown operation targets a missing resource."""
class MarkdownIdentityError(ValueError):
"""Raised when frontmatter changes a resource's stable identity."""
class MarkdownRevisionConflictError(ValueError):
"""Raised when a resource changed after an edit session began."""
def __init__(self, current_revision: str) -> None:
super().__init__("Markdown resource revision does not match.")
self.current_revision = current_revision
def markdown_document_revision(source: str) -> str:
"""Return the stable revision token for a canonical Markdown document."""
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()
return f"sha256:{digest}"
+30 -74
View File
@@ -8,16 +8,19 @@ from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .. import __version__
from ..context.agent_memory import AgentMemory
from ..context.context_graph import ContextGraph
from .dependencies import anonymous_access_allowed, get_expected_api_key, is_valid_api_key, require_auth
from .dependencies import anonymous_access_allowed, get_expected_api_key, require_auth
from .markdown_resources import MarkdownResourceRegistry
from .runtime import explorer_capabilities, install_mutation_bridge
from .session import GraphSession
from .ws import ConnectionManager
from .ws import ConnectionManager, install_graph_updates_websocket
def _read_int_env(name: str, default: int) -> int:
@@ -53,37 +56,23 @@ def _read_explorer_settings() -> dict:
}
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
if getattr(session.graph, "_mutation_bridge_installed", False):
return
session.graph._mutation_bridge_installed = True
previous_callback = getattr(session.graph, "mutation_callback", None)
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
session.handle_graph_mutation(event_type, entity_id, payload)
if callable(previous_callback):
previous_callback(event_type, entity_id, payload)
loop = getattr(app.state, "event_loop", None)
manager = getattr(app.state, "ws_manager", None)
if loop is None or manager is None or loop.is_closed():
return
message = {
"event_type": event_type,
"entity_id": entity_id,
"payload": payload,
}
asyncio.run_coroutine_threadsafe(
manager.broadcast("graph_mutation", message),
loop,
)
session.graph.mutation_callback = on_mutation
def create_app(
session: Optional[GraphSession] = None,
provenance_storage_path: Optional[str] = None,
agent_memory: Optional[AgentMemory] = None,
) -> FastAPI:
"""Create an Explorer application over live graph and memory objects.
Args:
session: Graph session exposed by the Explorer. A new in-memory graph
session is created when omitted.
provenance_storage_path: Optional per-app provenance database path.
agent_memory: Existing AgentMemory instance to expose in the Memories
workspace. The workspace is unavailable when omitted.
Returns:
Configured FastAPI application.
"""
settings = _read_explorer_settings()
prov_path = provenance_storage_path or settings.get("provenance_storage_path")
if session is None:
@@ -95,6 +84,7 @@ def create_app(
active_session = session
if prov_path is not None:
active_session.set_provenance_storage_path(prov_path)
markdown_resources = MarkdownResourceRegistry(active_session.graph, agent_memory)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -117,7 +107,9 @@ def create_app(
app.state.event_loop = asyncio.get_running_loop()
app.state.ws_manager = ConnectionManager()
app.state.session = active_session
_install_mutation_bridge(app, active_session)
app.state.agent_memory = agent_memory
app.state.markdown_resources = markdown_resources
install_mutation_bridge(app, active_session)
yield
app = FastAPI(
@@ -139,7 +131,7 @@ def create_app(
CORSMiddleware,
allow_origins=settings["allowed_origins"],
allow_credentials=_allow_credentials,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
max_age=600,
)
@@ -170,6 +162,8 @@ def create_app(
from .routes.enrich import router as enrich_router
from .routes.export_import import router as export_import_router
from .routes.graph import router as graph_router
from .routes.markdown import router as markdown_router
from .routes.memories import router as memories_router
from .routes.ontology import router as ontology_router
from .routes.provenance import router as provenance_router
from .routes.sparql import router as sparql_router
@@ -183,54 +177,15 @@ def create_app(
app.include_router(temporal_router, dependencies=_auth)
app.include_router(enrich_router, dependencies=_auth)
app.include_router(export_import_router, dependencies=_auth)
app.include_router(markdown_router, dependencies=_auth)
app.include_router(memories_router, dependencies=_auth)
app.include_router(annotations_router, dependencies=_auth)
app.include_router(sparql_router, dependencies=_auth)
app.include_router(provenance_router, dependencies=_auth)
app.include_router(vocabulary_router, dependencies=_auth)
app.include_router(ontology_router, dependencies=_auth)
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
@app.websocket("/ws/graph-updates")
async def websocket_endpoint(websocket: WebSocket):
# CORSMiddleware doesn't cover WebSocket handshakes (Starlette's
# CORS support only wraps HTTP), so under SEMANTICA_ALLOW_ANONYMOUS
# the key check below accepts any origin — loopback binding isn't a
# boundary against a browser, since any page the operator has open
# can still reach ws://localhost:.../ws/graph-updates directly.
# Reject a foreign Origin explicitly here, against the same
# allowlist CORSMiddleware already enforces for HTTP
# (GHSA-4643-wpgq-w329). Browsers always send Origin on a
# cross-origin WebSocket handshake; native/CLI clients omit it
# entirely, so a missing Origin is allowed through — the browser is
# the only threat this check is closing.
origin = websocket.headers.get("origin")
allowed_origins = app.state.explorer_settings["allowed_origins"]
if origin is not None and origin not in allowed_origins:
await websocket.close(code=4403) # forbidden
return
# Browsers can't set custom headers on a WebSocket handshake, so
# accept the key via header (non-browser clients) or query param
# (browser clients), same SEMANTICA_API_KEY the REST routes check.
candidate = websocket.headers.get("x-api-key") or websocket.query_params.get("api_key")
if not is_valid_api_key(candidate):
await websocket.close(code=4401) # unauthorized
return
manager: ConnectionManager = app.state.ws_manager
await manager.connect(websocket)
await manager.send_personal(websocket, "connection_ack", {"connected": True})
try:
while True:
message = await websocket.receive_text()
if len(message) > _WS_MAX_MESSAGE_BYTES:
await websocket.close(code=1009) # 1009 = message too big
break
if message.strip().lower() == "ping":
await manager.send_personal(websocket, "pong", {"ok": True})
except WebSocketDisconnect:
manager.disconnect(websocket)
install_graph_updates_websocket(app, settings["allowed_origins"])
@app.get("/", include_in_schema=False)
async def root():
@@ -269,6 +224,7 @@ def create_app(
"name": "Semantica Knowledge Explorer",
"version": __version__,
"status": "active",
"capabilities": explorer_capabilities(agent_memory),
}
static_dir = Path(__file__).resolve().parent.parent / "static"
+24
View File
@@ -14,6 +14,8 @@ from typing import Optional
from fastapi import Request, HTTPException, Security, status
from fastapi.security.api_key import APIKeyHeader
from ..context.agent_memory import AgentMemory
from .markdown_resources import MarkdownResourceRegistry
from .session import GraphSession
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@@ -80,3 +82,25 @@ def get_session(request: Request) -> GraphSession:
detail="GraphSession not initialized."
)
return request.app.state.session
def get_markdown_resources(request: Request) -> MarkdownResourceRegistry:
"""Retrieve the Markdown resource registry stored on ``app.state``."""
resources = getattr(request.app.state, "markdown_resources", None)
if resources is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Markdown resources are not initialized.",
)
return resources
def get_agent_memory(request: Request) -> AgentMemory:
"""Retrieve the optional AgentMemory configured for Explorer."""
memory = getattr(request.app.state, "agent_memory", None)
if memory is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AgentMemory is not configured for this Explorer instance.",
)
return memory
+283
View File
@@ -0,0 +1,283 @@
"""Single-resource Markdown access for Explorer."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Callable, Dict, Optional, Protocol, TypeVar
import yaml
from ..context.agent_memory import AgentMemory
from ..context.context_graph import ContextGraph
from ..context.markdown import (
MarkdownIdentityError,
MarkdownResourceNotFoundError,
MarkdownRevisionConflictError,
markdown_document_revision,
)
class MarkdownResourceKind(str, Enum):
CONTEXT_NODE = "context-node"
AGENT_MEMORY = "agent-memory"
@dataclass(frozen=True)
class MarkdownResourceRef:
kind: MarkdownResourceKind
id: str
@dataclass(frozen=True)
class MarkdownDocument:
resource: MarkdownResourceRef
source: str
body: str
revision: str
@dataclass(frozen=True)
class MarkdownApplyResult(MarkdownDocument):
changed: bool
class MarkdownResourceError(Exception):
"""Base class for safe, structured Explorer Markdown failures."""
code = "markdown_resource_error"
def __init__(
self,
message: str,
*,
field: Optional[str] = None,
current_revision: Optional[str] = None,
) -> None:
super().__init__(message)
self.message = message
self.field = field
self.current_revision = current_revision
class MarkdownResourceNotFound(MarkdownResourceError):
code = "markdown_resource_not_found"
class InvalidMarkdownFrontmatter(MarkdownResourceError):
code = "invalid_markdown_frontmatter"
class ResourceIdentityMismatch(MarkdownResourceError):
code = "resource_identity_mismatch"
class MarkdownRevisionConflict(MarkdownResourceError):
code = "markdown_revision_conflict"
class MarkdownSaveFailed(MarkdownResourceError):
code = "markdown_save_failed"
class MarkdownAdapter(Protocol):
def export(self, resource_id: str) -> str:
...
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
...
_Result = TypeVar("_Result")
def _translate_domain_errors(operation: Callable[[], _Result]) -> _Result:
try:
return operation()
except MarkdownResourceNotFoundError as exc:
raise MarkdownResourceNotFound(str(exc.args[0])) from exc
except MarkdownRevisionConflictError as exc:
raise MarkdownRevisionConflict(
"This item changed after editing began. Reload the latest "
"version before applying.",
current_revision=exc.current_revision,
) from exc
except MarkdownIdentityError as exc:
raise ResourceIdentityMismatch(str(exc), field="id") from exc
except ValueError as exc:
raise InvalidMarkdownFrontmatter(_safe_validation_message(exc)) from exc
class ContextGraphNodeMarkdownAdapter:
def __init__(self, graph: ContextGraph) -> None:
self._graph = graph
def export(self, resource_id: str) -> str:
return _translate_domain_errors(
lambda: self._graph.export_node_markdown(resource_id)
)
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
return _translate_domain_errors(
lambda: self._graph.apply_node_markdown(
resource_id,
source,
expected_revision=expected_revision,
)
)
class AgentMemoryItemMarkdownAdapter:
def __init__(self, memory: AgentMemory) -> None:
self._memory = memory
def export(self, resource_id: str) -> str:
return _translate_domain_errors(
lambda: self._memory.export_item_markdown(resource_id)
)
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
return _translate_domain_errors(
lambda: self._memory.apply_item_markdown(
resource_id,
source,
expected_revision=expected_revision,
)
)
def _safe_validation_message(exc: ValueError) -> str:
if isinstance(exc.__cause__, yaml.YAMLError):
return "Markdown frontmatter contains invalid YAML."
return str(exc)
def document_revision(source: str) -> str:
"""Return the stable revision token for canonical Markdown.
Args:
source: Canonical Markdown source.
Returns:
A SHA-256 revision token suitable for optimistic concurrency checks.
"""
return markdown_document_revision(source)
def markdown_body(source: str) -> str:
"""Extract the body from canonical Markdown emitted by a domain model."""
lines = source.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
raise MarkdownSaveFailed("The resource produced invalid canonical Markdown.")
closing_index = next(
(
index
for index, line in enumerate(lines[1:], start=1)
if line.rstrip("\r\n") == "---"
),
None,
)
if closing_index is None:
raise MarkdownSaveFailed("The resource produced invalid canonical Markdown.")
body = "".join(lines[closing_index + 1 :])
if body.startswith("\r\n"):
return body[2:]
if body.startswith("\n"):
return body[1:]
return body
class MarkdownResourceRegistry:
"""Route Markdown operations to their owning domain models."""
def __init__(
self,
context_graph: ContextGraph,
agent_memory: Optional[AgentMemory] = None,
) -> None:
self._adapters: Dict[MarkdownResourceKind, MarkdownAdapter] = {
MarkdownResourceKind.CONTEXT_NODE: ContextGraphNodeMarkdownAdapter(
context_graph
)
}
if agent_memory is not None:
self._adapters[
MarkdownResourceKind.AGENT_MEMORY
] = AgentMemoryItemMarkdownAdapter(agent_memory)
def _adapter(self, kind: MarkdownResourceKind) -> MarkdownAdapter:
adapter = self._adapters.get(kind)
if adapter is None:
raise MarkdownResourceNotFound(
f"Markdown resource kind {kind.value!r} is not available."
)
return adapter
def read(self, ref: MarkdownResourceRef) -> MarkdownDocument:
"""Read one resource as canonical Markdown.
Args:
ref: Explicit resource kind and stable identifier.
Returns:
The canonical source, body, and current revision.
Raises:
MarkdownResourceError: If the resource is unavailable or invalid.
"""
try:
source = self._adapter(ref.kind).export(ref.id)
except MarkdownResourceError:
raise
except Exception as exc:
raise MarkdownSaveFailed(
"The Markdown resource could not be read."
) from exc
return MarkdownDocument(
resource=ref,
source=source,
body=markdown_body(source),
revision=document_revision(source),
)
def apply(
self,
ref: MarkdownResourceRef,
markdown: str,
expected_revision: str,
) -> MarkdownApplyResult:
"""Apply validated Markdown to one existing resource.
Args:
ref: Explicit resource kind and stable identifier.
markdown: Replacement canonical Markdown.
expected_revision: Revision observed when editing began.
Returns:
The saved canonical document and whether it changed.
Raises:
MarkdownResourceError: If validation, identity, persistence, or
revision checks fail.
"""
try:
changed = self._adapter(ref.kind).apply(
ref.id,
markdown,
expected_revision,
)
except MarkdownResourceError:
raise
except Exception as exc:
raise MarkdownSaveFailed(
"The edit could not be applied. The existing item was not changed."
) from exc
saved = self.read(ref)
return MarkdownApplyResult(
resource=saved.resource,
source=saved.source,
body=saved.body,
revision=saved.revision,
changed=changed,
)
+18 -6
View File
@@ -87,6 +87,13 @@ def _node_response(node: dict) -> NodeResponse:
return NodeResponse(**node)
async def _get_node_or_404(node_id: str, session: GraphSession) -> NodeResponse:
node = await asyncio.to_thread(session.get_node, node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
return _node_response(node)
def _edge_response(edge: dict) -> EdgeResponse:
return EdgeResponse(**edge)
@@ -121,15 +128,20 @@ async def list_nodes(
)
@router.get("/node", response_model=NodeResponse)
async def get_node_by_query(
node_id: str = Query(..., description="Exact node ID"),
session: GraphSession = Depends(get_session),
):
return await _get_node_or_404(node_id, session)
@router.get("/node/{node_id}", response_model=NodeResponse)
async def get_node(
node_id: str,
session: GraphSession = Depends(get_session),
):
node = await asyncio.to_thread(session.get_node, node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
return _node_response(node)
return await _get_node_or_404(node_id, session)
@router.get("/node/{node_id}/neighbors", response_model=list[NeighborResponse])
@@ -441,7 +453,7 @@ async def distance_matrix(
embeddings = _get_cached_embeddings(session)
src_embedding = embeddings.get(src)
tgt_embedding = embeddings.get(tgt)
if src_embedding is None or tgt_embedding is None:
val = None
else:
@@ -451,7 +463,7 @@ async def distance_matrix(
tgt_vec = np.array(tgt_embedding)
sim = np.dot(src_vec, tgt_vec) / (np.linalg.norm(src_vec) * np.linalg.norm(tgt_vec))
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
matrix[i][j] = val
matrix[j][i] = val
elif path_finder is not None:
+115
View File
@@ -0,0 +1,115 @@
"""Explorer routes for canonical Markdown resources."""
from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, status
from ..dependencies import get_markdown_resources
from ..markdown_resources import (
InvalidMarkdownFrontmatter,
MarkdownApplyResult,
MarkdownDocument,
MarkdownResourceError,
MarkdownResourceKind,
MarkdownResourceNotFound,
MarkdownResourceRef,
MarkdownResourceRegistry,
MarkdownRevisionConflict,
ResourceIdentityMismatch,
)
from ..schemas import (
MarkdownApplyRequest,
MarkdownApplyResponse,
MarkdownDocumentResponse,
)
router = APIRouter(prefix="/api/markdown", tags=["markdown"])
def _resource_ref(kind: str, resource_id: str) -> MarkdownResourceRef:
try:
resource_kind = MarkdownResourceKind(kind)
except ValueError:
_raise_http_error(
MarkdownResourceNotFound(
f"Markdown resource kind {kind!r} is not available."
)
)
return MarkdownResourceRef(kind=resource_kind, id=resource_id)
def _error_detail(error: MarkdownResourceError) -> dict:
detail = {"code": error.code, "message": error.message}
if error.field is not None:
detail["field"] = error.field
if error.current_revision is not None:
detail["current_revision"] = error.current_revision
return detail
def _raise_http_error(error: MarkdownResourceError) -> NoReturn:
if isinstance(error, MarkdownResourceNotFound):
status_code = status.HTTP_404_NOT_FOUND
elif isinstance(error, MarkdownRevisionConflict):
status_code = status.HTTP_409_CONFLICT
elif isinstance(error, (InvalidMarkdownFrontmatter, ResourceIdentityMismatch)):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
else:
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
raise HTTPException(status_code=status_code, detail=_error_detail(error)) from error
def _document_response(
document: MarkdownDocument,
) -> MarkdownDocumentResponse:
return MarkdownDocumentResponse(
resource={
"kind": document.resource.kind.value,
"id": document.resource.id,
},
source=document.source,
body=document.body,
revision=document.revision,
editable=True,
)
def _apply_response(result: MarkdownApplyResult) -> MarkdownApplyResponse:
document = _document_response(result)
return MarkdownApplyResponse(**document.model_dump(), changed=result.changed)
@router.get(
"/{kind}/{resource_id:path}",
response_model=MarkdownDocumentResponse,
)
def read_markdown_resource(
kind: str,
resource_id: str,
resources: MarkdownResourceRegistry = Depends(get_markdown_resources),
) -> MarkdownDocumentResponse:
try:
return _document_response(resources.read(_resource_ref(kind, resource_id)))
except MarkdownResourceError as error:
_raise_http_error(error)
@router.put(
"/{kind}/{resource_id:path}",
response_model=MarkdownApplyResponse,
)
def apply_markdown_resource(
kind: str,
resource_id: str,
request: MarkdownApplyRequest,
resources: MarkdownResourceRegistry = Depends(get_markdown_resources),
) -> MarkdownApplyResponse:
try:
result = resources.apply(
_resource_ref(kind, resource_id),
request.markdown,
request.expected_revision,
)
return _apply_response(result)
except MarkdownResourceError as error:
_raise_http_error(error)
+41
View File
@@ -0,0 +1,41 @@
"""Minimal AgentMemory selection surface for Explorer."""
from fastapi import APIRouter, Depends, Query
from ...context.agent_memory import AgentMemory
from ..dependencies import get_agent_memory
from ..schemas import MemoryListResponse, MemorySummaryResponse
router = APIRouter(prefix="/api/memories", tags=["memories"])
@router.get("", response_model=MemoryListResponse)
def list_memories(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=100),
memory: AgentMemory = Depends(get_agent_memory),
) -> MemoryListResponse:
records, total = memory.list_snapshot(offset=skip, limit=limit)
items = []
for record in records:
metadata = record.get("metadata") or {}
content = record.get("content") or ""
excerpt = " ".join(str(content).split())[:160]
items.append(
MemorySummaryResponse(
id=record["memory_id"],
type=str(metadata.get("type") or "general"),
excerpt=excerpt,
updated_at=(
str(metadata["updated_at"])
if metadata.get("updated_at") is not None
else None
),
)
)
return MemoryListResponse(
items=items,
total=total,
skip=skip,
limit=limit,
)
+45
View File
@@ -0,0 +1,45 @@
"""Shared runtime assembly for Explorer entry points."""
import asyncio
from typing import Dict, Optional
from fastapi import FastAPI
from ..context.agent_memory import AgentMemory
from .session import GraphSession
def explorer_capabilities(agent_memory: Optional[AgentMemory]) -> Dict[str, bool]:
"""Describe optional Explorer features exposed by the current host."""
return {"agent_memory": agent_memory is not None}
def install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
"""Keep Explorer indexes and WebSocket clients in sync with graph writes."""
if getattr(app.state, "_semantica_mutation_bridge_session", None) is session:
return
previous_callback = getattr(session.graph, "mutation_callback", None)
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
session.handle_graph_mutation(event_type, entity_id, payload)
if callable(previous_callback):
previous_callback(event_type, entity_id, payload)
loop = getattr(app.state, "event_loop", None)
manager = getattr(app.state, "ws_manager", None)
if loop is None or manager is None or loop.is_closed():
return
asyncio.run_coroutine_threadsafe(
manager.broadcast(
"graph_mutation",
{
"event_type": event_type,
"entity_id": entity_id,
"payload": payload,
},
),
loop,
)
session.graph.mutation_callback = on_mutation
app.state._semantica_mutation_bridge = on_mutation
app.state._semantica_mutation_bridge_session = session
+36
View File
@@ -428,3 +428,39 @@ class DistanceExportRequest(BaseModel):
include: List[str] = Field(
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
)
class MarkdownResourceRefResponse(BaseModel):
kind: Literal["context-node", "agent-memory"]
id: str
class MarkdownDocumentResponse(BaseModel):
resource: MarkdownResourceRefResponse
source: str
body: str
revision: str
editable: bool = True
class MarkdownApplyRequest(BaseModel):
markdown: str
expected_revision: str = Field(..., pattern=r"^sha256:[0-9a-f]{64}$")
class MarkdownApplyResponse(MarkdownDocumentResponse):
changed: bool
class MemorySummaryResponse(BaseModel):
id: str
type: str
excerpt: str
updated_at: Optional[str] = None
class MemoryListResponse(BaseModel):
items: List[MemorySummaryResponse]
total: int
skip: int
limit: int
+47 -4
View File
@@ -9,9 +9,53 @@ import asyncio
import json
import threading
from datetime import datetime, timezone
from typing import Any, Dict, Set
from typing import Any, Dict, List, Sequence, Set
from fastapi import WebSocket
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from .dependencies import is_valid_api_key
_WS_MAX_MESSAGE_BYTES = 64 * 1024
def install_graph_updates_websocket(
app: FastAPI,
allowed_origins: Sequence[str],
) -> None:
"""Install the authenticated graph-mutation WebSocket endpoint."""
allowed_origin_set = frozenset(allowed_origins)
@app.websocket("/ws/graph-updates")
async def websocket_endpoint(websocket: WebSocket) -> None:
# CORSMiddleware does not cover WebSocket handshakes. Reject foreign
# browser origins against the same allowlist used for HTTP CORS.
origin = websocket.headers.get("origin")
if origin is not None and origin not in allowed_origin_set:
await websocket.close(code=4403)
return
# Browser clients pass the API key as a query parameter because the
# WebSocket API cannot set custom headers.
candidate = websocket.headers.get("x-api-key") or websocket.query_params.get(
"api_key"
)
if not is_valid_api_key(candidate):
await websocket.close(code=4401)
return
manager: ConnectionManager = app.state.ws_manager
await manager.connect(websocket)
await manager.send_personal(websocket, "connection_ack", {"connected": True})
try:
while True:
message = await websocket.receive_text()
if len(message) > _WS_MAX_MESSAGE_BYTES:
await websocket.close(code=1009)
break
if message.strip().lower() == "ping":
await manager.send_personal(websocket, "pong", {"ok": True})
except WebSocketDisconnect:
manager.disconnect(websocket)
class ConnectionManager:
@@ -63,8 +107,7 @@ class ConnectionManager:
with self._lock:
connections = set(self._active_connections)
disconnected: list[WebSocket] = []
disconnected: List[WebSocket] = []
for ws in connections:
try:
await ws.send_text(message)
+26 -5
View File
@@ -5,6 +5,7 @@ This module provides the REST API server for the Semantica framework
using FastAPI and uvicorn.
"""
import asyncio
import logging
import os
import uvicorn
@@ -24,8 +25,10 @@ from .utils.logging import setup_logging
try:
from .context.context_graph import ContextGraph
from .explorer.session import GraphSession
from .explorer.ws import ConnectionManager
from .explorer.ws import ConnectionManager, install_graph_updates_websocket
from .explorer.dependencies import anonymous_access_allowed, get_expected_api_key, require_auth
from .explorer.markdown_resources import MarkdownResourceRegistry
from .explorer.runtime import explorer_capabilities, install_mutation_bridge
EXPLORER_AVAILABLE = True
except ImportError:
EXPLORER_AVAILABLE = False
@@ -57,16 +60,24 @@ async def lifespan(app: FastAPI):
graph = ContextGraph()
app.state.session = GraphSession(graph)
app.state.ws_manager = ConnectionManager()
app.state.event_loop = asyncio.get_running_loop()
app.state.agent_memory = None
app.state.markdown_resources = MarkdownResourceRegistry(graph)
install_mutation_bridge(app, app.state.session)
logging.info("Database Session and WebSockets attached to app state.")
except Exception as e:
logging.error(f"Failed to initialize GraphSession: {e}")
app.state.session = None
app.state.ws_manager = None
app.state.agent_memory = None
app.state.markdown_resources = None
else:
app.state.session = None
app.state.ws_manager = None
app.state.agent_memory = None
app.state.markdown_resources = None
yield
yield
logging.info("Shutting down Semantica API...")
if getattr(app.state, "session", None) and hasattr(app.state.session.graph, "close"):
@@ -91,11 +102,14 @@ app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
max_age=600,
)
if EXPLORER_AVAILABLE:
install_graph_updates_websocket(app, _cors_origins)
# --- Security response headers -------------------------------------
class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
@@ -138,7 +152,10 @@ async def root():
return {
"name": "Semantica API",
"version": __version__,
"status": "active"
"status": "active",
"capabilities": explorer_capabilities(
getattr(app.state, "agent_memory", None)
),
}
@app.get("/health")
@@ -165,6 +182,8 @@ if EXPLORER_AVAILABLE:
enrich,
export_import,
graph,
markdown,
memories,
ontology,
temporal,
vocabulary,
@@ -179,6 +198,8 @@ if EXPLORER_AVAILABLE:
app.include_router(enrich.router, dependencies=_auth)
app.include_router(export_import.router, dependencies=_auth)
app.include_router(graph.router, dependencies=_auth)
app.include_router(markdown.router, dependencies=_auth)
app.include_router(memories.router, dependencies=_auth)
app.include_router(ontology.router, dependencies=_auth)
app.include_router(temporal.router, dependencies=_auth)
app.include_router(vocabulary.router, dependencies=_auth)
@@ -260,4 +281,4 @@ def main():
uvicorn.run(app, host=host, port=8000)
if __name__ == "__main__":
main()
main()
+145
View File
@@ -12,6 +12,10 @@ import pytest
import yaml
from semantica.context.agent_memory import AgentMemory
from semantica.context.markdown import (
MarkdownRevisionConflictError,
markdown_document_revision,
)
_ERROR_PRIVILEGE_NOT_HELD = 1314
@@ -1008,3 +1012,144 @@ def test_markdown_import_rejects_non_regular_file(tmp_path):
with patch("semantica.context.agent_memory.os.fstat", return_value=fake_stat):
with pytest.raises(ValueError, match="not a regular file"):
memory._read_markdown_file_content(real_file)
def _editable_memory(vector_store=None):
memory = AgentMemory(vector_store=vector_store)
memory.store(
"Original body",
memory_id="mem-edit",
timestamp=datetime.fromisoformat("2026-07-22T09:00:00+00:00"),
metadata={
"type": "note",
"updated_at": "2026-07-22T10:00:00+00:00",
"owner": "before",
},
)
return memory
def test_single_item_markdown_export_and_apply_preserve_identity():
memory = _editable_memory()
source = memory.export_item_markdown("mem-edit")
edited = source.replace("owner: before", "owner: after").replace(
"Original body", "Updated body"
)
assert memory.apply_item_markdown("mem-edit", edited) is True
item = memory.get("mem-edit")
assert item["memory_id"] == "mem-edit"
assert item["content"] == "Updated body"
assert item["metadata"]["owner"] == "after"
assert memory.export_item_markdown("mem-edit") == edited
def test_single_item_markdown_rejects_identity_change_without_mutation():
memory = _editable_memory()
before = deepcopy(memory.get("mem-edit"))
document = memory.export_item_markdown("mem-edit").replace(
"id: mem-edit", "id: mem-other"
)
with pytest.raises(ValueError, match="does not match resource id"):
memory.apply_item_markdown("mem-edit", document)
assert memory.get("mem-edit") == before
assert memory.get("mem-other") is None
def test_single_item_markdown_invalid_document_and_missing_item_are_safe():
memory = _editable_memory()
before = deepcopy(memory.get("mem-edit"))
with pytest.raises(ValueError, match="Invalid Markdown frontmatter"):
memory.apply_item_markdown("mem-edit", "---\nid: [\n---\n\nBroken")
with pytest.raises(KeyError, match="missing"):
memory.export_item_markdown("missing")
assert memory.get("mem-edit") == before
def test_single_item_markdown_noop_skips_vector_sync():
vector_store = TrackingVectorStore()
memory = _editable_memory(vector_store=vector_store)
vector_store.events.clear()
source = memory.export_item_markdown("mem-edit")
assert memory.apply_item_markdown("mem-edit", source) is False
assert vector_store.events == []
def test_single_item_markdown_revision_check_is_atomic_with_apply():
memory = _editable_memory()
source = memory.export_item_markdown("mem-edit")
expected_revision = markdown_document_revision(source)
memory.update("mem-edit", content="Concurrent update")
with pytest.raises(MarkdownRevisionConflictError):
memory.apply_item_markdown(
"mem-edit",
source.replace("Original body", "Stale edit"),
expected_revision=expected_revision,
)
assert memory.get("mem-edit")["content"] == "Concurrent update"
def test_apply_item_markdown_rolls_back_all_state_on_store_failure():
"""apply_item_markdown fully restores AgentMemory state if _replace_memory_item fails.
The test injects a failure inside the store() call that executes AFTER
delete_memory() completes, so that at least one real mutation has already
happened before the exception is raised. This verifies the rollback path
inside _replace_memory_item is exercised through apply_item_markdown, not
just through import_data.
"""
memory = _editable_memory()
original_item = deepcopy(memory.get("mem-edit"))
original_stats = deepcopy(memory.stats)
original_index = list(memory.memory_index)
original_stm = [item.memory_id for item in memory.short_term_memory]
original_vector_ids = deepcopy(memory._vector_ids)
source = memory.export_item_markdown("mem-edit")
edited = source.replace("Original body", "Replaced body")
# Patch store() so it succeeds (internally called by _replace_memory_item),
# but raises AFTER the new item is inserted into memory_items.
real_store = memory.store
call_count = 0
def fail_after_store(*args, **kwargs):
nonlocal call_count
call_count += 1
result = real_store(*args, **kwargs)
# Raise on the store() call made by _replace_memory_item (not any earlier call)
if call_count >= 1:
raise RuntimeError("simulated store failure after insert")
return result
with patch.object(memory, "store", side_effect=fail_after_store):
with pytest.raises(RuntimeError, match="simulated store failure"):
memory.apply_item_markdown("mem-edit", edited)
# All state must be identical to what it was before apply_item_markdown
assert memory.get("mem-edit") == original_item, (
"memory_items must be fully restored after rollback"
)
assert memory.stats == original_stats, (
"stats must be fully restored after rollback"
)
assert list(memory.memory_index) == original_index, (
"memory_index must be fully restored after rollback"
)
assert [item.memory_id for item in memory.short_term_memory] == original_stm, (
"short_term_memory must be fully restored after rollback"
)
assert memory._vector_ids == original_vector_ids, (
"vector_ids must be fully restored after rollback"
)
# The memory must still be readable and unchanged
assert memory.exists("mem-edit")
assert memory.get("mem-edit")["content"] == "Original body"
assert memory.count() == 1
@@ -9,6 +9,10 @@ import yaml
import semantica.context.context_graph as context_graph_module
from semantica.change_management.managers import TemporalVersionManager
from semantica.context.context_graph import ContextEdge, ContextGraph, ContextNode
from semantica.context.markdown import (
MarkdownRevisionConflictError,
markdown_document_revision,
)
def _read_markdown(path: Path):
@@ -711,3 +715,135 @@ def test_json_remains_default_and_unknown_format_is_rejected(tmp_path):
with pytest.raises(ValueError, match="Unsupported context graph"):
graph.save_to_file(tmp_path / "graph", format="html")
def _edited_node_document(graph, node_id, **frontmatter_updates):
source = graph.export_node_markdown(node_id)
frontmatter, body = graph._parse_markdown_document(source, f"node {node_id!r}")
frontmatter.update(frontmatter_updates)
return graph._render_markdown_document(
frontmatter,
body.replace("Keep evidence.", "Keep verified evidence."),
f"node {node_id!r}",
)
def test_single_node_markdown_export_and_apply_update_complete_node_state():
graph, _, _ = _sample_graph()
events = []
graph.mutation_callback = lambda *event: events.append(event)
before_edges = list(graph.edges)
document = _edited_node_document(
graph,
"policy/\u6771\u4eac",
type="Decision",
properties={"category": "retention", "reviewed": True},
metadata={"source": "editor"},
valid_from="2026-03-01T00:00:00+00:00",
valid_until="2028-01-01T00:00:00+00:00",
)
assert graph.apply_node_markdown("policy/\u6771\u4eac", document) is True
node = graph.nodes["policy/\u6771\u4eac"]
assert node.node_id == "policy/\u6771\u4eac"
assert node.node_type == "Decision"
assert node.content == "# Retention\n\nKeep verified evidence.\n---\n"
assert node.properties == {"category": "retention", "reviewed": True}
assert node.metadata == {"source": "editor"}
assert node.valid_from == "2026-03-01T00:00:00+00:00"
assert node.valid_until == "2028-01-01T00:00:00+00:00"
assert "policy/\u6771\u4eac" not in graph.node_type_index.get("Policy", set())
assert "policy/\u6771\u4eac" in graph.node_type_index["Decision"]
assert "policy/\u6771\u4eac" in graph._decisions
assert graph.edges == before_edges
assert [event[0] for event in events] == ["UPDATE_NODE"]
assert graph.export_node_markdown("policy/\u6771\u4eac") == document
def test_single_node_markdown_transition_out_of_decision_clears_indexes():
graph = ContextGraph(advanced_analytics=False)
graph.add_node("decision-1", "Decision", "Choose")
graph._rebuild_decision_indexes()
source = graph.export_node_markdown("decision-1")
document = source.replace("type: Decision", "type: Policy")
assert graph.apply_node_markdown("decision-1", document) is True
assert "decision-1" not in graph._decisions
def test_single_node_markdown_invalid_identity_and_yaml_do_not_mutate():
graph, _, _ = _sample_graph()
events = []
graph.mutation_callback = lambda *event: events.append(event)
before = _normalized_state(graph)
source = graph.export_node_markdown("policy/\u6771\u4eac")
with pytest.raises(ValueError, match="does not match resource id"):
graph.apply_node_markdown(
"policy/\u6771\u4eac",
source.replace("id: policy/", "id: changed/"),
)
with pytest.raises(ValueError, match="Invalid Markdown frontmatter"):
graph.apply_node_markdown("policy/\u6771\u4eac", "---\nid: [\n---\n\nBroken")
assert _normalized_state(graph) == before
assert events == []
def test_single_node_markdown_index_failure_rolls_back_complete_graph_state():
graph, _, _ = _sample_graph()
graph._analytics_cache["cached"] = {"score": 1.0}
events = []
graph.mutation_callback = lambda *event: events.append(event)
before = _normalized_state(graph)
before_type_index = {
node_type: set(node_ids)
for node_type, node_ids in graph.node_type_index.items()
}
before_analytics_cache = dict(graph._analytics_cache)
document = _edited_node_document(
graph,
"policy/\u6771\u4eac",
type="Decision",
properties={"confidence": {"invalid": True}},
)
with pytest.raises(TypeError):
graph.apply_node_markdown("policy/\u6771\u4eac", document)
assert _normalized_state(graph) == before
assert {
node_type: set(node_ids)
for node_type, node_ids in graph.node_type_index.items()
} == before_type_index
assert graph._analytics_cache == before_analytics_cache
assert not hasattr(graph, "_decisions")
assert events == []
def test_single_node_markdown_noop_and_missing_node_are_safe():
graph, _, _ = _sample_graph()
events = []
graph.mutation_callback = lambda *event: events.append(event)
source = graph.export_node_markdown("policy/\u6771\u4eac")
assert graph.apply_node_markdown("policy/\u6771\u4eac", source) is False
with pytest.raises(KeyError, match="missing"):
graph.export_node_markdown("missing")
assert events == []
def test_single_node_markdown_revision_check_is_atomic_with_apply():
graph, _, _ = _sample_graph()
source = graph.export_node_markdown("policy/\u6771\u4eac")
expected_revision = markdown_document_revision(source)
graph.add_node_attribute("policy/\u6771\u4eac", {"concurrent": True})
with pytest.raises(MarkdownRevisionConflictError):
graph.apply_node_markdown(
"policy/\u6771\u4eac",
source.replace("Keep evidence.", "Stale edit."),
expected_revision=expected_revision,
)
assert graph.nodes["policy/\u6771\u4eac"].properties["concurrent"] is True
+12
View File
@@ -247,6 +247,18 @@ class TestGraphNodes:
assert payload["id"] == "python"
assert payload["properties"]["content"] == "Python programming language"
def test_get_node_by_query_preserves_path_ids(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node("folder/node", node_type="note", content="Nested ID")
with TestClient(create_app(session=GraphSession(graph))) as test_client:
response = test_client.get(
"/api/graph/node",
params={"node_id": "folder/node"},
)
assert response.status_code == 200
assert response.json()["id"] == "folder/node"
def test_get_neighbors(self, client):
response = client.get("/api/graph/node/python/neighbors?depth=2")
assert response.status_code == 200
+16
View File
@@ -60,6 +60,22 @@ def test_write_route_also_refuses_when_auth_not_configured(client, monkeypatch):
assert resp.status_code == 503
def test_markdown_write_route_requires_api_key(client, monkeypatch):
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key")
response = client.put(
"/api/markdown/context-node/python",
headers={"X-API-Key": "wrong-key"},
json={
"markdown": "---\nid: python\ntype: language\n---\n\nChanged",
"expected_revision": "sha256:" + ("0" * 64),
},
)
assert response.status_code == 401
# ---------------------------------------------------------------------------
# Configured key: wrong/missing key rejected, correct key accepted.
# ---------------------------------------------------------------------------
+135
View File
@@ -0,0 +1,135 @@
import pytest
pytest.importorskip("fastapi")
from fastapi import FastAPI # noqa: E402
from starlette.testclient import TestClient # noqa: E402
from semantica.context.context_graph import ContextGraph # noqa: E402
from semantica.explorer.runtime import install_mutation_bridge # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
def test_mutation_bridge_supports_multiple_apps_for_one_graph(monkeypatch):
graph = ContextGraph(advanced_analytics=False)
first_session = GraphSession(graph)
second_session = GraphSession(graph)
received = []
graph.mutation_callback = lambda *event: received.append(("original", event))
monkeypatch.setattr(
first_session,
"handle_graph_mutation",
lambda *event: received.append(("first", event)),
)
monkeypatch.setattr(
second_session,
"handle_graph_mutation",
lambda *event: received.append(("second", event)),
)
first_app = FastAPI()
first_app.state.event_loop = None
first_app.state.ws_manager = None
second_app = FastAPI()
second_app.state.event_loop = None
second_app.state.ws_manager = None
install_mutation_bridge(first_app, first_session)
install_mutation_bridge(second_app, second_session)
graph.mutation_callback("UPDATE_NODE", "node-1", {"content": "Updated"})
assert [receiver for receiver, _ in received] == ["second", "first", "original"]
def test_mutation_bridge_is_idempotent_for_one_app(monkeypatch):
graph = ContextGraph(advanced_analytics=False)
session = GraphSession(graph)
received = []
monkeypatch.setattr(
session,
"handle_graph_mutation",
lambda *event: received.append(event),
)
app = FastAPI()
app.state.event_loop = None
app.state.ws_manager = None
install_mutation_bridge(app, session)
install_mutation_bridge(app, session)
graph.mutation_callback("UPDATE_NODE", "node-1", {"content": "Updated"})
assert len(received) == 1
def test_mutation_bridge_reinstalls_for_new_session_on_same_app(monkeypatch):
first_session = GraphSession(ContextGraph(advanced_analytics=False))
second_session = GraphSession(ContextGraph(advanced_analytics=False))
received = []
monkeypatch.setattr(
first_session,
"handle_graph_mutation",
lambda *event: received.append(("first", event)),
)
monkeypatch.setattr(
second_session,
"handle_graph_mutation",
lambda *event: received.append(("second", event)),
)
app = FastAPI()
app.state.event_loop = None
app.state.ws_manager = None
install_mutation_bridge(app, first_session)
install_mutation_bridge(app, second_session)
second_session.graph.mutation_callback(
"UPDATE_NODE",
"node-2",
{"content": "Updated"},
)
assert [receiver for receiver, _ in received] == ["second"]
def test_legacy_server_mounts_editable_markdown_routes(monkeypatch):
monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true")
from semantica import server
paths = {route.path for route in server.app.routes}
assert "/api/markdown/{kind}/{resource_id:path}" in paths
assert "/api/memories" in paths
assert "/ws/graph-updates" in paths
with TestClient(server.app) as client:
with client.websocket_connect("/ws/graph-updates") as websocket:
acknowledgement = websocket.receive_json()
assert acknowledgement["event"] == "connection_ack"
assert acknowledgement["data"] == {"connected": True}
assert acknowledgement["timestamp"]
info = client.get("/api/info")
memories = client.get("/api/memories")
server.app.state.session.graph.add_node(
"server-node",
"Note",
"Original server content",
)
current = client.get("/api/markdown/context-node/server-node").json()
saved = client.put(
"/api/markdown/context-node/server-node",
json={
"markdown": current["source"].replace(
"Original server content",
"Updated server content",
),
"expected_revision": current["revision"],
},
)
assert info.json()["capabilities"]["agent_memory"] is False
assert memories.status_code == 503
assert memories.json()["detail"] == (
"AgentMemory is not configured for this Explorer instance."
)
assert saved.status_code == 200
assert saved.json()["body"] == "Updated server content"
+319
View File
@@ -0,0 +1,319 @@
"""Integration tests for Explorer Markdown and memory selection routes."""
import threading
from datetime import datetime
import pytest
from semantica.context.agent_memory import AgentMemory
from semantica.context.context_graph import ContextGraph
pytest.importorskip("fastapi")
from starlette.testclient import TestClient # noqa: E402
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
@pytest.fixture
def markdown_client():
graph = ContextGraph(advanced_analytics=False)
graph.add_node("editable-node", "Note", "Original node")
events = []
graph.mutation_callback = lambda *event: events.append(event)
memory = AgentMemory()
memory.store(
"Original memory",
memory_id="editable-memory",
metadata={
"type": "note",
"updated_at": "2026-07-22T10:00:00+00:00",
},
timestamp=datetime.fromisoformat("2026-07-22T09:00:00+00:00"),
)
app = create_app(session=GraphSession(graph), agent_memory=memory)
with TestClient(app) as test_client:
yield test_client, graph, memory, events
def test_gets_context_node_and_agent_memory(markdown_client):
client, _, _, _ = markdown_client
info = client.get("/api/info")
node = client.get("/api/markdown/context-node/editable-node")
memory = client.get("/api/markdown/agent-memory/editable-memory")
assert info.json()["capabilities"]["agent_memory"] is True
assert node.status_code == 200
assert node.json()["body"] == "Original node"
assert node.json()["resource"] == {
"kind": "context-node",
"id": "editable-node",
}
assert node.json()["source"].startswith("---\n")
assert node.json()["revision"].startswith("sha256:")
assert node.json()["editable"] is True
assert memory.status_code == 200
assert memory.json()["body"] == "Original memory"
@pytest.mark.parametrize(
("kind", "resource_id", "original", "updated"),
[
("context-node", "editable-node", "Original node", "Updated node"),
("agent-memory", "editable-memory", "Original memory", "Updated memory"),
],
)
def test_put_updates_exact_resource(
markdown_client, kind, resource_id, original, updated
):
client, graph, memory, _ = markdown_client
current = client.get(f"/api/markdown/{kind}/{resource_id}").json()
response = client.put(
f"/api/markdown/{kind}/{resource_id}",
json={
"markdown": current["source"].replace(original, updated),
"expected_revision": current["revision"],
},
)
assert response.status_code == 200
assert response.json()["changed"] is True
assert response.json()["body"] == updated
assert client.get(f"/api/markdown/{kind}/{resource_id}").json()["body"] == updated
if kind == "context-node":
assert graph.nodes[resource_id].content == updated
assert memory.get("editable-memory")["content"] == "Original memory"
else:
assert memory.get(resource_id)["content"] == updated
assert graph.nodes["editable-node"].content == "Original node"
def test_validation_and_identity_errors_preserve_resource(markdown_client):
client, graph, _, events = markdown_client
current = client.get("/api/markdown/context-node/editable-node").json()
invalid = client.put(
"/api/markdown/context-node/editable-node",
json={
"markdown": "---\nid: [\n---\n\nBroken",
"expected_revision": current["revision"],
},
)
mismatch = client.put(
"/api/markdown/context-node/editable-node",
json={
"markdown": current["source"].replace(
"id: editable-node", "id: different-node"
),
"expected_revision": current["revision"],
},
)
assert invalid.status_code == 422
assert invalid.json()["detail"]["code"] == "invalid_markdown_frontmatter"
assert "invalid YAML" in invalid.json()["detail"]["message"]
assert mismatch.status_code == 422
assert mismatch.json()["detail"] == {
"code": "resource_identity_mismatch",
"message": (
"Frontmatter id 'different-node' does not match resource id "
"'editable-node'."
),
"field": "id",
}
assert graph.nodes["editable-node"].content == "Original node"
assert events == []
def test_stale_revision_returns_conflict(markdown_client):
client, _, _, _ = markdown_client
current = client.get("/api/markdown/context-node/editable-node").json()
first = client.put(
"/api/markdown/context-node/editable-node",
json={
"markdown": current["source"].replace("Original node", "First update"),
"expected_revision": current["revision"],
},
)
stale = client.put(
"/api/markdown/context-node/editable-node",
json={
"markdown": current["source"].replace("Original node", "Stale update"),
"expected_revision": current["revision"],
},
)
assert first.status_code == 200
assert stale.status_code == 409
assert stale.json()["detail"]["code"] == "markdown_revision_conflict"
assert stale.json()["detail"]["current_revision"] == first.json()["revision"]
def test_missing_and_failed_apply_are_structured(markdown_client, monkeypatch):
client, graph, _, _ = markdown_client
missing = client.get("/api/markdown/context-node/missing")
current = client.get("/api/markdown/context-node/editable-node").json()
def fail(_resource_id, _document):
raise RuntimeError("private storage failure")
monkeypatch.setattr(graph, "apply_node_markdown", fail)
failed = client.put(
"/api/markdown/context-node/editable-node",
json={
"markdown": current["source"].replace("Original node", "Will fail"),
"expected_revision": current["revision"],
},
)
assert missing.status_code == 404
assert missing.json()["detail"]["code"] == "markdown_resource_not_found"
assert failed.status_code == 500
assert failed.json()["detail"] == {
"code": "markdown_save_failed",
"message": "The edit could not be applied. The existing item was not changed.",
}
assert "private storage failure" not in failed.text
def test_lists_memories_for_selection(markdown_client):
client, _, _, _ = markdown_client
response = client.get("/api/memories?skip=0&limit=100")
assert response.status_code == 200
assert response.json() == {
"items": [
{
"id": "editable-memory",
"type": "note",
"excerpt": "Original memory",
"updated_at": "2026-07-22T10:00:00+00:00",
}
],
"total": 1,
"skip": 0,
"limit": 100,
}
def test_memory_listing_is_atomic_with_concurrent_mutation(
markdown_client, monkeypatch
):
client, _, memory, _ = markdown_client
snapshot_ready = threading.Event()
mutation_attempted = threading.Event()
mutation_finished = threading.Event()
def pause_after_key_snapshot(*, offset=0, limit=100, **_filters):
memory_ids = list(memory.memory_items.keys())[offset : offset + limit]
snapshot_ready.set()
assert mutation_attempted.wait(timeout=1)
mutation_finished.wait(timeout=0.25)
records = []
for memory_id in memory_ids:
# Match AgentMemory.list()'s second dictionary lookup after its
# key snapshot; this raises if a concurrent writer is not excluded.
memory.memory_items[memory_id]
record = memory.get_memory(memory_id)
if record is not None:
records.append(record)
return records
monkeypatch.setattr(memory, "list", pause_after_key_snapshot)
def delete_memory() -> None:
assert snapshot_ready.wait(timeout=1)
mutation_attempted.set()
memory.delete_memory("editable-memory")
mutation_finished.set()
writer = threading.Thread(target=delete_memory)
writer.start()
try:
response = client.get("/api/memories?skip=0&limit=100")
finally:
writer.join(timeout=2)
assert not writer.is_alive()
assert response.status_code == 200
assert [item["id"] for item in response.json()["items"]] == ["editable-memory"]
assert response.json()["total"] == 1
def test_default_app_does_not_expose_agent_memory():
graph = ContextGraph(advanced_analytics=False)
with TestClient(create_app(session=GraphSession(graph))) as client:
info = client.get("/api/info")
memories = client.get("/api/memories")
assert info.json()["capabilities"]["agent_memory"] is False
assert memories.status_code == 503
assert memories.json()["detail"] == (
"AgentMemory is not configured for this Explorer instance."
)
def test_slash_and_unicode_node_id_round_trip():
"""GET and PUT work for node IDs containing '/' and Unicode characters.
Tests that:
- encodeURIComponent-style %2F encoding is transparent to the route
- the returned resource.id is the original unencoded string
- a successful PUT persists to the correct node
- the node ID is never rewritten by the apply path
"""
node_id = "policy/\u6771\u4eac" # "policy/東京"
graph = ContextGraph(advanced_analytics=False)
graph.add_node(node_id, "Policy", "Original slash body")
app = create_app(session=GraphSession(graph))
with TestClient(app) as client:
# GET via percent-encoded path (what encodeURIComponent produces)
import urllib.parse
encoded_id = urllib.parse.quote(node_id, safe="")
get_response = client.get(f"/api/markdown/context-node/{encoded_id}")
assert get_response.status_code == 200, (
f"GET returned {get_response.status_code}: {get_response.text}"
)
doc = get_response.json()
assert doc["resource"]["id"] == node_id, (
f"resource.id should be {node_id!r}, got {doc['resource']['id']!r}"
)
assert doc["resource"]["kind"] == "context-node"
assert doc["body"] == "Original slash body"
assert doc["revision"].startswith("sha256:")
# PUT an edit back via the same percent-encoded URL
updated_markdown = doc["source"].replace("Original slash body", "Updated slash body")
put_response = client.put(
f"/api/markdown/context-node/{encoded_id}",
json={
"markdown": updated_markdown,
"expected_revision": doc["revision"],
},
)
assert put_response.status_code == 200, (
f"PUT returned {put_response.status_code}: {put_response.text}"
)
result = put_response.json()
assert result["changed"] is True
assert result["body"] == "Updated slash body"
assert result["resource"]["id"] == node_id, (
f"PUT response resource.id should be {node_id!r}, got {result['resource']['id']!r}"
)
# Verify the live graph node was updated
assert graph.nodes[node_id].content == "Updated slash body"
# Also verify GET via literal slash URL works identically
get_literal = client.get(f"/api/markdown/context-node/{node_id}")
assert get_literal.status_code == 200
assert get_literal.json()["resource"]["id"] == node_id
assert get_literal.json()["body"] == "Updated slash body"
+115
View File
@@ -0,0 +1,115 @@
from datetime import datetime
import pytest
from semantica.context.agent_memory import AgentMemory
from semantica.context.context_graph import ContextGraph
from semantica.explorer.markdown_resources import (
MarkdownResourceKind,
MarkdownResourceNotFound,
MarkdownResourceRef,
MarkdownResourceRegistry,
MarkdownRevisionConflict,
MarkdownSaveFailed,
document_revision,
)
def _resources():
graph = ContextGraph(advanced_analytics=False)
graph.add_node("node-1", "Note", "Original node")
graph.add_node("node-2", "Note", "Second node")
memory = AgentMemory()
memory.store(
"Original memory",
memory_id="mem-1",
timestamp=datetime.fromisoformat("2026-07-22T09:00:00+00:00"),
metadata={
"type": "note",
"updated_at": "2026-07-22T10:00:00+00:00",
},
)
return graph, memory, MarkdownResourceRegistry(graph, memory)
def test_registry_routes_context_nodes_and_returns_canonical_revision():
graph, _, resources = _resources()
ref = MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "node-1")
document = resources.read(ref)
assert document.body == "Original node"
assert document.revision == document_revision(document.source)
edited = document.source.replace("Original node", "Updated node")
result = resources.apply(ref, edited, document.revision)
assert result.changed is True
assert result.body == "Updated node"
assert graph.nodes["node-1"].content == "Updated node"
def test_registry_routes_agent_memory_without_touching_context_graph():
graph, memory, resources = _resources()
ref = MarkdownResourceRef(MarkdownResourceKind.AGENT_MEMORY, "mem-1")
before_nodes = dict(graph.nodes)
document = resources.read(ref)
result = resources.apply(
ref,
document.source.replace("Original memory", "Updated memory"),
document.revision,
)
assert result.body == "Updated memory"
assert memory.get("mem-1")["content"] == "Updated memory"
assert graph.nodes == before_nodes
def test_registry_rejects_stale_revision_and_preserves_current_state():
graph, _, resources = _resources()
ref = MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "node-1")
first = resources.read(ref)
resources.apply(
ref,
first.source.replace("Original node", "First update"),
first.revision,
)
with pytest.raises(MarkdownRevisionConflict) as error:
resources.apply(
ref,
first.source.replace("Original node", "Stale update"),
first.revision,
)
assert error.value.current_revision == resources.read(ref).revision
assert graph.nodes["node-1"].content == "First update"
def test_registry_missing_adapter_and_resources_are_structured():
graph, _, _ = _resources()
resources = MarkdownResourceRegistry(graph)
with pytest.raises(MarkdownResourceNotFound, match="not available"):
resources.read(MarkdownResourceRef(MarkdownResourceKind.AGENT_MEMORY, "mem-1"))
with pytest.raises(MarkdownResourceNotFound, match="missing"):
resources.read(
MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "missing")
)
def test_registry_does_not_expose_adapter_failure_details(monkeypatch):
graph, _, resources = _resources()
ref = MarkdownResourceRef(MarkdownResourceKind.CONTEXT_NODE, "node-1")
document = resources.read(ref)
def fail(_resource_id, _source):
raise RuntimeError("private backend failure")
monkeypatch.setattr(graph, "apply_node_markdown", fail)
with pytest.raises(MarkdownSaveFailed) as error:
resources.apply(ref, document.source + "changed", document.revision)
assert error.value.message == (
"The edit could not be applied. The existing item was not changed."
)
assert "private backend failure" not in error.value.message