pmx-canvas 0.2.0 → 0.2.2
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/CHANGELOG.md +124 -0
- package/Readme.md +2 -2
- package/dist/canvas/global.css +260 -0
- package/dist/canvas/index.js +76 -76
- package/dist/json-render/index.js +2 -2
- package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +10 -0
- package/dist/types/client/state/intent-store.d.ts +25 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/server/ax-state-manager.d.ts +11 -0
- package/dist/types/server/ax-state.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +13 -0
- package/dist/types/server/canvas-state.d.ts +5 -0
- package/dist/types/server/index.d.ts +34 -4
- package/dist/types/server/intent-registry.d.ts +45 -0
- package/dist/types/server/operations/ops/intent.d.ts +2 -0
- package/dist/types/shared/ax-intent.d.ts +58 -0
- package/docs/ax-host-adapter-contract.md +19 -1
- package/docs/http-api.md +4 -0
- package/docs/mcp.md +22 -3
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +197 -1283
- package/skills/pmx-canvas/evals/evals.json +199 -0
- package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
- package/skills/pmx-canvas/references/full-reference.md +1441 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +23 -7
- package/src/cli/index.ts +21 -4
- package/src/client/canvas/CanvasNode.tsx +13 -13
- package/src/client/canvas/CanvasViewport.tsx +2 -0
- package/src/client/canvas/ContextMenu.tsx +25 -19
- package/src/client/canvas/IntentLayer.tsx +278 -0
- package/src/client/nodes/ExtAppFrame.tsx +31 -22
- package/src/client/state/intent-bridge.ts +31 -0
- package/src/client/state/intent-store.ts +107 -0
- package/src/client/state/sse-bridge.ts +31 -0
- package/src/client/theme/global.css +260 -0
- package/src/json-render/charts/components.tsx +18 -4
- package/src/json-render/renderer/index.tsx +11 -2
- package/src/json-render/server.ts +1 -1
- package/src/server/ax-context.ts +8 -1
- package/src/server/ax-state-manager.ts +18 -0
- package/src/server/ax-state.ts +8 -0
- package/src/server/canvas-db.ts +35 -0
- package/src/server/canvas-state.ts +8 -0
- package/src/server/index.ts +240 -158
- package/src/server/intent-registry.ts +324 -0
- package/src/server/operations/composites.ts +11 -0
- package/src/server/operations/index.ts +2 -0
- package/src/server/operations/ops/edges.ts +1 -0
- package/src/server/operations/ops/groups.ts +3 -0
- package/src/server/operations/ops/intent.ts +132 -0
- package/src/server/operations/ops/json-render.ts +3 -0
- package/src/server/operations/ops/nodes.ts +3 -0
- package/src/server/operations/registry.ts +68 -3
- package/src/server/server.ts +40 -12
- package/src/shared/ax-intent.ts +64 -0
- package/src/shared/surface.ts +5 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntentRegistry — the server-side home of the Ghost Cursor of Intent.
|
|
3
|
+
*
|
|
4
|
+
* Intents are EPHEMERAL PRESENCE, deliberately modelled like the attention /
|
|
5
|
+
* timeline ephemerality layer rather than canvas-bound state:
|
|
6
|
+
* - an in-memory Map (NOT CanvasStateManager) — never serialized, never
|
|
7
|
+
* snapshotted, never returned by canvas_get_layout;
|
|
8
|
+
* - count-capped (oldest evicted) and TTL-swept so a ghost can never linger;
|
|
9
|
+
* - emitted over the same workbench SSE stream as `ax-intent` /
|
|
10
|
+
* `ax-intent-clear` frames via an INJECTED emitter (server.ts wires it,
|
|
11
|
+
* mirroring setOperationEventEmitter) so this module never imports server.ts.
|
|
12
|
+
*
|
|
13
|
+
* The single trust boundary is `signal()` / `update()`: every envelope is
|
|
14
|
+
* zod-validated and per-kind-checked here, so HTTP, MCP, and the SDK all funnel
|
|
15
|
+
* through the same validation (consistent with applyAxInteraction).
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_INTENT_TTL_MS,
|
|
20
|
+
INTENT_EDGE_TYPES,
|
|
21
|
+
INTENT_KINDS,
|
|
22
|
+
MAX_INTENT_TTL_MS,
|
|
23
|
+
MAX_LIVE_INTENTS,
|
|
24
|
+
type PmxAxIntent,
|
|
25
|
+
type PmxAxIntentKind,
|
|
26
|
+
} from '../shared/ax-intent.js';
|
|
27
|
+
import { OperationError } from './operations/types.js';
|
|
28
|
+
|
|
29
|
+
type IntentEmitter = (event: string, payload: Record<string, unknown>) => void;
|
|
30
|
+
const MAX_VETO_TOMBSTONES = 128;
|
|
31
|
+
|
|
32
|
+
const positionSchema = z.object({ x: z.number().finite(), y: z.number().finite() });
|
|
33
|
+
|
|
34
|
+
const intentSignalSchema = z.looseObject({
|
|
35
|
+
id: z.string().min(1).max(200).optional(),
|
|
36
|
+
kind: z.enum(INTENT_KINDS),
|
|
37
|
+
position: positionSchema.optional(),
|
|
38
|
+
nodeId: z.string().min(1).max(200).optional(),
|
|
39
|
+
edge: z
|
|
40
|
+
.object({
|
|
41
|
+
from: z.string().min(1).max(200),
|
|
42
|
+
to: z.string().min(1).max(200),
|
|
43
|
+
type: z.enum(INTENT_EDGE_TYPES),
|
|
44
|
+
})
|
|
45
|
+
.optional(),
|
|
46
|
+
nodeType: z.string().max(60).optional(),
|
|
47
|
+
label: z.string().max(120).optional(),
|
|
48
|
+
reason: z.string().max(400).optional(),
|
|
49
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
50
|
+
seq: z.number().int().min(0).max(9999).optional(),
|
|
51
|
+
ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
|
|
52
|
+
source: z.string().max(60).optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const intentUpdateSchema = z.looseObject({
|
|
56
|
+
position: positionSchema.optional(),
|
|
57
|
+
nodeType: z.string().max(60).optional(),
|
|
58
|
+
label: z.string().max(120).optional(),
|
|
59
|
+
reason: z.string().max(400).optional(),
|
|
60
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
61
|
+
seq: z.number().int().min(0).max(9999).optional(),
|
|
62
|
+
ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function parseOrThrow<T>(schema: z.ZodType<T>, raw: unknown, label: string): T {
|
|
66
|
+
const parsed = schema.safeParse(raw ?? {});
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
const detail = parsed.error.issues
|
|
69
|
+
.map((issue) => (issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ${issue.message}` : issue.message))
|
|
70
|
+
.join('; ');
|
|
71
|
+
throw new OperationError(`Invalid ${label}: ${detail}`);
|
|
72
|
+
}
|
|
73
|
+
return parsed.data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Each kind needs the spatial anchor it renders against — fail loud otherwise. */
|
|
77
|
+
function requireKindFields(kind: PmxAxIntentKind, value: z.infer<typeof intentSignalSchema>): void {
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case 'create':
|
|
80
|
+
if (!value.position) throw new OperationError('intent kind "create" requires a position.');
|
|
81
|
+
break;
|
|
82
|
+
case 'move':
|
|
83
|
+
if (!value.nodeId) throw new OperationError('intent kind "move" requires a nodeId.');
|
|
84
|
+
if (!value.position) throw new OperationError('intent kind "move" requires a destination position.');
|
|
85
|
+
break;
|
|
86
|
+
case 'connect':
|
|
87
|
+
if (!value.edge) throw new OperationError('intent kind "connect" requires an edge { from, to, type }.');
|
|
88
|
+
break;
|
|
89
|
+
case 'remove':
|
|
90
|
+
case 'edit':
|
|
91
|
+
if (!value.nodeId) throw new OperationError(`intent kind "${kind}" requires a nodeId.`);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let intentSeq = 0;
|
|
97
|
+
|
|
98
|
+
function nextIntentId(): string {
|
|
99
|
+
intentSeq += 1;
|
|
100
|
+
return `intent-${Date.now().toString(36)}-${intentSeq.toString(36)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class IntentRegistry {
|
|
104
|
+
private readonly intents = new Map<string, PmxAxIntent>();
|
|
105
|
+
private readonly vetoedIntentIds = new Map<string, number>();
|
|
106
|
+
private readonly committingIntentIds = new Set<string>();
|
|
107
|
+
private emit: IntentEmitter = () => {};
|
|
108
|
+
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
109
|
+
|
|
110
|
+
/** Inject the workbench SSE emitter (server.ts wires this at module load). */
|
|
111
|
+
setEmitter(emitter: IntentEmitter | null): void {
|
|
112
|
+
this.emit = emitter ?? (() => {});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
list(): PmxAxIntent[] {
|
|
116
|
+
return [...this.intents.values()];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Signal a new (or replace an existing) intent. Returns the stored envelope. */
|
|
120
|
+
signal(raw: unknown): PmxAxIntent {
|
|
121
|
+
const input = parseOrThrow(intentSignalSchema, raw, 'intent');
|
|
122
|
+
requireKindFields(input.kind, input);
|
|
123
|
+
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const ttl = typeof input.ttlMs === 'number' ? input.ttlMs : DEFAULT_INTENT_TTL_MS;
|
|
126
|
+
const id = input.id && this.intents.has(input.id) ? input.id : input.id ?? nextIntentId();
|
|
127
|
+
this.pruneVetoTombstones();
|
|
128
|
+
if (this.vetoedIntentIds.has(id)) {
|
|
129
|
+
throw new OperationError(`Intent "${id}" was vetoed. Use a new id for a revised intent.`, 409);
|
|
130
|
+
}
|
|
131
|
+
if (this.committingIntentIds.has(id)) {
|
|
132
|
+
throw new OperationError(`Intent "${id}" is already committing.`, 409);
|
|
133
|
+
}
|
|
134
|
+
const existing = this.intents.get(id);
|
|
135
|
+
|
|
136
|
+
const intent: PmxAxIntent = {
|
|
137
|
+
id,
|
|
138
|
+
kind: input.kind,
|
|
139
|
+
...(input.position ? { position: input.position } : {}),
|
|
140
|
+
...(input.nodeId ? { nodeId: input.nodeId } : {}),
|
|
141
|
+
...(input.edge ? { edge: input.edge } : {}),
|
|
142
|
+
...(input.nodeType ? { nodeType: input.nodeType } : {}),
|
|
143
|
+
...(input.label ? { label: input.label } : {}),
|
|
144
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
145
|
+
...(typeof input.confidence === 'number' ? { confidence: input.confidence } : {}),
|
|
146
|
+
...(typeof input.seq === 'number' ? { seq: input.seq } : {}),
|
|
147
|
+
...(input.source ? { source: input.source } : {}),
|
|
148
|
+
createdAt: existing?.createdAt ?? now,
|
|
149
|
+
expiresAt: now + ttl,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
this.intents.set(id, intent);
|
|
153
|
+
this.evictOverflow();
|
|
154
|
+
this.ensureSweeper();
|
|
155
|
+
this.emit('ax-intent', { intent });
|
|
156
|
+
return intent;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Patch a live intent (position/label/reason/confidence/seq) and bump its TTL. */
|
|
160
|
+
update(id: string, raw: unknown): PmxAxIntent {
|
|
161
|
+
if (this.committingIntentIds.has(id)) {
|
|
162
|
+
throw new OperationError(`Intent "${id}" is already committing.`, 409);
|
|
163
|
+
}
|
|
164
|
+
const existing = this.intents.get(id);
|
|
165
|
+
if (!existing) throw new OperationError(`No live intent "${id}".`, 404);
|
|
166
|
+
const patch = parseOrThrow(intentUpdateSchema, raw, 'intent update');
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
const ttl = typeof patch.ttlMs === 'number' ? patch.ttlMs : DEFAULT_INTENT_TTL_MS;
|
|
169
|
+
|
|
170
|
+
const intent: PmxAxIntent = {
|
|
171
|
+
...existing,
|
|
172
|
+
...(patch.position ? { position: patch.position } : {}),
|
|
173
|
+
...(patch.nodeType ? { nodeType: patch.nodeType } : {}),
|
|
174
|
+
...(patch.label ? { label: patch.label } : {}),
|
|
175
|
+
...(patch.reason ? { reason: patch.reason } : {}),
|
|
176
|
+
...(typeof patch.confidence === 'number' ? { confidence: patch.confidence } : {}),
|
|
177
|
+
...(typeof patch.seq === 'number' ? { seq: patch.seq } : {}),
|
|
178
|
+
expiresAt: now + ttl,
|
|
179
|
+
};
|
|
180
|
+
this.intents.set(id, intent);
|
|
181
|
+
this.emit('ax-intent', { intent });
|
|
182
|
+
return intent;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Clear an intent. `settledNodeId` resolves it INTO a real node (the settle
|
|
187
|
+
* morph); `vetoed` marks a human pre-emptive veto. Either way the ghost
|
|
188
|
+
* dissolves. Returns true when an intent was actually removed.
|
|
189
|
+
*/
|
|
190
|
+
clear(id: string, opts: { settledNodeId?: string; vetoed?: boolean } = {}): boolean {
|
|
191
|
+
if (this.committingIntentIds.has(id)) return false;
|
|
192
|
+
if (!this.intents.delete(id)) return false;
|
|
193
|
+
if (opts.vetoed) this.rememberVeto(id);
|
|
194
|
+
this.emit('ax-intent-clear', {
|
|
195
|
+
id,
|
|
196
|
+
...(opts.settledNodeId ? { nodeId: opts.settledNodeId, settled: true } : {}),
|
|
197
|
+
...(opts.vetoed ? { vetoed: true } : {}),
|
|
198
|
+
});
|
|
199
|
+
this.maybeStopSweeper();
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Gate one real mutation behind a live, non-vetoed intent. The claim is
|
|
205
|
+
* synchronous: once this method has accepted the intent, a later veto cannot
|
|
206
|
+
* race in between the check and the mutation.
|
|
207
|
+
*/
|
|
208
|
+
beginCommit(id: string, allowedKinds: readonly PmxAxIntentKind[]): PmxAxIntent {
|
|
209
|
+
this.pruneVetoTombstones();
|
|
210
|
+
if (this.committingIntentIds.has(id)) {
|
|
211
|
+
throw new OperationError(`Intent "${id}" is already committing.`, 409);
|
|
212
|
+
}
|
|
213
|
+
if (this.vetoedIntentIds.has(id)) {
|
|
214
|
+
throw new OperationError(`Intent "${id}" was vetoed.`, 409);
|
|
215
|
+
}
|
|
216
|
+
const intent = this.intents.get(id);
|
|
217
|
+
if (!intent) {
|
|
218
|
+
throw new OperationError(`No live intent "${id}" to commit.`, 409);
|
|
219
|
+
}
|
|
220
|
+
if (!allowedKinds.includes(intent.kind)) {
|
|
221
|
+
throw new OperationError(
|
|
222
|
+
`Intent "${id}" has kind "${intent.kind}", which cannot commit this mutation.`,
|
|
223
|
+
409,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
this.committingIntentIds.add(id);
|
|
227
|
+
return intent;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
completeCommit(id: string, settledNodeId?: string): void {
|
|
231
|
+
this.committingIntentIds.delete(id);
|
|
232
|
+
this.clear(id, { ...(settledNodeId ? { settledNodeId } : {}) });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
abortCommit(id: string): void {
|
|
236
|
+
this.committingIntentIds.delete(id);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async runCommit<T>(
|
|
240
|
+
id: string,
|
|
241
|
+
allowedKinds: readonly PmxAxIntentKind[],
|
|
242
|
+
mutate: () => T | Promise<T>,
|
|
243
|
+
settledNodeId: (result: T, intent: PmxAxIntent) => string | undefined,
|
|
244
|
+
): Promise<T> {
|
|
245
|
+
const intent = this.beginCommit(id, allowedKinds);
|
|
246
|
+
try {
|
|
247
|
+
const result = await mutate();
|
|
248
|
+
this.completeCommit(id, settledNodeId(result, intent));
|
|
249
|
+
return result;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
this.abortCommit(id);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Drop every live intent without per-id SSE (used on hard resets). */
|
|
257
|
+
reset(): void {
|
|
258
|
+
this.intents.clear();
|
|
259
|
+
this.vetoedIntentIds.clear();
|
|
260
|
+
this.committingIntentIds.clear();
|
|
261
|
+
this.maybeStopSweeper();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private rememberVeto(id: string): void {
|
|
265
|
+
this.vetoedIntentIds.delete(id);
|
|
266
|
+
this.vetoedIntentIds.set(id, Date.now() + MAX_INTENT_TTL_MS);
|
|
267
|
+
while (this.vetoedIntentIds.size > MAX_VETO_TOMBSTONES) {
|
|
268
|
+
const oldest = this.vetoedIntentIds.keys().next().value as string | undefined;
|
|
269
|
+
if (!oldest) break;
|
|
270
|
+
this.vetoedIntentIds.delete(oldest);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private pruneVetoTombstones(): void {
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
for (const [id, expiresAt] of this.vetoedIntentIds) {
|
|
277
|
+
if (expiresAt <= now) this.vetoedIntentIds.delete(id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private evictOverflow(): void {
|
|
282
|
+
while (this.intents.size > MAX_LIVE_INTENTS) {
|
|
283
|
+
// Map preserves insertion order; the first key is the oldest live intent.
|
|
284
|
+
let oldest: string | undefined;
|
|
285
|
+
for (const id of this.intents.keys()) {
|
|
286
|
+
if (!this.committingIntentIds.has(id)) {
|
|
287
|
+
oldest = id;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!oldest) break;
|
|
292
|
+
this.intents.delete(oldest);
|
|
293
|
+
this.emit('ax-intent-clear', { id: oldest, evicted: true });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private sweep(): void {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
for (const [id, intent] of this.intents) {
|
|
300
|
+
if (!this.committingIntentIds.has(id) && intent.expiresAt <= now) {
|
|
301
|
+
this.intents.delete(id);
|
|
302
|
+
this.emit('ax-intent-clear', { id, expired: true });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
this.maybeStopSweeper();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private ensureSweeper(): void {
|
|
309
|
+
if (this.sweepTimer || this.intents.size === 0) return;
|
|
310
|
+
this.sweepTimer = setInterval(() => this.sweep(), 1000);
|
|
311
|
+
// Don't keep the process (or a test runner) alive just for ghost expiry.
|
|
312
|
+
(this.sweepTimer as { unref?: () => void }).unref?.();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private maybeStopSweeper(): void {
|
|
316
|
+
if (this.sweepTimer && this.intents.size === 0) {
|
|
317
|
+
clearInterval(this.sweepTimer);
|
|
318
|
+
this.sweepTimer = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Process-wide singleton, shared across HTTP handlers, MCP ops, and the SDK. */
|
|
324
|
+
export const intentRegistry = new IntentRegistry();
|
|
@@ -317,6 +317,17 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
|
|
|
317
317
|
mark: 'ax.delivery.mark',
|
|
318
318
|
},
|
|
319
319
|
},
|
|
320
|
+
{
|
|
321
|
+
toolName: 'canvas_intent',
|
|
322
|
+
description:
|
|
323
|
+
'Ghost Cursor of Intent — announce the spatial move you are ABOUT to make so the canvas paints a faint pre-commit placeholder (legibility: the human sees the next move forming, and can veto it). Action "signal" registers an intent (kind create|move|connect|remove|edit; pass position for create/move, nodeId for move/edit/remove, edge for connect; optional label, reason, confidence 0..1, seq, ttlMs, and a stable id); "update" patches a live intent by id; "clear" abandons/dissolves it. To make veto authoritative, pass the returned id as intentId on the real canvas_node/canvas_edge/canvas_group/canvas_render mutation: vetoed or expired intents are rejected, and a successful linked mutation settles the ghost automatically. Intents are ephemeral presence: never persisted, never snapshotted, auto-expire (~8s).',
|
|
324
|
+
actionSummary: 'signal | update | clear',
|
|
325
|
+
actions: {
|
|
326
|
+
signal: 'intent.signal',
|
|
327
|
+
update: 'intent.update',
|
|
328
|
+
clear: 'intent.clear',
|
|
329
|
+
},
|
|
330
|
+
},
|
|
320
331
|
];
|
|
321
332
|
|
|
322
333
|
/**
|
|
@@ -19,6 +19,7 @@ import { axAwaitOperations } from './ops/ax-await.js';
|
|
|
19
19
|
import { batchOperations } from './ops/batch.js';
|
|
20
20
|
import { webviewOperations } from './ops/webview.js';
|
|
21
21
|
import { appOperations } from './ops/app.js';
|
|
22
|
+
import { intentOperations } from './ops/intent.js';
|
|
22
23
|
|
|
23
24
|
for (const op of [
|
|
24
25
|
...nodeOperations,
|
|
@@ -37,6 +38,7 @@ for (const op of [
|
|
|
37
38
|
...batchOperations,
|
|
38
39
|
...webviewOperations,
|
|
39
40
|
...appOperations,
|
|
41
|
+
...intentOperations,
|
|
40
42
|
]) {
|
|
41
43
|
registerOperation(op);
|
|
42
44
|
}
|
|
@@ -16,6 +16,7 @@ const VALID_EDGE_STYLES = new Set(['solid', 'dashed', 'dotted']);
|
|
|
16
16
|
// ── edge.add ──────────────────────────────────────────────────
|
|
17
17
|
|
|
18
18
|
const edgeAddShape = {
|
|
19
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
19
20
|
from: z.string().optional().catch(undefined).describe('Source node ID'),
|
|
20
21
|
to: z.string().optional().catch(undefined).describe('Target node ID'),
|
|
21
22
|
fromSearch: z.string().optional().catch(undefined).describe('Resolve the source node by exact or fuzzy title/content search'),
|
|
@@ -25,6 +25,7 @@ function pickChildLayout(value: unknown): 'grid' | 'column' | 'flow' | undefined
|
|
|
25
25
|
// ── group.create ──────────────────────────────────────────────
|
|
26
26
|
|
|
27
27
|
const groupCreateShape = {
|
|
28
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
28
29
|
title: z.string().optional().catch(undefined).describe('Group title (default: "Group")'),
|
|
29
30
|
childIds: z.unknown().optional().describe('Node IDs to include in the group. Group auto-sizes to fit them.'),
|
|
30
31
|
color: z.string().optional().catch(undefined).describe('Group accent color (CSS color string, e.g. "#4a9eff")'),
|
|
@@ -90,6 +91,7 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
|
|
|
90
91
|
// ── group.add ─────────────────────────────────────────────────
|
|
91
92
|
|
|
92
93
|
const groupAddShape = {
|
|
94
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
93
95
|
groupId: z.string().optional().catch(undefined).describe('The group node ID'),
|
|
94
96
|
childIds: z.unknown().optional().describe('Node IDs to add to the group'),
|
|
95
97
|
childLayout: z.enum(['grid', 'column', 'flow']).optional().catch(undefined).describe('Optional child layout to apply while grouping'),
|
|
@@ -136,6 +138,7 @@ const groupAddOperation = defineOperation<z.infer<typeof groupAddSchema>, Record
|
|
|
136
138
|
// ── group.remove (ungroup) ────────────────────────────────────
|
|
137
139
|
|
|
138
140
|
const groupRemoveShape = {
|
|
141
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
139
142
|
groupId: z.string().optional().catch(undefined).describe('The group node ID to ungroup'),
|
|
140
143
|
};
|
|
141
144
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghost Cursor of Intent operations: signal / update / clear a pre-commit
|
|
3
|
+
* intent. Surfaced over HTTP (POST/PATCH/DELETE /api/canvas/ax/intent) and,
|
|
4
|
+
* folded into the `canvas_intent` composite, over MCP.
|
|
5
|
+
*
|
|
6
|
+
* Intents are ephemeral presence — every op is `mutates: false` (NO
|
|
7
|
+
* `canvas-layout-update`; the ghost lives on its own `ax-intent` /
|
|
8
|
+
* `ax-intent-clear` channel, emitted by the IntentRegistry). These ops have no
|
|
9
|
+
* standalone `mcp` block on purpose: the composite reuses `op.inputShape` for
|
|
10
|
+
* advertising and dispatches by op name, so the only new MCP tool is the
|
|
11
|
+
* composite itself.
|
|
12
|
+
*
|
|
13
|
+
* The IntentRegistry is the single trust boundary (zod + per-kind validation);
|
|
14
|
+
* these handlers stay thin. This module must never import server.ts or index.ts.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { INTENT_EDGE_TYPES, INTENT_KINDS } from '../../../shared/ax-intent.js';
|
|
18
|
+
import { intentRegistry } from '../../intent-registry.js';
|
|
19
|
+
import { readJsonValue } from '../http.js';
|
|
20
|
+
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
21
|
+
|
|
22
|
+
const positionShape = z.object({ x: z.number(), y: z.number() });
|
|
23
|
+
|
|
24
|
+
// ── intent.signal (canvas_intent action "signal") ─────────────
|
|
25
|
+
|
|
26
|
+
const intentSignalShape = {
|
|
27
|
+
kind: z.enum(INTENT_KINDS).optional().describe('create | move | connect | remove | edit — the move about to be made.'),
|
|
28
|
+
position: positionShape.optional().describe('World coords: where a create forms, or the destination of a move.'),
|
|
29
|
+
nodeId: z.string().optional().describe('The existing node a move/edit/remove targets.'),
|
|
30
|
+
edge: z
|
|
31
|
+
.object({ from: z.string(), to: z.string(), type: z.enum(INTENT_EDGE_TYPES) })
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('connect: the edge about to be drawn (from/to node ids + type).'),
|
|
34
|
+
nodeType: z.string().optional().describe('Node type the ghost renders (icon + type badge).'),
|
|
35
|
+
label: z.string().optional().describe('Short action label shown on the ghost chip ("Add evidence").'),
|
|
36
|
+
reason: z.string().optional().describe('Why — shown beneath the ghost. The legibility payoff.'),
|
|
37
|
+
confidence: z.number().optional().describe('0..1 → ghost opacity/solidity.'),
|
|
38
|
+
seq: z.number().optional().describe('Ordering hint for staged-batch ghosts (numbered previsualization).'),
|
|
39
|
+
ttlMs: z.number().optional().describe('Auto-expire after this many ms (default 8000, max 60000).'),
|
|
40
|
+
id: z.string().optional().describe('Stable id to update/clear/veto later; auto-generated if omitted.'),
|
|
41
|
+
source: z.string().optional().describe('Optional source label of the signalling surface.'),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const intentSignalSchema = z.looseObject(intentSignalShape);
|
|
45
|
+
|
|
46
|
+
const intentSignalOperation = defineOperation<z.infer<typeof intentSignalSchema>, Record<string, unknown>>({
|
|
47
|
+
name: 'intent.signal',
|
|
48
|
+
mutates: false,
|
|
49
|
+
input: intentSignalSchema,
|
|
50
|
+
inputShape: intentSignalShape,
|
|
51
|
+
http: { method: 'POST', path: '/api/canvas/ax/intent' },
|
|
52
|
+
handler: (input) => {
|
|
53
|
+
const intent = intentRegistry.signal(input);
|
|
54
|
+
return { ok: true, intent } as unknown as Record<string, unknown>;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── intent.update (canvas_intent action "update") ─────────────
|
|
59
|
+
|
|
60
|
+
const intentUpdateShape = {
|
|
61
|
+
id: z.string().optional().describe('The intent id to update.'),
|
|
62
|
+
position: positionShape.optional().describe('New world coords for the ghost.'),
|
|
63
|
+
nodeType: z.string().optional().describe('New node type for the ghost.'),
|
|
64
|
+
label: z.string().optional().describe('New ghost chip label.'),
|
|
65
|
+
reason: z.string().optional().describe('New rationale shown beneath the ghost.'),
|
|
66
|
+
confidence: z.number().optional().describe('0..1 → ghost opacity/solidity.'),
|
|
67
|
+
seq: z.number().optional().describe('New ordering hint.'),
|
|
68
|
+
ttlMs: z.number().optional().describe('Reset the TTL to this many ms from now.'),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const intentUpdateSchema = z.looseObject(intentUpdateShape);
|
|
72
|
+
|
|
73
|
+
const intentUpdateOperation = defineOperation<z.infer<typeof intentUpdateSchema>, Record<string, unknown>>({
|
|
74
|
+
name: 'intent.update',
|
|
75
|
+
mutates: false,
|
|
76
|
+
input: intentUpdateSchema,
|
|
77
|
+
inputShape: intentUpdateShape,
|
|
78
|
+
http: { method: 'PATCH', path: '/api/canvas/ax/intent/:id' },
|
|
79
|
+
handler: (input) => {
|
|
80
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
81
|
+
if (!id) throw new OperationError('intent update requires an id.');
|
|
82
|
+
const intent = intentRegistry.update(id, input);
|
|
83
|
+
return { ok: true, intent } as unknown as Record<string, unknown>;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── intent.clear (canvas_intent action "clear") ───────────────
|
|
88
|
+
|
|
89
|
+
const intentClearShape = {
|
|
90
|
+
id: z.string().optional().describe('The intent id to clear.'),
|
|
91
|
+
settledNodeId: z.string().optional().describe('The real node this intent became — triggers the settle morph.'),
|
|
92
|
+
vetoed: z.boolean().optional().describe('Mark this as a human pre-emptive veto (dissolve).'),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const intentClearSchema = z.looseObject(intentClearShape);
|
|
96
|
+
|
|
97
|
+
const intentClearOperation = defineOperation<z.infer<typeof intentClearSchema>, Record<string, unknown>>({
|
|
98
|
+
name: 'intent.clear',
|
|
99
|
+
mutates: false,
|
|
100
|
+
input: intentClearSchema,
|
|
101
|
+
inputShape: intentClearShape,
|
|
102
|
+
http: {
|
|
103
|
+
method: 'DELETE',
|
|
104
|
+
path: '/api/canvas/ax/intent/:id',
|
|
105
|
+
readInput: async (req, params, url) => {
|
|
106
|
+
const query: Record<string, string> = {};
|
|
107
|
+
url.searchParams.forEach((value, key) => {
|
|
108
|
+
query[key] = value;
|
|
109
|
+
});
|
|
110
|
+
const body = await readJsonValue(req);
|
|
111
|
+
const record = body !== null && typeof body === 'object' && !Array.isArray(body)
|
|
112
|
+
? body as Record<string, unknown>
|
|
113
|
+
: {};
|
|
114
|
+
return { ...query, ...record, ...params };
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
handler: (input) => {
|
|
118
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
119
|
+
if (!id) throw new OperationError('intent clear requires an id.');
|
|
120
|
+
const cleared = intentRegistry.clear(id, {
|
|
121
|
+
...(typeof input.settledNodeId === 'string' ? { settledNodeId: input.settledNodeId } : {}),
|
|
122
|
+
...(input.vetoed === true ? { vetoed: true } : {}),
|
|
123
|
+
});
|
|
124
|
+
return { ok: true, cleared } as unknown as Record<string, unknown>;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const intentOperations: Operation[] = [
|
|
129
|
+
intentSignalOperation,
|
|
130
|
+
intentUpdateOperation,
|
|
131
|
+
intentClearOperation,
|
|
132
|
+
];
|
|
@@ -89,6 +89,7 @@ function structuredNodeToolResult(result: unknown): { content: Array<{ type: 'te
|
|
|
89
89
|
// ── jsonrender.add ────────────────────────────────────────────
|
|
90
90
|
|
|
91
91
|
const jsonRenderAddShape = {
|
|
92
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
92
93
|
title: z.string().optional().catch(undefined).describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
|
|
93
94
|
spec: z.unknown().describe('json-render spec. Prefer a complete {root, elements, state?} document; a single bare component object is accepted for legacy callers.'),
|
|
94
95
|
x: z.number().optional().catch(undefined).describe('Optional X position'),
|
|
@@ -193,6 +194,7 @@ export function streamJsonRenderCore(input: StreamJsonRenderInput): StreamJsonRe
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
const jsonRenderStreamShape = {
|
|
197
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
196
198
|
nodeId: z.string().optional().catch(undefined).describe('Existing streaming node id to append to; omit to create a new streaming node'),
|
|
197
199
|
title: z.string().optional().catch(undefined).describe('Title when creating a new streaming node'),
|
|
198
200
|
patches: z.unknown().optional().describe('SpecStream patches to apply this call: JSON-Patch objects ({op,path,value}) or raw JSONL patch lines'),
|
|
@@ -277,6 +279,7 @@ const jsonRenderStreamOperation = defineOperation<
|
|
|
277
279
|
// ── graph.add ─────────────────────────────────────────────────
|
|
278
280
|
|
|
279
281
|
const graphAddShape = {
|
|
282
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
280
283
|
title: z.string().optional().catch(undefined).describe('Optional node title'),
|
|
281
284
|
graphType: z.string().optional().catch(undefined).describe('Graph type: line, bar, pie, area, scatter, radar, stacked-bar (or "stack"), composed (or "combo"), sparkline, dot-plot (or "dot"), bullet, slopegraph (or "slope")'),
|
|
282
285
|
data: z.unknown().optional().describe('Array of chart data objects'),
|
|
@@ -653,6 +653,7 @@ function createGroupNode(body: Record<string, unknown>): NodeAddResult {
|
|
|
653
653
|
}
|
|
654
654
|
|
|
655
655
|
const nodeAddShape = {
|
|
656
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
656
657
|
type: z.string().optional().catch(undefined).describe('Node type (prefer canvas_create_group for groups)'),
|
|
657
658
|
title: z.string().optional().catch(undefined).describe('Node title'),
|
|
658
659
|
content: z.string().optional().catch(undefined).describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
|
|
@@ -834,6 +835,7 @@ const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, Serializ
|
|
|
834
835
|
|
|
835
836
|
const nodeUpdateShape = {
|
|
836
837
|
id: z.string().describe('Node ID to update'),
|
|
838
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
837
839
|
title: z.unknown().optional().describe('New title'),
|
|
838
840
|
content: z.unknown().optional().describe('New content'),
|
|
839
841
|
x: z.number().optional().catch(undefined).describe('New X position'),
|
|
@@ -965,6 +967,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
965
967
|
|
|
966
968
|
const nodeRemoveShape = {
|
|
967
969
|
id: z.string().describe('Node ID to remove'),
|
|
970
|
+
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
968
971
|
};
|
|
969
972
|
|
|
970
973
|
const nodeRemoveSchema = z.looseObject(nodeRemoveShape);
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* `mutates: true` is the single source; extra events go through `ctx.emit`.
|
|
9
9
|
*/
|
|
10
10
|
import { canvasState } from '../canvas-state.js';
|
|
11
|
+
import { intentRegistry } from '../intent-registry.js';
|
|
12
|
+
import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
|
|
11
13
|
import { OperationError, type Operation, type OperationContext } from './types.js';
|
|
12
14
|
|
|
13
15
|
const operations = new Map<string, Operation>();
|
|
@@ -69,11 +71,74 @@ export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T
|
|
|
69
71
|
|
|
70
72
|
const operationContext: OperationContext = { emit: emitOperationEvent };
|
|
71
73
|
|
|
74
|
+
const INTENT_KINDS_BY_OPERATION: Record<string, readonly PmxAxIntentKind[]> = {
|
|
75
|
+
'node.add': ['create'],
|
|
76
|
+
'jsonrender.add': ['create'],
|
|
77
|
+
'graph.add': ['create'],
|
|
78
|
+
'group.create': ['create'],
|
|
79
|
+
'node.update': ['move', 'edit'],
|
|
80
|
+
'group.add': ['edit'],
|
|
81
|
+
'group.remove': ['edit'],
|
|
82
|
+
'edge.add': ['connect'],
|
|
83
|
+
'node.remove': ['remove'],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function linkedIntentId(rawInput: unknown): string | undefined {
|
|
87
|
+
if (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput)) return undefined;
|
|
88
|
+
const record = rawInput as Record<string, unknown>;
|
|
89
|
+
if (record.intentId === undefined) return undefined;
|
|
90
|
+
if (typeof record.intentId !== 'string' || record.intentId.trim().length === 0) {
|
|
91
|
+
throw new OperationError('intentId must be a non-empty string.');
|
|
92
|
+
}
|
|
93
|
+
return record.intentId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxIntentKind[] | undefined {
|
|
97
|
+
if (name === 'jsonrender.stream') {
|
|
98
|
+
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
|
99
|
+
? rawInput as Record<string, unknown>
|
|
100
|
+
: {};
|
|
101
|
+
return typeof input.nodeId === 'string' && input.nodeId.length > 0 ? ['edit'] : ['create'];
|
|
102
|
+
}
|
|
103
|
+
return INTENT_KINDS_BY_OPERATION[name];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined {
|
|
107
|
+
if (intent.kind === 'connect' || intent.kind === 'remove') return undefined;
|
|
108
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
|
|
109
|
+
const record = result as Record<string, unknown>;
|
|
110
|
+
if (typeof record.nodeId === 'string') return record.nodeId;
|
|
111
|
+
if (record.node && typeof record.node === 'object' && !Array.isArray(record.node)) {
|
|
112
|
+
const id = (record.node as Record<string, unknown>).id;
|
|
113
|
+
if (typeof id === 'string') return id;
|
|
114
|
+
}
|
|
115
|
+
if (typeof record.groupId === 'string') return record.groupId;
|
|
116
|
+
return typeof record.id === 'string' ? record.id : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
export async function executeOperation(name: string, rawInput: unknown): Promise<unknown> {
|
|
73
120
|
const op = getOperation(name);
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
121
|
+
const intentId = linkedIntentId(rawInput);
|
|
122
|
+
const allowedKinds = intentId ? allowedIntentKinds(name, rawInput) : undefined;
|
|
123
|
+
if (intentId && !allowedKinds) {
|
|
124
|
+
throw new OperationError(`Operation "${name}" cannot be committed through a ghost intent.`);
|
|
77
125
|
}
|
|
126
|
+
if (intentId) {
|
|
127
|
+
return intentRegistry.runCommit(
|
|
128
|
+
intentId,
|
|
129
|
+
allowedKinds!,
|
|
130
|
+
async () => {
|
|
131
|
+
const result = await op.execute(rawInput, operationContext);
|
|
132
|
+
if (op.mutates) {
|
|
133
|
+
emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
},
|
|
137
|
+
settledNodeId,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = await op.execute(rawInput, operationContext);
|
|
142
|
+
if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
78
143
|
return result;
|
|
79
144
|
}
|