pmx-canvas 0.9.2 → 0.10.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 +9 -3
- package/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +9278 -2014
- package/CHANGELOG.md +34 -1
- package/dist/canvas/global.css +12 -17
- package/dist/canvas/index.js +58 -58
- package/dist/canvas/mcp-app.html +75 -80
- package/dist/types/client/canvas/submit-without-navigation.d.ts +3 -0
- package/dist/types/client/state/intent-bridge.d.ts +0 -3
- package/dist/types/client/state/presence-store.d.ts +3 -3
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +1 -12
- package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
- package/dist/types/mcp/server.d.ts +2 -0
- package/dist/types/server/ax-wait.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/index.d.ts +5 -5
- package/dist/types/server/knowledge-lifecycle.d.ts +11 -2
- package/dist/types/server/lifecycle-status.d.ts +3 -1
- package/dist/types/server/operations/http.d.ts +10 -0
- package/dist/types/server/operations/index.d.ts +2 -2
- package/dist/types/server/operations/invoker.d.ts +4 -3
- package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
- package/dist/types/server/operations/mcp.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +1 -0
- package/dist/types/server/operations/registry.d.ts +2 -2
- package/dist/types/server/operations/types.d.ts +12 -2
- package/dist/types/server/server.d.ts +2 -0
- package/dist/types/server/tour-control.d.ts +6 -3
- package/dist/types/server/webpage-node.d.ts +1 -0
- package/docs/api-reference.md +2 -2
- package/docs/cli.md +10 -1
- package/docs/design/Chrome.dc.html +1 -0
- package/docs/design.md +3 -1
- package/docs/http-api.md +31 -0
- package/docs/mcp.md +98 -22
- package/docs/node-types.md +3 -0
- package/docs/plans/plan-019-post-0.9-roadmap.md +187 -11
- package/docs/sdk.md +10 -0
- package/package.json +1 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +9 -3
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +9278 -2014
- package/skills/pmx-canvas/SKILL.md +12 -7
- package/skills/pmx-canvas/references/full-reference.md +35 -2
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
- package/src/client/canvas/BoardCopyDialog.tsx +2 -1
- package/src/client/canvas/KnowledgeDialog.tsx +3 -0
- package/src/client/canvas/LifecyclePanel.tsx +4 -0
- package/src/client/canvas/RelationPresetsDialog.tsx +2 -0
- package/src/client/canvas/ToolRail.tsx +2 -0
- package/src/client/canvas/submit-without-navigation.ts +14 -0
- package/src/client/state/canvas-store.ts +12 -24
- package/src/client/state/intent-bridge.ts +0 -6
- package/src/client/state/presence-store.ts +3 -3
- package/src/client/state/sse-bridge.ts +11 -6
- package/src/client/theme/global.css +12 -17
- package/src/mcp/canvas-access.ts +6 -47
- package/src/mcp/resource-subscriptions.ts +181 -0
- package/src/mcp/server.ts +185 -219
- package/src/server/ax-wait.ts +21 -6
- package/src/server/canvas-operations.ts +6 -2
- package/src/server/canvas-state.ts +10 -0
- package/src/server/index.ts +20 -32
- package/src/server/knowledge-lifecycle.ts +66 -0
- package/src/server/lifecycle-status.ts +79 -7
- package/src/server/operations/http.ts +69 -21
- package/src/server/operations/index.ts +8 -1
- package/src/server/operations/invoker.ts +53 -5
- package/src/server/operations/mcp-contracts.ts +339 -0
- package/src/server/operations/mcp.ts +153 -18
- package/src/server/operations/ops/ax-await.ts +18 -10
- package/src/server/operations/ops/ax-read.ts +2 -0
- package/src/server/operations/ops/ax-timeline.ts +2 -2
- package/src/server/operations/ops/batch.ts +27 -8
- package/src/server/operations/ops/lifecycle.ts +3 -7
- package/src/server/operations/ops/nodes.ts +20 -8
- package/src/server/operations/ops/viewport.ts +3 -7
- package/src/server/operations/registry.ts +24 -4
- package/src/server/operations/types.ts +16 -2
- package/src/server/server.ts +23 -10
- package/src/server/tour-control.ts +17 -8
- package/src/server/webpage-node.ts +9 -4
package/src/server/index.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { Camera, Tour, TourStop } from '../shared/tour.js';
|
|
3
|
+
import {
|
|
4
|
+
currentTour,
|
|
5
|
+
exitTour,
|
|
6
|
+
goToTourStop,
|
|
7
|
+
moveCamera,
|
|
8
|
+
type TourState,
|
|
9
|
+
type TourStep,
|
|
10
|
+
type TourStepResult,
|
|
11
|
+
} from './tour-control.js';
|
|
4
12
|
export type { Tour, TourStop } from '../shared/tour.js';
|
|
5
|
-
export type { TourStep, TourStepResult } from './tour-control.js';
|
|
13
|
+
export type { TourState, TourStep, TourStepResult } from './tour-control.js';
|
|
6
14
|
import { agentPresence } from './agent-presence.js';
|
|
7
15
|
import { checkFenceTarget, type FenceTarget } from './scope-fence.js';
|
|
8
16
|
import type { AgentPhase, AgentPresence, AgentPresenceSnapshot } from '../shared/agent-presence.js';
|
|
9
|
-
import { canvasState
|
|
10
|
-
import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout
|
|
17
|
+
import { canvasState } from './canvas-state.js';
|
|
18
|
+
import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
|
|
11
19
|
import { buildCanvasAxContext } from './ax-context.js';
|
|
12
20
|
import { applyAxInteraction, type AxInteractionInput, type AxInteractionPublicResult } from './ax-interaction.js';
|
|
13
21
|
import { intentRegistry } from './intent-registry.js';
|
|
@@ -46,12 +54,12 @@ import { workbenchToken } from './workbench-auth.js';
|
|
|
46
54
|
import type { BackupFile, BackupStatus } from './backup.js';
|
|
47
55
|
import type { ExportManifest } from './board-export.js';
|
|
48
56
|
import { onFileNodeChanged } from './file-watcher.js';
|
|
49
|
-
import { findOpenCanvasPosition
|
|
57
|
+
import { findOpenCanvasPosition } from './placement.js';
|
|
50
58
|
import { overlapsAny } from '../shared/placement.js';
|
|
51
59
|
import { buildSpatialContext } from './spatial-analysis.js';
|
|
52
60
|
import { searchCanvasNodes } from './operations/ops/query.js';
|
|
53
61
|
import { mutationHistory, diffLayouts, formatDiff } from './mutation-history.js';
|
|
54
|
-
import {
|
|
62
|
+
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
55
63
|
import {
|
|
56
64
|
addCanvasNode,
|
|
57
65
|
addCanvasEdge,
|
|
@@ -90,12 +98,7 @@ import { validateCanvasLayout } from './canvas-validation.js';
|
|
|
90
98
|
import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
|
|
91
99
|
import { serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
|
|
92
100
|
import { contextReadFromPayload } from './context-reads.js';
|
|
93
|
-
import {
|
|
94
|
-
buildHtmlPrimitive,
|
|
95
|
-
getHtmlPrimitiveSemanticMetadata,
|
|
96
|
-
isHtmlPrimitiveKind,
|
|
97
|
-
listHtmlPrimitiveDescriptors,
|
|
98
|
-
} from './html-primitives.js';
|
|
101
|
+
import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata } from './html-primitives.js';
|
|
99
102
|
import type { HtmlPrimitiveKind } from './html-primitives.js';
|
|
100
103
|
import {
|
|
101
104
|
buildWebArtifactOnCanvas,
|
|
@@ -104,15 +107,7 @@ import {
|
|
|
104
107
|
} from './web-artifacts.js';
|
|
105
108
|
import { closeMcpAppSession, type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
|
|
106
109
|
import type { DiagramPresetOpenInput } from './diagram-presets.js';
|
|
107
|
-
import {
|
|
108
|
-
buildGraphSpec,
|
|
109
|
-
GRAPH_NODE_SIZE,
|
|
110
|
-
JSON_RENDER_NODE_SIZE,
|
|
111
|
-
normalizeAndValidateJsonRenderSpec,
|
|
112
|
-
type GraphNodeInput,
|
|
113
|
-
type JsonRenderNodeInput,
|
|
114
|
-
type JsonRenderSpec,
|
|
115
|
-
} from '../json-render/server.js';
|
|
110
|
+
import type { GraphNodeInput, JsonRenderNodeInput, JsonRenderSpec } from '../json-render/server.js';
|
|
116
111
|
import {
|
|
117
112
|
startCanvasServer,
|
|
118
113
|
stopCanvasServer,
|
|
@@ -126,15 +121,8 @@ import {
|
|
|
126
121
|
screenshotCanvasAutomationWebView,
|
|
127
122
|
emitPrimaryWorkbenchEvent,
|
|
128
123
|
setPrimaryWorkbenchCanvasPromptHandler,
|
|
129
|
-
setPrimaryWorkbenchAutoOpenEnabled,
|
|
130
|
-
consumePrimaryWorkbenchIntents,
|
|
131
|
-
} from './server.js';
|
|
132
|
-
import type {
|
|
133
|
-
CanvasAutomationWebViewOptions,
|
|
134
|
-
CanvasAutomationWebViewStatus,
|
|
135
|
-
PrimaryWorkbenchCanvasPromptRequest,
|
|
136
|
-
PrimaryWorkbenchIntent,
|
|
137
124
|
} from './server.js';
|
|
125
|
+
import type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus } from './server.js';
|
|
138
126
|
|
|
139
127
|
/**
|
|
140
128
|
* Node object returned by the SDK's create/get methods. It is the fully
|
|
@@ -632,8 +620,8 @@ export class PmxCanvas extends EventEmitter {
|
|
|
632
620
|
);
|
|
633
621
|
}
|
|
634
622
|
|
|
635
|
-
getTour():
|
|
636
|
-
return
|
|
623
|
+
getTour(): TourState {
|
|
624
|
+
return currentTour();
|
|
637
625
|
}
|
|
638
626
|
|
|
639
627
|
setTour(tour: Tour | null): void {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { normalizeBoardFolder } from '../shared/boards.js';
|
|
4
|
+
import { memoryKey, type MemoryTarget } from '../shared/memory-graph.js';
|
|
4
5
|
import { currentActor, type ActorAttribution } from './attribution.js';
|
|
5
6
|
import { OperationError } from './operations/types.js';
|
|
6
7
|
import type { LifecycleQualification } from '../shared/lifecycle-status.js';
|
|
@@ -47,6 +48,16 @@ export interface ReviewRecord {
|
|
|
47
48
|
sourceRevisions?: Record<string, number | null>;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// SQLite trim defaults to ASCII space; match JavaScript evidence.trim(), including Unicode whitespace.
|
|
52
|
+
const evidenceWhitespace =
|
|
53
|
+
'\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff';
|
|
54
|
+
const validationPredicate = `json_extract(value, '$.applied') = 1
|
|
55
|
+
AND json_extract(value, '$.outcome') IN ('retain', 'revise', 'externalize')
|
|
56
|
+
AND trim(json_extract(value, '$.evidence'), '${evidenceWhitespace}') <> ''`;
|
|
57
|
+
const retirementPredicate = `(json_extract(value, '$.outcome') = 'restore'
|
|
58
|
+
OR (json_extract(value, '$.applied') = 1 AND json_extract(value, '$.outcome') = 'retire'))`;
|
|
59
|
+
const indexedDatabases = new WeakSet<Database>();
|
|
60
|
+
|
|
50
61
|
export type KnowledgeInput =
|
|
51
62
|
| { action: 'list' }
|
|
52
63
|
| { action: 'category-set'; category: KnowledgeCategory }
|
|
@@ -240,6 +251,61 @@ export class KnowledgeLifecycle {
|
|
|
240
251
|
return resolved;
|
|
241
252
|
}
|
|
242
253
|
|
|
254
|
+
private indexedDb(): Database {
|
|
255
|
+
const db = this.db();
|
|
256
|
+
if (!indexedDatabases.has(db)) {
|
|
257
|
+
db.exec(`CREATE INDEX IF NOT EXISTS review_latest_validation ON review_records
|
|
258
|
+
(board_id, node_id, sequence DESC) WHERE ${validationPredicate};
|
|
259
|
+
CREATE INDEX IF NOT EXISTS review_latest_retirement ON review_records
|
|
260
|
+
(board_id, node_id, sequence DESC) WHERE ${retirementPredicate};
|
|
261
|
+
CREATE INDEX IF NOT EXISTS lifecycle_incoming_sources ON edges (to_board_id, to_node)
|
|
262
|
+
WHERE COALESCE(kind, type) IN ('derived-from', 'contradicts', 'supersedes');`);
|
|
263
|
+
indexedDatabases.add(db);
|
|
264
|
+
}
|
|
265
|
+
return db;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Stored owners of semantic sources; the caller overlays the active board's unsaved edges. */
|
|
269
|
+
incomingSourceBoards(target: MemoryTarget, activeBoardId: string | null): string[] {
|
|
270
|
+
return this.indexedDb()
|
|
271
|
+
.query<{ board_id: string }, [string, string | null, string | null]>(
|
|
272
|
+
`SELECT DISTINCT board_id FROM edges WHERE to_board_id = ? AND to_node = ?
|
|
273
|
+
AND board_id IS NOT ? AND COALESCE(kind, type) IN ('derived-from', 'contradicts', 'supersedes')`,
|
|
274
|
+
)
|
|
275
|
+
.all(target.boardId, target.cardId ?? null, activeBoardId)
|
|
276
|
+
.map((row) => row.board_id);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One indexed latest validation and retirement per card, never its full audit history. */
|
|
280
|
+
reviewSummaries(targets: MemoryTarget[]): Map<string, { lastValidation: ReviewRecord | null; retired: boolean }> {
|
|
281
|
+
if (!targets.length) return new Map();
|
|
282
|
+
const rows = this.indexedDb()
|
|
283
|
+
.query<{ boardId: string; nodeId: string; validation: string | null; retirement: string | null }, [string]>(
|
|
284
|
+
`WITH targets AS (
|
|
285
|
+
SELECT json_extract(value, '$.boardId') AS boardId, json_extract(value, '$.cardId') AS nodeId
|
|
286
|
+
FROM json_each(?)
|
|
287
|
+
) SELECT boardId, nodeId,
|
|
288
|
+
(SELECT value FROM review_records WHERE board_id = boardId AND node_id = nodeId
|
|
289
|
+
AND ${validationPredicate} ORDER BY sequence DESC LIMIT 1) AS validation,
|
|
290
|
+
(SELECT json_extract(value, '$.outcome') FROM review_records WHERE board_id = boardId AND node_id = nodeId
|
|
291
|
+
AND ${retirementPredicate} ORDER BY sequence DESC LIMIT 1) AS retirement
|
|
292
|
+
FROM targets`,
|
|
293
|
+
)
|
|
294
|
+
.all(JSON.stringify(targets));
|
|
295
|
+
return new Map(
|
|
296
|
+
rows.map((row) => {
|
|
297
|
+
const record: ReviewRecord | null = row.validation ? JSON.parse(row.validation) : null;
|
|
298
|
+
return [
|
|
299
|
+
memoryKey({ boardId: row.boardId, cardId: row.nodeId }),
|
|
300
|
+
{
|
|
301
|
+
lastValidation: record ? { ...record, contentRevision: record.contentRevision ?? null } : null,
|
|
302
|
+
retired: row.retirement === 'retire',
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
243
309
|
reviews(boardId: string, nodeId: string) {
|
|
244
310
|
const records = (
|
|
245
311
|
this.db()
|
|
@@ -25,8 +25,70 @@ export function lifecycleQualifications(
|
|
|
25
25
|
return lifecycleSnapshot(now, atUse).cards;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** A card being inspected is in use, including its transitive sources and portal contents. */
|
|
29
|
+
export function lifecycleQualification(target: MemoryTarget, now = Date.now()): LifecycleQualification | undefined {
|
|
30
|
+
return lifecycleSnapshot(now, [target], target).cards[memoryKey(target)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read closure boards once; never activate them or flush the active board to discover edges. */
|
|
34
|
+
function dependencyClosure(target: MemoryTarget) {
|
|
35
|
+
const reads = new Map<string, ReturnType<typeof canvasState.readBoard>>();
|
|
36
|
+
const nodes = new Map<string, CanvasNodeState>();
|
|
37
|
+
const dependencies = new Map<string, MemoryTarget[]>();
|
|
38
|
+
const selected = new Set<string>();
|
|
39
|
+
const queue: MemoryTarget[] = [target];
|
|
40
|
+
const read = (boardId: string) => {
|
|
41
|
+
if (!reads.has(boardId)) {
|
|
42
|
+
const board = canvasState.readBoard(boardId, false);
|
|
43
|
+
reads.set(boardId, board);
|
|
44
|
+
for (const node of board?.layout.nodes ?? []) nodes.set(memoryKey({ boardId, cardId: node.id }), node);
|
|
45
|
+
for (const edge of board?.layout.edges ?? []) {
|
|
46
|
+
const end = relationSourceEnd(edge);
|
|
47
|
+
if (!end) continue;
|
|
48
|
+
const from = { boardId, cardId: edge.from };
|
|
49
|
+
const to = { boardId: edge.toBoardId ?? boardId, cardId: edge.to };
|
|
50
|
+
const key = memoryKey(end === 'from' ? to : from);
|
|
51
|
+
const sources = dependencies.get(key) ?? [];
|
|
52
|
+
sources.push(end === 'from' ? from : to);
|
|
53
|
+
dependencies.set(key, sources);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return reads.get(boardId);
|
|
57
|
+
};
|
|
58
|
+
const activeBoardId = canvasState.activeBoardId;
|
|
59
|
+
const incomingActive = new Set(
|
|
60
|
+
canvasState
|
|
61
|
+
.getEdges()
|
|
62
|
+
.flatMap((edge) =>
|
|
63
|
+
edge.toBoardId && relationSourceEnd(edge) === 'from'
|
|
64
|
+
? [memoryKey({ boardId: edge.toBoardId, cardId: edge.to })]
|
|
65
|
+
: [],
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
for (const current of queue) {
|
|
69
|
+
const key = memoryKey(current);
|
|
70
|
+
if (selected.has(key)) continue;
|
|
71
|
+
selected.add(key);
|
|
72
|
+
const board = read(current.boardId);
|
|
73
|
+
if (!current.cardId) {
|
|
74
|
+
for (const node of board?.layout.nodes ?? []) queue.push({ boardId: current.boardId, cardId: node.id });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const node = nodes.get(key);
|
|
78
|
+
if (!node) continue;
|
|
79
|
+
const provenance = normalizeCanvasNodeData(node.type, node.data).provenance as CanvasNodeProvenance | undefined;
|
|
80
|
+
queue.push(...(provenance?.sources ?? []).map(({ boardId, cardId }) => ({ boardId, cardId })));
|
|
81
|
+
if (node.type === 'board' && typeof node.data.boardId === 'string') queue.push({ boardId: node.data.boardId });
|
|
82
|
+
const owners = new Set([current.boardId, ...canvasState.knowledge.incomingSourceBoards(current, activeBoardId)]);
|
|
83
|
+
if (activeBoardId && incomingActive.has(key)) owners.add(activeBoardId);
|
|
84
|
+
for (const owner of owners) read(owner);
|
|
85
|
+
queue.push(...(dependencies.get(key) ?? []));
|
|
86
|
+
}
|
|
87
|
+
return { reads, selected };
|
|
88
|
+
}
|
|
89
|
+
|
|
28
90
|
/** Compact queue metadata and qualifications come from the same authoritative read. */
|
|
29
|
-
export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = []) {
|
|
91
|
+
export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = [], only?: MemoryTarget) {
|
|
30
92
|
const result: Record<string, LifecycleQualification> = {};
|
|
31
93
|
const cards = new Map<string, CanvasNodeState>();
|
|
32
94
|
const validations = new Map<string, ReviewRecord | null>();
|
|
@@ -34,26 +96,36 @@ export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = [])
|
|
|
34
96
|
const portals = new Map<string, string>();
|
|
35
97
|
const policies = new Map<string, Lifecycle | null>();
|
|
36
98
|
const used = new Set<string>();
|
|
37
|
-
const
|
|
99
|
+
const boards = canvasState.listBoards();
|
|
100
|
+
const closure = only ? dependencyClosure(only) : null;
|
|
101
|
+
const reads = boards
|
|
102
|
+
.filter((board) => !closure || closure.reads.has(board.id))
|
|
103
|
+
.map((board) => ({
|
|
104
|
+
board,
|
|
105
|
+
read: closure ? closure.reads.get(board.id) : canvasState.readBoard(board.id, false),
|
|
106
|
+
}));
|
|
107
|
+
const targets = reads
|
|
108
|
+
.flatMap(({ board, read }) => (read?.layout.nodes ?? []).map((node) => ({ boardId: board.id, cardId: node.id })))
|
|
109
|
+
.filter((target) => !closure || closure.selected.has(memoryKey(target)));
|
|
110
|
+
const summaries = canvasState.databasePath ? canvasState.knowledge.reviewSummaries(targets) : null;
|
|
38
111
|
const catalog = canvasState.databasePath
|
|
39
112
|
? canvasState.knowledge.list()
|
|
40
113
|
: { categories: [], folders: [], relationPresets: [] };
|
|
41
114
|
const session =
|
|
42
115
|
agentPresence.reviewSessionStartedAt() ??
|
|
43
116
|
Date.parse(
|
|
44
|
-
|
|
117
|
+
boards.find((board) => board.id === canvasState.activeBoardId)?.lastOpenedAt ??
|
|
45
118
|
new Date(processStartedAt).toISOString(),
|
|
46
119
|
);
|
|
47
120
|
for (const { board, read } of reads) {
|
|
48
121
|
const seen = new Map(canvasState.getNodeReadStatus(board.id).map((status) => [status.nodeId, status]));
|
|
49
122
|
for (const node of read?.layout.nodes ?? []) {
|
|
50
123
|
const key = memoryKey({ boardId: board.id, cardId: node.id });
|
|
124
|
+
if (closure && !closure.selected.has(key)) continue;
|
|
51
125
|
cards.set(key, node);
|
|
52
126
|
const { lifecycle } = canvasState.knowledge.resolveCard(board.folder, node, catalog);
|
|
53
127
|
policies.set(key, lifecycle);
|
|
54
|
-
const { lastValidation, retired } =
|
|
55
|
-
? canvasState.knowledge.reviews(board.id, node.id)
|
|
56
|
-
: { lastValidation: null, retired: false };
|
|
128
|
+
const { lastValidation, retired } = summaries?.get(key) ?? { lastValidation: null, retired: false };
|
|
57
129
|
validations.set(key, lastValidation);
|
|
58
130
|
const lastRead = seen.get(node.id);
|
|
59
131
|
const pinned = read!.state.contextPins.includes(node.id);
|
|
@@ -218,7 +290,7 @@ export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = [])
|
|
|
218
290
|
for (const { board, read } of reads) {
|
|
219
291
|
for (const node of read?.layout.nodes ?? []) {
|
|
220
292
|
const status = result[memoryKey({ boardId: board.id, cardId: node.id })];
|
|
221
|
-
if (status.retired || !(status.due || status.sources.length)) continue;
|
|
293
|
+
if (!status || status.retired || !(status.due || status.sources.length)) continue;
|
|
222
294
|
reviewCards.push({
|
|
223
295
|
boardId: board.id,
|
|
224
296
|
cardId: node.id,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { executeOperation, listOperations } from './registry.js';
|
|
10
10
|
import { SOURCE_LABEL_RE } from '../agent-presence.js';
|
|
11
|
-
import { OperationError } from './types.js';
|
|
11
|
+
import { OperationError, type OperationExecutionOptions, type OperationProgress } from './types.js';
|
|
12
12
|
import { isWorkbenchToken, refuseStaleWorkbenchWrite } from '../workbench-auth.js';
|
|
13
13
|
|
|
14
14
|
function responseJson(data: unknown, status = 200): Response {
|
|
@@ -20,6 +20,57 @@ function responseJson(data: unknown, status = 200): Response {
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Opt-in NDJSON; the terminal frame retains the ordinary HTTP status/body. */
|
|
24
|
+
export type OperationStreamFrame =
|
|
25
|
+
| { type: 'progress'; progress: OperationProgress }
|
|
26
|
+
| { type: 'result'; status: number; body: unknown };
|
|
27
|
+
|
|
28
|
+
function operationFailure(name: string, error: unknown, signal: AbortSignal): Response {
|
|
29
|
+
if (signal.aborted) return responseJson({ ok: false, cancelled: true, error: 'Operation cancelled.' }, 499);
|
|
30
|
+
if (error instanceof OperationError) {
|
|
31
|
+
return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
|
|
32
|
+
}
|
|
33
|
+
// Keep unexpected errors server-side, never leak a Bun development overlay.
|
|
34
|
+
console.error(`[operation] unhandled error dispatching ${name}:`, error);
|
|
35
|
+
return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function progressResponse(
|
|
39
|
+
req: Request,
|
|
40
|
+
name: string,
|
|
41
|
+
run: (options: OperationExecutionOptions) => Promise<Response>,
|
|
42
|
+
): Response {
|
|
43
|
+
const cancelled = new AbortController();
|
|
44
|
+
const signal = AbortSignal.any([req.signal, cancelled.signal]);
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
let closed = false;
|
|
47
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
48
|
+
async start(controller) {
|
|
49
|
+
const send = (frame: OperationStreamFrame): void => {
|
|
50
|
+
if (!closed) controller.enqueue(encoder.encode(`${JSON.stringify(frame)}\n`));
|
|
51
|
+
};
|
|
52
|
+
let response: Response;
|
|
53
|
+
try {
|
|
54
|
+
response = await run({ signal, onProgress: (progress) => send({ type: 'progress', progress }) });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
response = operationFailure(name, error, signal);
|
|
57
|
+
}
|
|
58
|
+
if (!closed) {
|
|
59
|
+
send({ type: 'result', status: response.status, body: await response.json() });
|
|
60
|
+
closed = true;
|
|
61
|
+
controller.close();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
cancel() {
|
|
65
|
+
closed = true;
|
|
66
|
+
cancelled.abort();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return new Response(stream, {
|
|
70
|
+
headers: { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
23
74
|
export function matchOperationPath(template: string, pathname: string): Record<string, string> | null {
|
|
24
75
|
const templateSegments = template.split('/');
|
|
25
76
|
const pathSegments = pathname.split('/');
|
|
@@ -83,6 +134,7 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
|
|
|
83
134
|
const params = matchOperationPath(route.path, url.pathname);
|
|
84
135
|
if (!params) continue;
|
|
85
136
|
try {
|
|
137
|
+
req.signal.throwIfAborted();
|
|
86
138
|
const input = route.readInput
|
|
87
139
|
? await route.readInput(req, params, url)
|
|
88
140
|
: await readOperationInput(req, params, url);
|
|
@@ -100,27 +152,23 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
|
|
|
100
152
|
// HTTP callers read as 'api'.
|
|
101
153
|
const sourceHeader = req.headers.get('x-pmx-source');
|
|
102
154
|
const source = sourceHeader && SOURCE_LABEL_RE.test(sourceHeader) ? sourceHeader : 'api';
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
const run = async (options: OperationExecutionOptions): Promise<Response> => {
|
|
156
|
+
const result = await executeOperation(op.name, input, {
|
|
157
|
+
...options,
|
|
158
|
+
// The legacy marker remains a UI-policy signal (ghost suppression).
|
|
159
|
+
// Only the unguessable boot token classifies authorship as human.
|
|
160
|
+
suppressAutoGhost: workbenchMarked,
|
|
161
|
+
fromWorkbench: workbenchMarked,
|
|
162
|
+
humanAuthor,
|
|
163
|
+
source,
|
|
164
|
+
proxiedRead: req.headers.get('x-pmx-proxied-read') === '1',
|
|
165
|
+
});
|
|
166
|
+
return responseJson(result, route.status ? route.status(result) : 200);
|
|
167
|
+
};
|
|
168
|
+
if (req.headers.get('accept') === 'application/x-ndjson') return progressResponse(req, op.name, run);
|
|
169
|
+
return await run({ signal: req.signal });
|
|
113
170
|
} catch (error) {
|
|
114
|
-
|
|
115
|
-
return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
|
|
116
|
-
}
|
|
117
|
-
// An unexpected (non-OperationError) throw from a handler MUST NOT escape the
|
|
118
|
-
// dispatcher: Bun.serve has no per-request boundary, so an escaped throw renders
|
|
119
|
-
// its dev error overlay (HTTP 500 text/html leaking the absolute server source
|
|
120
|
-
// path + stack). Return a clean JSON 500 with a generic message instead (the
|
|
121
|
-
// real error is logged server-side, never echoed to the client).
|
|
122
|
-
console.error(`[operation] unhandled error dispatching ${op.name}:`, error);
|
|
123
|
-
return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
|
|
171
|
+
return operationFailure(op.name, error, req.signal);
|
|
124
172
|
}
|
|
125
173
|
}
|
|
126
174
|
return null;
|
|
@@ -77,7 +77,12 @@ export { runCanvasBatchOperation, type BatchEnvelope } from './ops/batch.js';
|
|
|
77
77
|
// OpenMcpAppCoreResult is the SDK's cast target for executeOperation('mcpapp.open').
|
|
78
78
|
export type { OpenMcpAppCoreResult } from './ops/app.js';
|
|
79
79
|
export { LocalOperationInvoker, HttpOperationInvoker, type OperationInvoker } from './invoker.js';
|
|
80
|
-
export {
|
|
80
|
+
export {
|
|
81
|
+
registerOperationTools,
|
|
82
|
+
registerCompositeTools,
|
|
83
|
+
registerOperationHelpTool,
|
|
84
|
+
type OperationToolHost,
|
|
85
|
+
} from './mcp.js';
|
|
81
86
|
export { compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
|
|
82
87
|
export {
|
|
83
88
|
OperationError,
|
|
@@ -85,5 +90,7 @@ export {
|
|
|
85
90
|
type Operation,
|
|
86
91
|
type OperationContext,
|
|
87
92
|
type OperationDefinition,
|
|
93
|
+
type OperationExecutionOptions,
|
|
88
94
|
type OperationErrorStatus,
|
|
95
|
+
type OperationProgress,
|
|
89
96
|
} from './types.js';
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { SOURCE_LABEL_RE } from '../agent-presence.js';
|
|
7
7
|
import { executeOperation, getOperation } from './registry.js';
|
|
8
|
-
import { OperationError, type OperationErrorStatus } from './types.js';
|
|
8
|
+
import { OperationError, type OperationErrorStatus, type OperationExecutionOptions } from './types.js';
|
|
9
|
+
import type { OperationStreamFrame } from './http.js';
|
|
9
10
|
|
|
10
11
|
export interface OperationInvoker {
|
|
11
|
-
invoke(name: string, input: Record<string, unknown
|
|
12
|
+
invoke(name: string, input: Record<string, unknown>, options?: OperationExecutionOptions): Promise<unknown>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -40,9 +41,14 @@ export class LocalOperationInvoker implements OperationInvoker {
|
|
|
40
41
|
this.proxiedRead = source === 'mcp';
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
async invoke(
|
|
44
|
+
async invoke(
|
|
45
|
+
name: string,
|
|
46
|
+
input: Record<string, unknown>,
|
|
47
|
+
options: OperationExecutionOptions = {},
|
|
48
|
+
): Promise<unknown> {
|
|
44
49
|
const args = this.allowExternalMcp ? input : { ...input, allowExternalMcp: false };
|
|
45
50
|
return await executeOperation(name, defaultPresenceSource(name, args, this.source), {
|
|
51
|
+
...options,
|
|
46
52
|
source: this.source,
|
|
47
53
|
proxiedRead: this.proxiedRead,
|
|
48
54
|
});
|
|
@@ -53,6 +59,37 @@ function toOperationErrorStatus(status: number): OperationErrorStatus {
|
|
|
53
59
|
return status === 404 ? 404 : status === 409 ? 409 : status === 403 ? 403 : 400;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
async function readProgressResponse(response: Response, options: OperationExecutionOptions): Promise<Response> {
|
|
63
|
+
if (!response.body) throw new OperationError('Missing operation progress stream.');
|
|
64
|
+
const reader = response.body.getReader();
|
|
65
|
+
const decoder = new TextDecoder();
|
|
66
|
+
let buffer = '';
|
|
67
|
+
try {
|
|
68
|
+
while (true) {
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
71
|
+
let end = buffer.indexOf('\n');
|
|
72
|
+
while (end !== -1) {
|
|
73
|
+
const frame = JSON.parse(buffer.slice(0, end)) as OperationStreamFrame;
|
|
74
|
+
buffer = buffer.slice(end + 1);
|
|
75
|
+
if (frame.type === 'result') return Response.json(frame.body, { status: frame.status });
|
|
76
|
+
if (frame.type !== 'progress') throw new OperationError('Invalid operation progress frame.');
|
|
77
|
+
try {
|
|
78
|
+
await options.onProgress?.(frame.progress);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!options.signal?.aborted) console.error('[operation] progress callback failed:', error);
|
|
81
|
+
}
|
|
82
|
+
end = buffer.indexOf('\n');
|
|
83
|
+
}
|
|
84
|
+
if (done) throw new OperationError('Operation progress stream ended without a result.');
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
// A fetch abort already errors the reader; preserve that original failure.
|
|
88
|
+
await reader.cancel().catch(() => {});
|
|
89
|
+
reader.releaseLock();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
56
93
|
/** Builds the HTTP request from the op's route template (`:id` from input, GET flags to query). */
|
|
57
94
|
export class HttpOperationInvoker implements OperationInvoker {
|
|
58
95
|
private readonly baseUrl: string;
|
|
@@ -70,7 +107,12 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
70
107
|
this.proxiedRead = source === 'mcp';
|
|
71
108
|
}
|
|
72
109
|
|
|
73
|
-
async invoke(
|
|
110
|
+
async invoke(
|
|
111
|
+
name: string,
|
|
112
|
+
rawInput: Record<string, unknown>,
|
|
113
|
+
options: OperationExecutionOptions = {},
|
|
114
|
+
): Promise<unknown> {
|
|
115
|
+
options.signal?.throwIfAborted();
|
|
74
116
|
const args = this.allowExternalMcp ? rawInput : { ...rawInput, allowExternalMcp: false };
|
|
75
117
|
const input = defaultPresenceSource(name, args, this.source);
|
|
76
118
|
const op = getOperation(name);
|
|
@@ -96,8 +138,10 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
96
138
|
|
|
97
139
|
let url = `${this.baseUrl}${path}`;
|
|
98
140
|
const proxyHeaders: Record<string, string> = this.proxiedRead ? { 'x-pmx-proxied-read': '1' } : {};
|
|
141
|
+
if (options.onProgress) proxyHeaders.accept = 'application/x-ndjson';
|
|
99
142
|
const init: RequestInit = {
|
|
100
143
|
method: route.method,
|
|
144
|
+
signal: options.signal,
|
|
101
145
|
headers: { 'x-pmx-source': this.source, ...proxyHeaders },
|
|
102
146
|
};
|
|
103
147
|
if (route.method === 'GET' || route.method === 'DELETE') {
|
|
@@ -112,7 +156,10 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
112
156
|
init.body = JSON.stringify(rest);
|
|
113
157
|
}
|
|
114
158
|
|
|
115
|
-
|
|
159
|
+
let response = await fetch(url, init);
|
|
160
|
+
if (response.headers.get('content-type')?.startsWith('application/x-ndjson')) {
|
|
161
|
+
response = await readProgressResponse(response, options);
|
|
162
|
+
}
|
|
116
163
|
const text = await response.text();
|
|
117
164
|
let parsed: unknown = null;
|
|
118
165
|
let parsedAsJson = false;
|
|
@@ -125,6 +172,7 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
125
172
|
}
|
|
126
173
|
}
|
|
127
174
|
if (!response.ok) {
|
|
175
|
+
if (response.status === 499) throw new DOMException('Operation cancelled.', 'AbortError');
|
|
128
176
|
// errorBodyAsResult carries the route's STRUCTURED partial-failure
|
|
129
177
|
// envelope (e.g. canvas_batch { ok:false, failedIndex, ... }) through as
|
|
130
178
|
// a result. A non-JSON error body (an HTML 502 from a proxy, a foreign
|