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.
- package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/.github/extensions/pmx-canvas/extension.mjs +25 -172
- package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
- package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
- package/.github/extensions/pmx-canvas/runtime.json +3 -0
- package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
- package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
- package/.github/extensions/pmx-canvas/tools.json +2560 -0
- package/CHANGELOG.md +60 -0
- package/Readme.md +44 -16
- package/dist/canvas/fonts/OFL.txt +93 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/dist/canvas/global.css +844 -710
- package/dist/canvas/index.js +92 -90
- package/dist/canvas/mcp-app.html +7878 -0
- package/dist/canvas/mermaid-entry.js +1 -1
- package/dist/canvas/surface-theme.css +151 -46
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +55 -55
- package/dist/types/cli/commands/record.d.ts +1 -1
- package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
- package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
- package/dist/types/client/icons.d.ts +1 -2
- package/dist/types/client/mcp-app/index.d.ts +1 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
- package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
- package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
- package/dist/types/client/state/attention-store.d.ts +0 -5
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/context-status-store.d.ts +53 -0
- package/dist/types/client/state/intent-bridge.d.ts +4 -1
- package/dist/types/client/state/near-pin-store.d.ts +16 -0
- package/dist/types/client/state/presence-store.d.ts +5 -0
- package/dist/types/client/state/session-store.d.ts +33 -3
- package/dist/types/client/state/workbench-transport.d.ts +16 -0
- package/dist/types/client/theme/tokens.d.ts +1 -3
- package/dist/types/client/types.d.ts +9 -0
- package/dist/types/json-render/renderer/index.d.ts +2 -1
- package/dist/types/json-render/server.d.ts +3 -1
- package/dist/types/mcp/app-server.d.ts +3 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -1
- package/dist/types/mcp/canvas-app.d.ts +4 -0
- package/dist/types/mcp/workbench-app.d.ts +4 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +8 -0
- package/dist/types/server/canvas-geometry.d.ts +25 -0
- package/dist/types/server/canvas-operations.d.ts +1 -11
- package/dist/types/server/canvas-state.d.ts +25 -3
- package/dist/types/server/context-brief.d.ts +8 -1
- package/dist/types/server/context-reads.d.ts +30 -7
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/dist/types/server/operations/http.d.ts +2 -0
- package/dist/types/server/operations/invoker.d.ts +4 -2
- package/dist/types/server/operations/ops/nodes.d.ts +0 -6
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/spatial-analysis.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +22 -0
- package/dist/types/shared/agent-presence.d.ts +3 -10
- package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
- package/dist/types/shared/canvas-app.d.ts +54 -0
- package/dist/types/shared/themes.d.ts +1 -1
- package/docs/RELEASE.md +7 -3
- package/docs/agent-plugin.md +123 -0
- package/docs/ax-host-adapter-contract.md +13 -2
- package/docs/ax-state-contract.md +40 -0
- package/docs/cli.md +7 -2
- package/docs/design/AgentContext.dc.html +186 -0
- package/docs/design/Before.dc.html +208 -0
- package/docs/design/BoardPins.dc.html +151 -0
- package/docs/design/Chrome.dc.html +208 -0
- package/docs/design/Context.dc.html +239 -0
- package/docs/design/Flows.dc.html +181 -0
- package/docs/design/Home.dc.html +209 -0
- package/docs/design/HomeGraph.dc.html +211 -0
- package/docs/design/HomeMap.dc.html +117 -0
- package/docs/design/HomeTeam.dc.html +176 -0
- package/docs/design/Light.dc.html +28 -0
- package/docs/design/Main.dc.html +280 -0
- package/docs/design/NearPin.dc.html +260 -0
- package/docs/design/Pane600.dc.html +120 -0
- package/docs/design/Relations.dc.html +297 -0
- package/docs/design/Themes.dc.html +121 -0
- package/docs/design/TokenSystem.dc.html +136 -0
- package/docs/design/Tokens.dc.html +163 -0
- package/docs/design/Types1.dc.html +177 -0
- package/docs/design/Types2.dc.html +226 -0
- package/docs/design/Types3.dc.html +195 -0
- package/docs/design/Uses.dc.html +152 -0
- package/docs/design/Vision.dc.html +116 -0
- package/docs/design/Zoom.dc.html +82 -0
- package/docs/design/canvas.json +274 -0
- package/docs/design.md +214 -0
- package/docs/environment.md +1 -1
- package/docs/http-api.md +80 -15
- package/docs/mcp.md +198 -3
- package/docs/node-types.md +34 -10
- package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
- package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
- package/docs/product-vision-2026-09.md +47 -7
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +10 -4
- package/plugins/pmx-canvas/LICENSE +21 -0
- package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/plugins/pmx-canvas/assets/preview.png +0 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/plugins/pmx-canvas/mcp.json +12 -0
- package/plugins/pmx-canvas/plugin.json +24 -0
- package/plugins/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/start-mcp.mjs +51 -0
- package/plugins/pmx-canvas/tools.json +2560 -0
- package/skills/pmx-canvas/SKILL.md +31 -16
- package/skills/pmx-canvas/evals/evals.json +69 -0
- package/skills/pmx-canvas/references/full-reference.md +14 -10
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
- package/skills/pmx-canvas-testing/SKILL.md +54 -4
- package/src/cli/commands/copilot.ts +5 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +8 -3
- package/src/client/App.tsx +6 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
- package/src/client/canvas/AnnotationLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +208 -220
- package/src/client/canvas/CanvasViewport.tsx +6 -2
- package/src/client/canvas/ChoiceMenu.tsx +122 -0
- package/src/client/canvas/CommandBar.tsx +25 -23
- package/src/client/canvas/CommandPalette.tsx +3 -2
- package/src/client/canvas/ContextMenu.tsx +11 -0
- package/src/client/canvas/EdgeLayer.tsx +1 -1
- package/src/client/canvas/ExportDialog.tsx +33 -6
- package/src/client/canvas/FeedbackDialog.tsx +10 -8
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Minimap.tsx +12 -7
- package/src/client/canvas/NodeContextMark.tsx +156 -0
- package/src/client/canvas/SessionPanel.tsx +4 -7
- package/src/client/canvas/SessionReceipt.tsx +114 -17
- package/src/client/canvas/ShortcutOverlay.tsx +6 -1
- package/src/client/canvas/SnapshotPanel.tsx +11 -2
- package/src/client/canvas/ToolRail.tsx +58 -108
- package/src/client/canvas/TopBar.tsx +16 -11
- package/src/client/icons.tsx +2 -12
- package/src/client/mcp-app/index.tsx +137 -0
- package/src/client/mcp-app/style.css +10 -0
- package/src/client/nodes/AttachmentNode.tsx +8 -1
- package/src/client/nodes/AxStepControls.tsx +1 -1
- package/src/client/nodes/ContextNode.tsx +9 -9
- package/src/client/nodes/ExtAppFrame.tsx +13 -8
- package/src/client/nodes/FileNode.tsx +8 -5
- package/src/client/nodes/ImageNode.tsx +4 -2
- package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
- package/src/client/nodes/LedgerNode.tsx +2 -2
- package/src/client/nodes/MarkdownNode.tsx +44 -9
- package/src/client/nodes/McpAppNode.tsx +22 -4
- package/src/client/nodes/PromptNode.tsx +5 -17
- package/src/client/nodes/ResponseNode.tsx +3 -15
- package/src/client/nodes/StatusNode.tsx +2 -2
- package/src/client/nodes/StatusSummary.tsx +3 -1
- package/src/client/nodes/TraceNode.tsx +6 -4
- package/src/client/nodes/WebpageNode.tsx +1 -1
- package/src/client/nodes/ax-node-actions.ts +2 -2
- package/src/client/nodes/iframe-document-url.ts +3 -2
- package/src/client/nodes/markdown-sanitize.ts +10 -0
- package/src/client/nodes/surface-url.ts +10 -0
- package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/nodes/use-workbench-asset.ts +26 -0
- package/src/client/nodes/viewer-runtime-state.ts +78 -0
- package/src/client/state/attention-bridge.ts +0 -8
- package/src/client/state/attention-store.ts +0 -17
- package/src/client/state/canvas-store.ts +7 -2
- package/src/client/state/context-status-store.ts +66 -0
- package/src/client/state/human-store.ts +2 -0
- package/src/client/state/iframe-mode.ts +5 -0
- package/src/client/state/intent-bridge.ts +24 -6
- package/src/client/state/near-pin-store.ts +53 -0
- package/src/client/state/presence-store.ts +5 -10
- package/src/client/state/session-store.ts +106 -9
- package/src/client/state/sse-bridge.ts +26 -7
- package/src/client/state/theme-override.ts +1 -1
- package/src/client/state/workbench-transport.ts +36 -0
- package/src/client/theme/global.css +844 -710
- package/src/client/theme/surface-theme.css +151 -46
- package/src/client/theme/tokens.ts +7 -11
- package/src/client/types.ts +10 -0
- package/src/json-render/renderer/index.tsx +58 -38
- package/src/json-render/server.ts +79 -15
- package/src/mcp/app-server.ts +64 -0
- package/src/mcp/canvas-access.ts +14 -11
- package/src/mcp/canvas-app.ts +318 -0
- package/src/mcp/server.ts +1 -1
- package/src/mcp/workbench-app.ts +211 -0
- package/src/server/agent-presence.ts +45 -6
- package/src/server/ax-context.ts +1 -1
- package/src/server/ax-state.ts +1 -1
- package/src/server/board-export.ts +5 -2
- package/src/server/canvas-db.ts +46 -12
- package/src/server/canvas-geometry.ts +64 -0
- package/src/server/canvas-operations.ts +6 -24
- package/src/server/canvas-state.ts +105 -15
- package/src/server/canvas-validation.ts +19 -4
- package/src/server/context-brief.ts +39 -2
- package/src/server/context-reads.ts +86 -15
- package/src/server/demo-state.json +31 -31
- package/src/server/html-surface.ts +4 -5
- package/src/server/index.ts +19 -3
- package/src/server/mcp-app-runtime.ts +11 -0
- package/src/server/operations/composites.ts +4 -3
- package/src/server/operations/http.ts +4 -4
- package/src/server/operations/invoker.ts +13 -4
- package/src/server/operations/ops/ax-read.ts +1 -1
- package/src/server/operations/ops/ax-timeline.ts +37 -0
- package/src/server/operations/ops/boards.ts +1 -1
- package/src/server/operations/ops/canvas-wire.ts +3 -6
- package/src/server/operations/ops/intent.ts +31 -2
- package/src/server/operations/ops/json-render.ts +1 -1
- package/src/server/operations/ops/nodes.ts +8 -76
- package/src/server/operations/ops/query.ts +8 -2
- package/src/server/operations/ops/snapshots.ts +4 -1
- package/src/server/operations/ops/viewport.ts +29 -5
- package/src/server/operations/registry.ts +38 -4
- package/src/server/scope-fence.ts +55 -10
- package/src/server/server.ts +86 -6
- package/src/server/spatial-analysis.ts +2 -2
- package/src/server/workboard.ts +6 -1
- package/src/{server → shared}/agent-context.ts +2 -2
- package/src/shared/agent-presence.ts +3 -15
- package/src/shared/ax-surface-protocol.ts +3 -0
- package/src/shared/canvas-app.ts +62 -0
- package/src/shared/themes.ts +5 -5
- package/src/client/canvas/FocusFieldLayer.tsx +0 -116
- 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 = '
|
|
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))
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 '
|
|
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
|
-
*
|
|
61
|
-
* what the reader received. A bare id list, a
|
|
62
|
-
* not count — the agent got the
|
|
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
|
|
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
|
-
(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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,
|