pmx-canvas 0.6.5 → 0.7.1

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 (181) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +139 -14
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +32 -6
  4. package/dist/canvas/global.css +510 -31
  5. package/dist/canvas/index.js +92 -77
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/cli/daemon.d.ts +5 -5
  11. package/dist/types/cli/shared.d.ts +3 -1
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  19. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  20. package/dist/types/client/state/boards-store.d.ts +79 -0
  21. package/dist/types/client/state/canvas-store.d.ts +2 -0
  22. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  23. package/dist/types/client/state/session-store.d.ts +7 -0
  24. package/dist/types/client/types.d.ts +2 -2
  25. package/dist/types/json-render/catalog.d.ts +1 -1
  26. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  27. package/dist/types/json-render/server.d.ts +6 -0
  28. package/dist/types/mcp/canvas-access.d.ts +10 -3
  29. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  30. package/dist/types/server/agent-presence.d.ts +1 -1
  31. package/dist/types/server/attribution.d.ts +11 -0
  32. package/dist/types/server/ax-context.d.ts +3 -1
  33. package/dist/types/server/ax-interaction.d.ts +2 -2
  34. package/dist/types/server/ax-state.d.ts +13 -3
  35. package/dist/types/server/backup.d.ts +36 -0
  36. package/dist/types/server/board-export.d.ts +45 -0
  37. package/dist/types/server/canvas-db.d.ts +63 -9
  38. package/dist/types/server/canvas-operations.d.ts +23 -0
  39. package/dist/types/server/canvas-provenance.d.ts +1 -1
  40. package/dist/types/server/canvas-state.d.ts +140 -3
  41. package/dist/types/server/context-brief.d.ts +111 -0
  42. package/dist/types/server/context-reads.d.ts +55 -0
  43. package/dist/types/server/document-import.d.ts +66 -0
  44. package/dist/types/server/index.d.ts +103 -4
  45. package/dist/types/server/mutation-history.d.ts +1 -0
  46. package/dist/types/server/operations/invoker.d.ts +2 -0
  47. package/dist/types/server/operations/mcp.d.ts +7 -1
  48. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  49. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  50. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  51. package/dist/types/server/operations/ops/export.d.ts +2 -0
  52. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  53. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  54. package/dist/types/server/operations/ops/query.d.ts +2 -0
  55. package/dist/types/server/operations/registry.d.ts +9 -1
  56. package/dist/types/server/operations/types.d.ts +1 -1
  57. package/dist/types/server/server.d.ts +1 -0
  58. package/dist/types/server/trace-manager.d.ts +7 -0
  59. package/dist/types/server/webpage-node.d.ts +22 -1
  60. package/dist/types/server/workbench-auth.d.ts +3 -0
  61. package/dist/types/shared/boards.d.ts +6 -0
  62. package/docs/RELEASE.md +5 -0
  63. package/docs/cli.md +73 -0
  64. package/docs/http-api.md +198 -2
  65. package/docs/mcp.md +71 -6
  66. package/docs/node-types.md +26 -0
  67. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  68. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  69. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  70. package/docs/plans/plan-013-static-board-export.md +47 -0
  71. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  72. package/docs/product-vision-2026-09.md +118 -18
  73. package/docs/sdk.md +50 -0
  74. package/package.json +1 -1
  75. package/skills/pmx-canvas/SKILL.md +68 -11
  76. package/skills/pmx-canvas/evals/evals.json +130 -0
  77. package/skills/pmx-canvas/references/full-reference.md +43 -7
  78. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  79. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  80. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  81. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  82. package/skills/pmx-canvas-testing/SKILL.md +11 -2
  83. package/src/cli/agent.ts +4 -0
  84. package/src/cli/commands/apps.ts +1 -1
  85. package/src/cli/commands/ax.ts +13 -0
  86. package/src/cli/commands/backup.ts +66 -0
  87. package/src/cli/commands/boards.ts +71 -0
  88. package/src/cli/commands/export.ts +37 -0
  89. package/src/cli/commands/nodes.ts +4 -1
  90. package/src/cli/commands/pins.ts +6 -2
  91. package/src/cli/commands/pump.ts +3 -0
  92. package/src/cli/commands/query.ts +33 -7
  93. package/src/cli/commands/view.ts +7 -5
  94. package/src/cli/daemon.ts +37 -7
  95. package/src/cli/index.ts +19 -9
  96. package/src/cli/shared.ts +11 -2
  97. package/src/client/App.tsx +21 -4
  98. package/src/client/canvas/AttentionToast.tsx +5 -3
  99. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  100. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  101. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  102. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  103. package/src/client/canvas/CanvasNode.tsx +0 -1
  104. package/src/client/canvas/CanvasViewport.tsx +3 -0
  105. package/src/client/canvas/CommandPalette.tsx +107 -4
  106. package/src/client/canvas/ContextMenu.tsx +4 -1
  107. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  108. package/src/client/canvas/ExportDialog.tsx +199 -0
  109. package/src/client/canvas/HomeView.tsx +277 -0
  110. package/src/client/canvas/Presentation.tsx +39 -9
  111. package/src/client/canvas/SelectionBar.tsx +0 -2
  112. package/src/client/canvas/SessionPanel.tsx +28 -1
  113. package/src/client/canvas/ToolRail.tsx +38 -3
  114. package/src/client/canvas/TopBar.tsx +34 -18
  115. package/src/client/canvas/import-files.ts +19 -1
  116. package/src/client/canvas/kind-colors.ts +1 -0
  117. package/src/client/icons.tsx +2 -0
  118. package/src/client/nodes/AttachmentNode.tsx +145 -0
  119. package/src/client/nodes/BoardNode.tsx +35 -0
  120. package/src/client/nodes/FileNode.tsx +6 -0
  121. package/src/client/nodes/MarkdownNode.tsx +47 -19
  122. package/src/client/nodes/ax-node-actions.ts +2 -5
  123. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  124. package/src/client/state/attention-bridge.ts +3 -1
  125. package/src/client/state/boards-store.ts +167 -0
  126. package/src/client/state/canvas-store.ts +22 -0
  127. package/src/client/state/intent-bridge.ts +30 -2
  128. package/src/client/state/node-factory.ts +1 -0
  129. package/src/client/state/session-store.ts +5 -0
  130. package/src/client/state/sse-bridge.ts +52 -6
  131. package/src/client/theme/global.css +510 -31
  132. package/src/client/types.ts +2 -0
  133. package/src/json-render/server.ts +16 -9
  134. package/src/mcp/canvas-access.ts +59 -22
  135. package/src/mcp/context-uri-template.ts +28 -0
  136. package/src/mcp/server.ts +122 -15
  137. package/src/server/agent-context.ts +116 -29
  138. package/src/server/attribution.ts +29 -0
  139. package/src/server/ax-context.ts +4 -2
  140. package/src/server/ax-interaction.ts +1 -0
  141. package/src/server/ax-state-manager.ts +72 -3
  142. package/src/server/ax-state.ts +41 -0
  143. package/src/server/backup.ts +178 -0
  144. package/src/server/board-export.ts +686 -0
  145. package/src/server/canvas-db.ts +599 -107
  146. package/src/server/canvas-operations.ts +68 -9
  147. package/src/server/canvas-provenance.ts +1 -0
  148. package/src/server/canvas-schema.ts +12 -0
  149. package/src/server/canvas-state.ts +774 -45
  150. package/src/server/context-brief.ts +372 -0
  151. package/src/server/context-reads.ts +238 -0
  152. package/src/server/demo-state.json +659 -2
  153. package/src/server/document-import.ts +223 -0
  154. package/src/server/index.ts +231 -5
  155. package/src/server/mutation-history.ts +10 -13
  156. package/src/server/operations/composites.ts +26 -4
  157. package/src/server/operations/http.ts +9 -3
  158. package/src/server/operations/index.ts +8 -0
  159. package/src/server/operations/invoker.ts +14 -3
  160. package/src/server/operations/mcp.ts +50 -11
  161. package/src/server/operations/ops/app.ts +5 -0
  162. package/src/server/operations/ops/ax-read.ts +10 -6
  163. package/src/server/operations/ops/ax-timeline.ts +75 -0
  164. package/src/server/operations/ops/backup.ts +134 -0
  165. package/src/server/operations/ops/batch.ts +8 -1
  166. package/src/server/operations/ops/boards.ts +351 -0
  167. package/src/server/operations/ops/export.ts +74 -0
  168. package/src/server/operations/ops/ext-app.ts +4 -0
  169. package/src/server/operations/ops/imports.ts +236 -0
  170. package/src/server/operations/ops/nodes.ts +57 -5
  171. package/src/server/operations/ops/query.ts +206 -9
  172. package/src/server/operations/registry.ts +123 -31
  173. package/src/server/operations/types.ts +1 -1
  174. package/src/server/server.ts +146 -2
  175. package/src/server/spatial-analysis.ts +9 -17
  176. package/src/server/trace-manager.ts +19 -0
  177. package/src/server/web-artifacts.ts +2 -1
  178. package/src/server/webpage-node.ts +173 -13
  179. package/src/server/workbench-auth.ts +11 -0
  180. package/src/shared/boards.ts +18 -0
  181. package/src/shared/node-sizes.ts +1 -0
@@ -0,0 +1,223 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
5
+ export const MAX_INLINE_ATTACHMENT_BYTES = 2 * 1024 * 1024;
6
+ export const MAX_ATTACHMENT_NAME_LENGTH = 255;
7
+ export const MAX_IMPORT_SECTIONS = 32;
8
+ export const MAX_IMPORT_WARNINGS = 20;
9
+ export const MAX_IMPORT_TEXT = 200_000;
10
+ export const MAX_IMPORT_TITLE_LENGTH = 500;
11
+ export const MAX_IMPORT_REFERENCE_LENGTH = 2_000;
12
+ export const MAX_IMPORT_WARNING_LENGTH = 2_000;
13
+ export const MAX_IMPORT_AGENT_DESCRIPTION_LENGTH = 4_000;
14
+ export const MAX_IMPORT_REASON_LENGTH = 4_000;
15
+
16
+ export interface Attachment {
17
+ id: string;
18
+ boardId: string;
19
+ sha256: string;
20
+ name: string;
21
+ mime: string;
22
+ size: number;
23
+ createdAt: string;
24
+ }
25
+
26
+ export interface ImportSection {
27
+ title: string;
28
+ markdown: string;
29
+ reference?: string;
30
+ }
31
+ export type DocumentImportStatus = 'attached' | 'requested' | 'drafted' | 'unavailable' | 'cancelled' | 'committed';
32
+ export interface DocumentImport {
33
+ id: string;
34
+ boardId: string;
35
+ attachmentId: string;
36
+ status: DocumentImportStatus;
37
+ position: { x: number; y: number };
38
+ sections: ImportSection[];
39
+ warnings: string[];
40
+ agentDescription: string | null;
41
+ reason: string | null;
42
+ committedNodeIds: string[];
43
+ createdAt: string;
44
+ updatedAt: string;
45
+ }
46
+
47
+ interface AttachmentRow {
48
+ id: string;
49
+ board_id: string;
50
+ sha256: string;
51
+ name: string;
52
+ mime: string;
53
+ size: number;
54
+ created_at: string;
55
+ }
56
+ interface ImportRow {
57
+ id: string;
58
+ board_id: string;
59
+ attachment_id: string;
60
+ status: DocumentImportStatus;
61
+ position_x: number;
62
+ position_y: number;
63
+ sections: string | null;
64
+ warnings: string | null;
65
+ agent_description: string | null;
66
+ reason: string | null;
67
+ committed_node_ids: string | null;
68
+ created_at: string;
69
+ updated_at: string;
70
+ }
71
+ const attachment = (r: AttachmentRow): Attachment => ({
72
+ id: r.id,
73
+ boardId: r.board_id,
74
+ sha256: r.sha256,
75
+ name: r.name,
76
+ mime: r.mime,
77
+ size: r.size,
78
+ createdAt: r.created_at,
79
+ });
80
+ const imported = (r: ImportRow): DocumentImport => ({
81
+ id: r.id,
82
+ boardId: r.board_id,
83
+ attachmentId: r.attachment_id,
84
+ status: r.status,
85
+ position: { x: r.position_x, y: r.position_y },
86
+ sections: r.sections ? (JSON.parse(r.sections) as ImportSection[]) : [],
87
+ warnings: r.warnings ? (JSON.parse(r.warnings) as string[]) : [],
88
+ agentDescription: r.agent_description,
89
+ reason: r.reason,
90
+ committedNodeIds: r.committed_node_ids ? (JSON.parse(r.committed_node_ids) as string[]) : [],
91
+ createdAt: r.created_at,
92
+ updatedAt: r.updated_at,
93
+ });
94
+
95
+ export function storeAttachment(
96
+ db: Database,
97
+ input: { boardId: string; name: string; mime: string; bytes: Uint8Array },
98
+ ): Attachment {
99
+ if (!input.name.trim() || input.name.length > MAX_ATTACHMENT_NAME_LENGTH)
100
+ throw new Error(`Attachment name must be 1-${MAX_ATTACHMENT_NAME_LENGTH} characters.`);
101
+ if (input.bytes.byteLength === 0 || input.bytes.byteLength > MAX_ATTACHMENT_BYTES)
102
+ throw new Error(`Attachment must contain 1-${MAX_ATTACHMENT_BYTES} bytes.`);
103
+ const sha256 = new Bun.CryptoHasher('sha256').update(input.bytes).digest('hex');
104
+ const value: Attachment = {
105
+ id: randomUUID(),
106
+ boardId: input.boardId,
107
+ sha256,
108
+ name: input.name,
109
+ mime: input.mime,
110
+ size: input.bytes.byteLength,
111
+ createdAt: new Date().toISOString(),
112
+ };
113
+ db.transaction(() => {
114
+ db.run('INSERT OR IGNORE INTO attachment_bytes (sha256,data,size) VALUES (?,?,?)', [
115
+ sha256,
116
+ input.bytes,
117
+ value.size,
118
+ ]);
119
+ db.run('INSERT INTO attachments (id,board_id,sha256,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)', [
120
+ value.id,
121
+ value.boardId,
122
+ value.sha256,
123
+ value.name,
124
+ value.mime,
125
+ value.size,
126
+ value.createdAt,
127
+ ]);
128
+ })();
129
+ return value;
130
+ }
131
+
132
+ export function getAttachment(db: Database, id: string): Attachment | null {
133
+ const row = db
134
+ .query<AttachmentRow, [string]>('SELECT id,board_id,sha256,name,mime,size,created_at FROM attachments WHERE id=?')
135
+ .get(id);
136
+ return row ? attachment(row) : null;
137
+ }
138
+ export function readAttachmentBytes(db: Database, id: string): Uint8Array | null {
139
+ return (
140
+ db
141
+ .query<{ data: Uint8Array }, [string]>(
142
+ 'SELECT b.data FROM attachment_bytes b JOIN attachments a ON a.sha256=b.sha256 WHERE a.id=?',
143
+ )
144
+ .get(id)?.data ?? null
145
+ );
146
+ }
147
+ export function copyBoardAttachments(
148
+ db: Database,
149
+ sourceBoardId: string,
150
+ targetBoardId: string,
151
+ referencedIds: Set<string>,
152
+ ): Map<string, string> {
153
+ const map = new Map<string, string>();
154
+ const rows = db
155
+ .query<AttachmentRow, string[]>(
156
+ `SELECT id,board_id,sha256,name,mime,size,created_at FROM attachments WHERE board_id=? AND id IN (${[...referencedIds].map(() => '?').join(',') || "''"})`,
157
+ )
158
+ .all(sourceBoardId, ...referencedIds);
159
+ for (const row of rows) {
160
+ const id = randomUUID();
161
+ map.set(row.id, id);
162
+ db.run('INSERT INTO attachments (id,board_id,sha256,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)', [
163
+ id,
164
+ targetBoardId,
165
+ row.sha256,
166
+ row.name,
167
+ row.mime,
168
+ row.size,
169
+ new Date().toISOString(),
170
+ ]);
171
+ }
172
+ return map;
173
+ }
174
+ export function createImport(db: Database, attachmentId: string, position: { x: number; y: number }): DocumentImport {
175
+ const a = getAttachment(db, attachmentId);
176
+ if (!a) throw new Error('Attachment not found.');
177
+ const now = new Date().toISOString(),
178
+ id = randomUUID();
179
+ db.run(
180
+ "INSERT INTO document_imports (id,board_id,attachment_id,status,position_x,position_y,created_at,updated_at) VALUES (?,?,?,'attached',?,?,?,?)",
181
+ [id, a.boardId, attachmentId, position.x, position.y, now, now],
182
+ );
183
+ return getImport(db, id)!;
184
+ }
185
+ export function getImport(db: Database, id: string): DocumentImport | null {
186
+ const r = db.query<ImportRow, [string]>('SELECT * FROM document_imports WHERE id=?').get(id);
187
+ return r ? imported(r) : null;
188
+ }
189
+ export function listImports(db: Database, boardId?: string): DocumentImport[] {
190
+ const rows = boardId
191
+ ? db
192
+ .query<ImportRow, [string]>('SELECT * FROM document_imports WHERE board_id=? ORDER BY created_at DESC')
193
+ .all(boardId)
194
+ : db.query<ImportRow, []>('SELECT * FROM document_imports ORDER BY created_at DESC').all();
195
+ return rows.map(imported);
196
+ }
197
+ export function updateImport(
198
+ db: Database,
199
+ id: string,
200
+ status: DocumentImportStatus,
201
+ fields: {
202
+ sections?: ImportSection[];
203
+ warnings?: string[];
204
+ agentDescription?: string;
205
+ reason?: string;
206
+ committedNodeIds?: string[];
207
+ } = {},
208
+ ): DocumentImport {
209
+ db.run(
210
+ 'UPDATE document_imports SET status=?,sections=COALESCE(?,sections),warnings=COALESCE(?,warnings),agent_description=COALESCE(?,agent_description),reason=COALESCE(?,reason),committed_node_ids=COALESCE(?,committed_node_ids),updated_at=? WHERE id=?',
211
+ [
212
+ status,
213
+ fields.sections ? JSON.stringify(fields.sections) : null,
214
+ fields.warnings ? JSON.stringify(fields.warnings) : null,
215
+ fields.agentDescription ?? null,
216
+ fields.reason ?? null,
217
+ fields.committedNodeIds ? JSON.stringify(fields.committedNodeIds) : null,
218
+ new Date().toISOString(),
219
+ id,
220
+ ],
221
+ );
222
+ return getImport(db, id)!;
223
+ }
@@ -37,7 +37,11 @@ import type {
37
37
  PmxAxWorkItem,
38
38
  PmxAxWorkItemStatus,
39
39
  } from './ax-state.js';
40
- import type { AxTimelineQuery } from './canvas-db.js';
40
+ import type { AxTimelineQuery, CanvasBoard } from './canvas-db.js';
41
+ import type { Attachment, DocumentImport, ImportSection } from './document-import.js';
42
+ import { workbenchToken } from './workbench-auth.js';
43
+ import type { BackupFile, BackupStatus } from './backup.js';
44
+ import type { ExportManifest } from './board-export.js';
41
45
  import { onFileNodeChanged } from './file-watcher.js';
42
46
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
43
47
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
@@ -600,7 +604,7 @@ export class PmxCanvas extends EventEmitter {
600
604
  return canvasState.getAxState();
601
605
  }
602
606
 
603
- getAxContext(options?: { consumer?: string }): PmxAxContext {
607
+ getAxContext(options?: { consumer?: string }): PmxAxContext & { boardId: string | null } {
604
608
  return buildCanvasAxContext(options?.consumer);
605
609
  }
606
610
 
@@ -682,6 +686,218 @@ export class PmxCanvas extends EventEmitter {
682
686
  return canvasState.getAxTimeline(query);
683
687
  }
684
688
 
689
+ // ── Boards (plan 012) ─────────────────────────────────────────
690
+ // The SDK is the embedding host, so it may open and delete boards like the
691
+ // human's workbench. SDK writes made while no board is open (Home) land on a
692
+ // new board; open one first to write to an existing board.
693
+
694
+ listBoards(): { activeBoardId: string | null; boards: CanvasBoard[] } {
695
+ return { activeBoardId: canvasState.activeBoardId, boards: canvasState.listBoards() };
696
+ }
697
+
698
+ listImports(boardId?: string): DocumentImport[] {
699
+ return canvasState.listDocumentImports(boardId);
700
+ }
701
+ readImport(
702
+ id: string,
703
+ ): { import: DocumentImport; attachment: Attachment; downloadPath: string; bytes?: Uint8Array } | null {
704
+ const job = canvasState.getDocumentImport(id);
705
+ if (!job) return null;
706
+ const attachment = canvasState.getAttachment(job.attachmentId);
707
+ if (!attachment) return null;
708
+ const bytes =
709
+ attachment.size <= 2 * 1024 * 1024 ? (canvasState.readAttachmentBytes(attachment.id) ?? undefined) : undefined;
710
+ return {
711
+ import: job,
712
+ attachment,
713
+ downloadPath: `/api/canvas/attachments/${attachment.id}/bytes`,
714
+ ...(bytes ? { bytes } : {}),
715
+ };
716
+ }
717
+ async submitImport(
718
+ id: string,
719
+ sections: ImportSection[],
720
+ agentDescription: string,
721
+ warnings?: string[],
722
+ ): Promise<DocumentImport> {
723
+ const result = (await executeOperation(
724
+ 'import.submit',
725
+ { id, sections, agentDescription, warnings },
726
+ { source: 'sdk' },
727
+ )) as { import: DocumentImport };
728
+ return result.import;
729
+ }
730
+ async markImportUnavailable(id: string, reason: string): Promise<DocumentImport> {
731
+ const result = (await executeOperation('import.unavailable', { id, reason }, { source: 'sdk' })) as {
732
+ import: DocumentImport;
733
+ };
734
+ return result.import;
735
+ }
736
+
737
+ attachDocument(input: { boardId: string; name: string; mime?: string; bytes: Uint8Array; x?: number; y?: number }): {
738
+ attachment: Attachment;
739
+ import: DocumentImport;
740
+ nodeId: string;
741
+ } {
742
+ if (canvasState.activeBoardId !== input.boardId) throw new Error('The captured target board is not active.');
743
+ assertInsideFence('node.add', createTarget(input));
744
+ const attachment = canvasState.storeAttachment({ ...input, mime: input.mime ?? 'application/octet-stream' });
745
+ if (!attachment) throw new Error('Board not found.');
746
+ const { id: nodeId, node } = addCanvasNode({
747
+ type: 'file',
748
+ title: input.name,
749
+ data: { attachmentId: attachment.id, mime: attachment.mime, size: attachment.size },
750
+ x: input.x,
751
+ y: input.y,
752
+ defaultWidth: 440,
753
+ defaultHeight: 360,
754
+ fileMode: 'auto',
755
+ });
756
+ const job = canvasState.createDocumentImport(attachment.id, { x: node.position.x, y: node.position.y + 400 });
757
+ if (!job) throw new Error('Could not create import.');
758
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
759
+ return {
760
+ attachment,
761
+ import: job,
762
+ nodeId,
763
+ };
764
+ }
765
+
766
+ async requestImport(id: string, consent: true, trustedHumanToken: string): Promise<DocumentImport> {
767
+ if (trustedHumanToken !== workbenchToken) throw new Error('A trusted human token is required.');
768
+ const result = (await executeOperation(
769
+ 'import.request',
770
+ { id, consent },
771
+ { source: 'sdk', fromWorkbench: true, humanAuthor: true },
772
+ )) as { import: DocumentImport };
773
+ return result.import;
774
+ }
775
+
776
+ async cancelImport(id: string, trustedHumanToken: string): Promise<DocumentImport> {
777
+ if (trustedHumanToken !== workbenchToken) throw new Error('A trusted human token is required.');
778
+ const result = (await executeOperation(
779
+ 'import.cancel',
780
+ { id },
781
+ { source: 'sdk', fromWorkbench: true, humanAuthor: true },
782
+ )) as { import: DocumentImport };
783
+ return result.import;
784
+ }
785
+
786
+ async commitImport(id: string, trustedHumanToken: string): Promise<{ import: DocumentImport; nodeIds: string[] }> {
787
+ if (trustedHumanToken !== workbenchToken) throw new Error('A trusted human token is required.');
788
+ return (await executeOperation(
789
+ 'import.commit',
790
+ { id },
791
+ { source: 'sdk', fromWorkbench: true, humanAuthor: true },
792
+ )) as { import: DocumentImport; nodeIds: string[] };
793
+ }
794
+
795
+ async createBoard(name: string, category?: string): Promise<CanvasBoard> {
796
+ const result = (await executeOperation(
797
+ 'board.create',
798
+ { name, ...(category ? { category } : {}) },
799
+ { source: 'sdk' },
800
+ )) as {
801
+ board: CanvasBoard;
802
+ };
803
+ return result.board;
804
+ }
805
+
806
+ /** Rename a board and/or file it under a category (`category: null` removes it). */
807
+ async updateBoard(id: string, patch: { name?: string; category?: string | null }): Promise<void> {
808
+ await executeOperation('board.update', { id, ...patch }, { source: 'sdk' });
809
+ }
810
+
811
+ async setBoardReadme(id: string, readmeNodeId: string | null): Promise<void> {
812
+ await executeOperation('board.update', { id, readmeNodeId }, { source: 'sdk' });
813
+ }
814
+
815
+ async createBoardFrom(input: {
816
+ sourceBoardId: string;
817
+ name: string;
818
+ category?: string;
819
+ nodeIds?: string[];
820
+ includeReadme?: boolean;
821
+ includeStructure?: boolean;
822
+ preview?: boolean;
823
+ }): Promise<Record<string, unknown>> {
824
+ return (await executeOperation('board.create-from', input, { source: 'sdk' })) as Record<string, unknown>;
825
+ }
826
+
827
+ async searchLibrary(query: string, limit?: number): Promise<Record<string, unknown>> {
828
+ return (await executeOperation('search', { q: query, scope: 'library', limit }, { source: 'sdk' })) as Record<
829
+ string,
830
+ unknown
831
+ >;
832
+ }
833
+
834
+ /** Budgeted cross-board brief. Budget is measured in UTF-16 characters. */
835
+ async getContextBrief(
836
+ options: { consumer?: string; since?: number; budget?: number } = {},
837
+ ): Promise<Record<string, unknown>> {
838
+ return (await executeOperation('context.get', options, { source: 'sdk' })) as Record<string, unknown>;
839
+ }
840
+
841
+ /** Open a board, or Home with null. */
842
+ async openBoard(id: string | null): Promise<void> {
843
+ await executeOperation(
844
+ 'board.open',
845
+ { id },
846
+ { source: 'sdk', fromWorkbench: true, humanAuthor: true, suppressAutoGhost: true },
847
+ );
848
+ }
849
+
850
+ async deleteBoard(id: string): Promise<void> {
851
+ await executeOperation(
852
+ 'board.delete',
853
+ { id },
854
+ { source: 'sdk', fromWorkbench: true, humanAuthor: true, suppressAutoGhost: true },
855
+ );
856
+ }
857
+
858
+ // ── Static export (plan 013) ─────────────────────────────────
859
+
860
+ /** Write a self-contained HTML file of a board (default: the open board). */
861
+ async exportBoard(
862
+ options: { board?: string; includeFiles?: boolean; includeDerivedText?: boolean } = {},
863
+ ): Promise<{ path: string; url: string; bytes: number; manifest: ExportManifest }> {
864
+ return (await executeOperation('export.run', options, { source: 'sdk' })) as {
865
+ path: string;
866
+ url: string;
867
+ bytes: number;
868
+ manifest: ExportManifest;
869
+ };
870
+ }
871
+
872
+ // ── Library backup (plan 012) ─────────────────────────────────
873
+
874
+ async getBackupStatus(): Promise<BackupStatus> {
875
+ return (await executeOperation('backup.status', {}, { source: 'sdk' })) as BackupStatus;
876
+ }
877
+
878
+ /** Back up every board now; `to` and `keep` default to the configured folder and count. */
879
+ async backup(options: { to?: string; keep?: number } = {}): Promise<{ backup: BackupFile; removed: string[] }> {
880
+ return (await executeOperation('backup.run', options, { source: 'sdk' })) as {
881
+ backup: BackupFile;
882
+ removed: string[];
883
+ };
884
+ }
885
+
886
+ /** Back up on a schedule run by the server; `every: 'off'` stops it. */
887
+ async setBackupSchedule(options: { every: string; to?: string; keep?: number }): Promise<BackupStatus> {
888
+ return (await executeOperation('backup.schedule', options, { source: 'sdk' })) as BackupStatus;
889
+ }
890
+
891
+ /** Replace every board with a backup file; the current file is kept as `canvas.db.before-restore`. */
892
+ async restoreBackup(file: string): Promise<void> {
893
+ await executeOperation('backup.restore', { file }, { source: 'sdk' });
894
+ }
895
+
896
+ /** The context read log: which canvas context each agent read and which pinned nodes reached it. */
897
+ getContextReads(limit?: number): ReturnType<typeof canvasState.getContextReads> {
898
+ return canvasState.getContextReads(limit);
899
+ }
900
+
685
901
  listWorkItems(): PmxAxWorkItem[] {
686
902
  return canvasState.getWorkItems();
687
903
  }
@@ -1014,12 +1230,19 @@ export class PmxCanvas extends EventEmitter {
1014
1230
  return result;
1015
1231
  }
1016
1232
 
1017
- getLayout(): CanvasLayout {
1233
+ getLayout(options?: { board?: string }): CanvasLayout {
1234
+ if (options?.board) {
1235
+ const read = canvasState.readBoard(options.board);
1236
+ if (!read) throw new Error(`Board "${options.board}" not found.`);
1237
+ return read.layout;
1238
+ }
1018
1239
  return canvasState.getLayout();
1019
1240
  }
1020
1241
 
1021
- getNode(id: string): SdkCanvasNode | undefined {
1022
- const node = canvasState.getNode(id);
1242
+ getNode(id: string, options?: { board?: string }): SdkCanvasNode | undefined {
1243
+ const node = options?.board
1244
+ ? this.getLayout(options).nodes.find((candidate) => candidate.id === id)
1245
+ : canvasState.getNode(id);
1023
1246
  return node ? toSdkNode(node) : undefined;
1024
1247
  }
1025
1248
 
@@ -1435,6 +1658,9 @@ export {
1435
1658
  screenshotCanvasAutomationWebView,
1436
1659
  } from './server.js';
1437
1660
  export { canvasState } from './canvas-state.js';
1661
+ export type { CanvasBoard } from './canvas-db.js';
1662
+ export type { BackupFile, BackupStatus } from './backup.js';
1663
+ export type { ExportManifest } from './board-export.js';
1438
1664
  export type {
1439
1665
  CanvasAnnotation,
1440
1666
  CanvasSnapshot,
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
16
+ import { currentActor as getActor, setCurrentActor } from './attribution.js';
16
17
 
17
18
  // ── Types ────────────────────────────────────────────────────────────
18
19
 
@@ -116,10 +117,13 @@ class MutationHistory {
116
117
  record(entry: Omit<MutationEntry, 'id' | 'timestamp' | 'actor'> & { actor?: MutationActor }): void {
117
118
  if (this._replaying) return;
118
119
 
120
+ const attribution = getActor();
121
+ const actor = attribution.actor === 'human' ? 'human' : 'agent';
122
+ const writer = actor === 'agent' ? (attribution.agentId ?? attribution.source) : undefined;
119
123
  const full: MutationEntry = {
120
124
  ...entry,
121
- actor: entry.actor ?? currentActor ?? 'agent',
122
- ...((entry.writer ?? currentWriter) ? { writer: entry.writer ?? currentWriter ?? undefined } : {}),
125
+ actor: entry.actor ?? actor,
126
+ ...((entry.writer ?? writer) ? { writer: entry.writer ?? writer } : {}),
123
127
  id: `mut-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
124
128
  timestamp: new Date().toISOString(),
125
129
  };
@@ -415,16 +419,9 @@ export function formatDiff(diff: SnapshotDiffResult): string {
415
419
 
416
420
  export const mutationHistory = new MutationHistory();
417
421
 
418
- /**
419
- * Actor context for entries recorded while an operation runs. executeOperation
420
- * sets it from the workbench marker around each op; writes outside the
421
- * registry (the sync SDK) default to `agent`. Only the undo affordance reads
422
- * it, so an async op interleaving with another request costs at most a
423
- * mislabelled row — never a wrong undo.
424
- */
425
- let currentActor: MutationActor | null = null;
426
- let currentWriter: string | null = null;
422
+ /** Direct callers share the same async-local attribution as node mutations. */
427
423
  export function setMutationActor(actor: MutationActor | null, writer: string | null = null): void {
428
- currentActor = actor;
429
- currentWriter = actor === 'agent' ? writer : null;
424
+ setCurrentActor(
425
+ actor ? { actor, source: actor === 'human' ? 'browser' : 'sdk', ...(writer ? { agentId: writer } : {}) } : null,
426
+ );
430
427
  }
@@ -123,6 +123,13 @@ const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS
123
123
  );
124
124
 
125
125
  export const compositeToolDefinitions: CompositeToolDefinition[] = [
126
+ {
127
+ toolName: 'canvas_import',
128
+ description:
129
+ 'Agent-assisted document ingestion. List jobs, read attachment metadata/download bytes, submit bounded Markdown extraction, or report unavailable. Request, commit, and cancel are human-only HTTP actions.',
130
+ actionSummary: 'list | read | submit | unavailable',
131
+ actions: { list: 'import.list', read: 'import.read', submit: 'import.submit', unavailable: 'import.unavailable' },
132
+ },
126
133
  {
127
134
  // v0.4.0: the long-deferred snapshot composite (plan-006 #7). The name was
128
135
  // held by the legacy save-snapshot standalone through v0.3.x; listing the
@@ -141,6 +148,19 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
141
148
  diff: 'snapshot.diff',
142
149
  },
143
150
  },
151
+ {
152
+ toolName: 'canvas_board',
153
+ description:
154
+ 'Boards in this workspace — the library of the human\'s and your working memory, filed in categories on Home. Your writes always go to the board the human has open (activeBoardId); on Home (no board open) your first write opens their most recent board, or a new one when there are none. Action "list" returns every board (with its category), most recently opened first; "get" reads one (default: the open board); "create" makes a new empty board WITHOUT opening it (optional category); "update" renames a board, files it under a category (category "" removes it), or designates its README via readmeNodeId. As part of normal board authoring, read the board metadata; if readmeNodeId is null, choose or create a concise markdown introduction explaining its purpose and key context, then designate that node with "update". Preserve an existing README unless the human asks to change it; humans can override the designation manually. Only the human opens, switches or deletes boards — if you need another board, ask them in the chat to open it.',
155
+ actionSummary: 'list | get | create | create-from | update',
156
+ actions: {
157
+ list: 'board.list',
158
+ get: 'board.get',
159
+ create: 'board.create',
160
+ 'create-from': 'board.create-from',
161
+ update: 'board.update',
162
+ },
163
+ },
144
164
  {
145
165
  toolName: 'canvas_node',
146
166
  description:
@@ -217,9 +237,10 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
217
237
  {
218
238
  toolName: 'canvas_query',
219
239
  description:
220
- 'Read the board cheapest-first. Action "search" finds nodes by title/content keywords (prefer this before reading the full layout); "layout" returns the full node/edge layout; "validate" checks the board for node collisions, group-containment issues, and missing edge endpoints. Use search to locate, then layout or canvas_node get for detail.',
221
- actionSummary: 'search | layout | validate',
240
+ 'Read the board cheapest-first. Action "context" returns a budgeted cross-board brief with a durable consumer cursor (budget is UTF-16 characters); "search" finds nodes by title/content keywords; "layout" returns the full layout; "validate" checks geometry and endpoints.',
241
+ actionSummary: 'context | search | layout | validate',
222
242
  actions: {
243
+ context: 'context.get',
223
244
  search: 'search',
224
245
  layout: 'layout.get',
225
246
  validate: 'validate.get',
@@ -301,13 +322,14 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
301
322
  {
302
323
  toolName: 'canvas_ax_timeline',
303
324
  description:
304
- 'Read or write the bounded PMX AX timeline. Action "read" returns recent agent-events, evidence, and steering messages plus counts (limit); "record-event" records a normalized agent-event (kind prompt|assistant-message|tool-start|tool-result|failure|approval|steering, summary, detail, data); "add-evidence" records an evidence item (kind logs|tool-result|screenshot|file|diff|test-output, title, body, ref); "send-steering" records a steering message (message). Timeline rows persist for diagnostics/continuity but are not restored by snapshots; exposed via canvas://ax-timeline.',
305
- actionSummary: 'read | record-event | add-evidence | send-steering',
325
+ 'Read or write the bounded PMX AX timeline. Action "read" returns recent agent-events, evidence, and steering messages plus counts (limit); "record-event" records a normalized agent-event (kind prompt|assistant-message|tool-start|tool-result|failure|approval|steering, summary, detail, data); "add-evidence" records an evidence item (kind logs|tool-result|screenshot|file|diff|test-output, title, body, ref); "send-steering" records a steering message (message); "reads" returns the context read log — which canvas context each agent read and which pinned nodes reached it — with a per-consumer summary (limit). Timeline rows persist for diagnostics/continuity but are not restored by snapshots; exposed via canvas://ax-timeline.',
326
+ actionSummary: 'read | record-event | add-evidence | send-steering | reads',
306
327
  actions: {
307
328
  read: 'ax.timeline.get',
308
329
  'record-event': 'ax.event.record',
309
330
  'add-evidence': 'ax.evidence.add',
310
331
  'send-steering': 'ax.steer',
332
+ reads: 'ax.reads.list',
311
333
  },
312
334
  },
313
335
  {
@@ -9,6 +9,7 @@
9
9
  import { executeOperation, listOperations } from './registry.js';
10
10
  import { SOURCE_LABEL_RE } from '../agent-presence.js';
11
11
  import { OperationError } from './types.js';
12
+ import { isWorkbenchToken } from '../workbench-auth.js';
12
13
 
13
14
  function responseJson(data: unknown, status = 200): Response {
14
15
  return Response.json(data, {
@@ -88,15 +89,20 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
88
89
  // The workbench marks its own HTTP calls: a human dragging/editing in
89
90
  // the browser is not agent activity, so it never synthesizes an
90
91
  // auto-ghost. Everything else (CLI, scripts, adapters) counts as agent.
91
- const fromWorkbench = req.headers.get('x-pmx-workbench') === '1';
92
+ const workbenchMarked = req.headers.get('x-pmx-workbench') === '1';
93
+ const humanAuthor = isWorkbenchToken(req.headers.get('x-pmx-workbench-token'));
92
94
  // Presence writer label: adapters/CLI may identify themselves; plain
93
95
  // HTTP callers read as 'api'.
94
96
  const sourceHeader = req.headers.get('x-pmx-source');
95
97
  const source = sourceHeader && SOURCE_LABEL_RE.test(sourceHeader) ? sourceHeader : 'api';
96
98
  const result = await executeOperation(op.name, input, {
97
- suppressAutoGhost: fromWorkbench,
98
- fromWorkbench,
99
+ // The legacy marker remains a UI-policy signal (ghost suppression).
100
+ // Only the unguessable boot token classifies authorship as human.
101
+ suppressAutoGhost: workbenchMarked,
102
+ fromWorkbench: workbenchMarked,
103
+ humanAuthor,
99
104
  source,
105
+ proxiedRead: req.headers.get('x-pmx-proxied-read') === '1',
100
106
  });
101
107
  return responseJson(result, route.status ? route.status(result) : 200);
102
108
  } catch (error) {
@@ -11,6 +11,9 @@ import { queryOperations } from './ops/query.js';
11
11
  import { validateOperations } from './ops/validate.js';
12
12
  import { annotationOperations } from './ops/annotation.js';
13
13
  import { snapshotOperations } from './ops/snapshots.js';
14
+ import { boardOperations } from './ops/boards.js';
15
+ import { backupOperations } from './ops/backup.js';
16
+ import { exportOperations } from './ops/export.js';
14
17
  import { jsonRenderOperations } from './ops/json-render.js';
15
18
  import { axStateOperations } from './ops/ax-state.js';
16
19
  import { axWorkOperations } from './ops/ax-work.js';
@@ -25,6 +28,7 @@ import { axReadOperations } from './ops/ax-read.js';
25
28
  import { axPresenceOperations } from './ops/ax-presence.js';
26
29
  import { humanPresenceOperations } from './ops/human-presence.js';
27
30
  import { canvasWireOperations } from './ops/canvas-wire.js';
31
+ import { importOperations } from './ops/imports.js';
28
32
 
29
33
  for (const op of [
30
34
  ...nodeOperations,
@@ -35,6 +39,9 @@ for (const op of [
35
39
  ...validateOperations,
36
40
  ...annotationOperations,
37
41
  ...snapshotOperations,
42
+ ...boardOperations,
43
+ ...backupOperations,
44
+ ...exportOperations,
38
45
  ...jsonRenderOperations,
39
46
  ...axStateOperations,
40
47
  ...axWorkOperations,
@@ -49,6 +56,7 @@ for (const op of [
49
56
  ...axPresenceOperations,
50
57
  ...humanPresenceOperations,
51
58
  ...canvasWireOperations,
59
+ ...importOperations,
52
60
  ]) {
53
61
  registerOperation(op);
54
62
  }