pmx-canvas 0.8.2 → 0.9.1

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 (338) 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 +59 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2724 -476
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2899 -596
  8. package/dist/json-render/index.js +20 -20
  9. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  10. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  11. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  12. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  13. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  14. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  15. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  16. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  17. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  19. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  20. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  21. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  22. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  23. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  24. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  25. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  26. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  27. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  28. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  29. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  31. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  32. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  33. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  34. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  35. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  36. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  37. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  38. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  39. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  40. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  41. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  42. package/dist/types/client/icons.d.ts +0 -10
  43. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  44. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  45. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  46. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  47. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  48. package/dist/types/client/state/boards-store.d.ts +81 -9
  49. package/dist/types/client/state/canvas-store.d.ts +14 -1
  50. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  51. package/dist/types/client/state/context-status-store.d.ts +35 -0
  52. package/dist/types/client/state/human-store.d.ts +2 -1
  53. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  54. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  55. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  56. package/dist/types/client/state/presence-store.d.ts +4 -4
  57. package/dist/types/client/state/presentation.d.ts +28 -0
  58. package/dist/types/client/state/relation-store.d.ts +10 -0
  59. package/dist/types/client/state/session-store.d.ts +41 -2
  60. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  61. package/dist/types/client/types.d.ts +13 -1
  62. package/dist/types/client/utils/keyboard.d.ts +2 -0
  63. package/dist/types/client/utils/time-ago.d.ts +4 -0
  64. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  65. package/dist/types/server/agent-presence.d.ts +10 -3
  66. package/dist/types/server/ax-context.d.ts +1 -1
  67. package/dist/types/server/canvas-db.d.ts +30 -7
  68. package/dist/types/server/canvas-operations.d.ts +24 -2
  69. package/dist/types/server/canvas-provenance.d.ts +22 -2
  70. package/dist/types/server/canvas-serialization.d.ts +2 -0
  71. package/dist/types/server/canvas-state.d.ts +93 -15
  72. package/dist/types/server/canvas-validation.d.ts +1 -1
  73. package/dist/types/server/context-brief.d.ts +94 -6
  74. package/dist/types/server/context-reads.d.ts +33 -7
  75. package/dist/types/server/edit-summary.d.ts +20 -0
  76. package/dist/types/server/index.d.ts +71 -10
  77. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  78. package/dist/types/server/lifecycle-status.d.ts +10 -0
  79. package/dist/types/server/memory-graph.d.ts +9 -0
  80. package/dist/types/server/mutation-history.d.ts +2 -0
  81. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  82. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  83. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  84. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  85. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  86. package/dist/types/server/operations/ops/query.d.ts +23 -0
  87. package/dist/types/server/operations/registry.d.ts +1 -0
  88. package/dist/types/server/placement.d.ts +7 -0
  89. package/dist/types/server/scope-fence.d.ts +2 -1
  90. package/dist/types/server/spatial-analysis.d.ts +6 -3
  91. package/dist/types/server/tour-control.d.ts +32 -0
  92. package/dist/types/server/wiki-links.d.ts +14 -0
  93. package/dist/types/server/workbench-auth.d.ts +2 -0
  94. package/dist/types/shared/agent-context.d.ts +2 -0
  95. package/dist/types/shared/agent-presence.d.ts +15 -12
  96. package/dist/types/shared/auto-arrange.d.ts +1 -0
  97. package/dist/types/shared/boards.d.ts +4 -4
  98. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  99. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  100. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  101. package/dist/types/shared/memory-graph.d.ts +53 -0
  102. package/dist/types/shared/relations.d.ts +68 -0
  103. package/dist/types/shared/semantic-attention.d.ts +2 -0
  104. package/dist/types/shared/tour.d.ts +38 -0
  105. package/dist/types/shared/workspace-settings.d.ts +11 -0
  106. package/docs/RELEASE.md +21 -3
  107. package/docs/adr-001-bun-only-runtime.md +7 -1
  108. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  109. package/docs/api-reference.md +109 -0
  110. package/docs/ax-host-adapter-contract.md +1 -1
  111. package/docs/ax-state-contract.md +5 -4
  112. package/docs/cli.md +153 -15
  113. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  114. package/docs/design/AgentContext.dc.html +3 -3
  115. package/docs/design/BoardPins.dc.html +37 -22
  116. package/docs/design/Categories.dc.html +208 -0
  117. package/docs/design/CategoryEditor.dc.html +33 -0
  118. package/docs/design/Chrome.dc.html +52 -9
  119. package/docs/design/ContextChip.dc.html +224 -0
  120. package/docs/design/ContextChip600.dc.html +136 -0
  121. package/docs/design/EmptyState.dc.html +45 -0
  122. package/docs/design/Flows.dc.html +23 -1
  123. package/docs/design/HeaderMarks.dc.html +183 -0
  124. package/docs/design/Home.dc.html +19 -1
  125. package/docs/design/HomeGraph.dc.html +86 -202
  126. package/docs/design/HomeMap.dc.html +23 -1
  127. package/docs/design/HomeTeam.dc.html +32 -14
  128. package/docs/design/Lifecycle.dc.html +22 -0
  129. package/docs/design/Lifecycle600.dc.html +18 -0
  130. package/docs/design/LifecycleVariants.dc.html +157 -0
  131. package/docs/design/LinkAuthorship.dc.html +139 -0
  132. package/docs/design/LinksChanged.dc.html +203 -0
  133. package/docs/design/LinksOptions.dc.html +137 -0
  134. package/docs/design/LinksOptions600.dc.html +134 -0
  135. package/docs/design/NearPin.dc.html +9 -10
  136. package/docs/design/Pane600.dc.html +4 -3
  137. package/docs/design/Relations.dc.html +3 -2
  138. package/docs/design/Restore.dc.html +152 -0
  139. package/docs/design/SeeChange.dc.html +267 -0
  140. package/docs/design/Uses.dc.html +12 -6
  141. package/docs/design/canvas.json +146 -19
  142. package/docs/design.md +449 -16
  143. package/docs/environment.md +0 -1
  144. package/docs/evals/curation-effect.md +128 -0
  145. package/docs/http-api.md +459 -37
  146. package/docs/mcp.md +146 -18
  147. package/docs/node-types.md +58 -3
  148. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  149. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  150. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  151. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  152. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  153. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  154. package/docs/product-context-vision-2026-09.md +17 -1
  155. package/docs/product-vision-2026-09.md +1082 -43
  156. package/docs/product-vision-review-2026-09.md +13 -1
  157. package/docs/screenshots/workbench-live-dark.png +0 -0
  158. package/docs/screenshots/workbench-live-light.png +0 -0
  159. package/docs/sdk.md +62 -2
  160. package/package.json +1 -1
  161. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  162. package/plugins/pmx-canvas/plugin.json +1 -1
  163. package/plugins/pmx-canvas/runtime.json +1 -1
  164. package/plugins/pmx-canvas/tools.json +601 -22
  165. package/skills/pmx-canvas/SKILL.md +216 -42
  166. package/skills/pmx-canvas/evals/evals.json +224 -0
  167. package/skills/pmx-canvas/references/full-reference.md +120 -21
  168. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  169. package/skills/pmx-canvas/references/importing-context.md +132 -0
  170. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  171. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  172. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  173. package/src/cli/agent.ts +2 -0
  174. package/src/cli/commands/ax.ts +12 -0
  175. package/src/cli/commands/boards.ts +88 -11
  176. package/src/cli/commands/edges.ts +28 -0
  177. package/src/cli/commands/groups.ts +7 -0
  178. package/src/cli/commands/nodes.ts +1 -0
  179. package/src/cli/commands/query.ts +34 -2
  180. package/src/cli/commands/record.ts +199 -48
  181. package/src/cli/index.ts +6 -1
  182. package/src/client/App.tsx +177 -70
  183. package/src/client/canvas/AttentionHistory.tsx +0 -2
  184. package/src/client/canvas/BarHint.tsx +40 -2
  185. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  186. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  187. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  188. package/src/client/canvas/BoardPinButton.tsx +33 -0
  189. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  190. package/src/client/canvas/CanvasNode.tsx +360 -237
  191. package/src/client/canvas/CanvasViewport.tsx +45 -7
  192. package/src/client/canvas/CommandBar.tsx +29 -23
  193. package/src/client/canvas/CommandPalette.tsx +5 -12
  194. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  195. package/src/client/canvas/ContextChip.tsx +296 -0
  196. package/src/client/canvas/ContextMenu.tsx +63 -5
  197. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  198. package/src/client/canvas/EdgeLayer.tsx +339 -76
  199. package/src/client/canvas/EmptyState.tsx +33 -18
  200. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  201. package/src/client/canvas/ExternalWriters.tsx +57 -42
  202. package/src/client/canvas/HomeGraphView.tsx +942 -0
  203. package/src/client/canvas/HomeMapView.tsx +641 -0
  204. package/src/client/canvas/HomeReviewView.tsx +167 -0
  205. package/src/client/canvas/HomeView.tsx +687 -160
  206. package/src/client/canvas/IntentLayer.tsx +19 -12
  207. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  208. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  209. package/src/client/canvas/LifecycleFields.tsx +91 -0
  210. package/src/client/canvas/LifecyclePanel.tsx +543 -0
  211. package/src/client/canvas/MemoryInspector.tsx +104 -0
  212. package/src/client/canvas/Minimap.tsx +3 -3
  213. package/src/client/canvas/NearPinLayer.tsx +187 -0
  214. package/src/client/canvas/NodeContextMark.tsx +175 -97
  215. package/src/client/canvas/Presentation.tsx +23 -57
  216. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  217. package/src/client/canvas/RelationsPanel.tsx +82 -0
  218. package/src/client/canvas/SelectionBar.tsx +3 -6
  219. package/src/client/canvas/SessionPanel.tsx +90 -50
  220. package/src/client/canvas/SessionReceipt.tsx +311 -39
  221. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  222. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  223. package/src/client/canvas/ToolRail.tsx +367 -192
  224. package/src/client/canvas/TopBar.tsx +354 -124
  225. package/src/client/canvas/canvas-area.ts +10 -3
  226. package/src/client/canvas/graph-geometry.ts +195 -0
  227. package/src/client/canvas/graph-model.ts +254 -0
  228. package/src/client/canvas/memory-layout.ts +106 -0
  229. package/src/client/canvas/use-chrome-height.ts +20 -0
  230. package/src/client/canvas/use-focus-trap.ts +31 -5
  231. package/src/client/canvas/use-memory-graph.ts +58 -0
  232. package/src/client/canvas/use-node-drag.ts +2 -1
  233. package/src/client/canvas/use-pan-zoom.ts +64 -7
  234. package/src/client/icons.tsx +0 -63
  235. package/src/client/mcp-app/style.css +0 -6
  236. package/src/client/nodes/AxStepControls.tsx +74 -50
  237. package/src/client/nodes/BoardNode.tsx +5 -1
  238. package/src/client/nodes/ContextNode.tsx +18 -11
  239. package/src/client/nodes/ExtAppFrame.tsx +6 -3
  240. package/src/client/nodes/FileNode.tsx +46 -42
  241. package/src/client/nodes/HtmlNode.tsx +32 -0
  242. package/src/client/nodes/ImageNode.tsx +1 -1
  243. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  244. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  245. package/src/client/nodes/MarkdownNode.tsx +377 -109
  246. package/src/client/nodes/McpAppNode.tsx +50 -20
  247. package/src/client/nodes/MdFormatBar.tsx +21 -18
  248. package/src/client/nodes/PromptNode.tsx +1 -1
  249. package/src/client/nodes/ResponseNode.tsx +1 -1
  250. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  251. package/src/client/state/attention-bridge.ts +64 -15
  252. package/src/client/state/boards-store.ts +193 -39
  253. package/src/client/state/canvas-store.ts +99 -12
  254. package/src/client/state/context-chip-store.ts +70 -0
  255. package/src/client/state/context-status-store.ts +53 -0
  256. package/src/client/state/human-store.ts +13 -7
  257. package/src/client/state/intent-bridge.ts +51 -30
  258. package/src/client/state/knowledge-store.ts +43 -0
  259. package/src/client/state/near-pin-store.ts +115 -6
  260. package/src/client/state/presence-store.ts +7 -10
  261. package/src/client/state/presentation.ts +91 -0
  262. package/src/client/state/relation-store.ts +81 -0
  263. package/src/client/state/session-store.ts +144 -18
  264. package/src/client/state/sse-bridge.ts +235 -32
  265. package/src/client/state/workspace-settings.ts +11 -0
  266. package/src/client/theme/global.css +2724 -476
  267. package/src/client/types.ts +12 -0
  268. package/src/client/utils/keyboard.ts +10 -0
  269. package/src/client/utils/time-ago.ts +14 -0
  270. package/src/json-render/renderer/index.tsx +31 -0
  271. package/src/mcp/canvas-app.ts +12 -2
  272. package/src/mcp/context-uri-template.ts +7 -4
  273. package/src/mcp/server.ts +63 -41
  274. package/src/mcp/workbench-app.ts +39 -6
  275. package/src/server/agent-presence.ts +128 -28
  276. package/src/server/ax-context.ts +27 -13
  277. package/src/server/board-export.ts +12 -1
  278. package/src/server/canvas-db.ts +331 -42
  279. package/src/server/canvas-operations.ts +195 -83
  280. package/src/server/canvas-provenance.ts +53 -22
  281. package/src/server/canvas-serialization.ts +2 -0
  282. package/src/server/canvas-state.ts +950 -122
  283. package/src/server/canvas-validation.ts +9 -2
  284. package/src/server/context-brief.ts +345 -38
  285. package/src/server/context-reads.ts +178 -30
  286. package/src/server/demo-state.json +355 -24
  287. package/src/server/edit-summary.ts +74 -0
  288. package/src/server/html-surface.ts +8 -1
  289. package/src/server/index.ts +181 -11
  290. package/src/server/knowledge-lifecycle.ts +304 -0
  291. package/src/server/lifecycle-status.ts +232 -0
  292. package/src/server/memory-graph.ts +166 -0
  293. package/src/server/mutation-history.ts +18 -6
  294. package/src/server/operations/composites.ts +32 -11
  295. package/src/server/operations/http.ts +7 -2
  296. package/src/server/operations/index.ts +4 -0
  297. package/src/server/operations/mcp.ts +26 -1
  298. package/src/server/operations/ops/ax-presence.ts +1 -1
  299. package/src/server/operations/ops/ax-read.ts +57 -6
  300. package/src/server/operations/ops/ax-timeline.ts +28 -2
  301. package/src/server/operations/ops/backup.ts +5 -3
  302. package/src/server/operations/ops/batch.ts +35 -4
  303. package/src/server/operations/ops/boards.ts +188 -47
  304. package/src/server/operations/ops/canvas-wire.ts +31 -1
  305. package/src/server/operations/ops/edges.ts +36 -2
  306. package/src/server/operations/ops/groups.ts +6 -1
  307. package/src/server/operations/ops/lifecycle.ts +262 -0
  308. package/src/server/operations/ops/memory-graph.ts +45 -0
  309. package/src/server/operations/ops/nodes.ts +107 -24
  310. package/src/server/operations/ops/query.ts +281 -68
  311. package/src/server/operations/ops/snapshots.ts +73 -1
  312. package/src/server/operations/ops/validate.ts +5 -1
  313. package/src/server/operations/ops/viewport.ts +97 -3
  314. package/src/server/operations/registry.ts +74 -25
  315. package/src/server/placement.ts +29 -0
  316. package/src/server/scope-fence.ts +78 -6
  317. package/src/server/server.ts +115 -16
  318. package/src/server/spatial-analysis.ts +48 -23
  319. package/src/server/tour-control.ts +82 -0
  320. package/src/server/wiki-links.ts +137 -0
  321. package/src/server/workbench-auth.ts +11 -0
  322. package/src/shared/agent-context.ts +3 -0
  323. package/src/shared/agent-presence.ts +15 -15
  324. package/src/shared/auto-arrange.ts +2 -0
  325. package/src/shared/boards.ts +4 -4
  326. package/src/shared/knowledge-lifecycle.ts +22 -0
  327. package/src/shared/knowledge-presets.ts +80 -0
  328. package/src/shared/lifecycle-status.ts +26 -0
  329. package/src/shared/memory-graph.ts +74 -0
  330. package/src/shared/relations.ts +98 -0
  331. package/src/shared/semantic-attention.ts +15 -1
  332. package/src/shared/tour.ts +23 -5
  333. package/src/shared/workspace-settings.ts +13 -0
  334. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  335. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  336. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  337. package/dist/types/server/agent-context.d.ts +0 -22
  338. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -13,6 +13,16 @@
13
13
  */
14
14
 
15
15
  import { createHash, randomUUID } from 'node:crypto';
16
+ import {
17
+ DEFAULT_WORKSPACE_SETTINGS,
18
+ workspaceSettingsPatchSchema,
19
+ workspaceSettingsSchema,
20
+ type WorkspaceSettings,
21
+ } from '../shared/workspace-settings.js';
22
+ import { KnowledgeLifecycle, lifecycleSchema, type Lifecycle } from './knowledge-lifecycle.js';
23
+ import { relationSourceEnd } from '../shared/relations.js';
24
+ import { OperationError } from './operations/types.js';
25
+ import { humanPresence } from './human-presence.js';
16
26
  import { tourSchema, type Tour } from '../shared/tour.js';
17
27
  import {
18
28
  copyFileSync,
@@ -27,6 +37,8 @@ import {
27
37
  import { basename, isAbsolute, join, dirname, relative } from 'node:path';
28
38
  import { gzipSync, gunzipSync } from 'node:zlib';
29
39
  import { normalizeCanvasNodeData } from './canvas-provenance.js';
40
+ import { resolveWikiLinks, wikiReferences, type WikiBoard } from './wiki-links.js';
41
+ import { wikiLinksFromData, type WikiLink } from '../shared/memory-graph.js';
30
42
  import {
31
43
  openCanvasDb,
32
44
  saveStateToDB,
@@ -41,6 +53,8 @@ import {
41
53
  checkpointCanvasDb,
42
54
  createBoardInDB,
43
55
  createBoardWithStateInDB,
56
+ invalidateLinksInDB,
57
+ invalidateIncomingLinksInDB,
44
58
  readContextBriefCursor,
45
59
  advanceContextBriefCursor,
46
60
  deleteBoardFromDB,
@@ -50,6 +64,7 @@ import {
50
64
  readMetaFromDB,
51
65
  readThemeFromDB,
52
66
  updateBoardInDB,
67
+ setBoardPinInDB,
53
68
  writeMetaToDB,
54
69
  saveThemeToDB,
55
70
  setActiveBoardIdInDB,
@@ -78,9 +93,8 @@ import { normalizeCanvasTheme } from './canvas-db.js';
78
93
  import {
79
94
  type CanvasPlacementRect,
80
95
  computeGroupBounds,
96
+ computeManualGroupLayout,
81
97
  computePackedGroupLayout,
82
- GROUP_PAD,
83
- GROUP_TITLEBAR_HEIGHT,
84
98
  resolveGroupCollision,
85
99
  } from './placement.js';
86
100
  import {
@@ -117,6 +131,8 @@ import {
117
131
  appendContextReadToDB,
118
132
  loadContextReadsFromDB,
119
133
  loadNodeReadStatusFromDB,
134
+ loadSeenLinksFromDB,
135
+ loadBoardLastReadFromDB,
120
136
  loadReadNodeIdsSince,
121
137
  type ContextRead,
122
138
  type ContextReadConsumerSummary,
@@ -243,6 +259,12 @@ export interface CanvasNodeState {
243
259
  lastEditedBy?: ActorAttribution;
244
260
  /** Board-monotonic revision of this node's latest semantic content. */
245
261
  contentRevision?: number;
262
+ /** Time of that semantic change; absent for content created before this clock was recorded. */
263
+ contentChangedAt?: string;
264
+ /** Initial authored freshness, not evidence validation. Imports and undated existing content have none. */
265
+ freshnessStartedAt?: string;
266
+ /** Board-monotonic revision of this node's latest link change, kept apart from its text. */
267
+ linksRevision?: number;
246
268
  }
247
269
 
248
270
  export interface NodeDeletionTombstone {
@@ -274,10 +296,41 @@ export interface CanvasEdge {
274
296
  id: string;
275
297
  from: string;
276
298
  to: string;
299
+ /** Target on another board. Source and mutation ownership stay on the active board. */
300
+ toBoardId?: string;
277
301
  type: 'relation' | 'depends-on' | 'flow' | 'references';
302
+ /** Open semantic relation kind, independent of the structural edge type. */
303
+ kind?: string;
304
+ reason?: string;
305
+ /** Source content observed when an explicit dependency was authored. Never refreshed by reads. */
306
+ sourceRevision?: number;
278
307
  label?: string;
279
308
  style?: 'solid' | 'dashed' | 'dotted';
280
309
  animated?: boolean;
310
+ /** Board revision of this link's latest add, retype or relabel (0 = never stamped). */
311
+ revision?: number;
312
+ /** Who made that change, and when: the link's author until a person changes it again. */
313
+ changedBy?: ActorAttribution;
314
+ changedAt?: string;
315
+ /** Who first drew the link, and when (docs/design/LinkAuthorship.dc.html). */
316
+ createdBy?: ActorAttribution;
317
+ createdAt?: string;
318
+ }
319
+
320
+ type EdgeStamp = Pick<CanvasEdge, 'revision' | 'changedBy' | 'changedAt' | 'createdBy' | 'createdAt'>;
321
+
322
+ /** A link's authorship and revision fields, without the relation itself. */
323
+ function edgeStamp(edge: CanvasEdge): EdgeStamp {
324
+ const { revision, changedBy, changedAt, createdBy, createdAt } = edge;
325
+ return Object.fromEntries(
326
+ Object.entries({ revision, changedBy, changedAt, createdBy, createdAt }).filter(([, value]) => value !== undefined),
327
+ ) as EdgeStamp;
328
+ }
329
+
330
+ /** The relation itself, without its authorship and revision fields. */
331
+ function edgeWithoutStamp(edge: CanvasEdge): CanvasEdge {
332
+ const { revision: _r, changedBy: _cb, changedAt: _ca, createdBy: _crb, createdAt: _cra, ...rest } = edge;
333
+ return rest;
281
334
  }
282
335
 
283
336
  export interface CanvasAnnotationPoint {
@@ -389,14 +442,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
389
442
  return value !== null && typeof value === 'object' && !Array.isArray(value);
390
443
  }
391
444
 
392
- function replaceById<T extends { id: string }>(list: T[], item: T): T[] {
393
- const idx = list.findIndex((x) => x.id === item.id);
394
- if (idx === -1) return [...list, item];
395
- const copy = list.slice();
396
- copy[idx] = item;
397
- return copy;
398
- }
399
-
400
445
  function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
401
446
  return (
402
447
  isRecord(value) &&
@@ -597,6 +642,16 @@ class CanvasStateManager {
597
642
 
598
643
  private normalizeNode(node: CanvasNodeState): CanvasNodeState {
599
644
  const data = { ...node.data };
645
+ // Lifecycle is optional metadata, never a card taxonomy or validation clock.
646
+ delete data.categoryId;
647
+ if (data.contentOrigin !== undefined && data.contentOrigin !== 'authored' && data.contentOrigin !== 'imported') {
648
+ throw new OperationError('Content origin must be authored or imported.');
649
+ }
650
+ if (data.lifecycleOverride !== undefined && data.lifecycleOverride !== null) {
651
+ const lifecycle = lifecycleSchema.safeParse(data.lifecycleOverride);
652
+ if (!lifecycle.success) throw new OperationError('Invalid lifecycle override.');
653
+ data.lifecycleOverride = lifecycle.data;
654
+ }
600
655
  delete data.createdBy;
601
656
  delete data.lastEditedBy;
602
657
  delete data.contentRevision;
@@ -606,6 +661,7 @@ class CanvasStateManager {
606
661
  createdBy: node.createdBy ?? unknownActor(),
607
662
  lastEditedBy: node.lastEditedBy ?? unknownActor(),
608
663
  contentRevision: Number.isSafeInteger(node.contentRevision) ? node.contentRevision : 0,
664
+ linksRevision: Number.isSafeInteger(node.linksRevision) ? node.linksRevision : 0,
609
665
  };
610
666
  }
611
667
 
@@ -636,7 +692,7 @@ class CanvasStateManager {
636
692
  retentionFloor: this._revisionFloor,
637
693
  reset,
638
694
  nodes: Array.from(this.nodes.values())
639
- .filter((node) => reset || (node.contentRevision ?? 0) > cursor)
695
+ .filter((node) => reset || Math.max(node.contentRevision ?? 0, node.linksRevision ?? 0) > cursor)
640
696
  .map((node) => structuredClone(this.nodeForRead(node))),
641
697
  deleted: reset
642
698
  ? []
@@ -724,38 +780,8 @@ class CanvasStateManager {
724
780
  const snapshot = this.getGroupSnapshot(groupId);
725
781
  if (!snapshot || snapshot.children.length === 0) return;
726
782
  if (snapshot.group.data.frameMode === 'manual') {
727
- const sorted = [...snapshot.children].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
728
- const left = snapshot.group.position.x + GROUP_PAD;
729
- const top = snapshot.group.position.y + GROUP_TITLEBAR_HEIGHT + GROUP_PAD;
730
- const right = snapshot.group.position.x + snapshot.group.size.width - GROUP_PAD;
731
- const gap = 24;
732
- let cursorX = left;
733
- let cursorY = top;
734
- let rowHeight = 0;
735
-
736
- for (const child of sorted) {
737
- if (layout === 'column') {
738
- this.nodes.set(child.id, { ...child, position: { x: left, y: cursorY } });
739
- cursorY += child.size.height + gap;
740
- continue;
741
- }
742
-
743
- if (layout === 'flow') {
744
- this.nodes.set(child.id, { ...child, position: { x: cursorX, y: top } });
745
- cursorX += child.size.width + gap;
746
- continue;
747
- }
748
-
749
- if (cursorX > left && cursorX + child.size.width > right) {
750
- cursorX = left;
751
- cursorY += rowHeight + gap;
752
- rowHeight = 0;
753
- }
754
-
755
- this.nodes.set(child.id, { ...child, position: { x: cursorX, y: cursorY } });
756
- cursorX += child.size.width + gap;
757
- rowHeight = Math.max(rowHeight, child.size.height);
758
- }
783
+ const positions = computeManualGroupLayout(snapshot.children, snapshot.group, layout);
784
+ for (const child of snapshot.children) this.nodes.set(child.id, { ...child, position: positions.get(child.id)! });
759
785
  return;
760
786
  }
761
787
 
@@ -783,6 +809,16 @@ class CanvasStateManager {
783
809
  private _db: import('bun:sqlite').Database | null = null;
784
810
  private _dbPath: string | null = null;
785
811
  private _saveTimer: ReturnType<typeof setTimeout> | null = null;
812
+ readonly knowledge = new KnowledgeLifecycle(
813
+ () => this._db,
814
+ () => this.notifyChange('nodes'),
815
+ (boardId, nodeId) => {
816
+ const node = this.nodes.get(nodeId);
817
+ if (boardId !== this._activeBoardId || !node) throw new OperationError('Card not found.', 404);
818
+ return node.contentRevision ?? 0;
819
+ },
820
+ () => this.flushToDisk(),
821
+ );
786
822
 
787
823
  /** Workspace root backing persistence and workspace-relative path resolution. */
788
824
  get workspaceRoot(): string {
@@ -792,6 +828,8 @@ class CanvasStateManager {
792
828
  /** Set the workspace root to enable auto-persistence. */
793
829
  setWorkspaceRoot(workspaceRoot: string): void {
794
830
  this.close();
831
+ // Detached content cannot belong to the next database, even if loading it fails.
832
+ this.applyPersistedState(this.emptyPersistedState());
795
833
  this._workspaceRoot = workspaceRoot;
796
834
 
797
835
  // Determine DB path. PMX_CANVAS_DB_PATH wins; PMX_CANVAS_STATE_FILE is
@@ -1002,6 +1040,7 @@ class CanvasStateManager {
1002
1040
  const boardId = getActiveBoardIdFromDB(this._db);
1003
1041
  const state = boardId ? loadStateFromDB(this._db, boardId) : null;
1004
1042
  if (boardId && state) {
1043
+ this.initializePersistedWikiLinks(state, boardId);
1005
1044
  this.applyPersistedState(state);
1006
1045
  this._activeBoardId = boardId;
1007
1046
  return true;
@@ -1009,6 +1048,8 @@ class CanvasStateManager {
1009
1048
  this._theme = normalizeCanvasTheme(readThemeFromDB(this._db), this._theme);
1010
1049
  } catch (error) {
1011
1050
  logCanvasStateWarning('load state from sqlite failed', error, {});
1051
+ // Failed replacement is not an empty board: retain any live state and identity.
1052
+ return false;
1012
1053
  }
1013
1054
  }
1014
1055
 
@@ -1107,10 +1148,305 @@ class CanvasStateManager {
1107
1148
  return board ? { ...board, nodeCount: this.nodes.size } : null;
1108
1149
  }
1109
1150
 
1110
- /**
1111
- * One board's content without opening it: the open board from memory, any
1112
- * other straight from SQLite. Null when there is no such board.
1113
- */
1151
+ /** Synchronous, owner-controlled static creation. No board activation or runtime hydration. */
1152
+ createStoredNode(
1153
+ boardId: string,
1154
+ requestId: string | undefined,
1155
+ fingerprint: string,
1156
+ build: (nodes: CanvasNodeState[]) => CanvasNodeState,
1157
+ ): CanvasNodeState {
1158
+ return this.createStoredCreation(boardId, requestId, fingerprint, (state, invalidate) => {
1159
+ const next = this.stampStoredNode(this.normalizeNode(build(state.nodes)), state);
1160
+ if (next.type === 'markdown') {
1161
+ const catalog = listBoardsFromDB(this._db!).map((board) => ({
1162
+ id: board.id,
1163
+ name: board.name,
1164
+ cards: (board.id === boardId
1165
+ ? [...state.nodes, next]
1166
+ : (loadStateFromDB(this._db!, board.id)?.nodes ?? [])
1167
+ ).map((card) => ({ id: card.id, title: typeof card.data.title === 'string' ? card.data.title : card.id })),
1168
+ }));
1169
+ next.data.wikiLinks = resolveWikiLinks(String(next.data.content ?? ''), [], catalog);
1170
+ }
1171
+ state.nodes.push(next);
1172
+ const links = next.type === 'markdown' ? wikiLinksFromData(next.data) : [];
1173
+ if (links.length) {
1174
+ invalidate(boardId, next.id);
1175
+ const targets = new Map(
1176
+ links.flatMap(({ target }) => (target?.cardId ? [[JSON.stringify(target), target] as const] : [])),
1177
+ );
1178
+ for (const target of targets.values()) invalidate(target.boardId, target.cardId!);
1179
+ }
1180
+ return next;
1181
+ });
1182
+ }
1183
+
1184
+ private stampStoredNode(node: CanvasNodeState, state: PersistedCanvasState): CanvasNodeState {
1185
+ const now = new Date().toISOString();
1186
+ const provenance = node.data.provenance as { sourceKind?: string } | undefined;
1187
+ const imported =
1188
+ node.data.contentOrigin === 'imported' ||
1189
+ node.data.source !== undefined ||
1190
+ (provenance !== undefined && provenance.sourceKind !== 'recipe');
1191
+ return {
1192
+ ...node,
1193
+ createdBy: currentActor(),
1194
+ lastEditedBy: currentActor(),
1195
+ contentRevision: ++state.revisionState!.revision,
1196
+ contentChangedAt: now,
1197
+ freshnessStartedAt: imported ? undefined : now,
1198
+ };
1199
+ }
1200
+
1201
+ createStoredGroup(
1202
+ boardId: string,
1203
+ requestId: string | undefined,
1204
+ fingerprint: string,
1205
+ build: (nodes: CanvasNodeState[]) => CanvasNodeState,
1206
+ childLayout?: 'grid' | 'column' | 'flow',
1207
+ ): CanvasNodeState {
1208
+ return this.createStoredCreation(boardId, requestId, fingerprint, (state) => {
1209
+ const group = this.stampStoredNode(this.normalizeNode(build(state.nodes)), state);
1210
+ const ids = group.data.children as string[];
1211
+ const children = ids.map((id) => state.nodes.find((node) => node.id === id)!);
1212
+ const affected = new Set(ids);
1213
+ // A group can itself be a member; moving its frame moves its descendants too.
1214
+ const move = (node: CanvasNodeState, position: { x: number; y: number }) => {
1215
+ const dx = position.x - node.position.x,
1216
+ dy = position.y - node.position.y;
1217
+ const descendants = new Set<string>([node.id]);
1218
+ for (const id of descendants) {
1219
+ const child = state.nodes.find((item) => item.id === id)!;
1220
+ affected.add(id);
1221
+ if (child.type === 'group') for (const nested of child.data.children as string[]) descendants.add(nested);
1222
+ child.position = { x: child.position.x + dx, y: child.position.y + dy };
1223
+ }
1224
+ };
1225
+ if (childLayout) {
1226
+ if (group.data.frameMode === 'manual') {
1227
+ const positions = computeManualGroupLayout(children, group, childLayout);
1228
+ for (const child of children) move(child, positions.get(child.id)!);
1229
+ } else {
1230
+ const packed = computePackedGroupLayout(children);
1231
+ for (const child of children) move(child, packed.positions.get(child.id)!);
1232
+ if (packed.bounds) {
1233
+ group.position = { x: packed.bounds.x, y: packed.bounds.y };
1234
+ group.size = { width: packed.bounds.width, height: packed.bounds.height };
1235
+ }
1236
+ }
1237
+ }
1238
+ // Detach from old parents before attaching. Refit only affected ancestors.
1239
+ const parents = new Set<string>();
1240
+ for (const child of children) {
1241
+ if (typeof child.data.parentGroup === 'string') parents.add(child.data.parentGroup);
1242
+ child.data = { ...child.data, parentGroup: group.id };
1243
+ }
1244
+ for (const parentId of parents) {
1245
+ const parent = state.nodes.find((node) => node.id === parentId)!;
1246
+ affected.add(parentId);
1247
+ parent.data = {
1248
+ ...parent.data,
1249
+ children: (parent.data.children as string[]).filter((id) => !ids.includes(id)),
1250
+ };
1251
+ }
1252
+ const refit = (parentId: string) => {
1253
+ const parent = state.nodes.find((node) => node.id === parentId)!;
1254
+ if (parent.data.frameMode === 'manual') return;
1255
+ affected.add(parentId);
1256
+ const bounds = computeGroupBounds(
1257
+ state.nodes.filter((node) => (parent.data.children as string[]).includes(node.id)),
1258
+ );
1259
+ if (bounds) {
1260
+ parent.position = { x: bounds.x, y: bounds.y };
1261
+ parent.size = { width: bounds.width, height: bounds.height };
1262
+ }
1263
+ if (typeof parent.data.parentGroup === 'string') {
1264
+ refit(parent.data.parentGroup);
1265
+ }
1266
+ };
1267
+ for (const parentId of parents) refit(parentId);
1268
+ for (const node of state.nodes.filter((item) => affected.has(item.id))) {
1269
+ const held = humanPresence.lockedNodes().get(node.id);
1270
+ if (held) throw new OperationError(`Node "${node.id}" is being edited by ${held} right now.`, 409);
1271
+ // Group membership is semantic; geometry alone does not renew freshness.
1272
+ if (ids.includes(node.id) || parents.has(node.id)) {
1273
+ delete node.data.agentSummary;
1274
+ delete node.data.summary;
1275
+ node.contentRevision = ++state.revisionState!.revision;
1276
+ node.lastEditedBy = currentActor();
1277
+ node.contentChangedAt = new Date().toISOString();
1278
+ }
1279
+ }
1280
+ state.nodes.push(group);
1281
+ return group;
1282
+ });
1283
+ }
1284
+
1285
+ createStoredEdge(
1286
+ boardId: string,
1287
+ requestId: string | undefined,
1288
+ fingerprint: string,
1289
+ build: (nodes: CanvasNodeState[]) => CanvasEdge,
1290
+ ): CanvasEdge {
1291
+ return this.createStoredCreation(boardId, requestId, fingerprint, (state, invalidate) => {
1292
+ const edge = build(state.nodes);
1293
+ if (
1294
+ (!edge.toBoardId && edge.from === edge.to) ||
1295
+ state.edges.some(
1296
+ (item) =>
1297
+ item.from === edge.from &&
1298
+ item.to === edge.to &&
1299
+ item.toBoardId === edge.toBoardId &&
1300
+ item.type === edge.type &&
1301
+ item.kind === edge.kind,
1302
+ )
1303
+ )
1304
+ throw new OperationError('Duplicate or self-edge.');
1305
+ const targetBoard = edge.toBoardId ?? boardId;
1306
+ const target =
1307
+ targetBoard === boardId
1308
+ ? state.nodes.find((node) => node.id === edge.to)
1309
+ : this.readBoard(targetBoard)?.layout.nodes.find((node) => node.id === edge.to);
1310
+ for (const id of [edge.from, edge.to]) {
1311
+ const held = humanPresence.lockedNodes().get(id);
1312
+ if (held) throw new OperationError(`Node "${id}" is being edited by ${held} right now.`, 409);
1313
+ }
1314
+ const sourceEnd = relationSourceEnd(edge);
1315
+ const source = sourceEnd === 'to' ? target : state.nodes.find((node) => node.id === edge.from);
1316
+ const stamped = {
1317
+ ...edge,
1318
+ sourceRevision: sourceEnd ? source?.contentRevision : undefined,
1319
+ revision: ++state.revisionState!.revision,
1320
+ createdBy: currentActor(),
1321
+ changedBy: currentActor(),
1322
+ createdAt: new Date().toISOString(),
1323
+ changedAt: new Date().toISOString(),
1324
+ };
1325
+ state.edges.push(stamped);
1326
+ invalidate(boardId, edge.from);
1327
+ invalidate(targetBoard, edge.to);
1328
+ return stamped;
1329
+ });
1330
+ }
1331
+
1332
+ /** Stored creations share one durable receipt/commit boundary, never a temporary board switch. */
1333
+ private createStoredCreation<T extends CanvasNodeState | CanvasEdge>(
1334
+ boardId: string,
1335
+ requestId: string | undefined,
1336
+ fingerprint: string,
1337
+ build: (state: PersistedCanvasState, invalidate: (boardId: string, nodeId: string) => void) => T,
1338
+ ): T {
1339
+ const db = this._db;
1340
+ if (!db) throw new OperationError('Destination/retry creation requires persistent workspace storage.');
1341
+ const active = boardId === this._activeBoardId;
1342
+ let committed: PersistedCanvasState | undefined;
1343
+ let before: PersistedCanvasState | undefined;
1344
+ const activeLinkRevisions = new Map<string, number>();
1345
+ let activeRevision = this._contentRevision;
1346
+ const result = db.transaction(() => {
1347
+ const previous = requestId
1348
+ ? db
1349
+ .query<{ board_id: string; fingerprint: string; node: string }, [string]>(
1350
+ 'SELECT board_id, fingerprint, node FROM node_creation_requests WHERE request_id = ?',
1351
+ )
1352
+ .get(requestId)
1353
+ : null;
1354
+ if (previous) {
1355
+ if (previous.board_id !== boardId || previous.fingerprint !== fingerprint)
1356
+ throw new OperationError('requestId was already used with different creation input or destination.', 409);
1357
+ // Return the original receipt even if the node has since been edited or removed.
1358
+ return JSON.parse(previous.node) as T;
1359
+ }
1360
+ // Include pending edits in catalog resolution and durable relation clocks,
1361
+ // without publishing or changing the active board before commit.
1362
+ if (this._activeBoardId) saveStateToDB(db, this._activeBoardId, this.persistedState());
1363
+ const state = loadStateFromDB(db, boardId);
1364
+ if (!state) throw new OperationError(`Board "${boardId}" not found.`, 404);
1365
+ const policies = [this.getPolicy(), state.ax?.policy];
1366
+ if (policies.some((policy) => policy && (policy.scope || Object.keys(policy.agentScopes ?? {}).length)))
1367
+ throw new OperationError('Destination/retry creation is refused while either board has a scope fence.', 403);
1368
+ state.revisionState = {
1369
+ ...state.revisionState,
1370
+ revision: state.revisionState?.revision ?? 0,
1371
+ floor: state.revisionState?.floor ?? 0,
1372
+ tombstones: state.revisionState?.tombstones ?? [],
1373
+ };
1374
+ before = structuredClone(state);
1375
+ const invalidations: Array<[string, string]> = [];
1376
+ let next = build(state, (targetBoardId, nodeId) => invalidations.push([targetBoardId, nodeId]));
1377
+ // Theme is workspace-global; never let an inactive projection overwrite it.
1378
+ state.theme = this._theme;
1379
+ saveStateToDB(db, boardId, state);
1380
+ const invalidate = (targetBoardId: string, targetNodeId: string) => {
1381
+ const revision = invalidateLinksInDB(db, targetBoardId, targetNodeId);
1382
+ if (targetBoardId === this._activeBoardId && revision !== undefined) {
1383
+ activeLinkRevisions.set(targetNodeId, revision);
1384
+ activeRevision = Math.max(activeRevision, revision);
1385
+ }
1386
+ };
1387
+ for (const [targetBoardId, nodeId] of invalidations) invalidate(targetBoardId, nodeId);
1388
+ if ('data' in next) invalidateIncomingLinksInDB(db, boardId, next.id, invalidate);
1389
+ committed = loadStateFromDB(db, boardId)!;
1390
+ next = ([...committed.nodes, ...committed.edges].find((candidate) => candidate.id === next.id) ?? next) as T;
1391
+ if (active) activeRevision = committed.revisionState!.revision;
1392
+ if (requestId)
1393
+ db.run('INSERT INTO node_creation_requests VALUES (?, ?, ?, ?)', [
1394
+ requestId,
1395
+ boardId,
1396
+ fingerprint,
1397
+ JSON.stringify(next),
1398
+ ]);
1399
+ return next;
1400
+ })();
1401
+ if (committed) {
1402
+ if (active) {
1403
+ const changed = committed.nodes.filter(
1404
+ (node) => JSON.stringify(before!.nodes.find((old) => old.id === node.id)) !== JSON.stringify(node),
1405
+ );
1406
+ for (const node of changed) this.nodes.set(node.id, structuredClone(node));
1407
+ if ('data' in result && result.type !== 'group') this.recordNodeCreation(result);
1408
+ else {
1409
+ for (const edge of committed.edges) this.edges.set(edge.id, structuredClone(edge));
1410
+ const edge = 'data' in result ? undefined : (result as CanvasEdge);
1411
+ const restore = (nodes: CanvasNodeState[]) =>
1412
+ this.suppressed(() => {
1413
+ this.removeNode(result.id);
1414
+ for (const node of nodes) {
1415
+ const existing = this.nodes.get(node.id);
1416
+ if (existing) this.setSemanticNode({ ...structuredClone(node), linksRevision: existing.linksRevision });
1417
+ else
1418
+ this.nodes.set(node.id, {
1419
+ ...structuredClone(node),
1420
+ contentRevision: this.nextContentRevision(),
1421
+ lastEditedBy: currentActor(),
1422
+ contentChangedAt: new Date().toISOString(),
1423
+ });
1424
+ }
1425
+ this.scheduleSave();
1426
+ this.notifyChange('nodes');
1427
+ });
1428
+ this.recordMutation({
1429
+ operationType: 'data' in result ? 'groupNodes' : 'addEdge',
1430
+ description: `Created ${result.type} on stored board`,
1431
+ forward: edge ? this.suppressed(() => this.addEdge(structuredClone(edge))) : restore(changed),
1432
+ inverse:
1433
+ 'data' in result
1434
+ ? restore(before!.nodes.filter((node) => changed.some((item) => item.id === node.id)))
1435
+ : this.suppressed(() => this.removeEdge(result.id)),
1436
+ });
1437
+ }
1438
+ }
1439
+ for (const [id, revision] of activeLinkRevisions) {
1440
+ const existing = this.nodes.get(id);
1441
+ if (existing) this.nodes.set(id, { ...existing, linksRevision: revision });
1442
+ }
1443
+ this._contentRevision = activeRevision;
1444
+ if (active || activeLinkRevisions.size) this.notifyChange('nodes');
1445
+ }
1446
+ return result;
1447
+ }
1448
+
1449
+ /** Read a board without activating it. This projection is not a write payload. */
1114
1450
  readBoard(
1115
1451
  id: string,
1116
1452
  includeBlobs = true,
@@ -1145,6 +1481,7 @@ class CanvasStateManager {
1145
1481
  }
1146
1482
  const state = loadStateFromDB(this._db, id);
1147
1483
  if (!state) return null;
1484
+ this.initializePersistedWikiLinks(state, id);
1148
1485
  const pins = new Set(state.contextPins);
1149
1486
  state.nodes = state.nodes.map((node) => ({
1150
1487
  ...(includeBlobs ? this.resolveNodeDataBlobs(node) : node),
@@ -1229,12 +1566,15 @@ class CanvasStateManager {
1229
1566
  }
1230
1567
 
1231
1568
  /** Creates a board without opening it. */
1232
- createBoard(name: string, category: string | null = null): CanvasBoard | null {
1569
+ createBoard(name: string, folder: string | null = null): CanvasBoard | null {
1233
1570
  if (!this._db) return null;
1234
- return createBoardInDB(this._db, name, category);
1571
+ return createBoardInDB(this._db, name, folder);
1235
1572
  }
1236
1573
 
1237
- updateBoard(id: string, patch: { name?: string; category?: string | null }): boolean {
1574
+ updateBoard(
1575
+ id: string,
1576
+ patch: Pick<Parameters<typeof updateBoardInDB>[2], 'name' | 'folder' | 'mapPosition' | 'relationVocabulary'>,
1577
+ ): boolean {
1238
1578
  return this._db ? updateBoardInDB(this._db, id, patch) : false;
1239
1579
  }
1240
1580
 
@@ -1246,11 +1586,37 @@ class CanvasStateManager {
1246
1586
  return updateBoardInDB(this._db, id, { readmeNodeId: nodeId });
1247
1587
  }
1248
1588
 
1589
+ /**
1590
+ * Pin a board into the agent's working set (vision move 0a). Workspace-level:
1591
+ * survives board switches and restarts. A repeat pin keeps its first
1592
+ * attribution, like card pins. False when the board does not exist.
1593
+ */
1594
+ pinBoard(id: string, reason?: string): boolean {
1595
+ if (!this._db) return false;
1596
+ const board = this.listBoards().find((entry) => entry.id === id);
1597
+ if (!board) return false;
1598
+ if (board.pin) return true;
1599
+ const pinned = setBoardPinInDB(this._db, id, {
1600
+ pinnedBy: currentActor(),
1601
+ pinnedAt: new Date().toISOString(),
1602
+ ...(reason ? { reason } : {}),
1603
+ });
1604
+ if (pinned) this.notifyChange('pins');
1605
+ return pinned;
1606
+ }
1607
+
1608
+ unpinBoard(id: string): boolean {
1609
+ if (!this._db) return false;
1610
+ const unpinned = setBoardPinInDB(this._db, id, null);
1611
+ if (unpinned) this.notifyChange('pins');
1612
+ return unpinned;
1613
+ }
1614
+
1249
1615
  /** Bounded inactive-board creation; never replaces the state the human has open. */
1250
1616
  createBoardFromBoard(input: {
1251
1617
  sourceBoardId: string;
1252
1618
  name: string;
1253
- category?: string | null;
1619
+ folder?: string | null;
1254
1620
  nodeIds?: string[];
1255
1621
  includeReadme?: boolean;
1256
1622
  includeStructure?: boolean;
@@ -1314,6 +1680,8 @@ class CanvasStateManager {
1314
1680
  createdBy: actor,
1315
1681
  lastEditedBy: actor,
1316
1682
  contentRevision: index + 1,
1683
+ // The copy starts its own revision history; no read on it has carried links yet.
1684
+ linksRevision: 0,
1317
1685
  };
1318
1686
  });
1319
1687
  nodes.push({
@@ -1330,8 +1698,14 @@ class CanvasStateManager {
1330
1698
  contentRevision: nodes.length + 1,
1331
1699
  });
1332
1700
  const edges = source.layout.edges
1333
- .filter((edge) => ids.has(edge.from) && ids.has(edge.to))
1334
- .map((edge) => ({ ...edge, id: randomUUID(), from: ids.get(edge.from)!, to: ids.get(edge.to)! }));
1701
+ .filter((edge) => ids.has(edge.from) && (edge.toBoardId || ids.has(edge.to)))
1702
+ .map((source) => edgeWithoutStamp(source))
1703
+ .map((edge) => ({
1704
+ ...edge,
1705
+ id: `${edge.id.startsWith('codegraph-') ? 'codegraph-' : edge.id.startsWith('boardmap-') ? 'boardmap-' : ''}${randomUUID()}`,
1706
+ from: ids.get(edge.from)!,
1707
+ to: edge.toBoardId ? edge.to : ids.get(edge.to)!,
1708
+ }));
1335
1709
  const state: PersistedCanvasState = {
1336
1710
  version: 1,
1337
1711
  theme: source.state.theme,
@@ -1346,10 +1720,17 @@ class CanvasStateManager {
1346
1720
  const board = createBoardWithStateInDB(
1347
1721
  this._db,
1348
1722
  input.name,
1349
- input.category ?? null,
1723
+ input.folder ?? null,
1350
1724
  this.externalizePersistedStateBlobs(state),
1351
1725
  input.includeReadme && source.board.readmeNodeId ? (ids.get(source.board.readmeNodeId) ?? null) : null,
1726
+ (boardId, nodeId) => this.invalidateRelationTarget(boardId, nodeId),
1352
1727
  );
1728
+ updateBoardInDB(this._db, board.id, {
1729
+ previousBoardId: source.board.id,
1730
+ relationVocabulary: source.board.relationVocabulary,
1731
+ });
1732
+ board.previousBoardId = source.board.id;
1733
+ board.relationVocabulary = source.board.relationVocabulary;
1353
1734
  const referencedAttachmentIds = new Set<string>();
1354
1735
  for (const node of nodes) {
1355
1736
  if (typeof node.data.attachmentId === 'string') referencedAttachmentIds.add(node.data.attachmentId);
@@ -1389,7 +1770,9 @@ class CanvasStateManager {
1389
1770
  deleteBoard(id: string): boolean {
1390
1771
  if (!this._db) return false;
1391
1772
  if (id === this._activeBoardId && !this.switchBoard(null)) return false;
1392
- return deleteBoardFromDB(this._db, id);
1773
+ // Reverse relations must include outgoing links still debounced on the open board.
1774
+ if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
1775
+ return deleteBoardFromDB(this._db, id, (boardId, nodeId) => this.invalidateRelationTarget(boardId, nodeId));
1393
1776
  }
1394
1777
 
1395
1778
  /**
@@ -1410,6 +1793,7 @@ class CanvasStateManager {
1410
1793
  if (id) {
1411
1794
  const state = loadStateFromDB(this._db, id);
1412
1795
  if (!state) return false;
1796
+ this.initializePersistedWikiLinks(state, id);
1413
1797
  next = state;
1414
1798
  }
1415
1799
  this.applyPersistedState(next);
@@ -1476,6 +1860,22 @@ class CanvasStateManager {
1476
1860
  }
1477
1861
  }
1478
1862
 
1863
+ private persistedState(): PersistedCanvasState {
1864
+ return this.externalizePersistedStateBlobs({
1865
+ version: 1,
1866
+ theme: this._theme,
1867
+ tour: this._tour,
1868
+ viewport: this._viewport,
1869
+ nodes: Array.from(this.nodes.values()),
1870
+ edges: Array.from(this.edges.values()),
1871
+ annotations: Array.from(this.annotations.values()),
1872
+ contextPins: Array.from(this._contextPinnedNodeIds),
1873
+ contextPinMeta: this.pinMetaRecord(),
1874
+ ax: this.getAxState(),
1875
+ revisionState: this.revisionState(),
1876
+ });
1877
+ }
1878
+
1479
1879
  /** Write current state to SQLite immediately. */
1480
1880
  private saveToDisk(): boolean {
1481
1881
  if (!this._db) return false;
@@ -1487,20 +1887,7 @@ class CanvasStateManager {
1487
1887
  }
1488
1888
  const boardId = this.ensureActiveBoard();
1489
1889
  if (!boardId) return false;
1490
- const payload = this.externalizePersistedStateBlobs({
1491
- version: 1,
1492
- theme: this._theme,
1493
- tour: this._tour,
1494
- viewport: this._viewport,
1495
- nodes: Array.from(this.nodes.values()),
1496
- edges: Array.from(this.edges.values()),
1497
- annotations: Array.from(this.annotations.values()),
1498
- contextPins: Array.from(this._contextPinnedNodeIds),
1499
- contextPinMeta: this.pinMetaRecord(),
1500
- ax: this.getAxState(),
1501
- revisionState: this.revisionState(),
1502
- });
1503
- saveStateToDB(this._db, boardId, payload);
1890
+ saveStateToDB(this._db, boardId, this.persistedState());
1504
1891
  this._lastPersistenceError = null;
1505
1892
  return true;
1506
1893
  } catch (error) {
@@ -1553,6 +1940,10 @@ class CanvasStateManager {
1553
1940
  }
1554
1941
 
1555
1942
  private applyPersistedState(state: PersistedCanvasState): void {
1943
+ // Validate the complete replacement before discarding any live board state.
1944
+ const nodes = (Array.isArray(state.nodes) ? state.nodes : [])
1945
+ .filter((node) => node?.id)
1946
+ .map((node) => structuredClone(this.normalizeNode(node)));
1556
1947
  this._boardGeneration += 1;
1557
1948
  this._tour = state.tour ? structuredClone(state.tour) : undefined;
1558
1949
  this.nodes.clear();
@@ -1572,13 +1963,7 @@ class CanvasStateManager {
1572
1963
  };
1573
1964
  this._theme = normalizeCanvasTheme(state.theme, this._theme);
1574
1965
 
1575
- if (Array.isArray(state.nodes)) {
1576
- for (const node of state.nodes) {
1577
- if (node?.id) {
1578
- this.nodes.set(node.id, structuredClone(this.normalizeNode(node)));
1579
- }
1580
- }
1581
- }
1966
+ for (const node of nodes) this.nodes.set(node.id, node);
1582
1967
  if (Array.isArray(state.edges)) {
1583
1968
  for (const edge of state.edges) {
1584
1969
  if (edge?.id) this.edges.set(edge.id, structuredClone(edge));
@@ -1807,7 +2192,15 @@ class CanvasStateManager {
1807
2192
  tour: resolved.state.tour,
1808
2193
  theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
1809
2194
  viewport: structuredClone(resolved.state.viewport),
1810
- nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
2195
+ nodes: Array.isArray(resolved.state.nodes)
2196
+ ? resolved.state.nodes.map((node) => {
2197
+ const cloned = structuredClone(node);
2198
+ const current = this.nodes.get(cloned.id);
2199
+ return cloned.type === 'markdown' && cloned.data.wikiLinks === undefined
2200
+ ? this.bindWikiLinks(this.resolveNodeDataBlobs(cloned), current)
2201
+ : cloned;
2202
+ })
2203
+ : [],
1811
2204
  edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
1812
2205
  annotations: Array.isArray(resolved.state.annotations)
1813
2206
  ? resolved.state.annotations.map((annotation) => structuredClone(annotation))
@@ -1821,7 +2214,7 @@ class CanvasStateManager {
1821
2214
  try {
1822
2215
  const revisionBeforeRestore = this._contentRevision;
1823
2216
  this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
1824
- this.restampRestoredContent(previousState.nodes, revisionBeforeRestore);
2217
+ this.restampRestoredContent(previousState.nodes, previousState.edges, revisionBeforeRestore);
1825
2218
  this.scheduleSave();
1826
2219
  this.notifyChange('nodes');
1827
2220
  this.notifyChange('pins');
@@ -1831,9 +2224,10 @@ class CanvasStateManager {
1831
2224
  description: `Restored snapshot "${resolved.snapshot.name}"`,
1832
2225
  forward: this.suppressed(() => {
1833
2226
  const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
2227
+ const edgesBefore = this.getEdges();
1834
2228
  const revisionBeforeReplay = this._contentRevision;
1835
2229
  this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
1836
- this.restampRestoredContent(before, revisionBeforeReplay);
2230
+ this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
1837
2231
  this.scheduleSave();
1838
2232
  this.notifyChange('nodes');
1839
2233
  this.notifyChange('pins');
@@ -1841,9 +2235,10 @@ class CanvasStateManager {
1841
2235
  }),
1842
2236
  inverse: this.suppressed(() => {
1843
2237
  const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
2238
+ const edgesBefore = this.getEdges();
1844
2239
  const revisionBeforeReplay = this._contentRevision;
1845
2240
  this.applyPersistedState({ ...previousState, revisionState: this.revisionState() });
1846
- this.restampRestoredContent(before, revisionBeforeReplay);
2241
+ this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
1847
2242
  this.scheduleSave();
1848
2243
  this.notifyChange('nodes');
1849
2244
  this.notifyChange('pins');
@@ -1950,37 +2345,178 @@ class CanvasStateManager {
1950
2345
 
1951
2346
  // ── Node CRUD ──────────────────────────────────────────────
1952
2347
 
1953
- private restampRestoredContent(beforeNodes: CanvasNodeState[], minimumRevision: number): void {
2348
+ private restampRestoredContent(
2349
+ beforeNodes: CanvasNodeState[],
2350
+ beforeEdges: CanvasEdge[],
2351
+ minimumRevision: number,
2352
+ ): void {
1954
2353
  this._contentRevision = Math.max(this._contentRevision, minimumRevision);
1955
2354
  const before = new Map(beforeNodes.map((node) => [node.id, node]));
1956
2355
  const restoredIds = new Set(this.nodes.keys());
1957
2356
  for (const [id, node] of this.nodes) {
1958
2357
  const old = before.get(id);
2358
+ const linksRevision = old ? old.linksRevision : node.linksRevision;
1959
2359
  if (old && !this.nodeContentChanged(old, node)) {
1960
- this.nodes.set(id, { ...node, contentRevision: old.contentRevision, lastEditedBy: old.lastEditedBy });
2360
+ this.nodes.set(id, {
2361
+ ...node,
2362
+ contentRevision: old.contentRevision,
2363
+ contentChangedAt: old.contentChangedAt,
2364
+ linksRevision,
2365
+ lastEditedBy: old.lastEditedBy,
2366
+ });
1961
2367
  continue;
1962
2368
  }
1963
2369
  const revision = this.nextContentRevision();
1964
- this.nodes.set(id, { ...node, lastEditedBy: currentActor(), contentRevision: revision });
2370
+ this.nodes.set(id, {
2371
+ ...node,
2372
+ lastEditedBy: currentActor(),
2373
+ contentRevision: revision,
2374
+ contentChangedAt: new Date().toISOString(),
2375
+ linksRevision,
2376
+ });
2377
+ }
2378
+ for (const id of new Set([...before.keys(), ...restoredIds]))
2379
+ this.touchWikiRelations(before.get(id), this.nodes.get(id));
2380
+ for (const id of new Set([...before.keys(), ...restoredIds])) {
2381
+ if (before.has(id) !== restoredIds.has(id)) this.invalidateIncomingRelations(id);
1965
2382
  }
1966
2383
  for (const old of beforeNodes) {
1967
2384
  if (!restoredIds.has(old.id)) this.recordDeletion(old.id);
1968
2385
  }
2386
+ // A link the restore added, retyped, relabelled or removed is a link change on both ends.
2387
+ // It comes back as it was saved, author included, under a new revision.
2388
+ const relation = (edge: CanvasEdge) =>
2389
+ JSON.stringify([edge.from, edge.toBoardId, edge.to, edge.type, edge.kind, edge.label, edge.reason]);
2390
+ const edgesBefore = new Map(beforeEdges.map((edge) => [edge.id, edge]));
2391
+ for (const [id, edge] of this.edges) {
2392
+ const old = edgesBefore.get(id);
2393
+ if (old && relation(old) === relation(edge)) {
2394
+ this.edges.set(id, { ...edgeWithoutStamp(edge), ...edgeStamp(old) });
2395
+ continue;
2396
+ }
2397
+ if (old && (old.from !== edge.from || old.to !== edge.to || old.toBoardId !== edge.toBoardId))
2398
+ this.touchRelations(old);
2399
+ const stamp = this.touchRelations(edge);
2400
+ this.edges.set(
2401
+ id,
2402
+ edge.changedBy
2403
+ ? { ...edge, ...(stamp.revision !== undefined ? { revision: stamp.revision } : {}) }
2404
+ : { ...edge, ...stamp },
2405
+ );
2406
+ }
2407
+ for (const old of beforeEdges) {
2408
+ if (!this.edges.has(old.id)) this.touchRelations(old);
2409
+ }
1969
2410
  }
1970
2411
 
1971
2412
  private semanticData(data: Record<string, unknown>): Record<string, unknown> {
1972
2413
  const copy = { ...data };
1973
2414
  delete copy.strictSize;
1974
2415
  delete copy.arrangeLocked;
2416
+ delete copy.lifecycleOverride;
2417
+ delete copy.fetchedAt;
2418
+ if (copy.provenance && typeof copy.provenance === 'object') {
2419
+ const provenance = { ...(copy.provenance as Record<string, unknown>) };
2420
+ delete provenance.syncedAt;
2421
+ copy.provenance = provenance;
2422
+ }
1975
2423
  return copy;
1976
2424
  }
1977
2425
 
2426
+ /** A small name/ID catalog, read only when Markdown is saved, never on a drag frame. */
2427
+ wikiCatalog(): WikiBoard[] {
2428
+ return this.listBoards().map((board) => ({
2429
+ id: board.id,
2430
+ name: board.name,
2431
+ cards: (board.id === this._activeBoardId
2432
+ ? [...this.nodes.values()]
2433
+ : this._db
2434
+ ? (loadStateFromDB(this._db, board.id)?.nodes ?? [])
2435
+ : []
2436
+ ).map((node) => ({ id: node.id, title: typeof node.data.title === 'string' ? node.data.title : node.id })),
2437
+ }));
2438
+ }
2439
+
2440
+ /** Backfill only uninitialized live rows, not snapshots or authoritative empty/unresolved bindings. */
2441
+ private initializePersistedWikiLinks(state: PersistedCanvasState, boardId: string): void {
2442
+ const legacy = state.nodes.filter((node) => node.type === 'markdown' && node.data.wikiLinks === undefined);
2443
+ if (!legacy.length || !this._db) return;
2444
+ // Read the persisted catalog directly: readBoard would recursively initialize other boards.
2445
+ const catalog = listBoardsFromDB(this._db).map((board) => ({
2446
+ id: board.id,
2447
+ name: board.name,
2448
+ cards: (board.id === this._activeBoardId && board.id !== boardId
2449
+ ? [...this.nodes.values()]
2450
+ : board.id === boardId
2451
+ ? state.nodes
2452
+ : (loadStateFromDB(this._db!, board.id)?.nodes ?? [])
2453
+ ).map((node) => ({ id: node.id, title: typeof node.data.title === 'string' ? node.data.title : node.id })),
2454
+ }));
2455
+ this._db.transaction(() => {
2456
+ for (const node of legacy) {
2457
+ const resolved = this.resolveNodeDataBlobs(node);
2458
+ const content = typeof resolved.data.content === 'string' ? resolved.data.content : '';
2459
+ node.data = { ...node.data, wikiLinks: resolveWikiLinks(content, [], catalog) };
2460
+ this._db!.run('UPDATE nodes SET data = ? WHERE board_id = ? AND id = ?', [
2461
+ JSON.stringify(node.data),
2462
+ boardId,
2463
+ node.id,
2464
+ ]);
2465
+ }
2466
+ })();
2467
+ }
2468
+
2469
+ private bindWikiLinks(
2470
+ node: CanvasNodeState,
2471
+ existing?: CanvasNodeState,
2472
+ savedWikiLinks?: WikiLink[],
2473
+ savingContent = false,
2474
+ ): CanvasNodeState {
2475
+ if (node.type !== 'markdown') return node;
2476
+ if (savedWikiLinks !== undefined) {
2477
+ const data = { ...node.data };
2478
+ data.wikiLinks = structuredClone(savedWikiLinks);
2479
+ return { ...node, data };
2480
+ }
2481
+ if (
2482
+ !savingContent &&
2483
+ existing?.type === node.type &&
2484
+ existing.data.content === node.data.content &&
2485
+ existing.data.wikiLinks !== undefined
2486
+ ) {
2487
+ const data = { ...node.data };
2488
+ data.wikiLinks = structuredClone(existing.data.wikiLinks);
2489
+ return { ...node, data };
2490
+ }
2491
+ const content = typeof node.data.content === 'string' ? node.data.content : '';
2492
+ const previous = existing ? wikiLinksFromData(existing.data) : [];
2493
+ const initializedRefs =
2494
+ existing?.data.wikiLinks !== undefined && typeof existing.data.content === 'string'
2495
+ ? wikiReferences(existing.data.content)
2496
+ : [];
2497
+ const refs = wikiReferences(content).filter(
2498
+ (ref) => !initializedRefs.includes(ref) || previous.some((link) => link.ref === ref),
2499
+ );
2500
+ const needsResolution = refs.some(
2501
+ (ref) => !previous.some((link) => link.ref === ref && (!savingContent || link.target)),
2502
+ );
2503
+ const catalog = needsResolution ? this.wikiCatalog() : [];
2504
+ const links = refs.map((ref) => {
2505
+ const saved = previous.find((link) => link.ref === ref);
2506
+ return saved && (!savingContent || saved.target) ? saved : resolveWikiLinks(`[[${ref}]]`, [], catalog)[0];
2507
+ });
2508
+ const data = { ...node.data };
2509
+ data.wikiLinks = links;
2510
+ return { ...node, data };
2511
+ }
2512
+
1978
2513
  private nodeContentChanged(a: CanvasNodeState, b: CanvasNodeState): boolean {
1979
2514
  return a.type !== b.type || JSON.stringify(this.semanticData(a.data)) !== JSON.stringify(this.semanticData(b.data));
1980
2515
  }
1981
2516
 
1982
2517
  private setSemanticNode(node: CanvasNodeState): void {
1983
2518
  const existing = this.nodes.get(node.id);
2519
+ node = this.bindWikiLinks(node, existing);
1984
2520
  if (!existing || !this.nodeContentChanged(existing, node)) {
1985
2521
  this.nodes.set(node.id, node);
1986
2522
  return;
@@ -1994,6 +2530,7 @@ class CanvasStateManager {
1994
2530
  createdBy: existing.createdBy,
1995
2531
  lastEditedBy: currentActor(),
1996
2532
  contentRevision: this.nextContentRevision(),
2533
+ contentChangedAt: new Date().toISOString(),
1997
2534
  });
1998
2535
  }
1999
2536
 
@@ -2011,24 +2548,56 @@ class CanvasStateManager {
2011
2548
  return structuredClone(this._viewport);
2012
2549
  }
2013
2550
 
2014
- addNode(node: CanvasNodeState): void {
2551
+ addNode(node: CanvasNodeState, savedWikiLinks?: WikiLink[]): void {
2552
+ node = this.normalizeNode(node);
2015
2553
  const actor = currentActor();
2554
+ const now = new Date().toISOString();
2555
+ const provenance = node.data.provenance as { sourceKind?: string } | undefined;
2556
+ const imported =
2557
+ node.data.contentOrigin === 'imported' ||
2558
+ node.data.source !== undefined ||
2559
+ (provenance !== undefined && provenance.sourceKind !== 'recipe');
2016
2560
  const cloned = structuredClone(
2017
- this.normalizeNode({
2018
- ...node,
2019
- createdBy: actor,
2020
- lastEditedBy: actor,
2021
- contentRevision: this.nextContentRevision(),
2022
- }),
2561
+ this.normalizeNode(
2562
+ this.bindWikiLinks(
2563
+ {
2564
+ ...node,
2565
+ createdBy: actor,
2566
+ lastEditedBy: actor,
2567
+ contentRevision: this.nextContentRevision(),
2568
+ contentChangedAt: now,
2569
+ freshnessStartedAt: imported ? undefined : now,
2570
+ },
2571
+ undefined,
2572
+ savedWikiLinks,
2573
+ ),
2574
+ ),
2023
2575
  );
2024
2576
  this.nodes.set(node.id, cloned);
2577
+ this.touchWikiRelations(undefined, cloned);
2578
+ this.invalidateIncomingRelations(node.id);
2025
2579
  this.scheduleSave();
2026
2580
  this.notifyChange('nodes');
2581
+ this.recordNodeCreation(cloned);
2582
+ }
2583
+
2584
+ private recordNodeCreation(cloned: CanvasNodeState): void {
2027
2585
  this.recordMutation({
2028
2586
  operationType: 'addNode',
2029
- description: `Added ${node.type} node "${(node.data.title as string) ?? node.id}"`,
2030
- forward: this.suppressed(() => this.addNode(structuredClone(cloned))),
2031
- inverse: this.suppressed(() => this.removeNode(node.id)),
2587
+ description: `Added ${cloned.type} node "${(cloned.data.title as string) ?? cloned.id}"`,
2588
+ forward: this.suppressed(() => {
2589
+ this.nodes.set(cloned.id, {
2590
+ ...structuredClone(cloned),
2591
+ contentRevision: this.nextContentRevision(),
2592
+ contentChangedAt: new Date().toISOString(),
2593
+ lastEditedBy: currentActor(),
2594
+ });
2595
+ this.touchWikiRelations(undefined, this.nodes.get(cloned.id));
2596
+ this.invalidateIncomingRelations(cloned.id);
2597
+ this.scheduleSave();
2598
+ this.notifyChange('nodes');
2599
+ }),
2600
+ inverse: this.suppressed(() => this.removeNode(cloned.id)),
2032
2601
  });
2033
2602
  }
2034
2603
 
@@ -2040,10 +2609,39 @@ class CanvasStateManager {
2040
2609
  this.addNode(node);
2041
2610
  }
2042
2611
 
2043
- updateNode(id: string, patch: Partial<CanvasNodeState>): void {
2612
+ /** Metadata stays on the stored node: no blob hydration, wiki resolution or group reflow. */
2613
+ setNodeLifecycle(id: string, value: Lifecycle | null): void {
2614
+ const existing = this.nodes.get(id);
2615
+ if (!existing) throw new OperationError('Card not found.', 404);
2616
+ const lifecycle = value === null ? null : lifecycleSchema.parse(value);
2617
+ const previous = structuredClone(existing.data.lifecycleOverride) as Lifecycle | null | undefined;
2618
+ const apply = (override: Lifecycle | null | undefined) => {
2619
+ const node = this.nodes.get(id);
2620
+ if (!node) return;
2621
+ const data = { ...node.data };
2622
+ if (override === undefined) delete data.lifecycleOverride;
2623
+ else data.lifecycleOverride = structuredClone(override);
2624
+ this.nodes.set(id, { ...node, data });
2625
+ this.scheduleSave();
2626
+ this.notifyChange('nodes');
2627
+ };
2628
+ apply(lifecycle);
2629
+ this.recordMutation({
2630
+ operationType: 'updateNode',
2631
+ description: `Changed lifecycle of "${existing.data.title ?? id}"`,
2632
+ forward: this.suppressed(() => apply(lifecycle)),
2633
+ inverse: this.suppressed(() => apply(previous)),
2634
+ });
2635
+ }
2636
+
2637
+ // null replays a legacy snapshot using existing records before resolving new refs.
2638
+ updateNode(id: string, patch: Partial<CanvasNodeState>, savedWikiLinks?: WikiLink[] | null): void {
2044
2639
  const existing = this.nodes.get(id);
2045
2640
  if (!existing) return;
2046
2641
  if (patch.data) {
2642
+ if (patch.data.contentOrigin !== existing.data.contentOrigin) {
2643
+ throw new OperationError('Content origin is immutable after creation.');
2644
+ }
2047
2645
  const attachmentId = existing.data.attachmentId;
2048
2646
  if (typeof attachmentId === 'string' && patch.data.attachmentId !== attachmentId) {
2049
2647
  throw new Error('Attachment identity is immutable.');
@@ -2056,10 +2654,17 @@ class CanvasStateManager {
2056
2654
  }
2057
2655
  }
2058
2656
  const oldSnapshot = structuredClone(existing);
2657
+ let nextNode = this.normalizeNode(
2658
+ this.bindWikiLinks(
2659
+ { ...existing, ...patch, freshnessStartedAt: existing.freshnessStartedAt },
2660
+ existing,
2661
+ savedWikiLinks ?? undefined,
2662
+ savedWikiLinks !== null && !!patch.data && 'content' in patch.data,
2663
+ ),
2664
+ );
2059
2665
  if (existing.type === 'group' && patch.position) {
2060
2666
  this.translateGroupChildren(id, patch.position.x - existing.position.x, patch.position.y - existing.position.y);
2061
2667
  }
2062
- let nextNode = this.normalizeNode({ ...existing, ...patch });
2063
2668
  if (this.nodeContentChanged(existing, nextNode)) {
2064
2669
  const data = { ...nextNode.data };
2065
2670
  const suppliedData = patch.data;
@@ -2071,6 +2676,7 @@ class CanvasStateManager {
2071
2676
  createdBy: existing.createdBy ?? unknownActor(),
2072
2677
  lastEditedBy: currentActor(),
2073
2678
  contentRevision: this.nextContentRevision(),
2679
+ contentChangedAt: new Date().toISOString(),
2074
2680
  };
2075
2681
  } else {
2076
2682
  nextNode = {
@@ -2078,9 +2684,11 @@ class CanvasStateManager {
2078
2684
  createdBy: existing.createdBy,
2079
2685
  lastEditedBy: existing.lastEditedBy,
2080
2686
  contentRevision: existing.contentRevision,
2687
+ contentChangedAt: existing.contentChangedAt,
2081
2688
  };
2082
2689
  }
2083
2690
  this.nodes.set(id, nextNode);
2691
+ this.touchWikiRelations(existing, nextNode);
2084
2692
  const parentGroupId = existing.data.parentGroup as string | undefined;
2085
2693
  if (parentGroupId) {
2086
2694
  // Moving or resizing a grouped child re-fits the group frame but must NOT
@@ -2094,18 +2702,29 @@ class CanvasStateManager {
2094
2702
  this.recordMutation({
2095
2703
  operationType: 'updateNode',
2096
2704
  description: `Updated node "${(existing.data.title as string) ?? id}"`,
2097
- forward: this.suppressed(() => this.updateNode(id, structuredClone(patch))),
2705
+ forward: this.suppressed(() =>
2706
+ this.updateNode(
2707
+ id,
2708
+ structuredClone(patch),
2709
+ nextNode.type === 'markdown' ? wikiLinksFromData(nextNode.data) : undefined,
2710
+ ),
2711
+ ),
2098
2712
  inverse: this.suppressed(() => {
2099
2713
  const restored = structuredClone(oldSnapshot);
2100
2714
  const current = this.nodes.get(id);
2101
2715
  if (current && this.nodeContentChanged(current, restored)) {
2102
2716
  restored.contentRevision = this.nextContentRevision();
2717
+ restored.contentChangedAt = new Date().toISOString();
2103
2718
  restored.lastEditedBy = currentActor();
2104
2719
  } else if (current) {
2105
2720
  restored.contentRevision = current.contentRevision;
2721
+ restored.contentChangedAt = current.contentChangedAt;
2106
2722
  restored.lastEditedBy = current.lastEditedBy;
2107
2723
  }
2724
+ // Links are tracked apart: undoing a node change never rolls its links revision back.
2725
+ if (current) restored.linksRevision = current.linksRevision;
2108
2726
  this.nodes.set(id, restored);
2727
+ this.touchWikiRelations(current, restored);
2109
2728
  this.scheduleSave();
2110
2729
  this.notifyChange('nodes');
2111
2730
  }),
@@ -2150,6 +2769,10 @@ class CanvasStateManager {
2150
2769
  }
2151
2770
  }
2152
2771
 
2772
+ if (existing) {
2773
+ this.touchWikiRelations(existing, undefined);
2774
+ this.invalidateIncomingRelations(id);
2775
+ }
2153
2776
  this.nodes.delete(id);
2154
2777
  this.removeEdgesForNode(id);
2155
2778
  this._contextPinnedNodeIds.delete(id);
@@ -2200,7 +2823,10 @@ class CanvasStateManager {
2200
2823
  : `Removed ${cloned.type} node "${title}"`,
2201
2824
  forward: this.suppressed(() => this.removeNode(id)),
2202
2825
  inverse: this.suppressed(() => {
2203
- this.addNode(structuredClone(cloned));
2826
+ this.addNode(
2827
+ structuredClone(cloned),
2828
+ cloned.type === 'markdown' ? wikiLinksFromData(cloned.data) : undefined,
2829
+ );
2204
2830
  const restored = this.nodes.get(id);
2205
2831
  if (restored) this.nodes.set(id, { ...restored, createdBy: cloned.createdBy ?? unknownActor() });
2206
2832
  // A restored group takes its children back; the enclosing group's membership is restored verbatim.
@@ -2237,6 +2863,16 @@ class CanvasStateManager {
2237
2863
  return node ? structuredClone(this.nodeForRead(node)) : undefined;
2238
2864
  }
2239
2865
 
2866
+ /** Who made and last edited each card, and its content revision — no content, no copies. */
2867
+ nodeStamps(): Array<Pick<CanvasNodeState, 'id' | 'createdBy' | 'lastEditedBy' | 'contentRevision'>> {
2868
+ return [...this.nodes.values()].map(({ id, createdBy, lastEditedBy, contentRevision }) => ({
2869
+ id,
2870
+ createdBy,
2871
+ lastEditedBy,
2872
+ contentRevision,
2873
+ }));
2874
+ }
2875
+
2240
2876
  getNodeForPersistence(id: string): CanvasNodeState | undefined {
2241
2877
  const node = this.nodes.get(id);
2242
2878
  return node ? structuredClone(this.externalizeNodeDataBlobs(node)) : undefined;
@@ -2244,15 +2880,100 @@ class CanvasStateManager {
2244
2880
 
2245
2881
  // ── Edge CRUD ──────────────────────────────────────────────
2246
2882
 
2883
+ /**
2884
+ * Links are tracked apart from text (connection changes, option C of
2885
+ * docs/design/LinksOptions.dc.html): a link change advances both endpoints'
2886
+ * links revision, never their content revision, so cards keep their read
2887
+ * marks while the next incremental brief resends them with their new
2888
+ * relations. Generated auto-edges (code graph, board map) do not count.
2889
+ * Returns the stamp for the changed link.
2890
+ */
2891
+ private touchRelations(
2892
+ edge: Pick<CanvasEdge, 'id' | 'from' | 'to' | 'toBoardId'>,
2893
+ ): Pick<CanvasEdge, 'revision' | 'changedBy' | 'changedAt'> {
2894
+ if (edge.id.startsWith('codegraph-') || edge.id.startsWith('boardmap-')) return {};
2895
+ const revision = this.nextContentRevision();
2896
+ for (const id of [edge.from, ...(!edge.toBoardId || edge.toBoardId === this._activeBoardId ? [edge.to] : [])]) {
2897
+ const node = this.nodes.get(id);
2898
+ if (node) this.nodes.set(id, { ...node, linksRevision: revision });
2899
+ }
2900
+ if (edge.toBoardId && edge.toBoardId !== this._activeBoardId)
2901
+ this.invalidateRelationTarget(edge.toBoardId, edge.to);
2902
+ return { revision, changedBy: currentActor(), changedAt: new Date().toISOString() };
2903
+ }
2904
+
2905
+ private invalidateRelationTarget(boardId: string, nodeId: string): void {
2906
+ if (boardId === this._activeBoardId) {
2907
+ const node = this.nodes.get(nodeId);
2908
+ if (node) {
2909
+ this.nodes.set(nodeId, { ...node, linksRevision: this.nextContentRevision() });
2910
+ this.scheduleSave();
2911
+ this.notifyChange('nodes');
2912
+ }
2913
+ } else if (this._db) invalidateLinksInDB(this._db, boardId, nodeId);
2914
+ }
2915
+
2916
+ /** Saved wiki relationships have the same source/target clocks as native links. */
2917
+ private touchWikiRelations(before?: CanvasNodeState, after?: CanvasNodeState): void {
2918
+ const previous = before?.type === 'markdown' ? wikiLinksFromData(before.data) : [];
2919
+ const next = after?.type === 'markdown' ? wikiLinksFromData(after.data) : [];
2920
+ if (JSON.stringify(previous) === JSON.stringify(next)) return;
2921
+ const source = after ?? before;
2922
+ if (source && this._activeBoardId) this.invalidateRelationTarget(this._activeBoardId, source.id);
2923
+ const targets = new Map(
2924
+ [...previous, ...next].flatMap(({ target }) =>
2925
+ target?.cardId ? [[JSON.stringify(target), target] as const] : [],
2926
+ ),
2927
+ );
2928
+ for (const target of targets.values()) this.invalidateRelationTarget(target.boardId, target.cardId!);
2929
+ }
2930
+
2931
+ private invalidateIncomingRelations(nodeId: string): void {
2932
+ for (const source of this.nodes.values()) {
2933
+ if (
2934
+ source.type === 'markdown' &&
2935
+ wikiLinksFromData(source.data).some(
2936
+ ({ target }) => target?.boardId === this._activeBoardId && target.cardId === nodeId,
2937
+ )
2938
+ )
2939
+ this.invalidateRelationTarget(this._activeBoardId!, source.id);
2940
+ }
2941
+ if (this._db && this._activeBoardId)
2942
+ invalidateIncomingLinksInDB(this._db, this._activeBoardId, nodeId, (boardId, sourceId) =>
2943
+ this.invalidateRelationTarget(boardId, sourceId),
2944
+ );
2945
+ }
2946
+
2247
2947
  addEdge(edge: CanvasEdge): boolean {
2248
- if (edge.from === edge.to) return false;
2948
+ if (!edge.toBoardId && edge.from === edge.to) return false;
2249
2949
  for (const existing of this.edges.values()) {
2250
- if (existing.from === edge.from && existing.to === edge.to && existing.type === edge.type) {
2950
+ if (
2951
+ existing.from === edge.from &&
2952
+ existing.to === edge.to &&
2953
+ existing.toBoardId === edge.toBoardId &&
2954
+ existing.type === edge.type &&
2955
+ existing.kind === edge.kind
2956
+ ) {
2251
2957
  return false;
2252
2958
  }
2253
2959
  }
2254
- const cloned = structuredClone(edge);
2255
- this.edges.set(edge.id, edge);
2960
+ if (!edge.changedBy) edge = { ...edge, sourceRevision: this.relationSourceRevision(edge) };
2961
+ const stamp = this.touchRelations(edge);
2962
+ // A link that arrives with an author is being restored (undo of a removal, a
2963
+ // demo seed): it keeps its authorship and only takes a new revision. The API
2964
+ // builds new links from scratch, so a new link is always the caller's.
2965
+ this.edges.set(
2966
+ edge.id,
2967
+ edge.changedBy
2968
+ ? { ...edge, ...(stamp.revision !== undefined ? { revision: stamp.revision } : {}) }
2969
+ : {
2970
+ ...edge,
2971
+ ...stamp,
2972
+ ...(stamp.changedBy ? { createdBy: stamp.changedBy, createdAt: stamp.changedAt } : {}),
2973
+ },
2974
+ );
2975
+ // Redo replays the link as stored, with its author.
2976
+ const cloned = structuredClone(this.edges.get(edge.id) as CanvasEdge);
2256
2977
  this.scheduleSave();
2257
2978
  this.notifyChange('nodes');
2258
2979
  this.recordMutation({
@@ -2264,25 +2985,54 @@ class CanvasStateManager {
2264
2985
  return true;
2265
2986
  }
2266
2987
 
2267
- updateEdge(id: string, patch: Partial<Pick<CanvasEdge, 'type' | 'label' | 'style' | 'animated'>>): CanvasEdge | null {
2988
+ private relationSourceRevision(edge: CanvasEdge): number | undefined {
2989
+ const end = relationSourceEnd(edge);
2990
+ if (!end) return undefined;
2991
+ const node =
2992
+ end === 'to' && edge.toBoardId && edge.toBoardId !== this._activeBoardId
2993
+ ? this.readBoard(edge.toBoardId, false)?.layout.nodes.find((item) => item.id === edge.to)
2994
+ : this.nodes.get(end === 'from' ? edge.from : edge.to);
2995
+ return node?.contentRevision;
2996
+ }
2997
+
2998
+ updateEdge(
2999
+ id: string,
3000
+ patch: Partial<Pick<CanvasEdge, 'type' | 'kind' | 'reason' | 'label' | 'style' | 'animated'>>,
3001
+ ): CanvasEdge | null {
2268
3002
  const existing = this.edges.get(id);
2269
3003
  if (!existing) return null;
2270
3004
  // Retyping onto an existing from/to/type triple would create the duplicate
2271
3005
  // addEdge refuses.
2272
- if (patch.type && patch.type !== existing.type) {
3006
+ const candidate = { ...existing, ...patch };
3007
+ if (candidate.type !== existing.type || candidate.kind !== existing.kind) {
2273
3008
  for (const other of this.edges.values()) {
2274
- if (other.id !== id && other.from === existing.from && other.to === existing.to && other.type === patch.type) {
3009
+ if (
3010
+ other.id !== id &&
3011
+ other.from === existing.from &&
3012
+ other.to === existing.to &&
3013
+ other.toBoardId === existing.toBoardId &&
3014
+ other.type === candidate.type &&
3015
+ other.kind === candidate.kind
3016
+ ) {
2275
3017
  return null;
2276
3018
  }
2277
3019
  }
2278
3020
  }
2279
3021
  const before = structuredClone(existing);
2280
3022
  const updated: CanvasEdge = { ...existing, ...patch };
3023
+ if (candidate.type !== existing.type || candidate.kind !== existing.kind)
3024
+ updated.sourceRevision = this.relationSourceRevision(updated);
2281
3025
  // An explicit undefined in the patch clears the field (label/style/animated
2282
3026
  // are optional on the wire shape — never store undefined values).
2283
- for (const key of ['label', 'style', 'animated'] as const) {
3027
+ for (const key of ['kind', 'reason', 'label', 'style', 'animated'] as const) {
2284
3028
  if (key in patch && patch[key] === undefined) delete updated[key];
2285
3029
  }
3030
+ const relationChanged =
3031
+ updated.type !== before.type ||
3032
+ updated.label !== before.label ||
3033
+ updated.kind !== before.kind ||
3034
+ updated.reason !== before.reason;
3035
+ if (relationChanged) Object.assign(updated, this.touchRelations(updated));
2286
3036
  const after = structuredClone(updated);
2287
3037
  this.edges.set(id, updated);
2288
3038
  this.scheduleSave();
@@ -2291,12 +3041,12 @@ class CanvasStateManager {
2291
3041
  operationType: 'updateEdge',
2292
3042
  description: `Updated ${updated.type} edge ${updated.from} → ${updated.to}`,
2293
3043
  forward: this.suppressed(() => {
2294
- this.edges.set(id, structuredClone(after));
3044
+ this.edges.set(id, this.replayEdge(after, relationChanged));
2295
3045
  this.scheduleSave();
2296
3046
  this.notifyChange('nodes');
2297
3047
  }),
2298
3048
  inverse: this.suppressed(() => {
2299
- this.edges.set(id, structuredClone(before));
3049
+ this.edges.set(id, this.replayEdge(before, relationChanged));
2300
3050
  this.scheduleSave();
2301
3051
  this.notifyChange('nodes');
2302
3052
  }),
@@ -2304,11 +3054,27 @@ class CanvasStateManager {
2304
3054
  return structuredClone(updated);
2305
3055
  }
2306
3056
 
3057
+ /**
3058
+ * An edge as undo/redo puts it back. A relation change restores the link as it
3059
+ * was, author included, under a new revision so the next brief carries it; a
3060
+ * style-only change keeps the current stamp, so it never hides a later relabel.
3061
+ */
3062
+ private replayEdge(snapshot: CanvasEdge, relationChanged: boolean): CanvasEdge {
3063
+ const edge = structuredClone(snapshot);
3064
+ if (relationChanged) {
3065
+ const { revision } = this.touchRelations(edge);
3066
+ return revision === undefined ? edge : { ...edge, revision };
3067
+ }
3068
+ const current = this.edges.get(snapshot.id);
3069
+ return { ...edgeWithoutStamp(edge), ...(current ? edgeStamp(current) : {}) };
3070
+ }
3071
+
2307
3072
  removeEdge(id: string): boolean {
2308
3073
  const existing = this.edges.get(id);
2309
3074
  const cloned = existing ? structuredClone(existing) : null;
2310
3075
  const removed = this.edges.delete(id);
2311
3076
  if (removed && cloned) {
3077
+ this.touchRelations(cloned);
2312
3078
  this.scheduleSave();
2313
3079
  this.notifyChange('nodes');
2314
3080
  this.recordMutation({
@@ -2327,7 +3093,10 @@ class CanvasStateManager {
2327
3093
 
2328
3094
  getEdgesForNode(nodeId: string): CanvasEdge[] {
2329
3095
  return Array.from(this.edges.values())
2330
- .filter((edge) => edge.from === nodeId || edge.to === nodeId)
3096
+ .filter(
3097
+ (edge) =>
3098
+ edge.from === nodeId || ((!edge.toBoardId || edge.toBoardId === this._activeBoardId) && edge.to === nodeId),
3099
+ )
2331
3100
  .map((edge) => structuredClone(edge));
2332
3101
  }
2333
3102
 
@@ -2367,8 +3136,10 @@ class CanvasStateManager {
2367
3136
 
2368
3137
  private removeEdgesForNode(nodeId: string): void {
2369
3138
  for (const [id, edge] of this.edges) {
2370
- if (edge.from === nodeId || edge.to === nodeId) {
3139
+ if (edge.from === nodeId || ((!edge.toBoardId || edge.toBoardId === this._activeBoardId) && edge.to === nodeId)) {
2371
3140
  this.edges.delete(id);
3141
+ // The card on the other end lost a link.
3142
+ this.touchRelations(edge);
2372
3143
  }
2373
3144
  }
2374
3145
  }
@@ -2490,7 +3261,8 @@ class CanvasStateManager {
2490
3261
  }),
2491
3262
  inverse: this.suppressed(() => {
2492
3263
  for (const snapshot of inverseSnapshots) {
2493
- this.nodes.set(snapshot.id, structuredClone(snapshot.node));
3264
+ const linksRevision = this.nodes.get(snapshot.id)?.linksRevision ?? snapshot.node.linksRevision;
3265
+ this.nodes.set(snapshot.id, { ...structuredClone(snapshot.node), linksRevision });
2494
3266
  }
2495
3267
  this.reflowAllGroups();
2496
3268
  this.scheduleSave();
@@ -2522,6 +3294,23 @@ class CanvasStateManager {
2522
3294
  return this._theme;
2523
3295
  }
2524
3296
 
3297
+ getWorkspaceSettings(): WorkspaceSettings {
3298
+ const stored = this._db ? readMetaFromDB(this._db, 'workspace_settings') : null;
3299
+ return stored === null
3300
+ ? { ...DEFAULT_WORKSPACE_SETTINGS }
3301
+ : workspaceSettingsSchema.parse({ ...DEFAULT_WORKSPACE_SETTINGS, ...JSON.parse(stored) });
3302
+ }
3303
+
3304
+ updateWorkspaceSettings(patch: Partial<WorkspaceSettings>): WorkspaceSettings {
3305
+ if (!this._db) throw new OperationError('Settings need a workspace database.', 409);
3306
+ const next = workspaceSettingsSchema.parse({
3307
+ ...this.getWorkspaceSettings(),
3308
+ ...workspaceSettingsPatchSchema.parse(patch),
3309
+ });
3310
+ writeMetaToDB(this._db, 'workspace_settings', JSON.stringify(next));
3311
+ return next;
3312
+ }
3313
+
2525
3314
  setTheme(theme: CanvasTheme): CanvasTheme {
2526
3315
  const next = normalizeCanvasTheme(theme, this._theme);
2527
3316
  if (next === this._theme) return this._theme;
@@ -2878,7 +3667,11 @@ class CanvasStateManager {
2878
3667
  recordContextRead(input: ContextReadInput, boardId: string | null = this._activeBoardId): ContextRead | null {
2879
3668
  if (!this._db) return null;
2880
3669
  try {
2881
- const read = appendContextReadToDB(this._db, input, boardId, this.readRevisions(input, boardId));
3670
+ // A graph read may mark an inactive target seen while its outgoing source
3671
+ // edges are still debounced in memory. Never book seen before the source.
3672
+ if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
3673
+ const { readNodes, seenLinks } = this.readRevisions(input, boardId);
3674
+ const read = appendContextReadToDB(this._db, input, boardId, readNodes, seenLinks);
2882
3675
  this._contextReadListener?.(boardId);
2883
3676
  return read;
2884
3677
  } catch (error) {
@@ -2887,18 +3680,41 @@ class CanvasStateManager {
2887
3680
  }
2888
3681
  }
2889
3682
 
2890
- /** The delivered ids that are nodes on the read board, each with its content revision now. */
2891
- private readRevisions(input: ContextReadInput, boardId: string | null): Record<string, number> {
2892
- if (!boardId) return {};
3683
+ /**
3684
+ * The delivered ids that are nodes on the read board: those read in full with
3685
+ * their content revision now, and every one that arrived (summaries included)
3686
+ * with its links revision now.
3687
+ */
3688
+ private readRevisions(
3689
+ input: ContextReadInput,
3690
+ boardId: string | null,
3691
+ ): { readNodes: Record<string, number>; seenLinks: Record<string, number> } {
3692
+ const readIds = input.readNodeIds ?? input.deliveredNodeIds;
3693
+ const seenIds = input.seenNodeIds ?? readIds;
3694
+ if (!boardId || (readIds.length === 0 && seenIds.length === 0)) return { readNodes: {}, seenLinks: {} };
2893
3695
  const nodes =
2894
3696
  boardId === this._activeBoardId ? [...this.nodes.values()] : (this.readBoard(boardId, false)?.state.nodes ?? []);
2895
- const byId = new Map(nodes.map((node) => [node.id, node.contentRevision ?? 0]));
2896
- const revisions: Record<string, number> = {};
2897
- for (const id of input.readNodeIds ?? input.deliveredNodeIds) {
2898
- const revision = byId.get(id);
2899
- if (revision !== undefined) revisions[id] = revision;
3697
+ const byId = new Map(nodes.map((node) => [node.id, node]));
3698
+ const readNodes: Record<string, number> = {};
3699
+ for (const id of readIds) {
3700
+ const node = byId.get(id);
3701
+ if (node) readNodes[id] = node.contentRevision ?? 0;
3702
+ }
3703
+ const seenLinks: Record<string, number> = {};
3704
+ for (const id of seenIds) {
3705
+ const node = byId.get(id);
3706
+ if (!node) continue;
3707
+ const revision = input.seenLinkRevisions ? input.seenLinkRevisions[id] : (node.linksRevision ?? 0);
3708
+ if (revision !== undefined && Number.isSafeInteger(revision) && revision >= 0)
3709
+ seenLinks[id] = Math.min(revision, node.linksRevision ?? 0);
2900
3710
  }
2901
- return revisions;
3711
+ return { readNodes, seenLinks };
3712
+ }
3713
+
3714
+ /** Per node on a board, the newest links revision an agent read carried. */
3715
+ getSeenLinks(boardId: string | null = this._activeBoardId): Record<string, number> {
3716
+ if (!this._db || !boardId) return {};
3717
+ return loadSeenLinksFromDB(this._db, boardId);
2902
3718
  }
2903
3719
 
2904
3720
  /** Per node on a board, the latest agent read that delivered its content. */
@@ -2907,6 +3723,12 @@ class CanvasStateManager {
2907
3723
  return loadNodeReadStatusFromDB(this._db, boardId);
2908
3724
  }
2909
3725
 
3726
+ /** The latest agent read on a board, of any kind, or null. */
3727
+ getBoardLastRead(boardId: string | null = this._activeBoardId): { lastReadAt: string; lastReadBy: string } | null {
3728
+ if (!this._db || !boardId) return null;
3729
+ return loadBoardLastReadFromDB(this._db, boardId);
3730
+ }
3731
+
2910
3732
  /** Nodes on a board whose content an agent read at or after `since` (ISO). */
2911
3733
  getReadNodeIdsSince(boardId: string | null, since: string): string[] {
2912
3734
  if (!this._db || !boardId) return [];
@@ -3103,7 +3925,12 @@ class CanvasStateManager {
3103
3925
  const oldPins = Array.from(this._contextPinnedNodeIds);
3104
3926
  const oldAxState = this.getAxState();
3105
3927
  const oldViewport = { ...this._viewport };
3106
- for (const node of oldNodes) this.recordDeletion(node.id);
3928
+ for (const edge of oldEdges) this.touchRelations(edge);
3929
+ for (const node of oldNodes) {
3930
+ this.touchWikiRelations(node, undefined);
3931
+ this.invalidateIncomingRelations(node.id);
3932
+ this.recordDeletion(node.id);
3933
+ }
3107
3934
  this.nodes.clear();
3108
3935
  this.edges.clear();
3109
3936
  this.annotations.clear();
@@ -3123,7 +3950,8 @@ class CanvasStateManager {
3123
3950
  description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
3124
3951
  forward: this.suppressed(() => this.clear()),
3125
3952
  inverse: this.suppressed(() => {
3126
- for (const n of oldNodes) this.addNode(structuredClone(n));
3953
+ for (const n of oldNodes)
3954
+ this.addNode(structuredClone(n), n.type === 'markdown' ? wikiLinksFromData(n.data) : undefined);
3127
3955
  for (const e of oldEdges) this.addEdge(structuredClone(e));
3128
3956
  for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
3129
3957
  this.setContextPins(oldPins);