pmx-canvas 0.1.26 → 0.1.28

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.
Files changed (64) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +191 -0
  2. package/CHANGELOG.md +110 -0
  3. package/Readme.md +74 -27
  4. package/dist/canvas/index.js +82 -82
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/json-render/index.js +944 -164
  7. package/dist/types/json-render/catalog.d.ts +195 -20
  8. package/dist/types/json-render/charts/components.d.ts +17 -0
  9. package/dist/types/json-render/charts/definitions.d.ts +13 -1
  10. package/dist/types/json-render/charts/tufte-components.d.ts +65 -0
  11. package/dist/types/json-render/charts/tufte-definitions.d.ts +164 -0
  12. package/dist/types/json-render/directives.d.ts +33 -0
  13. package/dist/types/json-render/renderer/index.d.ts +1 -0
  14. package/dist/types/json-render/server.d.ts +32 -1
  15. package/dist/types/mcp/canvas-access.d.ts +62 -0
  16. package/dist/types/server/ax-state.d.ts +170 -0
  17. package/dist/types/server/canvas-db.d.ts +17 -1
  18. package/dist/types/server/canvas-operations.d.ts +53 -0
  19. package/dist/types/server/canvas-schema.d.ts +5 -1
  20. package/dist/types/server/canvas-state.d.ts +95 -4
  21. package/dist/types/server/index.d.ts +120 -3
  22. package/dist/types/server/mutation-history.d.ts +1 -1
  23. package/docs/cli.md +42 -0
  24. package/docs/http-api.md +64 -0
  25. package/docs/mcp.md +23 -5
  26. package/docs/node-types.md +1 -1
  27. package/docs/screenshots/codex-app.png +0 -0
  28. package/docs/screenshots/github-copilot-app.png +0 -0
  29. package/docs/sdk.md +23 -5
  30. package/package.json +10 -7
  31. package/skills/control-session-orchestrator/SKILL.md +359 -0
  32. package/skills/control-session-orchestrator/evals/evals.json +75 -0
  33. package/skills/data-analysis/SKILL.md +6 -0
  34. package/skills/pmx-canvas/SKILL.md +50 -4
  35. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +6 -0
  36. package/skills/tufte-viz/SKILL.md +157 -0
  37. package/skills/tufte-viz/references/analytical-design.md +217 -0
  38. package/skills/tufte-viz/references/tufte-principles.md +147 -0
  39. package/src/cli/agent.ts +302 -3
  40. package/src/cli/index.ts +2 -1
  41. package/src/client/nodes/ExtAppFrame.tsx +48 -1
  42. package/src/client/nodes/McpAppNode.tsx +6 -2
  43. package/src/json-render/catalog.ts +22 -1
  44. package/src/json-render/charts/components.tsx +127 -15
  45. package/src/json-render/charts/definitions.ts +19 -2
  46. package/src/json-render/charts/extra-components.tsx +5 -4
  47. package/src/json-render/charts/tufte-components.tsx +395 -0
  48. package/src/json-render/charts/tufte-definitions.ts +128 -0
  49. package/src/json-render/directives.ts +64 -0
  50. package/src/json-render/renderer/index.css +107 -1
  51. package/src/json-render/renderer/index.tsx +33 -0
  52. package/src/json-render/server.ts +275 -5
  53. package/src/mcp/canvas-access.ts +264 -1
  54. package/src/mcp/server.ts +498 -9
  55. package/src/server/ax-context.ts +8 -3
  56. package/src/server/ax-state.ts +447 -0
  57. package/src/server/canvas-db.ts +184 -1
  58. package/src/server/canvas-operations.ts +123 -2
  59. package/src/server/canvas-schema.ts +27 -3
  60. package/src/server/canvas-state.ts +349 -2
  61. package/src/server/index.ts +259 -7
  62. package/src/server/mutation-history.ts +6 -0
  63. package/src/server/server.ts +442 -5
  64. package/src/server/web-artifacts.ts +31 -5
@@ -10,9 +10,137 @@ export interface PmxAxFocusState {
10
10
  source: PmxAxSource | null;
11
11
  }
12
12
 
13
+ // ── New enums ──────────────────────────────────────────────────────
14
+ export type PmxAxEventKind =
15
+ | 'prompt' | 'assistant-message' | 'tool-start' | 'tool-result'
16
+ | 'failure' | 'approval' | 'steering';
17
+ export type PmxAxEvidenceKind =
18
+ | 'logs' | 'tool-result' | 'screenshot' | 'file' | 'diff' | 'test-output';
19
+ export type PmxAxWorkItemStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
20
+ export type PmxAxApprovalStatus = 'pending' | 'approved' | 'rejected';
21
+ export type PmxAxReviewKind = 'comment' | 'finding';
22
+ export type PmxAxReviewSeverity = 'info' | 'warning' | 'error';
23
+ export type PmxAxReviewStatus = 'open' | 'resolved' | 'dismissed';
24
+ export type PmxAxReviewAnchorType = 'node' | 'file' | 'region';
25
+
26
+ // ── Canvas-bound records (live inside PmxAxState; snapshotted) ──────
27
+ export interface PmxAxWorkItem {
28
+ id: string;
29
+ title: string;
30
+ status: PmxAxWorkItemStatus;
31
+ detail: string | null;
32
+ nodeIds: string[];
33
+ createdAt: string;
34
+ updatedAt: string;
35
+ source: PmxAxSource | null;
36
+ }
37
+
38
+ export interface PmxAxApprovalGate {
39
+ id: string;
40
+ title: string;
41
+ detail: string | null;
42
+ action: string | null;
43
+ status: PmxAxApprovalStatus;
44
+ nodeIds: string[];
45
+ createdAt: string;
46
+ resolvedAt: string | null;
47
+ resolution: string | null;
48
+ source: PmxAxSource | null;
49
+ }
50
+
51
+ export interface PmxAxReviewRegion {
52
+ line?: number;
53
+ endLine?: number;
54
+ label?: string;
55
+ }
56
+
57
+ export interface PmxAxReviewAnnotation {
58
+ id: string;
59
+ kind: PmxAxReviewKind;
60
+ body: string;
61
+ severity: PmxAxReviewSeverity;
62
+ status: PmxAxReviewStatus;
63
+ anchorType: PmxAxReviewAnchorType;
64
+ nodeId: string | null;
65
+ file: string | null;
66
+ region: PmxAxReviewRegion | null;
67
+ author: string | null;
68
+ createdAt: string;
69
+ updatedAt: string;
70
+ source: PmxAxSource | null;
71
+ }
72
+
73
+ // ── Timeline records (separate DB tables; NOT in PmxAxState) ────────
74
+ export interface PmxAxEvent {
75
+ id: string;
76
+ seq: number;
77
+ kind: PmxAxEventKind;
78
+ summary: string;
79
+ detail: string | null;
80
+ nodeIds: string[];
81
+ data: Record<string, unknown> | null;
82
+ createdAt: string;
83
+ source: PmxAxSource | null;
84
+ }
85
+
86
+ export interface PmxAxEvidence {
87
+ id: string;
88
+ seq: number;
89
+ kind: PmxAxEvidenceKind;
90
+ title: string;
91
+ body: string | null;
92
+ ref: string | null;
93
+ nodeIds: string[];
94
+ data: Record<string, unknown> | null;
95
+ createdAt: string;
96
+ source: PmxAxSource | null;
97
+ }
98
+
99
+ export interface PmxAxSteeringMessage {
100
+ id: string;
101
+ seq: number;
102
+ message: string;
103
+ delivered: boolean;
104
+ createdAt: string;
105
+ source: PmxAxSource | null;
106
+ }
107
+
108
+ // ── Host/session capability (own table; reported by adapters) ──────
109
+ export interface PmxAxHostCapability {
110
+ host: string | null;
111
+ canvas: boolean;
112
+ hooks: boolean;
113
+ tools: boolean;
114
+ sessionMessaging: boolean;
115
+ permissions: boolean;
116
+ files: boolean;
117
+ uiPrompts: boolean;
118
+ reportedAt: string | null;
119
+ raw: Record<string, unknown> | null;
120
+ }
121
+
122
+ // ── Bounded timeline summary for context export ────────────────────
123
+ export interface PmxAxTimelineSummary {
124
+ recentEvents: PmxAxEvent[];
125
+ recentEvidence: PmxAxEvidence[];
126
+ pendingSteering: PmxAxSteeringMessage[];
127
+ counts: { events: number; evidence: number; steering: number };
128
+ }
129
+
130
+ // ── Constants ──────────────────────────────────────────────────────
131
+ export const AX_TIMELINE_RETENTION = 500;
132
+ export const AX_TIMELINE_DEFAULT_LIMIT = 50;
133
+ export const AX_TIMELINE_MAX_LIMIT = 200;
134
+ export const AX_CONTEXT_EVENT_LIMIT = 20;
135
+ export const AX_CONTEXT_EVIDENCE_LIMIT = 10;
136
+ export const AX_CONTEXT_STEERING_LIMIT = 10;
137
+
13
138
  export interface PmxAxState {
14
139
  version: 1;
15
140
  focus: PmxAxFocusState;
141
+ workItems: PmxAxWorkItem[];
142
+ approvalGates: PmxAxApprovalGate[];
143
+ reviewAnnotations: PmxAxReviewAnnotation[];
16
144
  }
17
145
 
18
146
  export interface PmxAxPinnedContext {
@@ -35,6 +163,11 @@ export interface PmxAxContext {
35
163
  };
36
164
  pinned: PmxAxPinnedContext;
37
165
  focus: PmxAxFocusContext;
166
+ workItems: PmxAxWorkItem[];
167
+ approvalGates: PmxAxApprovalGate[];
168
+ reviewAnnotations: PmxAxReviewAnnotation[];
169
+ timeline: PmxAxTimelineSummary;
170
+ host: PmxAxHostCapability | null;
38
171
  }
39
172
 
40
173
  const AX_SOURCES = new Set<PmxAxSource>(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']);
@@ -66,6 +199,39 @@ function normalizeNodeIds(value: unknown, validNodeIds?: Set<string>): string[]
66
199
  return ids;
67
200
  }
68
201
 
202
+ const AX_EVENT_KINDS = new Set<PmxAxEventKind>(['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering']);
203
+ const AX_EVIDENCE_KINDS = new Set<PmxAxEvidenceKind>(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output']);
204
+ const AX_WORK_STATUSES = new Set<PmxAxWorkItemStatus>(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
205
+ const AX_APPROVAL_STATUSES = new Set<PmxAxApprovalStatus>(['pending', 'approved', 'rejected']);
206
+ const AX_REVIEW_KINDS = new Set<PmxAxReviewKind>(['comment', 'finding']);
207
+ const AX_REVIEW_SEVERITIES = new Set<PmxAxReviewSeverity>(['info', 'warning', 'error']);
208
+ const AX_REVIEW_STATUSES = new Set<PmxAxReviewStatus>(['open', 'resolved', 'dismissed']);
209
+ const AX_REVIEW_ANCHORS = new Set<PmxAxReviewAnchorType>(['node', 'file', 'region']);
210
+
211
+ export function isAxEventKind(value: unknown): value is PmxAxEventKind {
212
+ return typeof value === 'string' && AX_EVENT_KINDS.has(value as PmxAxEventKind);
213
+ }
214
+
215
+ export function isAxEvidenceKind(value: unknown): value is PmxAxEvidenceKind {
216
+ return typeof value === 'string' && AX_EVIDENCE_KINDS.has(value as PmxAxEvidenceKind);
217
+ }
218
+
219
+ function nowIso(): string {
220
+ return new Date().toISOString();
221
+ }
222
+
223
+ function optionalString(value: unknown): string | null {
224
+ return typeof value === 'string' && value.length > 0 ? value : null;
225
+ }
226
+
227
+ function boundedRecord(value: unknown): Record<string, unknown> | null {
228
+ return isRecord(value) ? value : null;
229
+ }
230
+
231
+ function axId(prefix: string): string {
232
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
233
+ }
234
+
69
235
  export function createEmptyAxFocusState(): PmxAxFocusState {
70
236
  return {
71
237
  nodeIds: [],
@@ -79,6 +245,24 @@ export function createEmptyAxState(): PmxAxState {
79
245
  return {
80
246
  version: 1,
81
247
  focus: createEmptyAxFocusState(),
248
+ workItems: [],
249
+ approvalGates: [],
250
+ reviewAnnotations: [],
251
+ };
252
+ }
253
+
254
+ export function createEmptyAxHostCapability(): PmxAxHostCapability {
255
+ return {
256
+ host: null,
257
+ canvas: false,
258
+ hooks: false,
259
+ tools: false,
260
+ sessionMessaging: false,
261
+ permissions: false,
262
+ files: false,
263
+ uiPrompts: false,
264
+ reportedAt: null,
265
+ raw: null,
82
266
  };
83
267
  }
84
268
 
@@ -96,11 +280,264 @@ export function normalizeAxFocusState(input: unknown, validNodeIds?: Set<string>
96
280
  };
97
281
  }
98
282
 
283
+ // ── Canvas-bound normalizers (drop on missing id) ──────────────────
284
+ export function normalizeAxWorkItem(input: unknown, validNodeIds?: Set<string>): PmxAxWorkItem | null {
285
+ if (!isRecord(input) || typeof input.id !== 'string') return null;
286
+ const createdAt = normalizeTimestamp(input.createdAt) ?? nowIso();
287
+ return {
288
+ id: input.id,
289
+ title: typeof input.title === 'string' ? input.title : '(untitled)',
290
+ status: AX_WORK_STATUSES.has(input.status as PmxAxWorkItemStatus) ? input.status as PmxAxWorkItemStatus : 'todo',
291
+ detail: optionalString(input.detail),
292
+ nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
293
+ createdAt,
294
+ updatedAt: normalizeTimestamp(input.updatedAt) ?? createdAt,
295
+ source: normalizeSource(input.source),
296
+ };
297
+ }
298
+
299
+ export function normalizeAxApprovalGate(input: unknown, validNodeIds?: Set<string>): PmxAxApprovalGate | null {
300
+ if (!isRecord(input) || typeof input.id !== 'string') return null;
301
+ return {
302
+ id: input.id,
303
+ title: typeof input.title === 'string' ? input.title : '(approval)',
304
+ detail: optionalString(input.detail),
305
+ action: optionalString(input.action),
306
+ status: AX_APPROVAL_STATUSES.has(input.status as PmxAxApprovalStatus) ? input.status as PmxAxApprovalStatus : 'pending',
307
+ nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
308
+ createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
309
+ resolvedAt: normalizeTimestamp(input.resolvedAt),
310
+ resolution: optionalString(input.resolution),
311
+ source: normalizeSource(input.source),
312
+ };
313
+ }
314
+
315
+ export function normalizeAxReviewAnnotation(input: unknown, validNodeIds?: Set<string>): PmxAxReviewAnnotation | null {
316
+ if (!isRecord(input) || typeof input.id !== 'string') return null;
317
+ const createdAt = normalizeTimestamp(input.createdAt) ?? nowIso();
318
+ const anchorType = AX_REVIEW_ANCHORS.has(input.anchorType as PmxAxReviewAnchorType) ? input.anchorType as PmxAxReviewAnchorType : 'node';
319
+ // node anchor pruned to a valid node; if invalid, drop the whole annotation (mirrors focus pruning)
320
+ let nodeId: string | null = null;
321
+ if (anchorType === 'node') {
322
+ if (typeof input.nodeId !== 'string') return null;
323
+ if (validNodeIds && !validNodeIds.has(input.nodeId)) return null;
324
+ nodeId = input.nodeId;
325
+ }
326
+ const region = isRecord(input.region) ? {
327
+ ...(typeof input.region.line === 'number' ? { line: input.region.line } : {}),
328
+ ...(typeof input.region.endLine === 'number' ? { endLine: input.region.endLine } : {}),
329
+ ...(typeof input.region.label === 'string' ? { label: input.region.label } : {}),
330
+ } : null;
331
+ return {
332
+ id: input.id,
333
+ kind: AX_REVIEW_KINDS.has(input.kind as PmxAxReviewKind) ? input.kind as PmxAxReviewKind : 'comment',
334
+ body: typeof input.body === 'string' ? input.body : '',
335
+ severity: AX_REVIEW_SEVERITIES.has(input.severity as PmxAxReviewSeverity) ? input.severity as PmxAxReviewSeverity : 'info',
336
+ status: AX_REVIEW_STATUSES.has(input.status as PmxAxReviewStatus) ? input.status as PmxAxReviewStatus : 'open',
337
+ anchorType,
338
+ nodeId,
339
+ file: anchorType === 'file' ? optionalString(input.file) : null,
340
+ region: anchorType === 'region' ? region : null,
341
+ author: optionalString(input.author),
342
+ createdAt,
343
+ updatedAt: normalizeTimestamp(input.updatedAt) ?? createdAt,
344
+ source: normalizeSource(input.source),
345
+ };
346
+ }
347
+
348
+ export function normalizeAxHostCapability(input: unknown): PmxAxHostCapability | null {
349
+ if (!isRecord(input)) return null;
350
+ const caps = isRecord(input.capabilities) ? input.capabilities : input;
351
+ const flag = (v: unknown): boolean => v === true;
352
+ return {
353
+ host: optionalString(input.host),
354
+ canvas: flag(caps.canvas),
355
+ hooks: flag(caps.hooks),
356
+ tools: flag(caps.tools),
357
+ sessionMessaging: flag(caps.sessionMessaging),
358
+ permissions: flag(caps.permissions),
359
+ files: flag(caps.files),
360
+ uiPrompts: flag(caps.uiPrompts),
361
+ reportedAt: normalizeTimestamp(input.reportedAt),
362
+ raw: boundedRecord(input.raw),
363
+ };
364
+ }
365
+
366
+ // ── Timeline normalizers (DB row parse; node ids kept as recorded) ──
367
+ export function normalizeAxEvent(input: unknown): PmxAxEvent | null {
368
+ if (!isRecord(input) || typeof input.id !== 'string') return null;
369
+ const kind = AX_EVENT_KINDS.has(input.kind as PmxAxEventKind) ? input.kind as PmxAxEventKind : null;
370
+ if (!kind) return null;
371
+ return {
372
+ id: input.id,
373
+ seq: typeof input.seq === 'number' ? input.seq : 0,
374
+ kind,
375
+ summary: typeof input.summary === 'string' ? input.summary : '',
376
+ detail: optionalString(input.detail),
377
+ nodeIds: normalizeNodeIds(input.nodeIds),
378
+ data: boundedRecord(input.data),
379
+ createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
380
+ source: normalizeSource(input.source),
381
+ };
382
+ }
383
+
384
+ export function normalizeAxEvidence(input: unknown): PmxAxEvidence | null {
385
+ if (!isRecord(input) || typeof input.id !== 'string') return null;
386
+ const kind = AX_EVIDENCE_KINDS.has(input.kind as PmxAxEvidenceKind) ? input.kind as PmxAxEvidenceKind : null;
387
+ if (!kind) return null;
388
+ return {
389
+ id: input.id,
390
+ seq: typeof input.seq === 'number' ? input.seq : 0,
391
+ kind,
392
+ title: typeof input.title === 'string' ? input.title : '',
393
+ body: typeof input.body === 'string' ? input.body : null,
394
+ ref: optionalString(input.ref),
395
+ nodeIds: normalizeNodeIds(input.nodeIds),
396
+ data: boundedRecord(input.data),
397
+ createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
398
+ source: normalizeSource(input.source),
399
+ };
400
+ }
401
+
402
+ export function normalizeAxSteeringMessage(input: unknown): PmxAxSteeringMessage | null {
403
+ if (!isRecord(input) || typeof input.id !== 'string' || typeof input.message !== 'string') return null;
404
+ return {
405
+ id: input.id,
406
+ seq: typeof input.seq === 'number' ? input.seq : 0,
407
+ message: input.message,
408
+ delivered: input.delivered === true,
409
+ createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
410
+ source: normalizeSource(input.source),
411
+ };
412
+ }
413
+
414
+ // ── Factories ──────────────────────────────────────────────────────
415
+ export function createAxWorkItem(
416
+ input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
417
+ source: PmxAxSource | null,
418
+ validNodeIds?: Set<string>,
419
+ ): PmxAxWorkItem {
420
+ const now = nowIso();
421
+ return {
422
+ id: axId('work'),
423
+ title: input.title,
424
+ status: input.status ?? 'todo',
425
+ detail: input.detail ?? null,
426
+ nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
427
+ createdAt: now,
428
+ updatedAt: now,
429
+ source,
430
+ };
431
+ }
432
+
433
+ export function createAxApprovalGate(
434
+ input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] },
435
+ source: PmxAxSource | null,
436
+ validNodeIds?: Set<string>,
437
+ ): PmxAxApprovalGate {
438
+ return {
439
+ id: axId('appr'),
440
+ title: input.title,
441
+ detail: input.detail ?? null,
442
+ action: input.action ?? null,
443
+ status: 'pending',
444
+ nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
445
+ createdAt: nowIso(),
446
+ resolvedAt: null,
447
+ resolution: null,
448
+ source,
449
+ };
450
+ }
451
+
452
+ export function createAxReviewAnnotation(
453
+ input: {
454
+ body: string;
455
+ kind?: PmxAxReviewKind;
456
+ severity?: PmxAxReviewSeverity;
457
+ anchorType?: PmxAxReviewAnchorType;
458
+ nodeId?: string | null;
459
+ file?: string | null;
460
+ region?: PmxAxReviewRegion | null;
461
+ author?: string | null;
462
+ },
463
+ source: PmxAxSource | null,
464
+ ): PmxAxReviewAnnotation {
465
+ const now = nowIso();
466
+ const anchorType = input.anchorType ?? 'node';
467
+ return {
468
+ id: axId('rev'),
469
+ kind: input.kind ?? 'comment',
470
+ body: input.body,
471
+ severity: input.severity ?? 'info',
472
+ status: 'open',
473
+ anchorType,
474
+ nodeId: anchorType === 'node' ? (input.nodeId ?? null) : null,
475
+ file: anchorType === 'file' ? (input.file ?? null) : null,
476
+ region: anchorType === 'region' ? (input.region ?? null) : null,
477
+ author: input.author ?? null,
478
+ createdAt: now,
479
+ updatedAt: now,
480
+ source,
481
+ };
482
+ }
483
+
484
+ export function createAxEvent(
485
+ input: { kind: PmxAxEventKind; summary: string; detail?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
486
+ source: PmxAxSource | null,
487
+ ): Omit<PmxAxEvent, 'seq'> {
488
+ return {
489
+ id: axId('evt'),
490
+ kind: input.kind,
491
+ summary: input.summary,
492
+ detail: input.detail ?? null,
493
+ nodeIds: normalizeNodeIds(input.nodeIds),
494
+ data: input.data ?? null,
495
+ createdAt: nowIso(),
496
+ source,
497
+ };
498
+ }
499
+
500
+ export function createAxEvidence(
501
+ input: { kind: PmxAxEvidenceKind; title: string; body?: string | null; ref?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
502
+ source: PmxAxSource | null,
503
+ ): Omit<PmxAxEvidence, 'seq'> {
504
+ return {
505
+ id: axId('evd'),
506
+ kind: input.kind,
507
+ title: input.title,
508
+ body: input.body ?? null,
509
+ ref: input.ref ?? null,
510
+ nodeIds: normalizeNodeIds(input.nodeIds),
511
+ data: input.data ?? null,
512
+ createdAt: nowIso(),
513
+ source,
514
+ };
515
+ }
516
+
517
+ export function createAxSteeringMessage(message: string, source: PmxAxSource | null): Omit<PmxAxSteeringMessage, 'seq'> {
518
+ return {
519
+ id: axId('steer'),
520
+ message,
521
+ delivered: false,
522
+ createdAt: nowIso(),
523
+ source,
524
+ };
525
+ }
526
+
99
527
  export function normalizeAxState(input: unknown, validNodeIds?: Set<string>): PmxAxState {
100
528
  if (!isRecord(input)) return createEmptyAxState();
101
529
  return {
102
530
  version: 1,
103
531
  focus: normalizeAxFocusState(input.focus, validNodeIds),
532
+ workItems: Array.isArray(input.workItems)
533
+ ? input.workItems.map((w) => normalizeAxWorkItem(w, validNodeIds)).filter((w): w is PmxAxWorkItem => w !== null)
534
+ : [],
535
+ approvalGates: Array.isArray(input.approvalGates)
536
+ ? input.approvalGates.map((g) => normalizeAxApprovalGate(g, validNodeIds)).filter((g): g is PmxAxApprovalGate => g !== null)
537
+ : [],
538
+ reviewAnnotations: Array.isArray(input.reviewAnnotations)
539
+ ? input.reviewAnnotations.map((r) => normalizeAxReviewAnnotation(r, validNodeIds)).filter((r): r is PmxAxReviewAnnotation => r !== null)
540
+ : [],
104
541
  };
105
542
  }
106
543
 
@@ -109,6 +546,11 @@ export function buildAxContext(input: {
109
546
  pinned: PmxAxPinnedContext;
110
547
  focus: PmxAxFocusState;
111
548
  focusNodes: AgentContextNode[];
549
+ workItems: PmxAxWorkItem[];
550
+ approvalGates: PmxAxApprovalGate[];
551
+ reviewAnnotations: PmxAxReviewAnnotation[];
552
+ timeline: PmxAxTimelineSummary;
553
+ host: PmxAxHostCapability | null;
112
554
  }): PmxAxContext {
113
555
  return {
114
556
  version: 1,
@@ -122,6 +564,11 @@ export function buildAxContext(input: {
122
564
  ...input.focus,
123
565
  nodes: input.focusNodes,
124
566
  },
567
+ workItems: input.workItems,
568
+ approvalGates: input.approvalGates,
569
+ reviewAnnotations: input.reviewAnnotations,
570
+ timeline: input.timeline,
571
+ host: input.host,
125
572
  };
126
573
  }
127
574
 
@@ -17,7 +17,26 @@ import type {
17
17
  CanvasSnapshotListOptions,
18
18
  ViewportState,
19
19
  } from './canvas-state.js';
20
- import { createEmptyAxState, normalizeAxState, type PmxAxState } from './ax-state.js';
20
+ import {
21
+ createEmptyAxState,
22
+ normalizeAxState,
23
+ normalizeAxEvent,
24
+ normalizeAxEvidence,
25
+ normalizeAxSteeringMessage,
26
+ normalizeAxHostCapability,
27
+ AX_TIMELINE_RETENTION,
28
+ AX_TIMELINE_DEFAULT_LIMIT,
29
+ AX_TIMELINE_MAX_LIMIT,
30
+ AX_CONTEXT_EVENT_LIMIT,
31
+ AX_CONTEXT_EVIDENCE_LIMIT,
32
+ AX_CONTEXT_STEERING_LIMIT,
33
+ type PmxAxState,
34
+ type PmxAxEvent,
35
+ type PmxAxEvidence,
36
+ type PmxAxSteeringMessage,
37
+ type PmxAxHostCapability,
38
+ type PmxAxTimelineSummary,
39
+ } from './ax-state.js';
21
40
 
22
41
  // ── Schema ──────────────────────────────────────────────────────
23
42
 
@@ -148,6 +167,49 @@ const SCHEMA_SQL = `
148
167
  data BLOB NOT NULL,
149
168
  json_bytes INTEGER NOT NULL
150
169
  );
170
+
171
+ CREATE TABLE IF NOT EXISTS ax_events (
172
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
173
+ id TEXT NOT NULL UNIQUE,
174
+ kind TEXT NOT NULL,
175
+ summary TEXT NOT NULL,
176
+ detail TEXT,
177
+ node_ids TEXT NOT NULL DEFAULT '[]',
178
+ data TEXT,
179
+ created_at TEXT NOT NULL,
180
+ source TEXT
181
+ );
182
+ CREATE INDEX IF NOT EXISTS idx_ax_events_seq ON ax_events (seq);
183
+
184
+ CREATE TABLE IF NOT EXISTS ax_evidence (
185
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
186
+ id TEXT NOT NULL UNIQUE,
187
+ kind TEXT NOT NULL,
188
+ title TEXT NOT NULL,
189
+ body TEXT,
190
+ ref TEXT,
191
+ node_ids TEXT NOT NULL DEFAULT '[]',
192
+ data TEXT,
193
+ created_at TEXT NOT NULL,
194
+ source TEXT
195
+ );
196
+ CREATE INDEX IF NOT EXISTS idx_ax_evidence_seq ON ax_evidence (seq);
197
+
198
+ CREATE TABLE IF NOT EXISTS ax_steering (
199
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
200
+ id TEXT NOT NULL UNIQUE,
201
+ message TEXT NOT NULL,
202
+ delivered INTEGER NOT NULL DEFAULT 0,
203
+ created_at TEXT NOT NULL,
204
+ source TEXT
205
+ );
206
+ CREATE INDEX IF NOT EXISTS idx_ax_steering_seq ON ax_steering (seq);
207
+
208
+ CREATE TABLE IF NOT EXISTS ax_host_capabilities (
209
+ host TEXT PRIMARY KEY,
210
+ reported_at TEXT NOT NULL,
211
+ payload TEXT NOT NULL
212
+ );
151
213
  `;
152
214
 
153
215
  function normalizePositiveInteger(value: number | undefined): number | undefined {
@@ -743,3 +805,124 @@ export function hasBlobInDB(db: Database, sha256: string): boolean {
743
805
  ).get(sha256);
744
806
  return (row?.c ?? 0) > 0;
745
807
  }
808
+
809
+ // ── AX Timeline Persistence (NOT snapshotted; bounded by retention) ──
810
+
811
+ function safeParseJson(raw: string | null | undefined): unknown {
812
+ if (!raw) return null;
813
+ try {
814
+ return JSON.parse(raw);
815
+ } catch {
816
+ return null;
817
+ }
818
+ }
819
+
820
+ export interface AxTimelineQuery {
821
+ limit?: number;
822
+ sessionId?: string;
823
+ }
824
+
825
+ function clampTimelineLimit(limit: number | undefined): number {
826
+ if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) return AX_TIMELINE_DEFAULT_LIMIT;
827
+ return Math.min(Math.floor(limit), AX_TIMELINE_MAX_LIMIT);
828
+ }
829
+
830
+ function trimAxTable(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): void {
831
+ db.run(
832
+ `DELETE FROM ${table} WHERE seq <= (SELECT seq FROM ${table} ORDER BY seq DESC LIMIT 1 OFFSET ?)`,
833
+ [AX_TIMELINE_RETENTION],
834
+ );
835
+ }
836
+
837
+ function readLastSeq(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): number {
838
+ const row = db.query<{ seq: number }, []>(`SELECT seq FROM ${table} ORDER BY seq DESC LIMIT 1`).get();
839
+ return row ? Number(row.seq) : 0;
840
+ }
841
+
842
+ export function appendAxEventToDB(db: Database, ev: Omit<PmxAxEvent, 'seq'>): PmxAxEvent {
843
+ db.run(
844
+ 'INSERT INTO ax_events (id, kind, summary, detail, node_ids, data, created_at, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
845
+ [ev.id, ev.kind, ev.summary, ev.detail, JSON.stringify(ev.nodeIds), ev.data ? JSON.stringify(ev.data) : null, ev.createdAt, ev.source],
846
+ );
847
+ const seq = readLastSeq(db, 'ax_events');
848
+ trimAxTable(db, 'ax_events');
849
+ return { ...ev, seq };
850
+ }
851
+
852
+ export function appendAxEvidenceToDB(db: Database, ev: Omit<PmxAxEvidence, 'seq'>): PmxAxEvidence {
853
+ db.run(
854
+ 'INSERT INTO ax_evidence (id, kind, title, body, ref, node_ids, data, created_at, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
855
+ [ev.id, ev.kind, ev.title, ev.body, ev.ref, JSON.stringify(ev.nodeIds), ev.data ? JSON.stringify(ev.data) : null, ev.createdAt, ev.source],
856
+ );
857
+ const seq = readLastSeq(db, 'ax_evidence');
858
+ trimAxTable(db, 'ax_evidence');
859
+ return { ...ev, seq };
860
+ }
861
+
862
+ export function appendAxSteeringToDB(db: Database, s: Omit<PmxAxSteeringMessage, 'seq'>): PmxAxSteeringMessage {
863
+ db.run(
864
+ 'INSERT INTO ax_steering (id, message, delivered, created_at, source) VALUES (?, ?, ?, ?, ?)',
865
+ [s.id, s.message, s.delivered ? 1 : 0, s.createdAt, s.source],
866
+ );
867
+ const seq = readLastSeq(db, 'ax_steering');
868
+ trimAxTable(db, 'ax_steering');
869
+ return { ...s, seq };
870
+ }
871
+
872
+ export function markAxSteeringDeliveredInDB(db: Database, id: string): boolean {
873
+ const r = db.run('UPDATE ax_steering SET delivered = 1 WHERE id = ?', [id]);
874
+ return r.changes > 0;
875
+ }
876
+
877
+ export function loadAxEventsFromDB(db: Database, q: AxTimelineQuery = {}): PmxAxEvent[] {
878
+ interface Row { seq: number; id: string; kind: string; summary: string; detail: string | null; node_ids: string; data: string | null; created_at: string; source: string | null }
879
+ const rows = db.query<Row, [number]>('SELECT * FROM ax_events ORDER BY seq DESC LIMIT ?').all(clampTimelineLimit(q.limit));
880
+ return rows
881
+ .map((r) => normalizeAxEvent({ ...r, createdAt: r.created_at, nodeIds: safeParseJson(r.node_ids), data: safeParseJson(r.data) }))
882
+ .filter((e): e is PmxAxEvent => e !== null);
883
+ }
884
+
885
+ export function loadAxEvidenceFromDB(db: Database, q: AxTimelineQuery = {}): PmxAxEvidence[] {
886
+ interface Row { seq: number; id: string; kind: string; title: string; body: string | null; ref: string | null; node_ids: string; data: string | null; created_at: string; source: string | null }
887
+ const rows = db.query<Row, [number]>('SELECT * FROM ax_evidence ORDER BY seq DESC LIMIT ?').all(clampTimelineLimit(q.limit));
888
+ return rows
889
+ .map((r) => normalizeAxEvidence({ ...r, createdAt: r.created_at, nodeIds: safeParseJson(r.node_ids), data: safeParseJson(r.data) }))
890
+ .filter((e): e is PmxAxEvidence => e !== null);
891
+ }
892
+
893
+ export function loadAxSteeringFromDB(db: Database, q: AxTimelineQuery & { onlyPending?: boolean } = {}): PmxAxSteeringMessage[] {
894
+ interface Row { seq: number; id: string; message: string; delivered: number; created_at: string; source: string | null }
895
+ const sql = q.onlyPending
896
+ ? 'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?'
897
+ : 'SELECT * FROM ax_steering ORDER BY seq DESC LIMIT ?';
898
+ const rows = db.query<Row, [number]>(sql).all(clampTimelineLimit(q.limit));
899
+ return rows
900
+ .map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
901
+ .filter((s): s is PmxAxSteeringMessage => s !== null);
902
+ }
903
+
904
+ function countRows(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): number {
905
+ return Number(db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0);
906
+ }
907
+
908
+ export function loadAxTimelineSummaryFromDB(db: Database): PmxAxTimelineSummary {
909
+ return {
910
+ recentEvents: loadAxEventsFromDB(db, { limit: AX_CONTEXT_EVENT_LIMIT }),
911
+ recentEvidence: loadAxEvidenceFromDB(db, { limit: AX_CONTEXT_EVIDENCE_LIMIT }),
912
+ pendingSteering: loadAxSteeringFromDB(db, { onlyPending: true, limit: AX_CONTEXT_STEERING_LIMIT }),
913
+ counts: { events: countRows(db, 'ax_events'), evidence: countRows(db, 'ax_evidence'), steering: countRows(db, 'ax_steering') },
914
+ };
915
+ }
916
+
917
+ export function upsertAxHostCapabilityToDB(db: Database, cap: PmxAxHostCapability): void {
918
+ const host = cap.host ?? 'default';
919
+ db.run(
920
+ 'INSERT OR REPLACE INTO ax_host_capabilities (host, reported_at, payload) VALUES (?, ?, ?)',
921
+ [host, cap.reportedAt ?? new Date().toISOString(), JSON.stringify(cap)],
922
+ );
923
+ }
924
+
925
+ export function loadAxHostCapabilityFromDB(db: Database): PmxAxHostCapability | null {
926
+ const row = db.query<{ payload: string }, []>('SELECT payload FROM ax_host_capabilities ORDER BY reported_at DESC LIMIT 1').get();
927
+ return row ? normalizeAxHostCapability(safeParseJson(row.payload)) : null;
928
+ }