pmx-canvas 0.6.4 → 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 +73 -0
- package/Readme.md +35 -5
- package/dist/canvas/global.css +498 -28
- package/dist/canvas/index.js +110 -95
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +4 -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/cli/commands/record.d.ts +12 -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/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- 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/presentation.d.ts +3 -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 +3 -3
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +8 -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 +65 -9
- package/dist/types/server/canvas-operations.d.ts +45 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +146 -3
- package/dist/types/server/canvas-validation.d.ts +3 -0
- 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/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +107 -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/app.d.ts +2 -0
- 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/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +4 -2
- 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/web-artifacts.d.ts +3 -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/dist/types/shared/tour.d.ts +72 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +157 -0
- package/docs/http-api.md +277 -6
- package/docs/mcp.md +96 -7
- package/docs/node-types.md +106 -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 +62 -5
- package/skills/pmx-canvas/references/full-reference.md +44 -8
- 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 +9 -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 +14 -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/record.ts +218 -0
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +21 -9
- package/src/client/App.tsx +28 -4
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- 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/CanvasNode.tsx +8 -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/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +49 -7
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/icons.tsx +2 -0
- package/src/client/mermaid-entry.ts +39 -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/HtmlNode.tsx +12 -13
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- 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/intent-store.ts +5 -1
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +8 -0
- package/src/client/state/sse-bridge.ts +52 -5
- package/src/client/theme/global.css +498 -28
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +45 -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/agent-presence.ts +15 -9
- 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 +612 -105
- package/src/server/canvas-operations.ts +183 -31
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +22 -0
- package/src/server/canvas-state.ts +812 -48
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +670 -9
- package/src/server/document-import.ts +223 -0
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +243 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +29 -5
- 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 +15 -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 +20 -4
- 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/groups.ts +14 -7
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +99 -21
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +129 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +155 -8
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +11 -3
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/server/workboard.ts +3 -3
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
- package/src/shared/tour.ts +87 -0
|
@@ -23,13 +23,22 @@ import { setCanvasContextPins, syncCanvasRuntimeBackends } from '../../canvas-op
|
|
|
23
23
|
import { buildCanvasSummary } from '../../canvas-serialization.js';
|
|
24
24
|
import { mutationHistory } from '../../mutation-history.js';
|
|
25
25
|
import { buildSpatialContext, searchNodes } from '../../spatial-analysis.js';
|
|
26
|
-
import {
|
|
27
|
-
|
|
26
|
+
import {
|
|
27
|
+
compileContextBrief,
|
|
28
|
+
type ContextBriefLibraryBoard,
|
|
29
|
+
type ContextBriefSourceEntry,
|
|
30
|
+
} from '../../context-brief.js';
|
|
31
|
+
import { summarizeNodeForAgentContext } from '../../agent-context.js';
|
|
32
|
+
import { defineOperation, OperationError, type Operation, type OperationMcpToolHost } from '../types.js';
|
|
33
|
+
import { buildSummaryFromLayout, isRecord } from './nodes.js';
|
|
34
|
+
import { readTargetBoard } from './boards.js';
|
|
28
35
|
|
|
29
36
|
// ── pin.set ───────────────────────────────────────────────────
|
|
30
37
|
|
|
31
38
|
/** Legacy server.ts handleContextPinsUpdate capped the requested list at 20. */
|
|
32
39
|
const MAX_PINS = 20;
|
|
40
|
+
export const DEFAULT_CONTEXT_BRIEF_BUDGET = 16_000;
|
|
41
|
+
export const MAX_CONTEXT_BRIEF_BUDGET = 100_000;
|
|
33
42
|
|
|
34
43
|
const pinShape = {
|
|
35
44
|
nodeIds: z.unknown().optional().describe('Array of node IDs to pin'),
|
|
@@ -92,6 +101,7 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
|
|
|
92
101
|
|
|
93
102
|
const searchShape = {
|
|
94
103
|
q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
|
|
104
|
+
scope: z.unknown().optional().describe('active (default) or library (all boards)'),
|
|
95
105
|
limit: z.unknown().optional().describe('Max results to return (default: all over HTTP, 10 via the MCP tool).'),
|
|
96
106
|
};
|
|
97
107
|
|
|
@@ -113,12 +123,14 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
113
123
|
extraShape: {
|
|
114
124
|
query: z.string().describe('Search query — matches against node titles, content, and file paths'),
|
|
115
125
|
limit: z.number().optional().describe('Max results to return (default: 10)'),
|
|
126
|
+
scope: z.enum(['active', 'library']).optional().describe('Search the active board or the full board library'),
|
|
116
127
|
},
|
|
117
128
|
// Map the MCP-facing `query` arg onto the wire's `q`. The handler caps by
|
|
118
129
|
// `limit` on every transport; the MCP tool additionally defaults it to 10.
|
|
119
130
|
buildInput: (input) => ({
|
|
120
131
|
q: typeof input.query === 'string' ? input.query : '',
|
|
121
132
|
...(typeof input.limit === 'number' ? { limit: input.limit } : {}),
|
|
133
|
+
...(input.scope === 'library' ? { scope: 'library' } : {}),
|
|
122
134
|
}),
|
|
123
135
|
formatResult: (result, input) => {
|
|
124
136
|
const body = isRecord(result) ? result : {};
|
|
@@ -154,7 +166,40 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
154
166
|
: typeof rawLimit === 'string' && rawLimit.trim() !== ''
|
|
155
167
|
? Number(rawLimit)
|
|
156
168
|
: Number.NaN;
|
|
157
|
-
const
|
|
169
|
+
const library = input.scope === 'library';
|
|
170
|
+
const results = library
|
|
171
|
+
? canvasState.listBoards().flatMap((board) => {
|
|
172
|
+
const read = canvasState.readBoard(board.id, true);
|
|
173
|
+
if (!read) return [];
|
|
174
|
+
const cards = searchNodes(read.layout.nodes, q).map((result) => ({
|
|
175
|
+
...result,
|
|
176
|
+
boardId: board.id,
|
|
177
|
+
boardTitle: board.name,
|
|
178
|
+
cardId: result.id,
|
|
179
|
+
}));
|
|
180
|
+
const metadata = `${board.name} ${board.category ?? ''}`.toLocaleLowerCase();
|
|
181
|
+
const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
|
|
182
|
+
const readmeText = typeof readme?.data.content === 'string' ? readme.data.content : '';
|
|
183
|
+
if (`${metadata} ${readmeText.toLocaleLowerCase()}`.includes(q.toLocaleLowerCase())) {
|
|
184
|
+
cards.unshift({
|
|
185
|
+
id: board.id,
|
|
186
|
+
type: 'board',
|
|
187
|
+
title: board.name,
|
|
188
|
+
snippet: readmeText.slice(0, 160) || board.category || board.name,
|
|
189
|
+
score: metadata.includes(q.toLocaleLowerCase()) ? 4 : 2,
|
|
190
|
+
boardId: board.id,
|
|
191
|
+
boardTitle: board.name,
|
|
192
|
+
cardId: readme?.id ?? board.id,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return cards;
|
|
196
|
+
})
|
|
197
|
+
: searchNodes(canvasState.getLayout().nodes, q).map((result) => ({
|
|
198
|
+
...result,
|
|
199
|
+
boardId: canvasState.activeBoardId,
|
|
200
|
+
cardId: result.id,
|
|
201
|
+
}));
|
|
202
|
+
results.sort((a, b) => b.score - a.score);
|
|
158
203
|
return {
|
|
159
204
|
results: Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results,
|
|
160
205
|
query: q,
|
|
@@ -162,6 +207,152 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
162
207
|
},
|
|
163
208
|
});
|
|
164
209
|
|
|
210
|
+
// ── context.get ───────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
const contextShape = {
|
|
213
|
+
consumer: z.unknown().optional().describe('Stable consumer id used for the durable board-scoped cursor'),
|
|
214
|
+
since: z.unknown().optional().describe('Explicit content revision; bypasses the durable consumer cursor'),
|
|
215
|
+
budget: z.unknown().optional().describe('Maximum response size in UTF-16 code units'),
|
|
216
|
+
};
|
|
217
|
+
const contextSchema = z.looseObject(contextShape);
|
|
218
|
+
|
|
219
|
+
const contextOperation = defineOperation<z.infer<typeof contextSchema>, Record<string, unknown>>({
|
|
220
|
+
name: 'context.get',
|
|
221
|
+
mutates: false,
|
|
222
|
+
input: contextSchema,
|
|
223
|
+
inputShape: contextShape,
|
|
224
|
+
http: { method: 'GET', path: '/api/canvas/context' },
|
|
225
|
+
handler: (input) => {
|
|
226
|
+
const active = canvasState.getActiveBoard();
|
|
227
|
+
if (!active) throw new OperationError('Open a board before requesting its context.', 409);
|
|
228
|
+
const consumer =
|
|
229
|
+
typeof input.consumer === 'string' && input.consumer.trim() ? input.consumer.trim().slice(0, 200) : null;
|
|
230
|
+
const explicitSince = input.since !== undefined;
|
|
231
|
+
const sinceValue = explicitSince
|
|
232
|
+
? Number(input.since)
|
|
233
|
+
: consumer
|
|
234
|
+
? canvasState.getContextBriefCursor(active.id, consumer)
|
|
235
|
+
: null;
|
|
236
|
+
const budgetValue = input.budget === undefined ? DEFAULT_CONTEXT_BRIEF_BUDGET : Number(input.budget);
|
|
237
|
+
const budget = Number.isFinite(budgetValue)
|
|
238
|
+
? Math.min(MAX_CONTEXT_BRIEF_BUDGET, Math.max(0, Math.floor(budgetValue)))
|
|
239
|
+
: DEFAULT_CONTEXT_BRIEF_BUDGET;
|
|
240
|
+
|
|
241
|
+
// Capture the active target and revision synchronously. Library boards are
|
|
242
|
+
// read from SQLite and are never opened or made writable by this operation.
|
|
243
|
+
const layout = canvasState.getLayout();
|
|
244
|
+
const revision = canvasState.getContentRevision();
|
|
245
|
+
const pinnedNodeIds = [...canvasState.contextPinnedNodeIds];
|
|
246
|
+
const linkedIds = new Map<string, string[]>();
|
|
247
|
+
for (const node of layout.nodes) {
|
|
248
|
+
if (node.type !== 'board' || typeof node.data.boardId !== 'string') continue;
|
|
249
|
+
const ids = linkedIds.get(node.data.boardId) ?? [];
|
|
250
|
+
ids.push(node.id);
|
|
251
|
+
linkedIds.set(node.data.boardId, ids);
|
|
252
|
+
}
|
|
253
|
+
const libraryBoards: ContextBriefLibraryBoard[] = [];
|
|
254
|
+
for (const board of canvasState.listBoards()) {
|
|
255
|
+
if (board.id === active.id) continue;
|
|
256
|
+
if (!linkedIds.has(board.id) && (!active.category || active.category !== board.category)) continue;
|
|
257
|
+
const read = canvasState.readBoard(board.id, true);
|
|
258
|
+
if (!read) continue;
|
|
259
|
+
const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
|
|
260
|
+
const pins = new Set(read.state.contextPins);
|
|
261
|
+
libraryBoards.push({
|
|
262
|
+
boardId: board.id,
|
|
263
|
+
name: board.name,
|
|
264
|
+
category: board.category,
|
|
265
|
+
...(readme
|
|
266
|
+
? {
|
|
267
|
+
readme: {
|
|
268
|
+
nodeId: readme.id,
|
|
269
|
+
title: typeof readme.data.title === 'string' ? readme.data.title : readme.id,
|
|
270
|
+
summary: summarizeNodeForAgentContext(readme, { defaultTextLength: 1_000, webpageTextLength: 1_000 }),
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
: {}),
|
|
274
|
+
pinnedTitles: read.layout.nodes
|
|
275
|
+
.filter((node) => pins.has(node.id))
|
|
276
|
+
.map((node) => ({
|
|
277
|
+
nodeId: node.id,
|
|
278
|
+
title: typeof node.data.title === 'string' ? node.data.title : node.id,
|
|
279
|
+
})),
|
|
280
|
+
linkIds: linkedIds.get(board.id) ?? [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const entries: ContextBriefSourceEntry[] = layout.nodes
|
|
284
|
+
.filter((node) => node.createdBy?.actor === 'human')
|
|
285
|
+
.map((node) => ({
|
|
286
|
+
sourceBoardId: active.id,
|
|
287
|
+
nodeId: node.id,
|
|
288
|
+
reason: 'human' as const,
|
|
289
|
+
title: typeof node.data.title === 'string' ? node.data.title : node.id,
|
|
290
|
+
text: summarizeNodeForAgentContext(node, { defaultTextLength: budget, webpageTextLength: budget }),
|
|
291
|
+
}));
|
|
292
|
+
entries.push(
|
|
293
|
+
...canvasState
|
|
294
|
+
.getApprovalGates()
|
|
295
|
+
.filter((item) => item.status === 'pending')
|
|
296
|
+
.map((item) => ({
|
|
297
|
+
sourceBoardId: active.id,
|
|
298
|
+
nodeId: `approval:${item.id}`,
|
|
299
|
+
reason: 'ask' as const,
|
|
300
|
+
title: item.title,
|
|
301
|
+
text: item.detail || item.action || 'Approval requested.',
|
|
302
|
+
})),
|
|
303
|
+
...canvasState
|
|
304
|
+
.getModeRequests()
|
|
305
|
+
.filter((item) => item.status === 'pending')
|
|
306
|
+
.map((item) => ({
|
|
307
|
+
sourceBoardId: active.id,
|
|
308
|
+
nodeId: `mode:${item.id}`,
|
|
309
|
+
reason: 'ask' as const,
|
|
310
|
+
title: `Mode request: ${item.mode}`,
|
|
311
|
+
text: item.reason || 'Mode change requested.',
|
|
312
|
+
})),
|
|
313
|
+
...canvasState
|
|
314
|
+
.getElicitations()
|
|
315
|
+
.filter((item) => item.status === 'pending')
|
|
316
|
+
.map((item) => ({
|
|
317
|
+
sourceBoardId: active.id,
|
|
318
|
+
nodeId: `elicitation:${item.id}`,
|
|
319
|
+
reason: 'ask' as const,
|
|
320
|
+
title: 'Open question',
|
|
321
|
+
text: item.prompt,
|
|
322
|
+
})),
|
|
323
|
+
);
|
|
324
|
+
entries.push(
|
|
325
|
+
...canvasState.getPendingSteering({ consumer: consumer ?? undefined, limit: 50 }).map((item) => ({
|
|
326
|
+
sourceBoardId: active.id,
|
|
327
|
+
nodeId: `steering:${item.id}`,
|
|
328
|
+
reason: 'steer' as const,
|
|
329
|
+
title: 'Human steering',
|
|
330
|
+
text: item.message,
|
|
331
|
+
})),
|
|
332
|
+
);
|
|
333
|
+
const result = compileContextBrief({
|
|
334
|
+
activeBoard: { boardId: active.id, name: active.name, category: active.category },
|
|
335
|
+
nodes: layout.nodes,
|
|
336
|
+
pinnedNodeIds,
|
|
337
|
+
contentRevision: revision.revision,
|
|
338
|
+
retentionFloor: revision.retentionFloor,
|
|
339
|
+
tombstones: canvasState.readContentDelta(sinceValue ?? 0).deleted,
|
|
340
|
+
since: explicitSince ? (Number.isSafeInteger(sinceValue) ? sinceValue : Number.NaN) : sinceValue,
|
|
341
|
+
libraryBoards,
|
|
342
|
+
entries,
|
|
343
|
+
budget,
|
|
344
|
+
});
|
|
345
|
+
if (consumer && !explicitSince && result.document && result.nextCursor !== null && !result.invalidCursor) {
|
|
346
|
+
canvasState.advanceContextBriefCursor(active.id, consumer, result.nextCursor);
|
|
347
|
+
}
|
|
348
|
+
return (result.document ?? {
|
|
349
|
+
version: 1,
|
|
350
|
+
budgetUnit: 'utf16-code-units',
|
|
351
|
+
error: 'Budget is too small for the context envelope.',
|
|
352
|
+
}) as unknown as Record<string, unknown>;
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
165
356
|
// ── history.get (HTTP/CLI only — canvas://history stays a resource) ──
|
|
166
357
|
|
|
167
358
|
const historyGetShape = {};
|
|
@@ -258,7 +449,7 @@ const redoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<str
|
|
|
258
449
|
|
|
259
450
|
// ── spatial.get (HTTP only — canvas://spatial-context stays a resource) ──
|
|
260
451
|
|
|
261
|
-
const spatialGetShape = {};
|
|
452
|
+
const spatialGetShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
|
|
262
453
|
|
|
263
454
|
const spatialGetSchema = z.looseObject(spatialGetShape);
|
|
264
455
|
|
|
@@ -271,12 +462,13 @@ const spatialGetOperation = defineOperation<z.infer<typeof spatialGetSchema>, Re
|
|
|
271
462
|
method: 'GET',
|
|
272
463
|
path: '/api/canvas/spatial-context',
|
|
273
464
|
},
|
|
274
|
-
handler: () => {
|
|
275
|
-
const
|
|
465
|
+
handler: ({ board }) => {
|
|
466
|
+
const target = readTargetBoard(board);
|
|
467
|
+
const layout = target.layout;
|
|
276
468
|
return buildSpatialContext(
|
|
277
469
|
layout.nodes,
|
|
278
470
|
layout.edges,
|
|
279
|
-
|
|
471
|
+
target.pinnedNodeIds,
|
|
280
472
|
layout.annotations,
|
|
281
473
|
) as unknown as Record<string, unknown>;
|
|
282
474
|
},
|
|
@@ -284,7 +476,7 @@ const spatialGetOperation = defineOperation<z.infer<typeof spatialGetSchema>, Re
|
|
|
284
476
|
|
|
285
477
|
// ── summary.get (HTTP only — canvas://summary stays a resource) ──
|
|
286
478
|
|
|
287
|
-
const summaryGetShape = {};
|
|
479
|
+
const summaryGetShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
|
|
288
480
|
|
|
289
481
|
const summaryGetSchema = z.looseObject(summaryGetShape);
|
|
290
482
|
|
|
@@ -297,12 +489,17 @@ const summaryGetOperation = defineOperation<z.infer<typeof summaryGetSchema>, Re
|
|
|
297
489
|
method: 'GET',
|
|
298
490
|
path: '/api/canvas/summary',
|
|
299
491
|
},
|
|
300
|
-
handler: (
|
|
492
|
+
handler: ({ board }) => {
|
|
493
|
+
if (typeof board !== 'string' || !board.trim()) return buildCanvasSummary() as unknown as Record<string, unknown>;
|
|
494
|
+
const target = readTargetBoard(board);
|
|
495
|
+
return buildSummaryFromLayout(target.layout, [...target.pinnedNodeIds]);
|
|
496
|
+
},
|
|
301
497
|
});
|
|
302
498
|
|
|
303
499
|
export const queryOperations: Operation[] = [
|
|
304
500
|
pinOperation,
|
|
305
501
|
searchOperation,
|
|
502
|
+
contextOperation,
|
|
306
503
|
historyGetOperation,
|
|
307
504
|
undoOperation,
|
|
308
505
|
redoOperation,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* This module must never import server.ts or index.ts.
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
+
import { tourSchema, derivedTour } from '../../../shared/tour.js';
|
|
16
17
|
import { canvasState } from '../../canvas-state.js';
|
|
17
18
|
import { arrangeCanvasNodes, clearCanvas, fitCanvasView, setClientViewportSize } from '../../canvas-operations.js';
|
|
18
19
|
import { validateCanvasLayout } from '../../canvas-validation.js';
|
|
@@ -272,7 +273,38 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
|
|
|
272
273
|
},
|
|
273
274
|
});
|
|
274
275
|
|
|
276
|
+
const tourSetShape = { tour: tourSchema.nullable().describe('Ordered camera stops, or null to derive from groups') };
|
|
277
|
+
const tourSetOperation = defineOperation({
|
|
278
|
+
name: 'tour.set',
|
|
279
|
+
mutates: true,
|
|
280
|
+
input: z.object(tourSetShape),
|
|
281
|
+
inputShape: tourSetShape,
|
|
282
|
+
http: { method: 'POST', path: '/api/canvas/tour' },
|
|
283
|
+
mcp: { toolName: 'canvas_set_tour', description: 'Persist an ordered board presentation tour.' },
|
|
284
|
+
handler: (input) => {
|
|
285
|
+
canvasState.setTour(input.tour);
|
|
286
|
+
return { ok: true };
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const tourGetOperation = defineOperation({
|
|
290
|
+
name: 'tour.get',
|
|
291
|
+
mutates: false,
|
|
292
|
+
input: z.object({}),
|
|
293
|
+
inputShape: {},
|
|
294
|
+
http: { method: 'GET', path: '/api/canvas/tour' },
|
|
295
|
+
mcp: {
|
|
296
|
+
toolName: 'canvas_get_tour',
|
|
297
|
+
description: 'Read the board tour, deriving groups in reading order when absent.',
|
|
298
|
+
},
|
|
299
|
+
handler: () => ({
|
|
300
|
+
tour: canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes),
|
|
301
|
+
derived: !canvasState.getTour(),
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
|
|
275
305
|
export const viewportOperations: Operation[] = [
|
|
306
|
+
tourSetOperation,
|
|
307
|
+
tourGetOperation,
|
|
276
308
|
arrangeOperation,
|
|
277
309
|
focusOperation,
|
|
278
310
|
fitOperation,
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
* Handlers never emit `canvas-layout-update` themselves for the final state —
|
|
8
8
|
* `mutates: true` is the single source; extra events go through `ctx.emit`.
|
|
9
9
|
*/
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
11
|
import { canvasState } from '../canvas-state.js';
|
|
11
12
|
import { intentRegistry } from '../intent-registry.js';
|
|
12
13
|
import { humanPresence } from '../human-presence.js';
|
|
13
|
-
import {
|
|
14
|
+
import { withCurrentActor } from '../attribution.js';
|
|
14
15
|
import { agentPresence, describeWrite } from '../agent-presence.js';
|
|
15
16
|
import { checkScopeFence, checkScopeOwnership } from '../scope-fence.js';
|
|
17
|
+
import { CONTEXT_READ_OPS, contextReadFromPayload } from '../context-reads.js';
|
|
18
|
+
import { openBoardForWrite } from '../canvas-operations.js';
|
|
19
|
+
import { boardsPayload } from './ops/boards.js';
|
|
16
20
|
import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
|
|
17
21
|
import { OperationError, type Operation, type OperationContext } from './types.js';
|
|
18
22
|
|
|
@@ -43,18 +47,12 @@ export function setOperationEventEmitter(emitter: OperationEventEmitter | null):
|
|
|
43
47
|
operationEventEmitter = emitter;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
// still emitting SSE — merging the two counters would break one or the other).
|
|
50
|
-
// While > 0, emitOperationEvent is a no-op so a meta-op (canvas.batch) can run
|
|
51
|
-
// many sub-ops without producing per-entry SSE frames, then emit ONE final
|
|
52
|
-
// layout frame itself. Both the `mutates` auto-emit and `ctx.emit` route through
|
|
53
|
-
// emitOperationEvent, so this covers both. Re-entrant-safe via the depth counter.
|
|
54
|
-
let suppressEmitDepth = 0;
|
|
50
|
+
// Batch suppression belongs to its async call chain, never concurrent human
|
|
51
|
+
// operations (especially board switches while a batch waits on the network).
|
|
52
|
+
const suppressedEmits = new AsyncLocalStorage<boolean>();
|
|
55
53
|
|
|
56
54
|
function emitOperationEvent(event: string, payload: Record<string, unknown> = {}): void {
|
|
57
|
-
if (
|
|
55
|
+
if (isEmitSuppressed()) return;
|
|
58
56
|
operationEventEmitter?.(event, payload);
|
|
59
57
|
}
|
|
60
58
|
|
|
@@ -63,17 +61,12 @@ function emitOperationEvent(event: string, payload: Record<string, unknown> = {}
|
|
|
63
61
|
* mcpapp.open, whose canvas node is created as a side-effect of `ext-app-open` —
|
|
64
62
|
* use this to reject loudly instead of silently no-op'ing in a suppressed run. */
|
|
65
63
|
export function isEmitSuppressed(): boolean {
|
|
66
|
-
return
|
|
64
|
+
return suppressedEmits.getStore() === true;
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
/**
|
|
67
|
+
/** Suppress this call chain's per-entry frames; the batch emits its final frame afterward. */
|
|
70
68
|
export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T> {
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
return await fn();
|
|
74
|
-
} finally {
|
|
75
|
-
suppressEmitDepth--;
|
|
76
|
-
}
|
|
69
|
+
return suppressedEmits.run(true, fn);
|
|
77
70
|
}
|
|
78
71
|
|
|
79
72
|
const operationContext: OperationContext = { emit: emitOperationEvent };
|
|
@@ -208,6 +201,14 @@ export interface ExecuteOperationMeta {
|
|
|
208
201
|
* to agents only, and batch inner writes are always agent-originated.
|
|
209
202
|
*/
|
|
210
203
|
fromWorkbench?: boolean;
|
|
204
|
+
/** Authorship only: true when the per-boot workbench token matched. */
|
|
205
|
+
humanAuthor?: boolean;
|
|
206
|
+
/**
|
|
207
|
+
* An MCP server attached to this daemon, or a host adapter, fetching on an
|
|
208
|
+
* agent's behalf. It records the read the agent actually made itself
|
|
209
|
+
* (POST /api/canvas/ax/context-reads), so this fetch is not recorded.
|
|
210
|
+
*/
|
|
211
|
+
proxiedRead?: boolean;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
/**
|
|
@@ -296,6 +297,20 @@ const PRESENCE_EXEMPT_OPS = new Set([
|
|
|
296
297
|
'ax.activity.ingest',
|
|
297
298
|
'human.presence.set',
|
|
298
299
|
'human.presence.get',
|
|
300
|
+
// Recording a context read is instrumentation of a read, not a write.
|
|
301
|
+
'ax.reads.record',
|
|
302
|
+
// Backup and restore are maintenance of the library file, not edits.
|
|
303
|
+
'backup.run',
|
|
304
|
+
'backup.schedule',
|
|
305
|
+
'backup.restore',
|
|
306
|
+
// Writing an export file shares nothing by itself and edits nothing.
|
|
307
|
+
'export.run',
|
|
308
|
+
// Camera and attention controls are observation/navigation, not edits.
|
|
309
|
+
// They must not create a writer, bump opCount, or refresh an existing one.
|
|
310
|
+
'viewport.set',
|
|
311
|
+
'view.fit',
|
|
312
|
+
'node.focus',
|
|
313
|
+
'ax.focus.set',
|
|
299
314
|
]);
|
|
300
315
|
|
|
301
316
|
/** Layout mutations AND non-GET AX writes (work items, gates, evidence, steering) are agent activity. */
|
|
@@ -347,22 +362,105 @@ export async function executeOperation(
|
|
|
347
362
|
rawInput: unknown,
|
|
348
363
|
meta: ExecuteOperationMeta = {},
|
|
349
364
|
): Promise<unknown> {
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
?
|
|
355
|
-
: (meta.source ?? 'api')
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
365
|
+
// Attribution follows this call chain across awaits and nested operations.
|
|
366
|
+
const inputAgentId = asRecord(rawInput).agentId;
|
|
367
|
+
return withCurrentActor(
|
|
368
|
+
{
|
|
369
|
+
actor: meta.humanAuthor ? 'human' : 'agent',
|
|
370
|
+
source: meta.humanAuthor ? 'browser' : (meta.source ?? 'api'),
|
|
371
|
+
...(typeof inputAgentId === 'string' && inputAgentId.trim() ? { agentId: inputAgentId.trim() } : {}),
|
|
372
|
+
},
|
|
373
|
+
async () => {
|
|
374
|
+
const recordRead = CONTEXT_READ_OPS.has(name) && !meta.fromWorkbench && !meta.proxiedRead;
|
|
375
|
+
const input = asRecord(rawInput);
|
|
376
|
+
const requestedBoard =
|
|
377
|
+
recordRead && 'board' in getOperation(name).inputShape && isString(input.board) && input.board.trim()
|
|
378
|
+
? input.board.trim()
|
|
379
|
+
: null;
|
|
380
|
+
const readBoardId = requestedBoard ?? canvasState.activeBoardId;
|
|
381
|
+
const readPins = recordRead
|
|
382
|
+
? requestedBoard
|
|
383
|
+
? (canvasState.readBoard(requestedBoard, false)?.state.contextPins ?? [])
|
|
384
|
+
: [...canvasState.contextPinnedNodeIds]
|
|
385
|
+
: [];
|
|
386
|
+
const result = await executeOperationInner(name, rawInput, meta);
|
|
387
|
+
if (recordRead) {
|
|
388
|
+
canvasState.recordContextRead(
|
|
389
|
+
contextReadFromPayload(
|
|
390
|
+
{
|
|
391
|
+
channel: 'operation',
|
|
392
|
+
resource: name,
|
|
393
|
+
source: meta.source ?? 'api',
|
|
394
|
+
consumer: isString(input.consumer) && input.consumer.trim() ? input.consumer.trim() : null,
|
|
395
|
+
agentId: isString(input.agentId) && input.agentId.trim() ? input.agentId.trim() : null,
|
|
396
|
+
pinnedNodeIds: readPins,
|
|
397
|
+
},
|
|
398
|
+
result,
|
|
399
|
+
),
|
|
400
|
+
readBoardId,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return result;
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Opening, switching and deleting boards is the human's; an agent asks for another board. */
|
|
409
|
+
const HUMAN_ONLY_OPS = new Set(['board.open', 'board.delete', 'import.request', 'import.cancel', 'import.commit']);
|
|
410
|
+
const TRUSTED_HUMAN_ONLY_OPS = new Set([
|
|
411
|
+
'board.open',
|
|
412
|
+
'board.delete',
|
|
413
|
+
'import.request',
|
|
414
|
+
'import.cancel',
|
|
415
|
+
'import.commit',
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Writes that need a board beyond the layout mutations: canvas-bound AX items,
|
|
420
|
+
* pins, snapshots and app nodes. `canvas.clear` on Home clears nothing, so it
|
|
421
|
+
* never opens a board it would then wipe.
|
|
422
|
+
*/
|
|
423
|
+
const BOARD_WRITE_OPS = new Set(['pin.set', 'snapshot.save', 'mcpapp.open', 'diagram.open', 'webartifact.build']);
|
|
424
|
+
|
|
425
|
+
const CANVAS_BOUND_AX_WRITE_PREFIXES = [
|
|
426
|
+
'ax.work.',
|
|
427
|
+
'ax.review.',
|
|
428
|
+
'ax.approval.',
|
|
429
|
+
'ax.elicitation.',
|
|
430
|
+
'ax.mode.',
|
|
431
|
+
] as const;
|
|
432
|
+
|
|
433
|
+
function isCanvasBoundAxWrite(name: string): boolean {
|
|
434
|
+
if (name === 'ax.focus.set' || name === 'ax.policy.set') return true;
|
|
435
|
+
return CANVAS_BOUND_AX_WRITE_PREFIXES.some((prefix) => name.startsWith(prefix)) && !name.endsWith('.await');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function needsOpenBoard(op: Operation): boolean {
|
|
439
|
+
if (op.name === 'canvas.clear' || PRESENCE_EXEMPT_OPS.has(op.name)) return false;
|
|
440
|
+
return op.mutates || BOARD_WRITE_OPS.has(op.name) || isCanvasBoundAxWrite(op.name);
|
|
362
441
|
}
|
|
363
442
|
|
|
364
443
|
async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
|
|
365
444
|
const op = getOperation(name);
|
|
445
|
+
if (HUMAN_ONLY_OPS.has(name) && !meta.fromWorkbench) {
|
|
446
|
+
throw new OperationError(
|
|
447
|
+
'Only the human opens or deletes boards. Ask them in the chat to open the board you need.',
|
|
448
|
+
403,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (TRUSTED_HUMAN_ONLY_OPS.has(name) && !meta.humanAuthor) {
|
|
452
|
+
throw new OperationError('This action requires trusted human authority.', 403);
|
|
453
|
+
}
|
|
454
|
+
// File drops carry their captured board through the HTTP round trip. Check
|
|
455
|
+
// before opening a recent board from Home, not just before reading the file.
|
|
456
|
+
const capturedBoard = asRecord(rawInput).boardId;
|
|
457
|
+
if (name === 'node.add' && typeof capturedBoard === 'string' && capturedBoard !== canvasState.activeBoardId) {
|
|
458
|
+
throw new OperationError('The board changed while the file was loading.', 409);
|
|
459
|
+
}
|
|
460
|
+
if (!canvasState.activeBoardId && needsOpenBoard(op)) {
|
|
461
|
+
openBoardForWrite();
|
|
462
|
+
if (canvasState.activeBoardId) emitOperationEvent('boards-changed', boardsPayload());
|
|
463
|
+
}
|
|
366
464
|
// Scope fence (design item 4): an attached agent's writes must stay inside
|
|
367
465
|
// the fence the human granted. Reads and the human's own writes pass, and
|
|
368
466
|
// the fence itself is the human's to set — an agent cannot clear or widen it.
|
|
@@ -60,7 +60,7 @@ export interface OperationHttpRoute {
|
|
|
60
60
|
|
|
61
61
|
/** Host capabilities available to MCP result formatters. */
|
|
62
62
|
export interface OperationMcpToolHost {
|
|
63
|
-
getPinnedNodeIds(): Promise<string[]>;
|
|
63
|
+
getPinnedNodeIds(board?: string): Promise<string[]>;
|
|
64
64
|
/**
|
|
65
65
|
* Invoke another registered operation over the host's transport (local or
|
|
66
66
|
* HTTP) — structural subset of OperationInvoker to avoid an import cycle.
|
|
@@ -111,7 +111,7 @@ function createTarget(input: Record<string, unknown>, extraNodeIds: string[] = [
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/** Ops that rewrite the whole board — never allowed under a fence. */
|
|
114
|
-
const BOARD_WIDE_OPS = new Set(['arrange', 'canvas.clear', 'snapshot.restore', 'render.workboard']);
|
|
114
|
+
const BOARD_WIDE_OPS = new Set(['arrange', 'canvas.clear', 'snapshot.restore', 'render.workboard', 'tour.set']);
|
|
115
115
|
|
|
116
116
|
/** Describe what a registry op would write, in fence terms. */
|
|
117
117
|
export function describeOpTarget(op: Operation, rawInput: unknown): FenceTarget {
|