pmx-canvas 0.7.2 → 0.8.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 (251) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +60 -0
  10. package/Readme.md +44 -16
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +844 -710
  23. package/dist/canvas/index.js +92 -90
  24. package/dist/canvas/mcp-app.html +7878 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +1 -1
  28. package/dist/json-render/index.js +55 -55
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  32. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  33. package/dist/types/client/icons.d.ts +1 -2
  34. package/dist/types/client/mcp-app/index.d.ts +1 -0
  35. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  36. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  37. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  38. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  39. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  40. package/dist/types/client/state/attention-store.d.ts +0 -5
  41. package/dist/types/client/state/canvas-store.d.ts +2 -0
  42. package/dist/types/client/state/context-status-store.d.ts +53 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +4 -1
  44. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  45. package/dist/types/client/state/presence-store.d.ts +5 -0
  46. package/dist/types/client/state/session-store.d.ts +33 -3
  47. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  48. package/dist/types/client/theme/tokens.d.ts +1 -3
  49. package/dist/types/client/types.d.ts +9 -0
  50. package/dist/types/json-render/renderer/index.d.ts +2 -1
  51. package/dist/types/json-render/server.d.ts +3 -1
  52. package/dist/types/mcp/app-server.d.ts +3 -0
  53. package/dist/types/mcp/canvas-access.d.ts +1 -1
  54. package/dist/types/mcp/canvas-app.d.ts +4 -0
  55. package/dist/types/mcp/workbench-app.d.ts +4 -0
  56. package/dist/types/server/agent-presence.d.ts +1 -1
  57. package/dist/types/server/ax-state.d.ts +1 -1
  58. package/dist/types/server/canvas-db.d.ts +8 -0
  59. package/dist/types/server/canvas-geometry.d.ts +25 -0
  60. package/dist/types/server/canvas-operations.d.ts +1 -11
  61. package/dist/types/server/canvas-state.d.ts +25 -3
  62. package/dist/types/server/context-brief.d.ts +8 -1
  63. package/dist/types/server/context-reads.d.ts +30 -7
  64. package/dist/types/server/index.d.ts +6 -1
  65. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  66. package/dist/types/server/operations/http.d.ts +2 -0
  67. package/dist/types/server/operations/invoker.d.ts +4 -2
  68. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  69. package/dist/types/server/server.d.ts +1 -0
  70. package/dist/types/server/spatial-analysis.d.ts +2 -0
  71. package/dist/types/shared/agent-context.d.ts +22 -0
  72. package/dist/types/shared/agent-presence.d.ts +3 -10
  73. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  74. package/dist/types/shared/canvas-app.d.ts +54 -0
  75. package/dist/types/shared/themes.d.ts +1 -1
  76. package/docs/RELEASE.md +7 -3
  77. package/docs/agent-plugin.md +123 -0
  78. package/docs/ax-host-adapter-contract.md +13 -2
  79. package/docs/ax-state-contract.md +40 -0
  80. package/docs/cli.md +7 -2
  81. package/docs/design/AgentContext.dc.html +186 -0
  82. package/docs/design/Before.dc.html +208 -0
  83. package/docs/design/BoardPins.dc.html +151 -0
  84. package/docs/design/Chrome.dc.html +208 -0
  85. package/docs/design/Context.dc.html +239 -0
  86. package/docs/design/Flows.dc.html +181 -0
  87. package/docs/design/Home.dc.html +209 -0
  88. package/docs/design/HomeGraph.dc.html +211 -0
  89. package/docs/design/HomeMap.dc.html +117 -0
  90. package/docs/design/HomeTeam.dc.html +176 -0
  91. package/docs/design/Light.dc.html +28 -0
  92. package/docs/design/Main.dc.html +280 -0
  93. package/docs/design/NearPin.dc.html +260 -0
  94. package/docs/design/Pane600.dc.html +120 -0
  95. package/docs/design/Relations.dc.html +297 -0
  96. package/docs/design/Themes.dc.html +121 -0
  97. package/docs/design/TokenSystem.dc.html +136 -0
  98. package/docs/design/Tokens.dc.html +163 -0
  99. package/docs/design/Types1.dc.html +177 -0
  100. package/docs/design/Types2.dc.html +226 -0
  101. package/docs/design/Types3.dc.html +195 -0
  102. package/docs/design/Uses.dc.html +152 -0
  103. package/docs/design/Vision.dc.html +116 -0
  104. package/docs/design/Zoom.dc.html +82 -0
  105. package/docs/design/canvas.json +274 -0
  106. package/docs/design.md +214 -0
  107. package/docs/environment.md +1 -1
  108. package/docs/http-api.md +80 -15
  109. package/docs/mcp.md +198 -3
  110. package/docs/node-types.md +34 -10
  111. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  112. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  113. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  114. package/docs/product-vision-2026-09.md +47 -7
  115. package/docs/screenshot.png +0 -0
  116. package/docs/sdk.md +1 -0
  117. package/package.json +10 -4
  118. package/plugins/pmx-canvas/LICENSE +21 -0
  119. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  120. package/plugins/pmx-canvas/assets/preview.png +0 -0
  121. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  122. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  123. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  124. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  125. package/plugins/pmx-canvas/mcp.json +12 -0
  126. package/plugins/pmx-canvas/plugin.json +24 -0
  127. package/plugins/pmx-canvas/runtime.json +3 -0
  128. package/plugins/pmx-canvas/runtime.mjs +194 -0
  129. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  130. package/plugins/pmx-canvas/tools.json +2560 -0
  131. package/skills/pmx-canvas/SKILL.md +31 -16
  132. package/skills/pmx-canvas/evals/evals.json +69 -0
  133. package/skills/pmx-canvas/references/full-reference.md +14 -10
  134. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  135. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  136. package/skills/pmx-canvas-testing/SKILL.md +54 -4
  137. package/src/cli/commands/copilot.ts +5 -0
  138. package/src/cli/commands/view.ts +1 -1
  139. package/src/cli/index.ts +8 -3
  140. package/src/client/App.tsx +6 -1
  141. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  142. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  143. package/src/client/canvas/CanvasNode.tsx +208 -220
  144. package/src/client/canvas/CanvasViewport.tsx +6 -2
  145. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  146. package/src/client/canvas/CommandBar.tsx +25 -23
  147. package/src/client/canvas/CommandPalette.tsx +3 -2
  148. package/src/client/canvas/ContextMenu.tsx +11 -0
  149. package/src/client/canvas/EdgeLayer.tsx +1 -1
  150. package/src/client/canvas/ExportDialog.tsx +33 -6
  151. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  152. package/src/client/canvas/IntentLayer.tsx +2 -2
  153. package/src/client/canvas/Minimap.tsx +12 -7
  154. package/src/client/canvas/NodeContextMark.tsx +156 -0
  155. package/src/client/canvas/SessionPanel.tsx +4 -7
  156. package/src/client/canvas/SessionReceipt.tsx +114 -17
  157. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  158. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  159. package/src/client/canvas/ToolRail.tsx +58 -108
  160. package/src/client/canvas/TopBar.tsx +16 -11
  161. package/src/client/icons.tsx +2 -12
  162. package/src/client/mcp-app/index.tsx +137 -0
  163. package/src/client/mcp-app/style.css +10 -0
  164. package/src/client/nodes/AttachmentNode.tsx +8 -1
  165. package/src/client/nodes/AxStepControls.tsx +1 -1
  166. package/src/client/nodes/ContextNode.tsx +9 -9
  167. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  168. package/src/client/nodes/FileNode.tsx +8 -5
  169. package/src/client/nodes/ImageNode.tsx +4 -2
  170. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  171. package/src/client/nodes/LedgerNode.tsx +2 -2
  172. package/src/client/nodes/MarkdownNode.tsx +44 -9
  173. package/src/client/nodes/McpAppNode.tsx +22 -4
  174. package/src/client/nodes/PromptNode.tsx +5 -17
  175. package/src/client/nodes/ResponseNode.tsx +3 -15
  176. package/src/client/nodes/StatusNode.tsx +2 -2
  177. package/src/client/nodes/StatusSummary.tsx +3 -1
  178. package/src/client/nodes/TraceNode.tsx +6 -4
  179. package/src/client/nodes/WebpageNode.tsx +1 -1
  180. package/src/client/nodes/ax-node-actions.ts +2 -2
  181. package/src/client/nodes/iframe-document-url.ts +3 -2
  182. package/src/client/nodes/markdown-sanitize.ts +10 -0
  183. package/src/client/nodes/surface-url.ts +10 -0
  184. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  185. package/src/client/nodes/use-surface-frame.ts +3 -1
  186. package/src/client/nodes/use-workbench-asset.ts +26 -0
  187. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  188. package/src/client/state/attention-bridge.ts +0 -8
  189. package/src/client/state/attention-store.ts +0 -17
  190. package/src/client/state/canvas-store.ts +7 -2
  191. package/src/client/state/context-status-store.ts +66 -0
  192. package/src/client/state/human-store.ts +2 -0
  193. package/src/client/state/iframe-mode.ts +5 -0
  194. package/src/client/state/intent-bridge.ts +24 -6
  195. package/src/client/state/near-pin-store.ts +53 -0
  196. package/src/client/state/presence-store.ts +5 -10
  197. package/src/client/state/session-store.ts +106 -9
  198. package/src/client/state/sse-bridge.ts +26 -7
  199. package/src/client/state/theme-override.ts +1 -1
  200. package/src/client/state/workbench-transport.ts +36 -0
  201. package/src/client/theme/global.css +844 -710
  202. package/src/client/theme/surface-theme.css +151 -46
  203. package/src/client/theme/tokens.ts +7 -11
  204. package/src/client/types.ts +10 -0
  205. package/src/json-render/renderer/index.tsx +58 -38
  206. package/src/json-render/server.ts +79 -15
  207. package/src/mcp/app-server.ts +64 -0
  208. package/src/mcp/canvas-access.ts +14 -11
  209. package/src/mcp/canvas-app.ts +318 -0
  210. package/src/mcp/server.ts +1 -1
  211. package/src/mcp/workbench-app.ts +211 -0
  212. package/src/server/agent-presence.ts +45 -6
  213. package/src/server/ax-context.ts +1 -1
  214. package/src/server/ax-state.ts +1 -1
  215. package/src/server/board-export.ts +5 -2
  216. package/src/server/canvas-db.ts +46 -12
  217. package/src/server/canvas-geometry.ts +64 -0
  218. package/src/server/canvas-operations.ts +6 -24
  219. package/src/server/canvas-state.ts +105 -15
  220. package/src/server/canvas-validation.ts +19 -4
  221. package/src/server/context-brief.ts +39 -2
  222. package/src/server/context-reads.ts +86 -15
  223. package/src/server/demo-state.json +31 -31
  224. package/src/server/html-surface.ts +4 -5
  225. package/src/server/index.ts +19 -3
  226. package/src/server/mcp-app-runtime.ts +11 -0
  227. package/src/server/operations/composites.ts +4 -3
  228. package/src/server/operations/http.ts +4 -4
  229. package/src/server/operations/invoker.ts +13 -4
  230. package/src/server/operations/ops/ax-read.ts +1 -1
  231. package/src/server/operations/ops/ax-timeline.ts +37 -0
  232. package/src/server/operations/ops/boards.ts +1 -1
  233. package/src/server/operations/ops/canvas-wire.ts +3 -6
  234. package/src/server/operations/ops/intent.ts +31 -2
  235. package/src/server/operations/ops/json-render.ts +1 -1
  236. package/src/server/operations/ops/nodes.ts +8 -76
  237. package/src/server/operations/ops/query.ts +8 -2
  238. package/src/server/operations/ops/snapshots.ts +4 -1
  239. package/src/server/operations/ops/viewport.ts +29 -5
  240. package/src/server/operations/registry.ts +38 -4
  241. package/src/server/scope-fence.ts +55 -10
  242. package/src/server/server.ts +86 -6
  243. package/src/server/spatial-analysis.ts +2 -2
  244. package/src/server/workboard.ts +6 -1
  245. package/src/{server → shared}/agent-context.ts +2 -2
  246. package/src/shared/agent-presence.ts +3 -15
  247. package/src/shared/ax-surface-protocol.ts +3 -0
  248. package/src/shared/canvas-app.ts +62 -0
  249. package/src/shared/themes.ts +5 -5
  250. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  251. package/src/client/canvas/kind-colors.ts +0 -23
@@ -55,6 +55,7 @@ import {
55
55
  setActiveBoardIdInDB,
56
56
  type CanvasBoard,
57
57
  finalizeCanvasDbForClose,
58
+ type ContextPinMeta,
58
59
  type PersistedCanvasState,
59
60
  type CanvasTheme,
60
61
  type AxTimelineQuery,
@@ -115,9 +116,12 @@ import { currentActor, unknownActor, type ActorAttribution } from './attribution
115
116
  import {
116
117
  appendContextReadToDB,
117
118
  loadContextReadsFromDB,
119
+ loadNodeReadStatusFromDB,
120
+ loadReadNodeIdsSince,
118
121
  type ContextRead,
119
122
  type ContextReadConsumerSummary,
120
123
  type ContextReadInput,
124
+ type NodeReadStatus,
121
125
  } from './context-reads.js';
122
126
 
123
127
  function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
@@ -411,8 +415,10 @@ class CanvasStateManager {
411
415
  private annotations = new Map<string, CanvasAnnotation>();
412
416
  private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
413
417
  private _tour: Tour | undefined;
414
- private _theme: CanvasTheme = 'dark';
418
+ private _theme: CanvasTheme = 'harbor';
415
419
  private _contextPinnedNodeIds = new Set<string>();
420
+ /** Who pinned each pin, when and why; kept only for ids in `_contextPinnedNodeIds`. */
421
+ private _contextPinMeta = new Map<string, ContextPinMeta>();
416
422
  private _workspaceRoot = process.cwd();
417
423
  private _contentRevision = 0;
418
424
  private _revisionFloor = 0;
@@ -450,6 +456,8 @@ class CanvasStateManager {
450
456
 
451
457
  // ── Work-item change listener (single slot, for live workboard refresh) ──
452
458
  private _workItemsChangedListener: (() => void) | null = null;
459
+ /** Single slot (architecture rule 8): server.ts tells the workbench a read landed. Never MCP. */
460
+ private _contextReadListener: ((boardId: string | null) => void) | null = null;
453
461
 
454
462
  /**
455
463
  * Register THE work-item change listener (single slot, last-write-wins).
@@ -457,6 +465,10 @@ class CanvasStateManager {
457
465
  * status mirror), so live views like the workboard can rebuild from the
458
466
  * fresh work-item list.
459
467
  */
468
+ setContextReadListener(listener: ((boardId: string | null) => void) | null): void {
469
+ this._contextReadListener = listener;
470
+ }
471
+
460
472
  setWorkItemsChangedListener(listener: (() => void) | null): void {
461
473
  this._workItemsChangedListener = listener;
462
474
  }
@@ -687,8 +699,9 @@ class CanvasStateManager {
687
699
  }
688
700
  }
689
701
 
690
- private recomputeParentGroupBounds(groupId: string | undefined): void {
691
- if (!groupId) return;
702
+ private recomputeParentGroupBounds(groupId: string | undefined, visited = new Set<string>()): void {
703
+ if (!groupId || visited.has(groupId)) return;
704
+ visited.add(groupId);
692
705
  const snapshot = this.getGroupSnapshot(groupId);
693
706
  if (!snapshot) return;
694
707
  if (snapshot.group.data.frameMode === 'manual') return;
@@ -701,6 +714,10 @@ class CanvasStateManager {
701
714
  position: { x: bounds.x, y: bounds.y },
702
715
  size: { width: bounds.width, height: bounds.height },
703
716
  });
717
+ // Only the affected ancestor chain may re-fit. Never repair unrelated
718
+ // automatic frames as a side effect of an otherwise scoped child update.
719
+ const parentId = snapshot.group.data.parentGroup;
720
+ if (typeof parentId === 'string') this.recomputeParentGroupBounds(parentId, visited);
704
721
  }
705
722
 
706
723
  private compactGroupChildren(groupId: string, layout: 'grid' | 'column' | 'flow' = 'grid'): void {
@@ -1121,6 +1138,7 @@ class CanvasStateManager {
1121
1138
  edges: layout.edges,
1122
1139
  annotations: layout.annotations,
1123
1140
  contextPins: Array.from(this._contextPinnedNodeIds),
1141
+ contextPinMeta: this.pinMetaRecord(),
1124
1142
  revisionState: this.revisionState(),
1125
1143
  },
1126
1144
  };
@@ -1478,6 +1496,7 @@ class CanvasStateManager {
1478
1496
  edges: Array.from(this.edges.values()),
1479
1497
  annotations: Array.from(this.annotations.values()),
1480
1498
  contextPins: Array.from(this._contextPinnedNodeIds),
1499
+ contextPinMeta: this.pinMetaRecord(),
1481
1500
  ax: this.getAxState(),
1482
1501
  revisionState: this.revisionState(),
1483
1502
  });
@@ -1540,6 +1559,7 @@ class CanvasStateManager {
1540
1559
  this.edges.clear();
1541
1560
  this.annotations.clear();
1542
1561
  this._contextPinnedNodeIds.clear();
1562
+ this._contextPinMeta.clear();
1543
1563
  this.ax.resetCanvasBound();
1544
1564
  this._contentRevision = state.revisionState?.revision ?? 0;
1545
1565
  this._revisionFloor = state.revisionState?.floor ?? 0;
@@ -1571,7 +1591,10 @@ class CanvasStateManager {
1571
1591
  }
1572
1592
  if (Array.isArray(state.contextPins)) {
1573
1593
  for (const pinId of state.contextPins) {
1574
- if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
1594
+ if (!this.nodes.has(pinId)) continue;
1595
+ this._contextPinnedNodeIds.add(pinId);
1596
+ const meta = state.contextPinMeta?.[pinId];
1597
+ if (meta) this._contextPinMeta.set(pinId, structuredClone(meta));
1575
1598
  }
1576
1599
  }
1577
1600
  this.ax.applyPersistedAx(state.ax);
@@ -1677,6 +1700,7 @@ class CanvasStateManager {
1677
1700
  edges: Array.from(this.edges.values()),
1678
1701
  annotations: Array.from(this.annotations.values()),
1679
1702
  contextPins: Array.from(this._contextPinnedNodeIds),
1703
+ contextPinMeta: this.pinMetaRecord(),
1680
1704
  ax: this.getAxState(),
1681
1705
  revisionState: this.revisionState(),
1682
1706
  });
@@ -1774,6 +1798,7 @@ class CanvasStateManager {
1774
1798
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1775
1799
  annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
1776
1800
  contextPins: Array.from(this._contextPinnedNodeIds),
1801
+ contextPinMeta: this.pinMetaRecord(),
1777
1802
  ax: this.getAxState(),
1778
1803
  revisionState: this.revisionState(),
1779
1804
  });
@@ -1788,6 +1813,7 @@ class CanvasStateManager {
1788
1813
  ? resolved.state.annotations.map((annotation) => structuredClone(annotation))
1789
1814
  : [],
1790
1815
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
1816
+ contextPinMeta: resolved.state.contextPinMeta ? structuredClone(resolved.state.contextPinMeta) : undefined,
1791
1817
  ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
1792
1818
  revisionState: resolved.state.revisionState ? structuredClone(resolved.state.revisionState) : undefined,
1793
1819
  };
@@ -2062,7 +2088,6 @@ class CanvasStateManager {
2062
2088
  // moved child's requested coordinates. Compaction is opt-in (group
2063
2089
  // create/add with childLayout, or arrange).
2064
2090
  this.recomputeParentGroupBounds(parentGroupId);
2065
- this.reflowAllGroups();
2066
2091
  }
2067
2092
  this.scheduleSave();
2068
2093
  this.notifyChange('nodes');
@@ -2093,6 +2118,7 @@ class CanvasStateManager {
2093
2118
  const cloned = existing ? structuredClone(existing) : null;
2094
2119
  const oldAxState = this.getAxState();
2095
2120
  const wasPinned = this._contextPinnedNodeIds.has(id);
2121
+ const pinMeta = this._contextPinMeta.get(id);
2096
2122
  // The enclosing group's membership before this removal — restored verbatim on undo.
2097
2123
  let enclosingBefore: { id: string; children: string[] } | null = null;
2098
2124
  // A removed group DISSOLVES: its children are released — into the enclosing
@@ -2127,6 +2153,7 @@ class CanvasStateManager {
2127
2153
  this.nodes.delete(id);
2128
2154
  this.removeEdgesForNode(id);
2129
2155
  this._contextPinnedNodeIds.delete(id);
2156
+ this._contextPinMeta.delete(id);
2130
2157
  // Re-normalize canvas-bound AX against the surviving node set. This strips the
2131
2158
  // dangling node ref from work items / approval gates / elicitations / mode
2132
2159
  // requests (re-anchored) and drops node-anchored review annotations (removed).
@@ -2191,7 +2218,10 @@ class CanvasStateManager {
2191
2218
  }
2192
2219
  }
2193
2220
  for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
2194
- if (wasPinned) this._contextPinnedNodeIds.add(id);
2221
+ if (wasPinned) {
2222
+ this._contextPinnedNodeIds.add(id);
2223
+ if (pinMeta) this._contextPinMeta.set(id, pinMeta);
2224
+ }
2195
2225
  this.ax.applyPersistedAx(oldAxState);
2196
2226
  this.scheduleSave();
2197
2227
  this.notifyChange('nodes');
@@ -2444,7 +2474,6 @@ class CanvasStateManager {
2444
2474
  for (const groupId of touchedParentGroups) {
2445
2475
  this.recomputeParentGroupBounds(groupId);
2446
2476
  }
2447
- if (touchedParentGroups.size > 0) this.reflowAllGroups();
2448
2477
 
2449
2478
  if (applied > 0) {
2450
2479
  this.scheduleSave();
@@ -2849,13 +2878,41 @@ class CanvasStateManager {
2849
2878
  recordContextRead(input: ContextReadInput, boardId: string | null = this._activeBoardId): ContextRead | null {
2850
2879
  if (!this._db) return null;
2851
2880
  try {
2852
- return appendContextReadToDB(this._db, input, boardId);
2881
+ const read = appendContextReadToDB(this._db, input, boardId, this.readRevisions(input, boardId));
2882
+ this._contextReadListener?.(boardId);
2883
+ return read;
2853
2884
  } catch (error) {
2854
2885
  logCanvasStateWarning('record context read failed', error);
2855
2886
  return null;
2856
2887
  }
2857
2888
  }
2858
2889
 
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 {};
2893
+ const nodes =
2894
+ 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;
2900
+ }
2901
+ return revisions;
2902
+ }
2903
+
2904
+ /** Per node on a board, the latest agent read that delivered its content. */
2905
+ getNodeReadStatus(boardId: string | null = this._activeBoardId): NodeReadStatus[] {
2906
+ if (!this._db || !boardId) return [];
2907
+ return loadNodeReadStatusFromDB(this._db, boardId);
2908
+ }
2909
+
2910
+ /** Nodes on a board whose content an agent read at or after `since` (ISO). */
2911
+ getReadNodeIdsSince(boardId: string | null, since: string): string[] {
2912
+ if (!this._db || !boardId) return [];
2913
+ return loadReadNodeIdsSince(this._db, boardId, since);
2914
+ }
2915
+
2859
2916
  getContextReads(limit?: number): { reads: ContextRead[]; summary: ContextReadConsumerSummary[] } {
2860
2917
  return this._db ? loadContextReadsFromDB(this._db, limit) : { reads: [], summary: [] };
2861
2918
  }
@@ -2869,25 +2926,57 @@ class CanvasStateManager {
2869
2926
  return this.ax.getAxTimeline(q);
2870
2927
  }
2871
2928
 
2872
- setContextPins(nodeIds: string[]): void {
2929
+ /**
2930
+ * Replace the pin set. Newly pinned nodes are attributed to the current
2931
+ * actor (with `reason` when given); pins that stay keep their attribution.
2932
+ * `restore` re-applies saved attribution, so undo does not re-attribute.
2933
+ */
2934
+ setContextPins(nodeIds: string[], options: { reason?: string; restore?: Map<string, ContextPinMeta> } = {}): void {
2873
2935
  const oldPins = Array.from(this._contextPinnedNodeIds);
2936
+ const oldMeta = new Map(this._contextPinMeta);
2937
+ const pinnedAt = new Date().toISOString();
2874
2938
  this._contextPinnedNodeIds.clear();
2875
2939
  for (const id of nodeIds) {
2876
- if (this.nodes.has(id)) {
2877
- this._contextPinnedNodeIds.add(id);
2878
- }
2879
- }
2940
+ if (!this.nodes.has(id)) continue;
2941
+ this._contextPinnedNodeIds.add(id);
2942
+ const meta =
2943
+ options.restore?.get(id) ??
2944
+ (oldPins.includes(id) ? oldMeta.get(id) : undefined) ??
2945
+ (oldPins.includes(id)
2946
+ ? undefined
2947
+ : { pinnedBy: currentActor(), pinnedAt, ...(options.reason ? { reason: options.reason } : {}) });
2948
+ if (meta) this._contextPinMeta.set(id, meta);
2949
+ }
2950
+ for (const id of [...this._contextPinMeta.keys()]) {
2951
+ if (!this._contextPinnedNodeIds.has(id)) this._contextPinMeta.delete(id);
2952
+ }
2953
+ const newMeta = new Map(this._contextPinMeta);
2880
2954
  this.scheduleSave();
2881
2955
  this.notifyChange('pins');
2882
2956
  this.recordMutation({
2883
2957
  operationType: 'setPins',
2884
2958
  description: `Set context pins (${this._contextPinnedNodeIds.size} nodes)`,
2885
- forward: this.suppressed(() => this.setContextPins([...nodeIds])),
2886
- inverse: this.suppressed(() => this.setContextPins(oldPins)),
2959
+ forward: this.suppressed(() => this.setContextPins([...nodeIds], { restore: newMeta })),
2960
+ inverse: this.suppressed(() => this.setContextPins(oldPins, { restore: oldMeta })),
2887
2961
  });
2888
2962
  }
2889
2963
 
2964
+ /** Who pinned each current pin, when and why (pins with an unknown pinner are absent). */
2965
+ getContextPinMeta(): Record<string, ContextPinMeta> {
2966
+ return this.pinMetaRecord();
2967
+ }
2968
+
2969
+ private pinMetaRecord(): Record<string, ContextPinMeta> {
2970
+ const record: Record<string, ContextPinMeta> = {};
2971
+ for (const id of this._contextPinnedNodeIds) {
2972
+ const meta = this._contextPinMeta.get(id);
2973
+ if (meta) record[id] = structuredClone(meta);
2974
+ }
2975
+ return record;
2976
+ }
2977
+
2890
2978
  clearContextPins(): void {
2979
+ this._contextPinMeta.clear();
2891
2980
  this._contextPinnedNodeIds.clear();
2892
2981
  this.scheduleSave();
2893
2982
  this.notifyChange('pins');
@@ -3019,6 +3108,7 @@ class CanvasStateManager {
3019
3108
  this.edges.clear();
3020
3109
  this.annotations.clear();
3021
3110
  this._contextPinnedNodeIds.clear();
3111
+ this._contextPinMeta.clear();
3022
3112
  // Clears canvas-bound AX state (focus, work items, approvals, review annotations).
3023
3113
  // Timeline tables (ax_events/ax_evidence/ax_steering) and host capability are
3024
3114
  // deliberately retained per the AX state-partition policy.
@@ -71,13 +71,27 @@ function fullyContains(group: CanvasNodeState, child: CanvasNodeState): boolean
71
71
  );
72
72
  }
73
73
 
74
- function isGroupChildPair(group: CanvasNodeState, child: CanvasNodeState): boolean {
75
- if (group.type !== 'group') return false;
74
+ function isDirectGroupChild(group: CanvasNodeState, child: CanvasNodeState): boolean {
76
75
  if (child.data.parentGroup === group.id) return true;
77
76
  const children = group.data.children;
78
77
  return Array.isArray(children) && children.includes(child.id);
79
78
  }
80
79
 
80
+ /** True when `child` sits anywhere inside `group` — a nested group's members are
81
+ * inside every ancestor frame, not overlapping it. */
82
+ function isGroupChildPair(group: CanvasNodeState, child: CanvasNodeState, byId: Map<string, CanvasNodeState>): boolean {
83
+ if (group.type !== 'group') return false;
84
+ const seen = new Set<string>();
85
+ let current: CanvasNodeState | undefined = child;
86
+ while (current && !seen.has(current.id)) {
87
+ if (isDirectGroupChild(group, current)) return true;
88
+ seen.add(current.id);
89
+ const parentId: unknown = current.data.parentGroup;
90
+ current = typeof parentId === 'string' ? byId.get(parentId) : undefined;
91
+ }
92
+ return false;
93
+ }
94
+
81
95
  function pair(a: CanvasNodeState, b: CanvasNodeState): CanvasValidationPair {
82
96
  return {
83
97
  aId: a.id,
@@ -101,6 +115,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
101
115
  const containments: CanvasContainmentIssue[] = [];
102
116
  const containmentViolations: CanvasContainmentIssue[] = [];
103
117
  const groupFrameOverlaps: CanvasValidationPair[] = [];
118
+ const byId = new Map(layout.nodes.map((node) => [node.id, node]));
104
119
 
105
120
  for (let i = 0; i < layout.nodes.length; i++) {
106
121
  const a = layout.nodes[i]!;
@@ -108,11 +123,11 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
108
123
  const b = layout.nodes[j]!;
109
124
  if (!overlaps(a, b)) continue;
110
125
 
111
- if (isGroupChildPair(a, b)) {
126
+ if (isGroupChildPair(a, b, byId)) {
112
127
  (fullyContains(a, b) ? containments : containmentViolations).push(containment(a, b));
113
128
  continue;
114
129
  }
115
- if (isGroupChildPair(b, a)) {
130
+ if (isGroupChildPair(b, a, byId)) {
116
131
  (fullyContains(b, a) ? containments : containmentViolations).push(containment(b, a));
117
132
  continue;
118
133
  }
@@ -1,7 +1,11 @@
1
- import { summarizeNodeForAgentContext } from './agent-context.js';
1
+ import { summarizeNodeForAgentContext } from '../shared/agent-context.js';
2
2
  import type { CanvasNodeState, NodeDeletionTombstone } from './canvas-state.js';
3
+ import { findNeighborhoods } from './spatial-analysis.js';
3
4
 
4
- export type ContextBriefReason = 'pinned' | 'changed' | 'human' | 'ask' | 'steer' | 'linked' | 'category';
5
+ export type ContextBriefReason = 'pinned' | 'near' | 'changed' | 'human' | 'ask' | 'steer' | 'linked' | 'category';
6
+
7
+ /** A near entry carries a short summary, never full content: pin the node to send that. */
8
+ export const NEAR_SUMMARY_LENGTH = 280;
5
9
 
6
10
  export interface ContextBriefSourceEntry {
7
11
  sourceBoardId: string;
@@ -45,6 +49,8 @@ export interface CompiledContextEntry {
45
49
  text: string;
46
50
  /** A linked board's pin title is discovery metadata, not delivery of that pinned card. */
47
51
  titleOnly?: true;
52
+ /** reason "near": the pin this node sits next to on the board (docs/design/NearPin.dc.html). */
53
+ near?: { pinNodeId: string; pinTitle: string };
48
54
  /** The body was shortened to fit; the same revision remains eligible on the next pull. */
49
55
  truncated?: true;
50
56
  provenance?: { kind: 'imported'; source: string; trust: 'source-material-not-instructions' };
@@ -155,6 +161,32 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
155
161
  reset !== null || (validRevision(node.contentRevision ?? -1) && (node.contentRevision ?? -1) > deltaSince),
156
162
  )
157
163
  .sort((a, b) => (a.contentRevision ?? 0) - (b.contentRevision ?? 0) || a.id.localeCompare(b.id));
164
+
165
+ // Near a pin: each pin's unpinned neighbours (up to 5 within 600 px, nearest
166
+ // first) as title + short summary. A neighbour that changed arrives in full
167
+ // as a change instead, so it is not repeated here.
168
+ const changedIds = new Set(changed.map((node) => node.id));
169
+ for (const neighborhood of findNeighborhoods(nodes, new Set(pinIds))) {
170
+ const pinTitle = neighborhood.pinnedNodeTitle ?? neighborhood.pinnedNodeId;
171
+ for (const neighbor of neighborhood.neighbors) {
172
+ const node = nodeById.get(neighbor.id);
173
+ if (!node || changedIds.has(node.id)) continue;
174
+ add({
175
+ entry: {
176
+ sourceBoardId: input.activeBoard.boardId,
177
+ nodeId: node.id,
178
+ reason: 'near',
179
+ title: typeof node.data.title === 'string' && node.data.title ? node.data.title : node.id,
180
+ text: summarizeNodeForAgentContext(node, {
181
+ defaultTextLength: NEAR_SUMMARY_LENGTH,
182
+ webpageTextLength: NEAR_SUMMARY_LENGTH,
183
+ }),
184
+ near: { pinNodeId: neighborhood.pinnedNodeId, pinTitle },
185
+ },
186
+ });
187
+ }
188
+ }
189
+
158
190
  for (const node of changed) add(compileNode(node, input.activeBoard.boardId, 'changed', budget));
159
191
 
160
192
  for (const entry of [...(input.entries ?? [])].sort((a, b) => {
@@ -285,6 +317,11 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
285
317
  if (partial) delivered.push(partial);
286
318
  }
287
319
  }
320
+ // Neighbours come right after the pins they sit next to; each is whole or absent.
321
+ for (const candidate of candidates.filter((item) => !reset && item.entry.reason === 'near')) {
322
+ const proposed = [...delivered, candidate.entry];
323
+ if (fits(makeDocument(proposed))) delivered.push(candidate.entry);
324
+ }
288
325
  if (reset && !deltaBlocked) {
289
326
  const resetEntries = candidates
290
327
  .filter((item) => item.replacement && !delivered.includes(item.entry))
@@ -37,16 +37,32 @@ export interface ContextRead {
37
37
  agentId: string | null;
38
38
  pinnedNodeIds: string[];
39
39
  deliveredNodeIds: string[];
40
+ /** Every node on the read board whose content was delivered, with its content revision at read time. */
41
+ readNodes: Record<string, number>;
40
42
  bytes: number;
41
43
  /** The board open when the read happened (null on Home). */
42
44
  boardId: string | null;
43
45
  }
44
46
 
45
- export type ContextReadInput = Omit<ContextRead, 'seq' | 'id' | 'at' | 'boardId'> & {
47
+ export type ContextReadInput = Omit<ContextRead, 'seq' | 'id' | 'at' | 'boardId' | 'readNodes'> & {
46
48
  /** Explicit read target, captured before asynchronous formatting/proxy work. */
47
49
  boardId?: string | null;
50
+ /** Ids of every serialized node in what the reader received; the server keeps those on the board. */
51
+ readNodeIds?: string[];
48
52
  };
49
53
 
54
+ /** One node's read state on a board: the latest agent read that delivered its content. */
55
+ export interface NodeReadStatus {
56
+ nodeId: string;
57
+ lastReadAt: string;
58
+ /** Who read it: consumer, else agent id, else transport label. */
59
+ lastReadBy: string;
60
+ /** The node's content revision in that read; newer current revision means "changed since read". */
61
+ readRevision: number;
62
+ /** How many retained agent reads delivered its content. */
63
+ readCount: number;
64
+ }
65
+
50
66
  export interface ContextReadConsumerSummary {
51
67
  consumer: string;
52
68
  reads: number;
@@ -57,12 +73,13 @@ export interface ContextReadConsumerSummary {
57
73
  }
58
74
 
59
75
  /**
60
- * Pinned nodes whose serialized node (an object carrying `"id": "<id>"`) is in
61
- * what the reader received. A bare id list, a title, or a clipped-off node does
62
- * not count — the agent got the pin's name, not its content.
76
+ * Ids of every serialized node (an object carrying `"id": "<id>"`, or a
77
+ * `nodeId` with its text) in what the reader received. A bare id list, a
78
+ * title, or a clipped-off node does not count — the agent got the node's
79
+ * name, not its content. Non-node ids (edges, intents) are dropped by the
80
+ * caller against the board's nodes.
63
81
  */
64
- export function deliveredPinnedIds(pinnedNodeIds: string[], payloadText: string): string[] {
65
- const pinned = new Set(pinnedNodeIds);
82
+ export function deliveredIds(payloadText: string): Set<string> {
66
83
  const delivered = new Set<string>();
67
84
  const objectStarts: number[] = [];
68
85
  let inString = false;
@@ -89,11 +106,10 @@ export function deliveredPinnedIds(pinnedNodeIds: string[], payloadText: string)
89
106
  typeof value === 'object' &&
90
107
  value !== null &&
91
108
  !Array.isArray(value) &&
92
- ((typeof (value as { id?: unknown }).id === 'string' && pinned.has((value as { id: string }).id)) ||
109
+ (typeof (value as { id?: unknown }).id === 'string' ||
93
110
  (typeof (value as { nodeId?: unknown }).nodeId === 'string' &&
94
111
  (value as { titleOnly?: unknown }).titleOnly !== true &&
95
- typeof (value as { text?: unknown }).text === 'string' &&
96
- pinned.has((value as { nodeId: string }).nodeId)))
112
+ typeof (value as { text?: unknown }).text === 'string'))
97
113
  ) {
98
114
  delivered.add(
99
115
  typeof (value as { id?: unknown }).id === 'string'
@@ -107,17 +123,25 @@ export function deliveredPinnedIds(pinnedNodeIds: string[], payloadText: string)
107
123
  }
108
124
  }
109
125
 
126
+ return delivered;
127
+ }
128
+
129
+ /** Pinned nodes whose content is in what the reader received (see `deliveredIds`). */
130
+ export function deliveredPinnedIds(pinnedNodeIds: string[], payloadText: string): string[] {
131
+ const delivered = deliveredIds(payloadText);
110
132
  return pinnedNodeIds.filter((id) => delivered.has(id));
111
133
  }
112
134
 
113
135
  export function contextReadFromPayload(
114
- base: Omit<ContextReadInput, 'deliveredNodeIds' | 'bytes'>,
136
+ base: Omit<ContextReadInput, 'deliveredNodeIds' | 'bytes' | 'readNodeIds'>,
115
137
  payload: unknown,
116
138
  ): ContextReadInput {
117
139
  const text = typeof payload === 'string' ? payload : (JSON.stringify(payload) ?? '');
140
+ const delivered = deliveredIds(text);
118
141
  return {
119
142
  ...base,
120
- deliveredNodeIds: deliveredPinnedIds(base.pinnedNodeIds, text),
143
+ deliveredNodeIds: base.pinnedNodeIds.filter((id) => delivered.has(id)),
144
+ readNodeIds: [...delivered],
121
145
  bytes: Buffer.byteLength(text, 'utf-8'),
122
146
  };
123
147
  }
@@ -135,15 +159,21 @@ export const CONTEXT_READS_SCHEMA_SQL = `
135
159
  pinned_node_ids TEXT NOT NULL DEFAULT '[]',
136
160
  delivered_node_ids TEXT NOT NULL DEFAULT '[]',
137
161
  bytes INTEGER NOT NULL DEFAULT 0,
138
- board_id TEXT
162
+ board_id TEXT,
163
+ read_nodes TEXT NOT NULL DEFAULT '{}'
139
164
  );
140
165
  `;
141
166
 
142
- export function appendContextReadToDB(db: Database, input: ContextReadInput, boardId: string | null): ContextRead {
167
+ export function appendContextReadToDB(
168
+ db: Database,
169
+ input: ContextReadInput,
170
+ boardId: string | null,
171
+ readNodes: Record<string, number> = {},
172
+ ): ContextRead {
143
173
  const id = `read-${randomUUID()}`;
144
174
  const at = new Date().toISOString();
145
175
  db.run(
146
- 'INSERT INTO context_reads (id, at, channel, resource, source, consumer, agent_id, pinned_node_ids, delivered_node_ids, bytes, board_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
176
+ 'INSERT INTO context_reads (id, at, channel, resource, source, consumer, agent_id, pinned_node_ids, delivered_node_ids, bytes, board_id, read_nodes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
147
177
  [
148
178
  id,
149
179
  at,
@@ -156,11 +186,13 @@ export function appendContextReadToDB(db: Database, input: ContextReadInput, boa
156
186
  JSON.stringify(input.deliveredNodeIds),
157
187
  input.bytes,
158
188
  boardId,
189
+ JSON.stringify(readNodes),
159
190
  ],
160
191
  );
161
192
  const seq = Number(db.query<{ seq: number }, []>('SELECT last_insert_rowid() AS seq').get()?.seq ?? 0);
162
193
  db.run('DELETE FROM context_reads WHERE seq <= ?', [seq - CONTEXT_READ_RETENTION]);
163
- return { ...input, seq, id, at, boardId };
194
+ const { readNodeIds: _readNodeIds, ...record } = input;
195
+ return { ...record, seq, id, at, boardId, readNodes };
164
196
  }
165
197
 
166
198
  interface ContextReadRow {
@@ -176,6 +208,7 @@ interface ContextReadRow {
176
208
  delivered_node_ids: string;
177
209
  bytes: number;
178
210
  board_id: string | null;
211
+ read_nodes: string;
179
212
  }
180
213
 
181
214
  function rowToContextRead(row: ContextReadRow): ContextRead {
@@ -190,11 +223,49 @@ function rowToContextRead(row: ContextReadRow): ContextRead {
190
223
  agentId: row.agent_id,
191
224
  pinnedNodeIds: JSON.parse(row.pinned_node_ids) as string[],
192
225
  deliveredNodeIds: JSON.parse(row.delivered_node_ids) as string[],
226
+ readNodes: JSON.parse(row.read_nodes) as Record<string, number>,
193
227
  bytes: row.bytes,
194
228
  boardId: row.board_id,
195
229
  };
196
230
  }
197
231
 
232
+ /** Per node on a board, the latest agent read that delivered its content (newest row wins). */
233
+ export function loadNodeReadStatusFromDB(db: Database, boardId: string): NodeReadStatus[] {
234
+ const rows = db
235
+ .query<ContextReadRow, [string]>('SELECT * FROM context_reads WHERE board_id = ? ORDER BY seq DESC')
236
+ .all(boardId);
237
+ const status = new Map<string, NodeReadStatus>();
238
+ for (const read of rows.map(rowToContextRead)) {
239
+ for (const [nodeId, readRevision] of Object.entries(read.readNodes)) {
240
+ const known = status.get(nodeId);
241
+ if (known) {
242
+ known.readCount += 1;
243
+ continue;
244
+ }
245
+ status.set(nodeId, {
246
+ nodeId,
247
+ lastReadAt: read.at,
248
+ lastReadBy: read.consumer ?? read.agentId ?? read.source,
249
+ readRevision,
250
+ readCount: 1,
251
+ });
252
+ }
253
+ }
254
+ return [...status.values()];
255
+ }
256
+
257
+ /** Every node on a board whose content an agent read at or after `since` (ISO). */
258
+ export function loadReadNodeIdsSince(db: Database, boardId: string, since: string): string[] {
259
+ const rows = db
260
+ .query<{ read_nodes: string }, [string, string]>(
261
+ 'SELECT read_nodes FROM context_reads WHERE board_id = ? AND at >= ? ORDER BY seq',
262
+ )
263
+ .all(boardId, since);
264
+ const ids = new Set<string>();
265
+ for (const row of rows) for (const id of Object.keys(JSON.parse(row.read_nodes) as object)) ids.add(id);
266
+ return [...ids];
267
+ }
268
+
198
269
  /** Newest first. The summary covers every retained row, not just the returned page. */
199
270
  export function loadContextReadsFromDB(
200
271
  db: Database,