pmx-canvas 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +601 -22
  3. package/CHANGELOG.md +66 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2759 -510
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2936 -632
  8. package/dist/json-render/index.css +81 -1
  9. package/dist/json-render/index.js +20 -20
  10. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  11. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  12. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  13. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  15. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  17. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  18. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  19. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  20. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  21. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  22. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  23. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  24. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  25. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  26. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  27. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  28. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  29. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  30. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  32. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  33. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  34. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  35. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  36. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  37. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  38. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  39. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  40. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  41. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  42. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  43. package/dist/types/client/icons.d.ts +0 -10
  44. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  45. package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
  46. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  47. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  48. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  49. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  50. package/dist/types/client/state/boards-store.d.ts +81 -9
  51. package/dist/types/client/state/canvas-store.d.ts +14 -1
  52. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  53. package/dist/types/client/state/context-status-store.d.ts +35 -0
  54. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  55. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  56. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  57. package/dist/types/client/state/presence-store.d.ts +4 -4
  58. package/dist/types/client/state/presentation.d.ts +28 -0
  59. package/dist/types/client/state/relation-store.d.ts +10 -0
  60. package/dist/types/client/state/session-store.d.ts +41 -2
  61. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  62. package/dist/types/client/types.d.ts +13 -1
  63. package/dist/types/client/utils/keyboard.d.ts +2 -0
  64. package/dist/types/client/utils/time-ago.d.ts +4 -0
  65. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  66. package/dist/types/server/agent-presence.d.ts +10 -3
  67. package/dist/types/server/ax-context.d.ts +1 -1
  68. package/dist/types/server/canvas-db.d.ts +30 -7
  69. package/dist/types/server/canvas-operations.d.ts +24 -2
  70. package/dist/types/server/canvas-provenance.d.ts +22 -2
  71. package/dist/types/server/canvas-serialization.d.ts +2 -0
  72. package/dist/types/server/canvas-state.d.ts +93 -15
  73. package/dist/types/server/canvas-validation.d.ts +1 -1
  74. package/dist/types/server/context-brief.d.ts +94 -6
  75. package/dist/types/server/context-reads.d.ts +33 -7
  76. package/dist/types/server/edit-summary.d.ts +20 -0
  77. package/dist/types/server/index.d.ts +71 -10
  78. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  79. package/dist/types/server/lifecycle-status.d.ts +10 -0
  80. package/dist/types/server/memory-graph.d.ts +9 -0
  81. package/dist/types/server/mutation-history.d.ts +2 -0
  82. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  83. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  84. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  85. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  86. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  87. package/dist/types/server/operations/ops/query.d.ts +23 -0
  88. package/dist/types/server/operations/registry.d.ts +1 -0
  89. package/dist/types/server/placement.d.ts +7 -0
  90. package/dist/types/server/scope-fence.d.ts +2 -1
  91. package/dist/types/server/spatial-analysis.d.ts +6 -3
  92. package/dist/types/server/tour-control.d.ts +32 -0
  93. package/dist/types/server/wiki-links.d.ts +14 -0
  94. package/dist/types/server/workbench-auth.d.ts +2 -0
  95. package/dist/types/shared/agent-context.d.ts +2 -0
  96. package/dist/types/shared/agent-presence.d.ts +15 -12
  97. package/dist/types/shared/auto-arrange.d.ts +1 -0
  98. package/dist/types/shared/boards.d.ts +4 -4
  99. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  100. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  101. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  102. package/dist/types/shared/memory-graph.d.ts +53 -0
  103. package/dist/types/shared/relations.d.ts +68 -0
  104. package/dist/types/shared/semantic-attention.d.ts +2 -0
  105. package/dist/types/shared/tour.d.ts +38 -0
  106. package/dist/types/shared/workspace-settings.d.ts +11 -0
  107. package/docs/RELEASE.md +21 -3
  108. package/docs/adr-001-bun-only-runtime.md +7 -1
  109. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  110. package/docs/api-reference.md +109 -0
  111. package/docs/ax-host-adapter-contract.md +1 -1
  112. package/docs/ax-state-contract.md +5 -4
  113. package/docs/cli.md +153 -15
  114. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  115. package/docs/design/AgentContext.dc.html +3 -3
  116. package/docs/design/BoardPins.dc.html +37 -22
  117. package/docs/design/Categories.dc.html +208 -0
  118. package/docs/design/CategoryEditor.dc.html +33 -0
  119. package/docs/design/Chrome.dc.html +55 -9
  120. package/docs/design/ContextChip.dc.html +224 -0
  121. package/docs/design/ContextChip600.dc.html +136 -0
  122. package/docs/design/EmptyState.dc.html +45 -0
  123. package/docs/design/Flows.dc.html +23 -1
  124. package/docs/design/HeaderMarks.dc.html +183 -0
  125. package/docs/design/Home.dc.html +19 -1
  126. package/docs/design/HomeGraph.dc.html +86 -202
  127. package/docs/design/HomeMap.dc.html +23 -1
  128. package/docs/design/HomeTeam.dc.html +32 -14
  129. package/docs/design/Lifecycle.dc.html +22 -0
  130. package/docs/design/Lifecycle600.dc.html +18 -0
  131. package/docs/design/LifecycleVariants.dc.html +157 -0
  132. package/docs/design/LinkAuthorship.dc.html +139 -0
  133. package/docs/design/LinksChanged.dc.html +203 -0
  134. package/docs/design/LinksOptions.dc.html +137 -0
  135. package/docs/design/LinksOptions600.dc.html +134 -0
  136. package/docs/design/NearPin.dc.html +9 -10
  137. package/docs/design/Pane600.dc.html +4 -3
  138. package/docs/design/Relations.dc.html +3 -2
  139. package/docs/design/Restore.dc.html +152 -0
  140. package/docs/design/SeeChange.dc.html +267 -0
  141. package/docs/design/Uses.dc.html +12 -6
  142. package/docs/design/canvas.json +146 -19
  143. package/docs/design.md +454 -18
  144. package/docs/environment.md +0 -1
  145. package/docs/evals/curation-effect.md +128 -0
  146. package/docs/http-api.md +465 -38
  147. package/docs/mcp.md +150 -19
  148. package/docs/node-types.md +58 -3
  149. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  150. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  151. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  152. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  153. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  154. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  155. package/docs/product-context-vision-2026-09.md +17 -1
  156. package/docs/product-vision-2026-09.md +1082 -43
  157. package/docs/product-vision-review-2026-09.md +13 -1
  158. package/docs/screenshots/workbench-live-dark.png +0 -0
  159. package/docs/screenshots/workbench-live-light.png +0 -0
  160. package/docs/sdk.md +62 -2
  161. package/package.json +1 -1
  162. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  163. package/plugins/pmx-canvas/plugin.json +1 -1
  164. package/plugins/pmx-canvas/runtime.json +1 -1
  165. package/plugins/pmx-canvas/tools.json +601 -22
  166. package/skills/pmx-canvas/SKILL.md +234 -43
  167. package/skills/pmx-canvas/evals/evals.json +62 -0
  168. package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
  169. package/skills/pmx-canvas/references/full-reference.md +120 -21
  170. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  171. package/skills/pmx-canvas/references/importing-context.md +132 -0
  172. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  173. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  174. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  175. package/src/cli/agent.ts +2 -0
  176. package/src/cli/commands/ax.ts +12 -0
  177. package/src/cli/commands/boards.ts +87 -11
  178. package/src/cli/commands/edges.ts +28 -0
  179. package/src/cli/commands/groups.ts +7 -0
  180. package/src/cli/commands/nodes.ts +4 -2
  181. package/src/cli/commands/query.ts +34 -2
  182. package/src/cli/commands/record.ts +199 -48
  183. package/src/cli/index.ts +7 -1
  184. package/src/client/App.tsx +177 -70
  185. package/src/client/canvas/AttentionHistory.tsx +0 -2
  186. package/src/client/canvas/BarHint.tsx +40 -2
  187. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  188. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  189. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  190. package/src/client/canvas/BoardPinButton.tsx +33 -0
  191. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  192. package/src/client/canvas/CanvasNode.tsx +360 -237
  193. package/src/client/canvas/CanvasViewport.tsx +45 -7
  194. package/src/client/canvas/CommandBar.tsx +29 -23
  195. package/src/client/canvas/CommandPalette.tsx +5 -12
  196. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  197. package/src/client/canvas/ContextChip.tsx +296 -0
  198. package/src/client/canvas/ContextMenu.tsx +63 -5
  199. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  200. package/src/client/canvas/EdgeLayer.tsx +339 -76
  201. package/src/client/canvas/EmptyState.tsx +33 -18
  202. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  203. package/src/client/canvas/ExternalWriters.tsx +57 -42
  204. package/src/client/canvas/HomeGraphView.tsx +942 -0
  205. package/src/client/canvas/HomeMapView.tsx +641 -0
  206. package/src/client/canvas/HomeReviewView.tsx +167 -0
  207. package/src/client/canvas/HomeView.tsx +687 -160
  208. package/src/client/canvas/IntentLayer.tsx +19 -12
  209. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  210. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  211. package/src/client/canvas/LifecycleFields.tsx +91 -0
  212. package/src/client/canvas/LifecyclePanel.tsx +533 -0
  213. package/src/client/canvas/MemoryInspector.tsx +104 -0
  214. package/src/client/canvas/Minimap.tsx +3 -3
  215. package/src/client/canvas/NearPinLayer.tsx +187 -0
  216. package/src/client/canvas/NodeContextMark.tsx +175 -97
  217. package/src/client/canvas/Presentation.tsx +23 -57
  218. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  219. package/src/client/canvas/RelationsPanel.tsx +82 -0
  220. package/src/client/canvas/SelectionBar.tsx +3 -6
  221. package/src/client/canvas/SessionPanel.tsx +90 -50
  222. package/src/client/canvas/SessionReceipt.tsx +311 -39
  223. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  224. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  225. package/src/client/canvas/ToolRail.tsx +367 -192
  226. package/src/client/canvas/TopBar.tsx +354 -124
  227. package/src/client/canvas/canvas-area.ts +10 -3
  228. package/src/client/canvas/graph-geometry.ts +195 -0
  229. package/src/client/canvas/graph-model.ts +254 -0
  230. package/src/client/canvas/memory-layout.ts +106 -0
  231. package/src/client/canvas/use-chrome-height.ts +20 -0
  232. package/src/client/canvas/use-focus-trap.ts +31 -5
  233. package/src/client/canvas/use-memory-graph.ts +58 -0
  234. package/src/client/canvas/use-node-drag.ts +2 -1
  235. package/src/client/canvas/use-pan-zoom.ts +64 -7
  236. package/src/client/icons.tsx +0 -63
  237. package/src/client/mcp-app/style.css +0 -6
  238. package/src/client/nodes/AxStepControls.tsx +74 -50
  239. package/src/client/nodes/BoardNode.tsx +5 -1
  240. package/src/client/nodes/ContextNode.tsx +18 -11
  241. package/src/client/nodes/ExtAppFrame.tsx +0 -1
  242. package/src/client/nodes/FileNode.tsx +46 -42
  243. package/src/client/nodes/HtmlNode.tsx +44 -2
  244. package/src/client/nodes/ImageNode.tsx +1 -1
  245. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  246. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  247. package/src/client/nodes/MarkdownNode.tsx +377 -109
  248. package/src/client/nodes/McpAppNode.tsx +50 -20
  249. package/src/client/nodes/MdFormatBar.tsx +21 -18
  250. package/src/client/nodes/PromptNode.tsx +1 -1
  251. package/src/client/nodes/ResponseNode.tsx +1 -1
  252. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  253. package/src/client/state/attention-bridge.ts +64 -15
  254. package/src/client/state/boards-store.ts +193 -39
  255. package/src/client/state/canvas-store.ts +99 -12
  256. package/src/client/state/context-chip-store.ts +70 -0
  257. package/src/client/state/context-status-store.ts +53 -0
  258. package/src/client/state/intent-bridge.ts +51 -30
  259. package/src/client/state/knowledge-store.ts +43 -0
  260. package/src/client/state/near-pin-store.ts +115 -6
  261. package/src/client/state/presence-store.ts +7 -10
  262. package/src/client/state/presentation.ts +91 -0
  263. package/src/client/state/relation-store.ts +81 -0
  264. package/src/client/state/session-store.ts +144 -18
  265. package/src/client/state/sse-bridge.ts +206 -29
  266. package/src/client/state/workspace-settings.ts +11 -0
  267. package/src/client/theme/global.css +2759 -510
  268. package/src/client/types.ts +12 -0
  269. package/src/client/utils/keyboard.ts +10 -0
  270. package/src/client/utils/time-ago.ts +14 -0
  271. package/src/json-render/renderer/index.css +6 -1
  272. package/src/json-render/renderer/index.tsx +31 -1
  273. package/src/mcp/canvas-app.ts +12 -2
  274. package/src/mcp/context-uri-template.ts +7 -4
  275. package/src/mcp/server.ts +63 -41
  276. package/src/mcp/workbench-app.ts +39 -6
  277. package/src/server/agent-presence.ts +128 -28
  278. package/src/server/ax-context.ts +27 -13
  279. package/src/server/board-export.ts +12 -1
  280. package/src/server/canvas-db.ts +331 -42
  281. package/src/server/canvas-operations.ts +196 -84
  282. package/src/server/canvas-provenance.ts +53 -22
  283. package/src/server/canvas-serialization.ts +2 -0
  284. package/src/server/canvas-state.ts +950 -122
  285. package/src/server/canvas-validation.ts +9 -2
  286. package/src/server/context-brief.ts +345 -38
  287. package/src/server/context-reads.ts +179 -30
  288. package/src/server/demo-state.json +355 -24
  289. package/src/server/edit-summary.ts +74 -0
  290. package/src/server/html-surface.ts +8 -1
  291. package/src/server/index.ts +203 -13
  292. package/src/server/knowledge-lifecycle.ts +304 -0
  293. package/src/server/lifecycle-status.ts +232 -0
  294. package/src/server/memory-graph.ts +166 -0
  295. package/src/server/mutation-history.ts +18 -6
  296. package/src/server/operations/composites.ts +32 -11
  297. package/src/server/operations/http.ts +7 -2
  298. package/src/server/operations/index.ts +4 -0
  299. package/src/server/operations/mcp.ts +32 -1
  300. package/src/server/operations/ops/ax-presence.ts +1 -1
  301. package/src/server/operations/ops/ax-read.ts +57 -6
  302. package/src/server/operations/ops/ax-timeline.ts +28 -2
  303. package/src/server/operations/ops/backup.ts +5 -3
  304. package/src/server/operations/ops/batch.ts +35 -4
  305. package/src/server/operations/ops/boards.ts +188 -47
  306. package/src/server/operations/ops/canvas-wire.ts +31 -1
  307. package/src/server/operations/ops/edges.ts +36 -2
  308. package/src/server/operations/ops/groups.ts +6 -1
  309. package/src/server/operations/ops/json-render.ts +5 -1
  310. package/src/server/operations/ops/lifecycle.ts +262 -0
  311. package/src/server/operations/ops/memory-graph.ts +45 -0
  312. package/src/server/operations/ops/nodes.ts +107 -24
  313. package/src/server/operations/ops/query.ts +281 -68
  314. package/src/server/operations/ops/snapshots.ts +73 -1
  315. package/src/server/operations/ops/validate.ts +5 -1
  316. package/src/server/operations/ops/viewport.ts +97 -3
  317. package/src/server/operations/registry.ts +77 -26
  318. package/src/server/placement.ts +29 -0
  319. package/src/server/scope-fence.ts +78 -6
  320. package/src/server/server.ts +115 -16
  321. package/src/server/spatial-analysis.ts +48 -23
  322. package/src/server/tour-control.ts +82 -0
  323. package/src/server/wiki-links.ts +137 -0
  324. package/src/server/workbench-auth.ts +11 -0
  325. package/src/shared/agent-context.ts +3 -0
  326. package/src/shared/agent-presence.ts +15 -15
  327. package/src/shared/auto-arrange.ts +2 -0
  328. package/src/shared/boards.ts +4 -4
  329. package/src/shared/knowledge-lifecycle.ts +22 -0
  330. package/src/shared/knowledge-presets.ts +80 -0
  331. package/src/shared/lifecycle-status.ts +26 -0
  332. package/src/shared/memory-graph.ts +74 -0
  333. package/src/shared/relations.ts +98 -0
  334. package/src/shared/semantic-attention.ts +15 -1
  335. package/src/shared/tour.ts +23 -5
  336. package/src/shared/workspace-settings.ts +13 -0
  337. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  338. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  339. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  340. package/dist/types/server/agent-context.d.ts +0 -22
  341. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -2,33 +2,39 @@
2
2
  * Slice 3 operations (plan-005, migration item 4): pin.set / search /
3
3
  * history.get / canvas.undo / canvas.redo / spatial.get / summary.get.
4
4
  *
5
- * Event notes (matching the legacy HTTP handlers exactly):
5
+ * Event notes:
6
6
  * - pin.set: legacy POST /api/canvas/context-pins emitted ONLY
7
7
  * context-pins-changed (no canvas-layout-update), so mutates: false with a
8
8
  * manual ctx.emit. The injected emitter adds the sessionId/timestamp fields
9
9
  * the legacy handler set explicitly. (The SDK's setContextPins emits the
10
10
  * same context-pins-changed event — its old layout-update drift was erased
11
11
  * in v0.3.0.)
12
- * - canvas.undo / canvas.redo: legacy handlers emitted canvas-viewport-update
13
- * then canvas-layout-update only after an entry was actually undone/redone,
14
- * so mutates: false with conditional manual emits. (The SDK's undo/redo also
15
- * emit ax-state-changed; that was never part of the HTTP wire and the SDK
16
- * methods are untouched.)
12
+ * - canvas.undo / canvas.redo: successful replay emits viewport, layout and raw
13
+ * pin membership, since layout updates do not replace browser pins. Empty
14
+ * history emits nothing, so mutates: false with conditional manual emits.
15
+ * The SDK also emits ax-state-changed.
17
16
  *
18
17
  * This module must never import server.ts or index.ts.
19
18
  */
20
19
  import { z } from 'zod';
21
- import { canvasState } from '../../canvas-state.js';
20
+ import { canvasState, type CanvasNodeState } from '../../canvas-state.js';
22
21
  import { setCanvasContextPins, syncCanvasRuntimeBackends } from '../../canvas-operations.js';
23
22
  import { buildCanvasSummary } from '../../canvas-serialization.js';
24
23
  import { mutationHistory } from '../../mutation-history.js';
25
- import { buildSpatialContext, searchNodes } from '../../spatial-analysis.js';
24
+ import { buildSpatialContext, searchNodes, searchRelevance } from '../../spatial-analysis.js';
25
+ import { boardConnections, memoryGraph } from '../../memory-graph.js';
26
+ import { relationText } from '../../../shared/relations.js';
27
+ import { normalizeBoardFolder } from '../../../shared/boards.js';
28
+ import { lifecycleQualifications } from '../../lifecycle-status.js';
26
29
  import {
27
30
  compileContextBrief,
31
+ BRIEF_SUMMARY_LENGTH,
28
32
  type ContextBriefLibraryBoard,
33
+ type ContextBriefPinnedBoard,
29
34
  type ContextBriefSourceEntry,
30
35
  } from '../../context-brief.js';
31
36
  import { summarizeNodeForAgentContext } from '../../../shared/agent-context.js';
37
+ import { memoryKey, type MemoryTarget } from '../../../shared/memory-graph.js';
32
38
  import { defineOperation, OperationError, type Operation, type OperationMcpToolHost } from '../types.js';
33
39
  import { buildSummaryFromLayout, isRecord } from './nodes.js';
34
40
  import { readTargetBoard } from './boards.js';
@@ -37,6 +43,11 @@ import { readTargetBoard } from './boards.js';
37
43
 
38
44
  /** Legacy server.ts handleContextPinsUpdate capped the requested list at 20. */
39
45
  const MAX_PINS = 20;
46
+
47
+ /**
48
+ * The brief's page size in UTF-16 characters (canvas://context?budget=N). It is
49
+ * not shown to the human: a brief longer than one page continues on the next read.
50
+ */
40
51
  export const DEFAULT_CONTEXT_BRIEF_BUDGET = 16_000;
41
52
  export const MAX_CONTEXT_BRIEF_BUDGET = 100_000;
42
53
 
@@ -108,6 +119,8 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
108
119
  const searchShape = {
109
120
  q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
110
121
  scope: z.unknown().optional().describe('active (default) or library (all boards)'),
122
+ boardId: z.string().optional().describe('Stable board ID to search; defaults scope to library when supplied'),
123
+ folder: z.string().optional().describe('Folder and descendants; empty string selects unfiled boards'),
111
124
  limit: z.unknown().optional().describe('Max results to return (default: all over HTTP, 10 via the MCP tool).'),
112
125
  };
113
126
 
@@ -130,13 +143,17 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
130
143
  query: z.string().describe('Search query — matches against node titles, content, and file paths'),
131
144
  limit: z.number().optional().describe('Max results to return (default: 10)'),
132
145
  scope: z.enum(['active', 'library']).optional().describe('Search the active board or the full board library'),
146
+ boardId: searchShape.boardId,
147
+ folder: searchShape.folder,
133
148
  },
134
149
  // Map the MCP-facing `query` arg onto the wire's `q`. The handler caps by
135
150
  // `limit` on every transport; the MCP tool additionally defaults it to 10.
136
151
  buildInput: (input) => ({
137
152
  q: typeof input.query === 'string' ? input.query : '',
138
153
  ...(typeof input.limit === 'number' ? { limit: input.limit } : {}),
139
- ...(input.scope === 'library' ? { scope: 'library' } : {}),
154
+ ...(input.scope === 'library' || input.scope === 'active' ? { scope: input.scope } : {}),
155
+ ...(typeof input.boardId === 'string' ? { boardId: input.boardId } : {}),
156
+ ...(typeof input.folder === 'string' ? { folder: input.folder } : {}),
140
157
  }),
141
158
  formatResult: (result, input) => {
142
159
  const body = isRecord(result) ? result : {};
@@ -160,58 +177,106 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
160
177
  };
161
178
  },
162
179
  },
163
- handler: (input) => {
164
- const q = typeof input.q === 'string' ? input.q : '';
165
- if (!q.trim()) {
166
- return { results: [], query: q };
167
- }
168
- const rawLimit = input.limit;
169
- const limit =
170
- typeof rawLimit === 'number'
171
- ? rawLimit
172
- : typeof rawLimit === 'string' && rawLimit.trim() !== ''
173
- ? Number(rawLimit)
174
- : Number.NaN;
175
- const library = input.scope === 'library';
176
- const results = library
177
- ? canvasState.listBoards().flatMap((board) => {
178
- const read = canvasState.readBoard(board.id, true);
179
- if (!read) return [];
180
- const cards = searchNodes(read.layout.nodes, q).map((result) => ({
181
- ...result,
182
- boardId: board.id,
183
- boardTitle: board.name,
184
- cardId: result.id,
185
- }));
186
- const metadata = `${board.name} ${board.category ?? ''}`.toLocaleLowerCase();
187
- const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
188
- const readmeText = typeof readme?.data.content === 'string' ? readme.data.content : '';
189
- if (`${metadata} ${readmeText.toLocaleLowerCase()}`.includes(q.toLocaleLowerCase())) {
190
- cards.unshift({
191
- id: board.id,
192
- type: 'board',
193
- title: board.name,
194
- snippet: readmeText.slice(0, 160) || board.category || board.name,
195
- score: metadata.includes(q.toLocaleLowerCase()) ? 4 : 2,
196
- boardId: board.id,
197
- boardTitle: board.name,
198
- cardId: readme?.id ?? board.id,
199
- });
200
- }
201
- return cards;
202
- })
203
- : searchNodes(canvasState.getLayout().nodes, q).map((result) => ({
180
+ handler: (input) =>
181
+ searchCanvasNodes(typeof input.q === 'string' ? input.q : '', {
182
+ scope: input.scope === 'active' || input.scope === 'library' ? input.scope : undefined,
183
+ boardId: input.boardId,
184
+ folder: input.folder,
185
+ limit: typeof input.limit === 'number' || typeof input.limit === 'string' ? input.limit : undefined,
186
+ }),
187
+ });
188
+
189
+ /** Search without opening boards; HTTP, MCP and SDK share discovery and ranking. */
190
+ export function searchCanvasNodes(
191
+ q: string,
192
+ options: { scope?: 'active' | 'library'; boardId?: string; folder?: string; limit?: number | string } = {},
193
+ ) {
194
+ if (!q.trim()) {
195
+ return { results: [], query: q };
196
+ }
197
+ const rawLimit = options.limit;
198
+ const limit =
199
+ typeof rawLimit === 'number'
200
+ ? rawLimit
201
+ : typeof rawLimit === 'string' && rawLimit.trim() !== ''
202
+ ? Number(rawLimit)
203
+ : Number.NaN;
204
+ const hasFilter = options.boardId !== undefined || options.folder !== undefined;
205
+ const library = options.scope === 'library' || (options.scope === undefined && hasFilter);
206
+ const folder = options.folder === undefined ? undefined : normalizeBoardFolder(options.folder);
207
+ const selectedBoards = canvasState
208
+ .listBoards()
209
+ .filter(
210
+ (board) =>
211
+ (options.boardId === undefined || board.id === options.boardId) &&
212
+ (folder === undefined ||
213
+ board.folder === folder ||
214
+ (folder !== null && board.folder?.startsWith(`${folder}/`))),
215
+ );
216
+ const results = library
217
+ ? selectedBoards.flatMap((board) => {
218
+ const read = canvasState.readBoard(board.id, true);
219
+ if (!read) return [];
220
+ const cards = searchNodes(read.layout.nodes, q).map((result) => ({
204
221
  ...result,
205
- boardId: canvasState.activeBoardId,
222
+ boardId: board.id,
223
+ boardTitle: board.name,
206
224
  cardId: result.id,
207
225
  }));
208
- results.sort((a, b) => b.score - a.score);
209
- return {
210
- results: Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results,
211
- query: q,
212
- };
213
- },
214
- });
226
+ const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
227
+ const readmeText = typeof readme?.data.content === 'string' ? readme.data.content : '';
228
+ const score = searchRelevance(
229
+ board.name,
230
+ [
231
+ [board.folder ?? '', 2],
232
+ [readmeText, 1],
233
+ ],
234
+ q,
235
+ );
236
+ if (score > 0) {
237
+ cards.unshift({
238
+ id: board.id,
239
+ type: 'board',
240
+ title: board.name,
241
+ snippet: readmeText.slice(0, 160) || board.folder || board.name,
242
+ score,
243
+ boardId: board.id,
244
+ boardTitle: board.name,
245
+ cardId: readme?.id ?? board.id,
246
+ });
247
+ }
248
+ return cards;
249
+ })
250
+ : searchNodes(
251
+ !hasFilter || selectedBoards.some((board) => board.id === canvasState.activeBoardId)
252
+ ? canvasState.getLayout().nodes
253
+ : [],
254
+ q,
255
+ ).map((result) => ({
256
+ ...result,
257
+ boardId: canvasState.activeBoardId,
258
+ cardId: result.id,
259
+ }));
260
+ results.sort((a, b) => {
261
+ const aKey = `${a.boardId ?? ''}/${a.id}`;
262
+ const bKey = `${b.boardId ?? ''}/${b.id}`;
263
+ return b.score - a.score || (aKey < bKey ? -1 : aKey > bKey ? 1 : 0);
264
+ });
265
+ const delivered = Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results;
266
+ const qualifications = lifecycleQualifications(
267
+ Date.now(),
268
+ delivered.flatMap((result) => (result.boardId ? [{ boardId: result.boardId, cardId: result.cardId }] : [])),
269
+ );
270
+ return {
271
+ results: delivered.map((result) => ({
272
+ ...result,
273
+ ...(result.boardId
274
+ ? { qualification: qualifications[memoryKey({ boardId: result.boardId, cardId: result.cardId })] }
275
+ : {}),
276
+ })),
277
+ query: q,
278
+ };
279
+ }
215
280
 
216
281
  // ── context.get ───────────────────────────────────────────────
217
282
 
@@ -246,28 +311,83 @@ const contextOperation = defineOperation<z.infer<typeof contextSchema>, Record<s
246
311
 
247
312
  // Capture the active target and revision synchronously. Library boards are
248
313
  // read from SQLite and are never opened or made writable by this operation.
314
+ let qualifications = lifecycleQualifications();
315
+ const included = (boardId: string, node: CanvasNodeState) =>
316
+ !qualifications[memoryKey({ boardId, cardId: node.id })]?.retired;
249
317
  const layout = canvasState.getLayout();
318
+ layout.nodes = layout.nodes.filter((node) => included(active.id, node));
319
+ // One read per board per brief: linked/same-folder tiers, README summaries and
320
+ // pinned boards all share it (the link scan below reads without blobs).
321
+ const fullReads = new Map<string, ReturnType<typeof canvasState.readBoard>>();
322
+ const readFull = (boardId: string) => {
323
+ if (!fullReads.has(boardId)) {
324
+ const read = canvasState.readBoard(boardId, true);
325
+ if (read) read.layout.nodes = read.layout.nodes.filter((node) => included(boardId, node));
326
+ fullReads.set(boardId, read);
327
+ }
328
+ return fullReads.get(boardId) ?? null;
329
+ };
250
330
  const revision = canvasState.getContentRevision();
251
331
  const pinnedNodeIds = [...canvasState.contextPinnedNodeIds];
252
332
  const linkedIds = new Map<string, string[]>();
253
- for (const node of layout.nodes) {
254
- if (node.type !== 'board' || typeof node.data.boardId !== 'string') continue;
255
- const ids = linkedIds.get(node.data.boardId) ?? [];
256
- ids.push(node.id);
257
- linkedIds.set(node.data.boardId, ids);
333
+ const graph = memoryGraph();
334
+ const graphNodes = new Map(graph.nodes.map((node) => [node.key, node]));
335
+ const qualifiedTitle = (target: MemoryTarget): string | undefined => {
336
+ const card = graphNodes.get(memoryKey(target));
337
+ const board = graphNodes.get(memoryKey({ boardId: target.boardId }));
338
+ return card && board ? (target.cardId ? `${board.title} / ${card.title}` : board.title) : undefined;
339
+ };
340
+ const connections = boardConnections(graph).filter((edge) => !edge.missing);
341
+ const memoryRelations: Record<string, string[]> = {};
342
+ for (const edge of graph.edges) {
343
+ if (edge.kind === 'member') continue;
344
+ // Local and outgoing card edges are compiled below with their revision marks.
345
+ // Only incoming cross-board card edges need the library graph here.
346
+ if (edge.kind === 'card' && (edge.source.boardId === edge.target.boardId || edge.target.boardId !== active.id))
347
+ continue;
348
+ for (const [source, target, direction] of [
349
+ [edge.source, edge.target, '→'],
350
+ [edge.target, edge.source, '←'],
351
+ ] as const) {
352
+ if (source.boardId !== active.id || !source.cardId) continue;
353
+ const other = graphNodes.get(direction === '→' ? edge.to : edge.from);
354
+ const kind = edge.kind === 'card' ? relationText({ ...edge, kind: edge.relationKind }) : edge.kind;
355
+ memoryRelations[source.cardId] ??= [];
356
+ memoryRelations[source.cardId].push(
357
+ `${direction} ${kind}: ${qualifiedTitle(target) ?? other?.title ?? 'Missing target'} (${target.boardId}${target.cardId ? ` / ${target.cardId}` : ''})`,
358
+ );
359
+ }
360
+ }
361
+ for (const link of graph.unresolvedLinks) {
362
+ if (link.source.boardId === active.id && link.source.cardId) {
363
+ memoryRelations[link.source.cardId] ??= [];
364
+ memoryRelations[link.source.cardId].push(`Unresolved wiki: [[${link.ref}]]`);
365
+ }
366
+ }
367
+ for (const edge of connections) {
368
+ const other =
369
+ edge.source.boardId === active.id
370
+ ? edge.target.boardId
371
+ : edge.target.boardId === active.id
372
+ ? edge.source.boardId
373
+ : null;
374
+ if (!other) continue;
375
+ const ids = linkedIds.get(other) ?? [];
376
+ if (edge.source.boardId === active.id && edge.source.cardId) ids.push(edge.source.cardId);
377
+ linkedIds.set(other, ids);
258
378
  }
259
379
  const libraryBoards: ContextBriefLibraryBoard[] = [];
260
380
  for (const board of canvasState.listBoards()) {
261
381
  if (board.id === active.id) continue;
262
- if (!linkedIds.has(board.id) && (!active.category || active.category !== board.category)) continue;
263
- const read = canvasState.readBoard(board.id, true);
382
+ if (!linkedIds.has(board.id) && (!active.folder || active.folder !== board.folder)) continue;
383
+ const read = readFull(board.id);
264
384
  if (!read) continue;
265
385
  const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
266
386
  const pins = new Set(read.state.contextPins);
267
387
  libraryBoards.push({
268
388
  boardId: board.id,
269
389
  name: board.name,
270
- category: board.category,
390
+ folder: board.folder,
271
391
  ...(readme
272
392
  ? {
273
393
  readme: {
@@ -286,6 +406,65 @@ const contextOperation = defineOperation<z.infer<typeof contextSchema>, Record<s
286
406
  linkIds: linkedIds.get(board.id) ?? [],
287
407
  });
288
408
  }
409
+ // The brief is a map (vision move 0a and the maintainer's 2026-10-07 rule):
410
+ // titles, short summaries and relations; the agent pulls what it wants in full.
411
+ const title = (node: { id: string; data: Record<string, unknown> }) =>
412
+ typeof node.data.title === 'string' && node.data.title ? node.data.title : node.id;
413
+ const short = (node: CanvasNodeState) =>
414
+ summarizeNodeForAgentContext(node, {
415
+ defaultTextLength: BRIEF_SUMMARY_LENGTH,
416
+ webpageTextLength: BRIEF_SUMMARY_LENGTH,
417
+ });
418
+ // Connections come from the shared graph; README summaries are read
419
+ // only for the open board and pinned boards, so a large library stays cheap.
420
+ const library = canvasState.listBoards();
421
+ const names = new Map(library.map((board) => [board.id, board.name]));
422
+ const linksFrom = new Map<string, string[]>();
423
+ for (const board of library) {
424
+ linksFrom.set(
425
+ board.id,
426
+ connections.filter((edge) => edge.source.boardId === board.id).map((edge) => edge.target.boardId),
427
+ );
428
+ }
429
+ const readmeSummary = (boardId: string): string | null => {
430
+ const board = library.find((entry) => entry.id === boardId);
431
+ if (!board?.readmeNodeId) return null;
432
+ const nodes = boardId === active.id ? layout.nodes : (readFull(boardId)?.layout.nodes ?? []);
433
+ const readme = nodes.find((node) => node.id === board.readmeNodeId && node.type === 'markdown');
434
+ return readme ? summarizeNodeForAgentContext(readme, { defaultTextLength: 1_000 }) : null;
435
+ };
436
+ const relationsOf = (boardId: string) => ({
437
+ folder: library.find((board) => board.id === boardId)?.folder ?? null,
438
+ readmeSummary: readmeSummary(boardId),
439
+ readmeNodeId: library.find((board) => board.id === boardId)?.readmeNodeId ?? undefined,
440
+ links: [...new Set((linksFrom.get(boardId) ?? []).flatMap((id) => names.get(id) ?? []))],
441
+ backlinks: [
442
+ ...new Set(
443
+ [...linksFrom].filter(([, targets]) => targets.includes(boardId)).flatMap(([id]) => names.get(id) ?? []),
444
+ ),
445
+ ],
446
+ });
447
+ const overview = relationsOf(active.id);
448
+ const pinReasons: Record<string, string> = {};
449
+ for (const [nodeId, meta] of Object.entries(canvasState.getContextPinMeta())) {
450
+ if (meta.reason) pinReasons[nodeId] = meta.reason;
451
+ }
452
+ const pinnedBoards: ContextBriefPinnedBoard[] = [];
453
+ for (const board of library) {
454
+ if (!board.pin || board.id === active.id) continue;
455
+ const read = readFull(board.id);
456
+ if (!read) continue;
457
+ const pins = new Set(read.state.contextPins);
458
+ pinnedBoards.push({
459
+ boardId: board.id,
460
+ name: board.name,
461
+ ...relationsOf(board.id),
462
+ cards: read.layout.nodes
463
+ .filter((node) => pins.has(node.id))
464
+ .sort((a, b) => a.id.localeCompare(b.id))
465
+ .map((node) => ({ nodeId: node.id, title: title(node), summary: short(node) })),
466
+ });
467
+ }
289
468
  const entries: ContextBriefSourceEntry[] = layout.nodes
290
469
  .filter((node) => node.createdBy?.actor === 'human')
291
470
  .map((node) => ({
@@ -293,7 +472,7 @@ const contextOperation = defineOperation<z.infer<typeof contextSchema>, Record<s
293
472
  nodeId: node.id,
294
473
  reason: 'human' as const,
295
474
  title: typeof node.data.title === 'string' ? node.data.title : node.id,
296
- text: summarizeNodeForAgentContext(node, { defaultTextLength: budget, webpageTextLength: budget }),
475
+ text: short(node),
297
476
  }));
298
477
  entries.push(
299
478
  ...canvasState
@@ -336,15 +515,41 @@ const contextOperation = defineOperation<z.infer<typeof contextSchema>, Record<s
336
515
  text: item.message,
337
516
  })),
338
517
  );
518
+ qualifications = lifecycleQualifications(
519
+ Date.now(),
520
+ [...fullReads.keys()].map((boardId) => ({ boardId })),
521
+ );
339
522
  const result = compileContextBrief({
340
- activeBoard: { boardId: active.id, name: active.name, category: active.category },
523
+ qualifications,
524
+ activeBoard: { boardId: active.id, name: active.name, folder: active.folder },
341
525
  nodes: layout.nodes,
342
526
  pinnedNodeIds,
343
527
  contentRevision: revision.revision,
344
528
  retentionFloor: revision.retentionFloor,
345
529
  tombstones: canvasState.readContentDelta(sinceValue ?? 0).deleted,
346
530
  since: explicitSince ? (Number.isSafeInteger(sinceValue) ? sinceValue : Number.NaN) : sinceValue,
531
+ overview,
532
+ memoryRelations,
533
+ edges: layout.edges.map((edge) => ({
534
+ from: edge.from,
535
+ to: edge.to,
536
+ toBoardId: edge.toBoardId,
537
+ remoteTitle: edge.toBoardId
538
+ ? (qualifiedTitle({ boardId: edge.toBoardId, cardId: edge.to }) ??
539
+ `Missing target (${edge.toBoardId} / ${edge.to})`)
540
+ : undefined,
541
+ type: edge.type,
542
+ kind: edge.kind,
543
+ reason: edge.reason,
544
+ label: edge.label ?? null,
545
+ revision: edge.revision,
546
+ changedBy: edge.changedBy,
547
+ createdAt: edge.createdAt,
548
+ changedAt: edge.changedAt,
549
+ })),
550
+ pinReasons,
347
551
  libraryBoards,
552
+ pinnedBoards,
348
553
  entries,
349
554
  budget,
350
555
  });
@@ -425,6 +630,10 @@ const undoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<str
425
630
  await syncCanvasRuntimeBackends();
426
631
  ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
427
632
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
633
+ ctx.emit('context-pins-changed', {
634
+ count: canvasState.contextPinnedNodeIds.size,
635
+ nodeIds: Array.from(canvasState.contextPinnedNodeIds),
636
+ });
428
637
  return { ok: true, description: `Undid: ${entry.description}` };
429
638
  },
430
639
  });
@@ -449,6 +658,10 @@ const redoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<str
449
658
  await syncCanvasRuntimeBackends();
450
659
  ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
451
660
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
661
+ ctx.emit('context-pins-changed', {
662
+ count: canvasState.contextPinnedNodeIds.size,
663
+ nodeIds: Array.from(canvasState.contextPinnedNodeIds),
664
+ });
452
665
  return { ok: true, description: `Redid: ${entry.description}` };
453
666
  },
454
667
  });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Slice 3 operations (plan-005, migration item 5): snapshot.list /
3
3
  * snapshot.save / snapshot.gc / snapshot.diff (+ query-param variant) /
4
- * snapshot.restore / snapshot.delete.
4
+ * snapshot.restore / snapshot.restore-node / snapshot.delete.
5
5
  *
6
6
  * Wire notes:
7
7
  * - snapshot.list serves the bare-array body (tests/e2e clearSnapshots reads
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { z } from 'zod';
26
26
  import { canvasState } from '../../canvas-state.js';
27
+ import { wikiLinksFromData } from '../../../shared/memory-graph.js';
27
28
  import {
28
29
  deleteCanvasSnapshot,
29
30
  gcCanvasSnapshots,
@@ -333,6 +334,76 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
333
334
  },
334
335
  });
335
336
 
337
+ // ── snapshot.restore-node ─────────────────────────────────────
338
+
339
+ const snapshotRestoreNodeShape = {
340
+ id: z.string().optional().catch(undefined).describe('Snapshot ID or name holding the earlier version'),
341
+ nodeId: z.string().optional().catch(undefined).describe('The card to put back as it was in that snapshot'),
342
+ preserveHumanEdits: z.boolean().optional().describe('Skip a card whose latest editor is human (receipt Undo)'),
343
+ };
344
+
345
+ const snapshotRestoreNodeSchema = z.looseObject(snapshotRestoreNodeShape);
346
+
347
+ /**
348
+ * One card back to its content in a snapshot — the receipt's Undo on an edit
349
+ * (docs/design/AgentContext.dc.html), without restoring the rest of the board.
350
+ * A normal, undoable node edit: position, links and pins stay as they are.
351
+ */
352
+ const snapshotRestoreNodeOperation = defineOperation<
353
+ z.infer<typeof snapshotRestoreNodeSchema>,
354
+ Record<string, unknown>
355
+ >({
356
+ name: 'snapshot.restore-node',
357
+ mutates: true,
358
+ input: snapshotRestoreNodeSchema,
359
+ inputShape: snapshotRestoreNodeShape,
360
+ http: {
361
+ method: 'POST',
362
+ path: '/api/canvas/snapshots/:id/restore-node',
363
+ },
364
+ handler: ({ id, nodeId, preserveHumanEdits }) => {
365
+ const snapshot = canvasState.getSnapshotData(id ?? '');
366
+ if (!snapshot) throw new OperationError('Snapshot not found', 404);
367
+ const current = canvasState.getNode(nodeId ?? '');
368
+ const earlier = snapshot.nodes.find((node) => node.id === nodeId);
369
+ if (!current || !earlier)
370
+ throw new OperationError(`Node "${nodeId}" is not in both the board and the snapshot.`, 404);
371
+ // A file card mirrors its file on disk; old text would only be overwritten by the watcher.
372
+ if (current.type === 'file') throw new OperationError('A file card follows its file; restore the file instead.');
373
+ if (earlier.type !== current.type) {
374
+ throw new OperationError(
375
+ `Node "${current.id}" was a ${earlier.type} card in that snapshot; it is a ${current.type} now.`,
376
+ 409,
377
+ );
378
+ }
379
+ // Check current ownership at the write boundary, not a browser's last poll.
380
+ if (preserveHumanEdits && current.lastEditedBy?.actor === 'human') {
381
+ return { ok: true, id: current.id, restored: false, skipped: 'human-edit' };
382
+ }
383
+ // Content only: where the card sits in groups is today's, not the snapshot's.
384
+ const data = { ...earlier.data };
385
+ for (const key of ['parentGroup', 'children'] as const) {
386
+ if (current.data[key] === undefined) delete data[key];
387
+ else data[key] = current.data[key];
388
+ }
389
+ canvasState.updateNode(
390
+ current.id,
391
+ { data },
392
+ earlier.type === 'markdown'
393
+ ? earlier.data.wikiLinks === undefined
394
+ ? null
395
+ : wikiLinksFromData(earlier.data)
396
+ : undefined,
397
+ );
398
+ return {
399
+ ok: true,
400
+ id: current.id,
401
+ restored: true,
402
+ contentRevision: canvasState.getNode(current.id)?.contentRevision ?? null,
403
+ };
404
+ },
405
+ });
406
+
336
407
  // ── snapshot.delete ───────────────────────────────────────────
337
408
 
338
409
  const snapshotDeleteShape = {
@@ -375,6 +446,7 @@ export const snapshotOperations: Operation[] = [
375
446
  snapshotGcOperation,
376
447
  snapshotDiffQueryOperation,
377
448
  snapshotDiffOperation,
449
+ snapshotRestoreNodeOperation,
378
450
  snapshotRestoreOperation,
379
451
  snapshotDeleteOperation,
380
452
  ];
@@ -32,7 +32,11 @@ const validateGetOperation = defineOperation<z.infer<typeof validateGetSchema>,
32
32
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
33
33
  }),
34
34
  },
35
- handler: () => validateCanvasLayout(canvasState.getLayout()),
35
+ handler: () =>
36
+ validateCanvasLayout(
37
+ canvasState.getLayout(),
38
+ (boardId, cardId) => !!canvasState.readBoard(boardId, false)?.layout.nodes.some((node) => node.id === cardId),
39
+ ),
36
40
  });
37
41
 
38
42
  export const validateOperations: Operation[] = [validateGetOperation];