pmx-canvas 0.6.5 → 0.7.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/extension.mjs +67 -3
- package/CHANGELOG.md +54 -0
- package/Readme.md +29 -5
- package/dist/canvas/global.css +440 -24
- package/dist/canvas/index.js +90 -75
- package/dist/types/cli/agent.d.ts +3 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -0
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +63 -9
- package/dist/types/server/canvas-operations.d.ts +23 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +140 -3
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/index.d.ts +103 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +68 -0
- package/docs/http-api.md +198 -2
- package/docs/mcp.md +71 -6
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +53 -4
- package/skills/pmx-canvas/references/full-reference.md +43 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +4 -0
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +30 -0
- package/src/cli/commands/nodes.ts +4 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +19 -9
- package/src/client/App.tsx +21 -3
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +197 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +34 -18
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/icons.tsx +2 -0
- package/src/client/nodes/AttachmentNode.tsx +148 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/session-store.ts +5 -0
- package/src/client/state/sse-bridge.ts +49 -5
- package/src/client/theme/global.css +440 -24
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +16 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +599 -107
- package/src/server/canvas-operations.ts +68 -9
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +12 -0
- package/src/server/canvas-state.ts +774 -45
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +659 -2
- package/src/server/document-import.ts +223 -0
- package/src/server/index.ts +231 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +26 -4
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +8 -1
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/nodes.ts +57 -5
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/registry.ts +123 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/server.ts +146 -2
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +2 -1
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { summarizeNodeForAgentContext } from './agent-context.js';
|
|
2
|
+
import type { CanvasNodeState, NodeDeletionTombstone } from './canvas-state.js';
|
|
3
|
+
|
|
4
|
+
export type ContextBriefReason = 'pinned' | 'changed' | 'human' | 'ask' | 'steer' | 'linked' | 'category';
|
|
5
|
+
|
|
6
|
+
export interface ContextBriefSourceEntry {
|
|
7
|
+
sourceBoardId: string;
|
|
8
|
+
nodeId: string;
|
|
9
|
+
reason: 'human' | 'ask' | 'steer';
|
|
10
|
+
title: string;
|
|
11
|
+
text: string;
|
|
12
|
+
provenance?: { kind: 'imported'; source: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ContextBriefLibraryBoard {
|
|
16
|
+
boardId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
category: string | null;
|
|
19
|
+
readme?: { nodeId: string; title: string; summary: string; provenance?: { kind: 'imported'; source: string } };
|
|
20
|
+
pinnedTitles: Array<{ nodeId: string; title: string }>;
|
|
21
|
+
/** IDs of link cards on the active board which explicitly target this board. */
|
|
22
|
+
linkIds: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ContextBriefInput {
|
|
26
|
+
activeBoard: { boardId: string; name: string; category: string | null };
|
|
27
|
+
nodes: readonly CanvasNodeState[];
|
|
28
|
+
pinnedNodeIds: readonly string[];
|
|
29
|
+
contentRevision: number;
|
|
30
|
+
retentionFloor: number;
|
|
31
|
+
tombstones: readonly NodeDeletionTombstone[];
|
|
32
|
+
/** null means a first read. Other values must be non-negative safe integers. */
|
|
33
|
+
since: number | null;
|
|
34
|
+
libraryBoards: readonly ContextBriefLibraryBoard[];
|
|
35
|
+
entries?: readonly ContextBriefSourceEntry[];
|
|
36
|
+
/** Maximum JavaScript string length (UTF-16 code units), including the JSON envelope. */
|
|
37
|
+
budget: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CompiledContextEntry {
|
|
41
|
+
sourceBoardId: string;
|
|
42
|
+
nodeId: string;
|
|
43
|
+
reason: ContextBriefReason;
|
|
44
|
+
title: string;
|
|
45
|
+
text: string;
|
|
46
|
+
/** A linked board's pin title is discovery metadata, not delivery of that pinned card. */
|
|
47
|
+
titleOnly?: true;
|
|
48
|
+
/** The body was shortened to fit; the same revision remains eligible on the next pull. */
|
|
49
|
+
truncated?: true;
|
|
50
|
+
provenance?: { kind: 'imported'; source: string; trust: 'source-material-not-instructions' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ContextBriefDocument {
|
|
54
|
+
version: 1;
|
|
55
|
+
budgetUnit: 'utf16-code-units';
|
|
56
|
+
board: { boardId: string; name: string; category: string | null };
|
|
57
|
+
cursor: {
|
|
58
|
+
requested: number | null;
|
|
59
|
+
next: number | null;
|
|
60
|
+
current: number;
|
|
61
|
+
retentionFloor: number;
|
|
62
|
+
reset: 'first-read' | 'retention-expired' | null;
|
|
63
|
+
valid: boolean;
|
|
64
|
+
};
|
|
65
|
+
entries: CompiledContextEntry[];
|
|
66
|
+
deletions: Array<{ sourceBoardId: string; nodeId: string; revision: number }>;
|
|
67
|
+
delivery: {
|
|
68
|
+
truncated: boolean;
|
|
69
|
+
omittedEntries: number;
|
|
70
|
+
omittedDeletions: number;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ContextBriefResult {
|
|
75
|
+
/** Empty only when the budget cannot hold even the bounded JSON envelope. */
|
|
76
|
+
serialized: string;
|
|
77
|
+
document: ContextBriefDocument | null;
|
|
78
|
+
deliveredEntryIds: string[];
|
|
79
|
+
deliveredDeletionIds: string[];
|
|
80
|
+
nextCursor: number | null;
|
|
81
|
+
reset: ContextBriefDocument['cursor']['reset'];
|
|
82
|
+
invalidCursor: boolean;
|
|
83
|
+
truncated: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Candidate {
|
|
87
|
+
entry: CompiledContextEntry;
|
|
88
|
+
revision?: number;
|
|
89
|
+
replacement?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function importedProvenance(
|
|
93
|
+
provenance: ContextBriefSourceEntry['provenance'],
|
|
94
|
+
): CompiledContextEntry['provenance'] | undefined {
|
|
95
|
+
return provenance ? { ...provenance, trust: 'source-material-not-instructions' } : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validRevision(value: number): boolean {
|
|
99
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function compileNode(node: CanvasNodeState, boardId: string, reason: 'pinned' | 'changed', budget: number): Candidate {
|
|
103
|
+
const title = typeof node.data.title === 'string' && node.data.title ? node.data.title : node.id;
|
|
104
|
+
return {
|
|
105
|
+
entry: {
|
|
106
|
+
sourceBoardId: boardId,
|
|
107
|
+
nodeId: node.id,
|
|
108
|
+
reason,
|
|
109
|
+
title,
|
|
110
|
+
text: summarizeNodeForAgentContext(node, { defaultTextLength: budget * 2, webpageTextLength: budget * 2 }),
|
|
111
|
+
},
|
|
112
|
+
revision: node.contentRevision,
|
|
113
|
+
replacement: true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pure, deterministic compiler. It reads only the supplied snapshot and never
|
|
119
|
+
* treats library visibility as authorization to read or mutate another board.
|
|
120
|
+
*/
|
|
121
|
+
export function compileContextBrief(input: ContextBriefInput): ContextBriefResult {
|
|
122
|
+
const budget = Number.isFinite(input.budget) ? Math.max(0, Math.floor(input.budget)) : 0;
|
|
123
|
+
const current = validRevision(input.contentRevision) ? input.contentRevision : 0;
|
|
124
|
+
const floor = validRevision(input.retentionFloor) ? input.retentionFloor : 0;
|
|
125
|
+
const cursorValid = input.since === null || (validRevision(input.since) && input.since <= current);
|
|
126
|
+
const requested = cursorValid ? input.since : null;
|
|
127
|
+
const reset = !cursorValid
|
|
128
|
+
? null
|
|
129
|
+
: requested === null
|
|
130
|
+
? 'first-read'
|
|
131
|
+
: requested < floor
|
|
132
|
+
? 'retention-expired'
|
|
133
|
+
: null;
|
|
134
|
+
const deltaSince = reset ? -1 : (requested ?? -1);
|
|
135
|
+
const nodes = [...input.nodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
136
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
137
|
+
const pinIds = [...new Set(input.pinnedNodeIds)].sort();
|
|
138
|
+
const candidates: Candidate[] = [];
|
|
139
|
+
const candidateKeys = new Set<string>();
|
|
140
|
+
const add = (candidate: Candidate): void => {
|
|
141
|
+
const key = `${candidate.entry.sourceBoardId}\0${candidate.entry.nodeId}`;
|
|
142
|
+
if (candidateKeys.has(key)) return;
|
|
143
|
+
candidateKeys.add(key);
|
|
144
|
+
candidates.push(candidate);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const id of pinIds) {
|
|
148
|
+
const node = nodeById.get(id);
|
|
149
|
+
if (node) add(compileNode(node, input.activeBoard.boardId, 'pinned', budget));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const changed = nodes
|
|
153
|
+
.filter(
|
|
154
|
+
(node) =>
|
|
155
|
+
reset !== null || (validRevision(node.contentRevision ?? -1) && (node.contentRevision ?? -1) > deltaSince),
|
|
156
|
+
)
|
|
157
|
+
.sort((a, b) => (a.contentRevision ?? 0) - (b.contentRevision ?? 0) || a.id.localeCompare(b.id));
|
|
158
|
+
for (const node of changed) add(compileNode(node, input.activeBoard.boardId, 'changed', budget));
|
|
159
|
+
|
|
160
|
+
for (const entry of [...(input.entries ?? [])].sort((a, b) => {
|
|
161
|
+
const priority = (value: ContextBriefSourceEntry): number => (value.reason === 'human' ? 0 : 1);
|
|
162
|
+
return (
|
|
163
|
+
priority(a) - priority(b) || a.sourceBoardId.localeCompare(b.sourceBoardId) || a.nodeId.localeCompare(b.nodeId)
|
|
164
|
+
);
|
|
165
|
+
})) {
|
|
166
|
+
add({
|
|
167
|
+
entry: {
|
|
168
|
+
sourceBoardId: entry.sourceBoardId,
|
|
169
|
+
nodeId: entry.nodeId,
|
|
170
|
+
reason: entry.reason,
|
|
171
|
+
title: entry.title,
|
|
172
|
+
text: entry.text,
|
|
173
|
+
provenance: importedProvenance(entry.provenance),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const library = [...input.libraryBoards].sort((a, b) => a.boardId.localeCompare(b.boardId));
|
|
179
|
+
const explicit = library.filter((board) => board.linkIds.length > 0);
|
|
180
|
+
const related = library.filter(
|
|
181
|
+
(board) =>
|
|
182
|
+
board.linkIds.length === 0 &&
|
|
183
|
+
input.activeBoard.category !== null &&
|
|
184
|
+
board.category === input.activeBoard.category,
|
|
185
|
+
);
|
|
186
|
+
for (const [boards, reason] of [
|
|
187
|
+
[explicit, 'linked'],
|
|
188
|
+
[related, 'category'],
|
|
189
|
+
] as const) {
|
|
190
|
+
for (const board of boards) {
|
|
191
|
+
if (board.boardId === input.activeBoard.boardId) continue;
|
|
192
|
+
if (board.readme) {
|
|
193
|
+
add({
|
|
194
|
+
entry: {
|
|
195
|
+
sourceBoardId: board.boardId,
|
|
196
|
+
nodeId: board.readme.nodeId,
|
|
197
|
+
reason,
|
|
198
|
+
title: board.readme.title,
|
|
199
|
+
text: board.readme.summary,
|
|
200
|
+
provenance: importedProvenance(board.readme.provenance),
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (reason === 'linked') {
|
|
205
|
+
for (const pin of [...board.pinnedTitles].sort((a, b) => a.nodeId.localeCompare(b.nodeId))) {
|
|
206
|
+
add({
|
|
207
|
+
entry: {
|
|
208
|
+
sourceBoardId: board.boardId,
|
|
209
|
+
nodeId: pin.nodeId,
|
|
210
|
+
reason,
|
|
211
|
+
title: pin.title,
|
|
212
|
+
text: `Pinned card title on linked board “${board.name}”: ${pin.title}`,
|
|
213
|
+
titleOnly: true,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const tombstones = [...input.tombstones]
|
|
222
|
+
.filter(
|
|
223
|
+
(item) =>
|
|
224
|
+
validRevision(item.revision) &&
|
|
225
|
+
item.revision > deltaSince &&
|
|
226
|
+
(nodeById.get(item.nodeId)?.contentRevision ?? -1) < item.revision,
|
|
227
|
+
)
|
|
228
|
+
.sort((a, b) => a.revision - b.revision || a.nodeId.localeCompare(b.nodeId));
|
|
229
|
+
const delivered: CompiledContextEntry[] = [];
|
|
230
|
+
const deletions: ContextBriefDocument['deletions'] = [];
|
|
231
|
+
let safeCursor = requested ?? 0;
|
|
232
|
+
let deltaBlocked = !cursorValid;
|
|
233
|
+
let resetComplete = false;
|
|
234
|
+
|
|
235
|
+
const makeDocument = (
|
|
236
|
+
entries = delivered,
|
|
237
|
+
deleted = deletions,
|
|
238
|
+
next: number | null = reset && !resetComplete ? requested : safeCursor,
|
|
239
|
+
): ContextBriefDocument => ({
|
|
240
|
+
version: 1,
|
|
241
|
+
budgetUnit: 'utf16-code-units',
|
|
242
|
+
board: { ...input.activeBoard },
|
|
243
|
+
cursor: {
|
|
244
|
+
requested,
|
|
245
|
+
next: cursorValid ? next : null,
|
|
246
|
+
current,
|
|
247
|
+
retentionFloor: floor,
|
|
248
|
+
reset,
|
|
249
|
+
valid: cursorValid,
|
|
250
|
+
},
|
|
251
|
+
entries,
|
|
252
|
+
deletions: deleted,
|
|
253
|
+
delivery: {
|
|
254
|
+
truncated:
|
|
255
|
+
entries.filter((entry) => !entry.truncated).length < candidates.length || deleted.length < tombstones.length,
|
|
256
|
+
omittedEntries: candidates.length - entries.filter((entry) => !entry.truncated).length,
|
|
257
|
+
omittedDeletions: tombstones.length - deleted.length,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
const fits = (document: ContextBriefDocument): boolean => JSON.stringify(document).length <= budget;
|
|
261
|
+
const fitPartial = (entry: CompiledContextEntry, base = delivered): CompiledContextEntry | null => {
|
|
262
|
+
let low = 0;
|
|
263
|
+
let high = entry.text.length;
|
|
264
|
+
let best: CompiledContextEntry | null = null;
|
|
265
|
+
while (low <= high) {
|
|
266
|
+
const length = Math.floor((low + high) / 2);
|
|
267
|
+
const partial = { ...entry, text: entry.text.slice(0, length), truncated: true as const };
|
|
268
|
+
if (fits(makeDocument([...base, partial]))) {
|
|
269
|
+
best = partial;
|
|
270
|
+
low = length + 1;
|
|
271
|
+
} else {
|
|
272
|
+
high = length - 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return best && best.text.length > 0 ? best : null;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Pins precede revision groups. Revision groups are all-or-nothing so a cursor
|
|
279
|
+
// can never skip a sibling change or deletion at the same revision.
|
|
280
|
+
for (const candidate of candidates.filter((item) => !reset && item.entry.reason === 'pinned')) {
|
|
281
|
+
const proposed = [...delivered, candidate.entry];
|
|
282
|
+
if (fits(makeDocument(proposed))) delivered.push(candidate.entry);
|
|
283
|
+
else {
|
|
284
|
+
const partial = fitPartial(candidate.entry);
|
|
285
|
+
if (partial) delivered.push(partial);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (reset && !deltaBlocked) {
|
|
289
|
+
const resetEntries = candidates
|
|
290
|
+
.filter((item) => item.replacement && !delivered.includes(item.entry))
|
|
291
|
+
.map((item) => item.entry);
|
|
292
|
+
const proposed = [...delivered, ...resetEntries];
|
|
293
|
+
if (fits(makeDocument(proposed, [], current))) {
|
|
294
|
+
delivered.push(...resetEntries);
|
|
295
|
+
safeCursor = current;
|
|
296
|
+
resetComplete = true;
|
|
297
|
+
} else {
|
|
298
|
+
const first = resetEntries[0];
|
|
299
|
+
if (first) {
|
|
300
|
+
const partial = fitPartial(first);
|
|
301
|
+
if (partial) delivered.push(partial);
|
|
302
|
+
}
|
|
303
|
+
deltaBlocked = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const revisions = [
|
|
307
|
+
...new Set([...changed.map((node) => node.contentRevision!), ...tombstones.map((item) => item.revision)]),
|
|
308
|
+
]
|
|
309
|
+
.filter((revision) => revision > safeCursor)
|
|
310
|
+
.sort((a, b) => a - b);
|
|
311
|
+
for (const revision of revisions) {
|
|
312
|
+
if (deltaBlocked || reset) break;
|
|
313
|
+
if (
|
|
314
|
+
delivered.some(
|
|
315
|
+
(entry) =>
|
|
316
|
+
entry.truncated &&
|
|
317
|
+
candidates.some(
|
|
318
|
+
(item) =>
|
|
319
|
+
item.entry.nodeId === entry.nodeId &&
|
|
320
|
+
item.entry.sourceBoardId === entry.sourceBoardId &&
|
|
321
|
+
item.revision === revision,
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
) {
|
|
325
|
+
deltaBlocked = true;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
const entriesAtRevision = candidates
|
|
329
|
+
.filter((item) => item.revision === revision && !delivered.includes(item.entry))
|
|
330
|
+
.map((item) => item.entry);
|
|
331
|
+
const deletesAtRevision = tombstones
|
|
332
|
+
.filter((item) => item.revision === revision)
|
|
333
|
+
.map((item) => ({ sourceBoardId: input.activeBoard.boardId, nodeId: item.nodeId, revision: item.revision }));
|
|
334
|
+
const proposedEntries = [...delivered, ...entriesAtRevision];
|
|
335
|
+
const proposedDeletes = [...deletions, ...deletesAtRevision];
|
|
336
|
+
if (!fits(makeDocument(proposedEntries, proposedDeletes, revision))) {
|
|
337
|
+
const first = entriesAtRevision[0];
|
|
338
|
+
if (first) {
|
|
339
|
+
const partial = fitPartial(first);
|
|
340
|
+
if (partial) delivered.push(partial);
|
|
341
|
+
}
|
|
342
|
+
deltaBlocked = true;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
delivered.push(...entriesAtRevision);
|
|
346
|
+
deletions.push(...deletesAtRevision);
|
|
347
|
+
safeCursor = revision;
|
|
348
|
+
}
|
|
349
|
+
for (const candidate of candidates.filter(
|
|
350
|
+
(item) => item.entry.reason !== 'pinned' && item.revision === undefined && !delivered.includes(item.entry),
|
|
351
|
+
)) {
|
|
352
|
+
const proposed = [...delivered, candidate.entry];
|
|
353
|
+
if (fits(makeDocument(proposed))) delivered.push(candidate.entry);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const document = makeDocument();
|
|
357
|
+
if (reset && !resetComplete) document.cursor.next = requested;
|
|
358
|
+
const serialized = fits(document) ? JSON.stringify(document) : '';
|
|
359
|
+
const outputDocument = serialized ? document : null;
|
|
360
|
+
return {
|
|
361
|
+
serialized,
|
|
362
|
+
document: outputDocument,
|
|
363
|
+
deliveredEntryIds: outputDocument
|
|
364
|
+
? outputDocument.entries.map((entry) => `${entry.sourceBoardId}:${entry.nodeId}`)
|
|
365
|
+
: [],
|
|
366
|
+
deliveredDeletionIds: outputDocument ? outputDocument.deletions.map((entry) => entry.nodeId) : [],
|
|
367
|
+
nextCursor: outputDocument && cursorValid ? (reset && !resetComplete ? requested : safeCursor) : null,
|
|
368
|
+
reset,
|
|
369
|
+
invalidCursor: !cursorValid,
|
|
370
|
+
truncated: !outputDocument || document.delivery.truncated,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context read instrumentation (vision Part 1 item 4, the delivery half):
|
|
3
|
+
* one row per agent read of canvas context — which surface, by whom, and
|
|
4
|
+
* which pinned nodes were in what came back. Diagnostics only, like the AX
|
|
5
|
+
* timeline: never snapshotted, never cleared with the canvas, bounded, and
|
|
6
|
+
* recording never notifies (a read must not trigger more reads).
|
|
7
|
+
*/
|
|
8
|
+
import type { Database } from 'bun:sqlite';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
export const CONTEXT_READ_RETENTION = 5000;
|
|
12
|
+
const CONTEXT_READ_DEFAULT_LIMIT = 50;
|
|
13
|
+
const CONTEXT_READ_MAX_LIMIT = 500;
|
|
14
|
+
|
|
15
|
+
export const CONTEXT_READ_CHANNELS = ['operation', 'mcp-resource', 'mcp-prompt', 'adapter'] as const;
|
|
16
|
+
export type ContextReadChannel = (typeof CONTEXT_READ_CHANNELS)[number];
|
|
17
|
+
|
|
18
|
+
/** Registry reads that hand an agent canvas context; recorded in executeOperation. */
|
|
19
|
+
export const CONTEXT_READ_OPS = new Set([
|
|
20
|
+
'pinned-context.get',
|
|
21
|
+
'ax.context.get',
|
|
22
|
+
'ax.get',
|
|
23
|
+
'summary.get',
|
|
24
|
+
'spatial.get',
|
|
25
|
+
'layout.get',
|
|
26
|
+
'context.get',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export interface ContextRead {
|
|
30
|
+
seq: number;
|
|
31
|
+
id: string;
|
|
32
|
+
at: string;
|
|
33
|
+
channel: ContextReadChannel;
|
|
34
|
+
resource: string;
|
|
35
|
+
source: string;
|
|
36
|
+
consumer: string | null;
|
|
37
|
+
agentId: string | null;
|
|
38
|
+
pinnedNodeIds: string[];
|
|
39
|
+
deliveredNodeIds: string[];
|
|
40
|
+
bytes: number;
|
|
41
|
+
/** The board open when the read happened (null on Home). */
|
|
42
|
+
boardId: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ContextReadInput = Omit<ContextRead, 'seq' | 'id' | 'at' | 'boardId'> & {
|
|
46
|
+
/** Explicit read target, captured before asynchronous formatting/proxy work. */
|
|
47
|
+
boardId?: string | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface ContextReadConsumerSummary {
|
|
51
|
+
consumer: string;
|
|
52
|
+
reads: number;
|
|
53
|
+
readsWithPins: number;
|
|
54
|
+
readsDeliveringAllPins: number;
|
|
55
|
+
lastReadAt: string;
|
|
56
|
+
resources: Record<string, number>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pinned nodes whose serialized node (an object carrying `"id": "<id>"`) is in
|
|
61
|
+
* what the reader received. A bare id list, a title, or a clipped-off node does
|
|
62
|
+
* not count — the agent got the pin's name, not its content.
|
|
63
|
+
*/
|
|
64
|
+
export function deliveredPinnedIds(pinnedNodeIds: string[], payloadText: string): string[] {
|
|
65
|
+
const pinned = new Set(pinnedNodeIds);
|
|
66
|
+
const delivered = new Set<string>();
|
|
67
|
+
const objectStarts: number[] = [];
|
|
68
|
+
let inString = false;
|
|
69
|
+
let escaped = false;
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < payloadText.length; index += 1) {
|
|
72
|
+
const character = payloadText[index];
|
|
73
|
+
if (inString) {
|
|
74
|
+
if (escaped) escaped = false;
|
|
75
|
+
else if (character === '\\') escaped = true;
|
|
76
|
+
else if (character === '"') inString = false;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (character === '"') {
|
|
80
|
+
inString = true;
|
|
81
|
+
} else if (character === '{') {
|
|
82
|
+
objectStarts.push(index);
|
|
83
|
+
} else if (character === '}' && objectStarts.length > 0) {
|
|
84
|
+
const start = objectStarts.pop();
|
|
85
|
+
if (start === undefined) continue;
|
|
86
|
+
try {
|
|
87
|
+
const value = JSON.parse(payloadText.slice(start, index + 1)) as unknown;
|
|
88
|
+
if (
|
|
89
|
+
typeof value === 'object' &&
|
|
90
|
+
value !== null &&
|
|
91
|
+
!Array.isArray(value) &&
|
|
92
|
+
((typeof (value as { id?: unknown }).id === 'string' && pinned.has((value as { id: string }).id)) ||
|
|
93
|
+
(typeof (value as { nodeId?: unknown }).nodeId === 'string' &&
|
|
94
|
+
(value as { titleOnly?: unknown }).titleOnly !== true &&
|
|
95
|
+
typeof (value as { text?: unknown }).text === 'string' &&
|
|
96
|
+
pinned.has((value as { nodeId: string }).nodeId)))
|
|
97
|
+
) {
|
|
98
|
+
delivered.add(
|
|
99
|
+
typeof (value as { id?: unknown }).id === 'string'
|
|
100
|
+
? (value as { id: string }).id
|
|
101
|
+
: (value as { nodeId: string }).nodeId,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// An inner object may be complete even when its containing object was clipped.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return pinnedNodeIds.filter((id) => delivered.has(id));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function contextReadFromPayload(
|
|
114
|
+
base: Omit<ContextReadInput, 'deliveredNodeIds' | 'bytes'>,
|
|
115
|
+
payload: unknown,
|
|
116
|
+
): ContextReadInput {
|
|
117
|
+
const text = typeof payload === 'string' ? payload : (JSON.stringify(payload) ?? '');
|
|
118
|
+
return {
|
|
119
|
+
...base,
|
|
120
|
+
deliveredNodeIds: deliveredPinnedIds(base.pinnedNodeIds, text),
|
|
121
|
+
bytes: Buffer.byteLength(text, 'utf-8'),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const CONTEXT_READS_SCHEMA_SQL = `
|
|
126
|
+
CREATE TABLE IF NOT EXISTS context_reads (
|
|
127
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
128
|
+
id TEXT NOT NULL UNIQUE,
|
|
129
|
+
at TEXT NOT NULL,
|
|
130
|
+
channel TEXT NOT NULL,
|
|
131
|
+
resource TEXT NOT NULL,
|
|
132
|
+
source TEXT NOT NULL,
|
|
133
|
+
consumer TEXT,
|
|
134
|
+
agent_id TEXT,
|
|
135
|
+
pinned_node_ids TEXT NOT NULL DEFAULT '[]',
|
|
136
|
+
delivered_node_ids TEXT NOT NULL DEFAULT '[]',
|
|
137
|
+
bytes INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
board_id TEXT
|
|
139
|
+
);
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
export function appendContextReadToDB(db: Database, input: ContextReadInput, boardId: string | null): ContextRead {
|
|
143
|
+
const id = `read-${randomUUID()}`;
|
|
144
|
+
const at = new Date().toISOString();
|
|
145
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
147
|
+
[
|
|
148
|
+
id,
|
|
149
|
+
at,
|
|
150
|
+
input.channel,
|
|
151
|
+
input.resource,
|
|
152
|
+
input.source,
|
|
153
|
+
input.consumer,
|
|
154
|
+
input.agentId,
|
|
155
|
+
JSON.stringify(input.pinnedNodeIds),
|
|
156
|
+
JSON.stringify(input.deliveredNodeIds),
|
|
157
|
+
input.bytes,
|
|
158
|
+
boardId,
|
|
159
|
+
],
|
|
160
|
+
);
|
|
161
|
+
const seq = Number(db.query<{ seq: number }, []>('SELECT last_insert_rowid() AS seq').get()?.seq ?? 0);
|
|
162
|
+
db.run('DELETE FROM context_reads WHERE seq <= ?', [seq - CONTEXT_READ_RETENTION]);
|
|
163
|
+
return { ...input, seq, id, at, boardId };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface ContextReadRow {
|
|
167
|
+
seq: number;
|
|
168
|
+
id: string;
|
|
169
|
+
at: string;
|
|
170
|
+
channel: ContextReadChannel;
|
|
171
|
+
resource: string;
|
|
172
|
+
source: string;
|
|
173
|
+
consumer: string | null;
|
|
174
|
+
agent_id: string | null;
|
|
175
|
+
pinned_node_ids: string;
|
|
176
|
+
delivered_node_ids: string;
|
|
177
|
+
bytes: number;
|
|
178
|
+
board_id: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function rowToContextRead(row: ContextReadRow): ContextRead {
|
|
182
|
+
return {
|
|
183
|
+
seq: row.seq,
|
|
184
|
+
id: row.id,
|
|
185
|
+
at: row.at,
|
|
186
|
+
channel: row.channel,
|
|
187
|
+
resource: row.resource,
|
|
188
|
+
source: row.source,
|
|
189
|
+
consumer: row.consumer,
|
|
190
|
+
agentId: row.agent_id,
|
|
191
|
+
pinnedNodeIds: JSON.parse(row.pinned_node_ids) as string[],
|
|
192
|
+
deliveredNodeIds: JSON.parse(row.delivered_node_ids) as string[],
|
|
193
|
+
bytes: row.bytes,
|
|
194
|
+
boardId: row.board_id,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Newest first. The summary covers every retained row, not just the returned page. */
|
|
199
|
+
export function loadContextReadsFromDB(
|
|
200
|
+
db: Database,
|
|
201
|
+
limit?: number,
|
|
202
|
+
): { reads: ContextRead[]; summary: ContextReadConsumerSummary[] } {
|
|
203
|
+
const pageSize =
|
|
204
|
+
typeof limit === 'number' && Number.isFinite(limit) && limit > 0
|
|
205
|
+
? Math.min(Math.floor(limit), CONTEXT_READ_MAX_LIMIT)
|
|
206
|
+
: CONTEXT_READ_DEFAULT_LIMIT;
|
|
207
|
+
const rows = db.query<ContextReadRow, []>('SELECT * FROM context_reads ORDER BY seq DESC').all();
|
|
208
|
+
const all = rows.map(rowToContextRead);
|
|
209
|
+
return { reads: all.slice(0, pageSize), summary: summarizeContextReads(all) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function summarizeContextReads(reads: ContextRead[]): ContextReadConsumerSummary[] {
|
|
213
|
+
const byConsumer = new Map<string, ContextReadConsumerSummary>();
|
|
214
|
+
for (const read of reads) {
|
|
215
|
+
const key = read.consumer ?? read.agentId ?? read.source;
|
|
216
|
+
const entry = byConsumer.get(key) ?? {
|
|
217
|
+
consumer: key,
|
|
218
|
+
reads: 0,
|
|
219
|
+
readsWithPins: 0,
|
|
220
|
+
readsDeliveringAllPins: 0,
|
|
221
|
+
lastReadAt: read.at,
|
|
222
|
+
resources: {},
|
|
223
|
+
};
|
|
224
|
+
entry.reads += 1;
|
|
225
|
+
if (read.pinnedNodeIds.length > 0) {
|
|
226
|
+
entry.readsWithPins += 1;
|
|
227
|
+
const pins = new Set(read.pinnedNodeIds);
|
|
228
|
+
const delivered = new Set(read.deliveredNodeIds);
|
|
229
|
+
if (delivered.size === pins.size && [...delivered].every((id) => pins.has(id))) {
|
|
230
|
+
entry.readsDeliveringAllPins += 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (read.at > entry.lastReadAt) entry.lastReadAt = read.at;
|
|
234
|
+
entry.resources[read.resource] = (entry.resources[read.resource] ?? 0) + 1;
|
|
235
|
+
byConsumer.set(key, entry);
|
|
236
|
+
}
|
|
237
|
+
return [...byConsumer.values()].sort((a, b) => b.lastReadAt.localeCompare(a.lastReadAt));
|
|
238
|
+
}
|