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
@@ -110,7 +110,10 @@ function containment(group: CanvasNodeState, child: CanvasNodeState): CanvasCont
110
110
  };
111
111
  }
112
112
 
113
- export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResult {
113
+ export function validateCanvasLayout(
114
+ layout: CanvasLayout,
115
+ remoteEndpointExists?: (boardId: string, cardId: string) => boolean,
116
+ ): CanvasValidationResult {
114
117
  const collisions: CanvasValidationPair[] = [];
115
118
  const containments: CanvasContainmentIssue[] = [];
116
119
  const containmentViolations: CanvasContainmentIssue[] = [];
@@ -140,7 +143,11 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
140
143
 
141
144
  const nodeIds = new Set(layout.nodes.map((node) => node.id));
142
145
  const missingEdgeEndpoints = layout.edges
143
- .filter((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to))
146
+ .filter(
147
+ (edge) =>
148
+ !nodeIds.has(edge.from) ||
149
+ (edge.toBoardId ? remoteEndpointExists?.(edge.toBoardId, edge.to) === false : !nodeIds.has(edge.to)),
150
+ )
144
151
  .map((edge) => ({ edgeId: edge.id, from: edge.from, to: edge.to }));
145
152
 
146
153
  // Advisory: nodes below their type's readable floor (clipped/unreadable
@@ -1,11 +1,24 @@
1
1
  import { summarizeNodeForAgentContext } from '../shared/agent-context.js';
2
+ import { relationText } from '../shared/relations.js';
3
+ import { memoryKey } from '../shared/memory-graph.js';
4
+ import type { LifecycleQualification } from '../shared/lifecycle-status.js';
2
5
  import type { CanvasNodeState, NodeDeletionTombstone } from './canvas-state.js';
3
6
  import { findNeighborhoods } from './spatial-analysis.js';
4
7
 
5
- export type ContextBriefReason = 'pinned' | 'near' | 'changed' | 'human' | 'ask' | 'steer' | 'linked' | 'category';
8
+ export type ContextBriefReason =
9
+ | 'overview'
10
+ | 'pinned'
11
+ | 'near'
12
+ | 'changed'
13
+ | 'pinned-board'
14
+ | 'human'
15
+ | 'ask'
16
+ | 'steer'
17
+ | 'linked'
18
+ | 'folder';
6
19
 
7
- /** A near entry carries a short summary, never full content: pin the node to send that. */
8
- export const NEAR_SUMMARY_LENGTH = 280;
20
+ /** How long a card's summary is in the brief: enough to know what it is. */
21
+ export const BRIEF_SUMMARY_LENGTH = 280;
9
22
 
10
23
  export interface ContextBriefSourceEntry {
11
24
  sourceBoardId: string;
@@ -19,15 +32,68 @@ export interface ContextBriefSourceEntry {
19
32
  export interface ContextBriefLibraryBoard {
20
33
  boardId: string;
21
34
  name: string;
22
- category: string | null;
35
+ folder: string | null;
23
36
  readme?: { nodeId: string; title: string; summary: string; provenance?: { kind: 'imported'; source: string } };
24
37
  pinnedTitles: Array<{ nodeId: string; title: string }>;
25
38
  /** IDs of link cards on the active board which explicitly target this board. */
26
39
  linkIds: string[];
27
40
  }
28
41
 
42
+ /**
43
+ * A board in the working set (vision move 0a), sent as a map: enough to know what
44
+ * it is and how it relates, so the agent decides what to pull in full.
45
+ */
46
+ export interface ContextBriefPinnedBoard {
47
+ boardId: string;
48
+ name: string;
49
+ folder: string | null;
50
+ readmeSummary: string | null;
51
+ readmeNodeId?: string;
52
+ /** Pinned cards as title + short summary, each pullable by id. */
53
+ cards: Array<{ nodeId: string; title: string; summary: string }>;
54
+ /** Names of the boards it links to and is linked from. */
55
+ links: string[];
56
+ backlinks: string[];
57
+ }
58
+
59
+ /** The open board at a glance: what it is and how it relates to other boards. */
60
+ export interface ContextBriefOverview {
61
+ folder: string | null;
62
+ readmeSummary: string | null;
63
+ readmeNodeId?: string;
64
+ links: string[];
65
+ backlinks: string[];
66
+ }
67
+
68
+ export interface ContextBriefEdge {
69
+ from: string;
70
+ to: string;
71
+ toBoardId?: string;
72
+ /** Readable qualified target title, resolved by the shared memory graph. */
73
+ remoteTitle?: string;
74
+ type: string;
75
+ kind?: string;
76
+ reason?: string;
77
+ label?: string | null;
78
+ /** Board revision of the link's latest add, retype or relabel, and who made it. */
79
+ revision?: number;
80
+ changedBy?: { actor: string; source: string; agentId?: string };
81
+ /** Equal to changedAt while the link is as first drawn: "new" rather than "changed". */
82
+ createdAt?: string;
83
+ changedAt?: string;
84
+ }
85
+
29
86
  export interface ContextBriefInput {
30
- activeBoard: { boardId: string; name: string; category: string | null };
87
+ qualifications?: Readonly<Record<string, LifecycleQualification>>;
88
+ activeBoard: { boardId: string; name: string; folder: string | null };
89
+ /** The brief is a map, not a dump: overview and relations let the agent choose what to pull. */
90
+ overview?: ContextBriefOverview;
91
+ /** Edges on the open board, carried as each card's relations. */
92
+ edges?: readonly ContextBriefEdge[];
93
+ /** Saved wiki/portal connections from the shared library projection. */
94
+ memoryRelations?: Readonly<Record<string, string[]>>;
95
+ /** Why each pin was pinned, when someone said. */
96
+ pinReasons?: Readonly<Record<string, string>>;
31
97
  nodes: readonly CanvasNodeState[];
32
98
  pinnedNodeIds: readonly string[];
33
99
  contentRevision: number;
@@ -36,12 +102,16 @@ export interface ContextBriefInput {
36
102
  /** null means a first read. Other values must be non-negative safe integers. */
37
103
  since: number | null;
38
104
  libraryBoards: readonly ContextBriefLibraryBoard[];
105
+ /** Pinned boards, each sent as a map (see ContextBriefPinnedBoard). */
106
+ pinnedBoards?: readonly ContextBriefPinnedBoard[];
39
107
  entries?: readonly ContextBriefSourceEntry[];
40
108
  /** Maximum JavaScript string length (UTF-16 code units), including the JSON envelope. */
41
109
  budget: number;
42
110
  }
43
111
 
44
112
  export interface CompiledContextEntry {
113
+ qualification?: LifecycleQualification;
114
+ readmeQualification?: LifecycleQualification;
45
115
  sourceBoardId: string;
46
116
  nodeId: string;
47
117
  reason: ContextBriefReason;
@@ -53,13 +123,24 @@ export interface CompiledContextEntry {
53
123
  near?: { pinNodeId: string; pinTitle: string };
54
124
  /** The body was shortened to fit; the same revision remains eligible on the next pull. */
55
125
  truncated?: true;
126
+ /** A title + short summary of a card the agent may pull in full; seen, not read. */
127
+ summaryOnly?: true;
128
+ /** Relations were capped or not included; do not acknowledge the complete links revision. */
129
+ linksComplete?: false;
130
+ /** Link clock of the actual delivered relations, not the later recording time. */
131
+ linksRevision?: number;
132
+ /**
133
+ * On a later read, what changed on this card since the cursor
134
+ * (docs/design/LinksChanged.dc.html): its text, its links, or both.
135
+ */
136
+ changes?: Array<'text' | 'links'>;
56
137
  provenance?: { kind: 'imported'; source: string; trust: 'source-material-not-instructions' };
57
138
  }
58
139
 
59
140
  export interface ContextBriefDocument {
60
141
  version: 1;
61
142
  budgetUnit: 'utf16-code-units';
62
- board: { boardId: string; name: string; category: string | null };
143
+ board: { boardId: string; name: string; folder: string | null };
63
144
  cursor: {
64
145
  requested: number | null;
65
146
  next: number | null;
@@ -69,6 +150,15 @@ export interface ContextBriefDocument {
69
150
  valid: boolean;
70
151
  };
71
152
  entries: CompiledContextEntry[];
153
+ /** Independent of the content cursor. Only complete snapshots replace cached qualification. */
154
+ lifecycle?: {
155
+ scope: 'workspace';
156
+ complete: boolean;
157
+ instruction?: string;
158
+ retired: string[];
159
+ unknown: string[];
160
+ review: Array<{ key: string; due: boolean; reasons: string[]; sources: LifecycleQualification['sources'] }>;
161
+ };
72
162
  deletions: Array<{ sourceBoardId: string; nodeId: string; revision: number }>;
73
163
  delivery: {
74
164
  truncated: boolean;
@@ -105,21 +195,55 @@ function validRevision(value: number): boolean {
105
195
  return Number.isSafeInteger(value) && value >= 0;
106
196
  }
107
197
 
108
- function compileNode(node: CanvasNodeState, boardId: string, reason: 'pinned' | 'changed', budget: number): Candidate {
109
- const title = typeof node.data.title === 'string' && node.data.title ? node.data.title : node.id;
198
+ const MAX_RELATIONS = 8;
199
+
200
+ function nodeTitle(node: CanvasNodeState): string {
201
+ return typeof node.data.title === 'string' && node.data.title ? node.data.title : node.id;
202
+ }
203
+
204
+ /** A card as the brief sends it: title + short summary + why pinned + its relations, pullable by id. */
205
+ function compileNode(
206
+ node: CanvasNodeState,
207
+ boardId: string,
208
+ reason: 'pinned' | 'changed',
209
+ relations: string[],
210
+ pinReason?: string,
211
+ changes?: Array<'text' | 'links'>,
212
+ ): Candidate {
213
+ const summary = summarizeNodeForAgentContext(node, {
214
+ defaultTextLength: BRIEF_SUMMARY_LENGTH,
215
+ webpageTextLength: BRIEF_SUMMARY_LENGTH,
216
+ });
217
+ const capped = relations.length > MAX_RELATIONS;
218
+ const relationLines = capped
219
+ ? [...relations.slice(0, MAX_RELATIONS), `… ${relations.length - MAX_RELATIONS} more relations`]
220
+ : relations;
110
221
  return {
111
222
  entry: {
112
223
  sourceBoardId: boardId,
113
224
  nodeId: node.id,
114
225
  reason,
115
- title,
116
- text: summarizeNodeForAgentContext(node, { defaultTextLength: budget * 2, webpageTextLength: budget * 2 }),
226
+ title: nodeTitle(node),
227
+ text: [summary, pinReason ? `Pinned because: ${pinReason}` : '', ...relationLines].filter(Boolean).join('\n'),
228
+ summaryOnly: true,
229
+ linksRevision: node.linksRevision ?? 0,
230
+ ...(capped ? { linksComplete: false as const } : {}),
231
+ ...(changes && changes.length > 0 ? { changes } : {}),
117
232
  },
118
- revision: node.contentRevision,
233
+ revision: briefRevision(node),
119
234
  replacement: true,
120
235
  };
121
236
  }
122
237
 
238
+ /**
239
+ * A card's place in the delta: its newest text or links change (links are
240
+ * tracked apart, option C). Undefined for a legacy card with neither.
241
+ */
242
+ function briefRevision(node: CanvasNodeState): number | undefined {
243
+ if (node.contentRevision === undefined && node.linksRevision === undefined) return undefined;
244
+ return Math.max(node.contentRevision ?? -1, node.linksRevision ?? -1);
245
+ }
246
+
123
247
  /**
124
248
  * Pure, deterministic compiler. It reads only the supplied snapshot and never
125
249
  * treats library visibility as authorization to read or mutate another board.
@@ -138,12 +262,64 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
138
262
  ? 'retention-expired'
139
263
  : null;
140
264
  const deltaSince = reset ? -1 : (requested ?? -1);
265
+ // A reader with stale state must replace it whole; a first reader has none to
266
+ // protect, so it reads as a delta from zero: pins first, then whole revision
267
+ // groups oldest first, with the cursor advancing past each complete group.
268
+ const atomic = reset === 'retention-expired';
269
+ const firstRead = reset === 'first-read';
270
+ let firstReadCursor: number | null = null;
141
271
  const nodes = [...input.nodes].sort((a, b) => a.id.localeCompare(b.id));
142
272
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
143
273
  const pinIds = [...new Set(input.pinnedNodeIds)].sort();
274
+ const relationsOf = (id: string): string[] => {
275
+ const lines: string[] = [];
276
+ const marked: string[] = [];
277
+ for (const edge of input.edges ?? []) {
278
+ const other = edge.from === id ? edge.to : !edge.toBoardId && edge.to === id ? edge.from : null;
279
+ if (!other) continue;
280
+ const remote = edge.from === id && edge.toBoardId;
281
+ const otherTitle = remote
282
+ ? (edge.remoteTitle ?? `${edge.toBoardId} / ${other}`)
283
+ : nodeById.get(other)
284
+ ? nodeTitle(nodeById.get(other) as CanvasNodeState)
285
+ : other;
286
+ const kind = relationText(edge);
287
+ const mark = linkMark(edge);
288
+ // Changed local/outgoing links lead; the cap can still omit other relations.
289
+ (mark ? marked : lines).push(
290
+ edge.from === id ? `→ ${kind}: ${otherTitle} (${other})${mark}` : `← ${kind}: ${otherTitle} (${other})${mark}`,
291
+ );
292
+ }
293
+ lines.unshift(...marked);
294
+ lines.push(...(input.memoryRelations?.[id] ?? []));
295
+ return lines;
296
+ };
297
+ // A later read marks a link drawn or changed since the cursor, and who did it.
298
+ const linkMark = (edge: ContextBriefEdge): string => {
299
+ if (deltaSince < 0 || edge.revision === undefined || edge.revision <= deltaSince) return '';
300
+ const status = edge.createdAt !== undefined && edge.createdAt === edge.changedAt ? 'new' : 'changed';
301
+ const by = !edge.changedBy
302
+ ? ''
303
+ : edge.changedBy.actor === 'agent'
304
+ ? `, by ${edge.changedBy.agentId?.trim() || edge.changedBy.source}`
305
+ : ', by a person';
306
+ return ` (${status}${by})`;
307
+ };
308
+ // What changed on a card since the cursor: never on a first or reset read.
309
+ const changesOf = (node: CanvasNodeState): Array<'text' | 'links'> | undefined => {
310
+ if (deltaSince < 0) return undefined;
311
+ const changes: Array<'text' | 'links'> = [];
312
+ if ((node.contentRevision ?? -1) > deltaSince) changes.push('text');
313
+ if ((node.linksRevision ?? -1) > deltaSince) changes.push('links');
314
+ return changes;
315
+ };
144
316
  const candidates: Candidate[] = [];
145
317
  const candidateKeys = new Set<string>();
146
318
  const add = (candidate: Candidate): void => {
319
+ const qualification =
320
+ input.qualifications?.[memoryKey({ boardId: candidate.entry.sourceBoardId, cardId: candidate.entry.nodeId })];
321
+ if (qualification?.retired) return;
322
+ if (qualification) candidate.entry.qualification = qualification;
147
323
  const key = `${candidate.entry.sourceBoardId}\0${candidate.entry.nodeId}`;
148
324
  if (candidateKeys.has(key)) return;
149
325
  candidateKeys.add(key);
@@ -152,15 +328,23 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
152
328
 
153
329
  for (const id of pinIds) {
154
330
  const node = nodeById.get(id);
155
- if (node) add(compileNode(node, input.activeBoard.boardId, 'pinned', budget));
331
+ if (node) {
332
+ add(
333
+ compileNode(
334
+ node,
335
+ input.activeBoard.boardId,
336
+ 'pinned',
337
+ relationsOf(id),
338
+ input.pinReasons?.[id],
339
+ changesOf(node),
340
+ ),
341
+ );
342
+ }
156
343
  }
157
344
 
158
345
  const changed = nodes
159
- .filter(
160
- (node) =>
161
- reset !== null || (validRevision(node.contentRevision ?? -1) && (node.contentRevision ?? -1) > deltaSince),
162
- )
163
- .sort((a, b) => (a.contentRevision ?? 0) - (b.contentRevision ?? 0) || a.id.localeCompare(b.id));
346
+ .filter((node) => reset !== null || (briefRevision(node) ?? -1) > Math.max(deltaSince, -1))
347
+ .sort((a, b) => (briefRevision(a) ?? 0) - (briefRevision(b) ?? 0) || a.id.localeCompare(b.id));
164
348
 
165
349
  // Near a pin: each pin's unpinned neighbours (up to 5 within 600 px, nearest
166
350
  // first) as title + short summary. A neighbour that changed arrives in full
@@ -178,16 +362,20 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
178
362
  reason: 'near',
179
363
  title: typeof node.data.title === 'string' && node.data.title ? node.data.title : node.id,
180
364
  text: summarizeNodeForAgentContext(node, {
181
- defaultTextLength: NEAR_SUMMARY_LENGTH,
182
- webpageTextLength: NEAR_SUMMARY_LENGTH,
365
+ defaultTextLength: BRIEF_SUMMARY_LENGTH,
366
+ webpageTextLength: BRIEF_SUMMARY_LENGTH,
183
367
  }),
184
368
  near: { pinNodeId: neighborhood.pinnedNodeId, pinTitle },
369
+ summaryOnly: true,
370
+ linksComplete: false,
185
371
  },
186
372
  });
187
373
  }
188
374
  }
189
375
 
190
- for (const node of changed) add(compileNode(node, input.activeBoard.boardId, 'changed', budget));
376
+ for (const node of changed) {
377
+ add(compileNode(node, input.activeBoard.boardId, 'changed', relationsOf(node.id), undefined, changesOf(node)));
378
+ }
191
379
 
192
380
  for (const entry of [...(input.entries ?? [])].sort((a, b) => {
193
381
  const priority = (value: ContextBriefSourceEntry): number => (value.reason === 'human' ? 0 : 1);
@@ -203,21 +391,65 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
203
391
  title: entry.title,
204
392
  text: entry.text,
205
393
  provenance: importedProvenance(entry.provenance),
394
+ linksComplete: false,
395
+ ...(entry.reason === 'human' ? { summaryOnly: true as const } : {}),
206
396
  },
207
397
  });
208
398
  }
209
399
 
210
- const library = [...input.libraryBoards].sort((a, b) => a.boardId.localeCompare(b.boardId));
400
+ const pinnedBoards = [...(input.pinnedBoards ?? [])]
401
+ .filter((board) => board.boardId !== input.activeBoard.boardId)
402
+ .sort((a, b) => a.boardId.localeCompare(b.boardId));
403
+ const pinnedBoardIds = new Set(pinnedBoards.map((board) => board.boardId));
404
+ // Pinned boards travel as maps, each entry whole or absent, after the open
405
+ // board's own context: the agent pulls what it wants in full.
406
+ for (const board of pinnedBoards) {
407
+ const relations = [
408
+ board.folder ? `Folder: ${board.folder}.` : '',
409
+ board.links.length ? `Links to: ${board.links.join(', ')}.` : '',
410
+ board.backlinks.length ? `Linked from: ${board.backlinks.join(', ')}.` : '',
411
+ board.cards.length ? `Pinned cards: ${board.cards.length}.` : '',
412
+ ].filter(Boolean);
413
+ add({
414
+ entry: {
415
+ sourceBoardId: board.boardId,
416
+ nodeId: `board:${board.boardId}`,
417
+ reason: 'pinned-board',
418
+ title: board.name,
419
+ text: [board.readmeSummary ?? '', ...relations].filter(Boolean).join('\n'),
420
+ ...(board.readmeNodeId && board.readmeSummary
421
+ ? {
422
+ readmeQualification:
423
+ input.qualifications?.[memoryKey({ boardId: board.boardId, cardId: board.readmeNodeId })],
424
+ }
425
+ : {}),
426
+ },
427
+ });
428
+ for (const card of board.cards) {
429
+ add({
430
+ entry: {
431
+ sourceBoardId: board.boardId,
432
+ nodeId: card.nodeId,
433
+ reason: 'pinned-board',
434
+ title: card.title,
435
+ text: card.summary,
436
+ summaryOnly: true,
437
+ linksComplete: false,
438
+ },
439
+ });
440
+ }
441
+ }
442
+ const library = [...input.libraryBoards]
443
+ .filter((board) => !pinnedBoardIds.has(board.boardId))
444
+ .sort((a, b) => a.boardId.localeCompare(b.boardId));
211
445
  const explicit = library.filter((board) => board.linkIds.length > 0);
212
446
  const related = library.filter(
213
447
  (board) =>
214
- board.linkIds.length === 0 &&
215
- input.activeBoard.category !== null &&
216
- board.category === input.activeBoard.category,
448
+ board.linkIds.length === 0 && input.activeBoard.folder !== null && board.folder === input.activeBoard.folder,
217
449
  );
218
450
  for (const [boards, reason] of [
219
451
  [explicit, 'linked'],
220
- [related, 'category'],
452
+ [related, 'folder'],
221
453
  ] as const) {
222
454
  for (const board of boards) {
223
455
  if (board.boardId === input.activeBoard.boardId) continue;
@@ -230,6 +462,8 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
230
462
  title: board.readme.title,
231
463
  text: board.readme.summary,
232
464
  provenance: importedProvenance(board.readme.provenance),
465
+ summaryOnly: true,
466
+ linksComplete: false,
233
467
  },
234
468
  });
235
469
  }
@@ -264,10 +498,35 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
264
498
  let deltaBlocked = !cursorValid;
265
499
  let resetComplete = false;
266
500
 
501
+ const qualifications = Object.entries(input.qualifications ?? {});
502
+ let lifecycle: ContextBriefDocument['lifecycle'] = input.qualifications
503
+ ? {
504
+ scope: 'workspace',
505
+ complete: true,
506
+ retired: qualifications.filter(([, status]) => status.retired).map(([key]) => key),
507
+ unknown: qualifications.filter(([, status]) => status.unknown && !status.retired).map(([key]) => key),
508
+ review: qualifications
509
+ .filter(([, status]) => !status.retired && (status.due || status.sources.length))
510
+ .map(([key, status]) => ({ key, due: status.due, reasons: status.reasons, sources: status.sources })),
511
+ }
512
+ : undefined;
513
+ // A large library must not consume the entire content budget. Do not send a
514
+ // partial list that a consumer could mistake for an authoritative empty one.
515
+ if (lifecycle && JSON.stringify(lifecycle).length > budget / 4)
516
+ lifecycle = {
517
+ scope: 'workspace',
518
+ complete: false,
519
+ instruction:
520
+ 'Recheck cached cards with canvas_node lifecycle-get before reuse. This budget omits the workspace qualification snapshot; empty lists do not mean healthy or active.',
521
+ retired: [],
522
+ unknown: [],
523
+ review: [],
524
+ };
525
+
267
526
  const makeDocument = (
268
527
  entries = delivered,
269
528
  deleted = deletions,
270
- next: number | null = reset && !resetComplete ? requested : safeCursor,
529
+ next: number | null = atomic && !resetComplete ? requested : firstRead ? firstReadCursor : safeCursor,
271
530
  ): ContextBriefDocument => ({
272
531
  version: 1,
273
532
  budgetUnit: 'utf16-code-units',
@@ -281,15 +540,19 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
281
540
  valid: cursorValid,
282
541
  },
283
542
  entries,
543
+ ...(lifecycle ? { lifecycle } : {}),
284
544
  deletions: deleted,
285
545
  delivery: {
286
- truncated:
287
- entries.filter((entry) => !entry.truncated).length < candidates.length || deleted.length < tombstones.length,
288
- omittedEntries: candidates.length - entries.filter((entry) => !entry.truncated).length,
546
+ truncated: counted(entries) < candidates.length || deleted.length < tombstones.length,
547
+ omittedEntries: candidates.length - counted(entries),
289
548
  omittedDeletions: tombstones.length - deleted.length,
290
549
  },
291
550
  });
292
551
  const fits = (document: ContextBriefDocument): boolean => JSON.stringify(document).length <= budget;
552
+ // The overview is not a candidate, so it never counts as delivered or omitted.
553
+ function counted(entries: readonly CompiledContextEntry[]): number {
554
+ return entries.filter((entry) => !entry.truncated && entry.reason !== 'overview').length;
555
+ }
293
556
  const fitPartial = (entry: CompiledContextEntry, base = delivered): CompiledContextEntry | null => {
294
557
  let low = 0;
295
558
  let high = entry.text.length;
@@ -307,9 +570,36 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
307
570
  return best && best.text.length > 0 ? best : null;
308
571
  };
309
572
 
573
+ // The overview leads: what this board is and how it relates to other boards.
574
+ if (input.overview) {
575
+ const overview = input.overview;
576
+ const overviewEntry: CompiledContextEntry = {
577
+ sourceBoardId: input.activeBoard.boardId,
578
+ nodeId: `board:${input.activeBoard.boardId}`,
579
+ reason: 'overview',
580
+ title: input.activeBoard.name,
581
+ ...(overview.readmeNodeId && overview.readmeSummary
582
+ ? {
583
+ readmeQualification:
584
+ input.qualifications?.[memoryKey({ boardId: input.activeBoard.boardId, cardId: overview.readmeNodeId })],
585
+ }
586
+ : {}),
587
+ text: [
588
+ overview.readmeSummary ?? '',
589
+ overview.folder ? `Folder: ${overview.folder}.` : '',
590
+ `${input.nodes.length} cards, ${pinIds.length} pinned.`,
591
+ overview.links.length ? `Links to: ${overview.links.join(', ')}.` : '',
592
+ overview.backlinks.length ? `Linked from: ${overview.backlinks.join(', ')}.` : '',
593
+ 'Entries are summaries: pull a card in full with canvas_node { action: "get", board, id, full: true }.',
594
+ ]
595
+ .filter(Boolean)
596
+ .join('\n'),
597
+ };
598
+ if (fits(makeDocument([...delivered, overviewEntry]))) delivered.push(overviewEntry);
599
+ }
310
600
  // Pins precede revision groups. Revision groups are all-or-nothing so a cursor
311
601
  // can never skip a sibling change or deletion at the same revision.
312
- for (const candidate of candidates.filter((item) => !reset && item.entry.reason === 'pinned')) {
602
+ for (const candidate of candidates.filter((item) => !atomic && item.entry.reason === 'pinned')) {
313
603
  const proposed = [...delivered, candidate.entry];
314
604
  if (fits(makeDocument(proposed))) delivered.push(candidate.entry);
315
605
  else {
@@ -318,11 +608,11 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
318
608
  }
319
609
  }
320
610
  // 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')) {
611
+ for (const candidate of candidates.filter((item) => !atomic && item.entry.reason === 'near')) {
322
612
  const proposed = [...delivered, candidate.entry];
323
613
  if (fits(makeDocument(proposed))) delivered.push(candidate.entry);
324
614
  }
325
- if (reset && !deltaBlocked) {
615
+ if (atomic && !deltaBlocked) {
326
616
  const resetEntries = candidates
327
617
  .filter((item) => item.replacement && !delivered.includes(item.entry))
328
618
  .map((item) => item.entry);
@@ -341,12 +631,15 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
341
631
  }
342
632
  }
343
633
  const revisions = [
344
- ...new Set([...changed.map((node) => node.contentRevision!), ...tombstones.map((item) => item.revision)]),
634
+ ...new Set([
635
+ ...changed.map((node) => briefRevision(node) ?? -1).filter(validRevision),
636
+ ...(firstRead ? [] : tombstones.map((item) => item.revision)),
637
+ ]),
345
638
  ]
346
- .filter((revision) => revision > safeCursor)
639
+ .filter((revision) => revision > (firstRead ? -1 : safeCursor))
347
640
  .sort((a, b) => a - b);
348
641
  for (const revision of revisions) {
349
- if (deltaBlocked || reset) break;
642
+ if (deltaBlocked || atomic) break;
350
643
  if (
351
644
  delivered.some(
352
645
  (entry) =>
@@ -366,7 +659,7 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
366
659
  .filter((item) => item.revision === revision && !delivered.includes(item.entry))
367
660
  .map((item) => item.entry);
368
661
  const deletesAtRevision = tombstones
369
- .filter((item) => item.revision === revision)
662
+ .filter((item) => !firstRead && item.revision === revision)
370
663
  .map((item) => ({ sourceBoardId: input.activeBoard.boardId, nodeId: item.nodeId, revision: item.revision }));
371
664
  const proposedEntries = [...delivered, ...entriesAtRevision];
372
665
  const proposedDeletes = [...deletions, ...deletesAtRevision];
@@ -382,6 +675,13 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
382
675
  delivered.push(...entriesAtRevision);
383
676
  deletions.push(...deletesAtRevision);
384
677
  safeCursor = revision;
678
+ // A first-read page cursor below the retention floor would read back as
679
+ // retention-expired, so a page only advances the cursor from the floor up.
680
+ if (firstRead && revision >= floor) firstReadCursor = revision;
681
+ }
682
+ if (firstRead && !deltaBlocked) {
683
+ safeCursor = current;
684
+ firstReadCursor = current;
385
685
  }
386
686
  for (const candidate of candidates.filter(
387
687
  (item) => item.entry.reason !== 'pinned' && item.revision === undefined && !delivered.includes(item.entry),
@@ -391,7 +691,7 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
391
691
  }
392
692
 
393
693
  const document = makeDocument();
394
- if (reset && !resetComplete) document.cursor.next = requested;
694
+ if (atomic && !resetComplete) document.cursor.next = requested;
395
695
  const serialized = fits(document) ? JSON.stringify(document) : '';
396
696
  const outputDocument = serialized ? document : null;
397
697
  return {
@@ -401,7 +701,14 @@ export function compileContextBrief(input: ContextBriefInput): ContextBriefResul
401
701
  ? outputDocument.entries.map((entry) => `${entry.sourceBoardId}:${entry.nodeId}`)
402
702
  : [],
403
703
  deliveredDeletionIds: outputDocument ? outputDocument.deletions.map((entry) => entry.nodeId) : [],
404
- nextCursor: outputDocument && cursorValid ? (reset && !resetComplete ? requested : safeCursor) : null,
704
+ nextCursor:
705
+ outputDocument && cursorValid
706
+ ? atomic && !resetComplete
707
+ ? requested
708
+ : firstRead
709
+ ? firstReadCursor
710
+ : safeCursor
711
+ : null,
405
712
  reset,
406
713
  invalidCursor: !cursorValid,
407
714
  truncated: !outputDocument || document.delivery.truncated,