pmx-canvas 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  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/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -1,4 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { derivedTour, type Tour } from '../shared/tour.js';
3
+ export type { Tour, TourStop } from '../shared/tour.js';
2
4
  import { agentPresence } from './agent-presence.js';
3
5
  import { checkFenceTarget, type FenceTarget } from './scope-fence.js';
4
6
  import type { AgentPhase, AgentPresence, AgentPresenceSnapshot } from '../shared/agent-presence.js';
@@ -35,7 +37,11 @@ import type {
35
37
  PmxAxWorkItem,
36
38
  PmxAxWorkItemStatus,
37
39
  } from './ax-state.js';
38
- 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';
39
45
  import { onFileNodeChanged } from './file-watcher.js';
40
46
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
41
47
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
@@ -547,6 +553,16 @@ export class PmxCanvas extends EventEmitter {
547
553
  );
548
554
  }
549
555
 
556
+ getTour(): Tour {
557
+ return canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes);
558
+ }
559
+
560
+ setTour(tour: Tour | null): void {
561
+ assertInsideFence('tour.set', { boardWide: true });
562
+ canvasState.setTour(tour);
563
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
564
+ }
565
+
550
566
  clear(): void {
551
567
  assertInsideFence('canvas.clear', { boardWide: true });
552
568
  for (const node of canvasState.getLayout().nodes) {
@@ -588,7 +604,7 @@ export class PmxCanvas extends EventEmitter {
588
604
  return canvasState.getAxState();
589
605
  }
590
606
 
591
- getAxContext(options?: { consumer?: string }): PmxAxContext {
607
+ getAxContext(options?: { consumer?: string }): PmxAxContext & { boardId: string | null } {
592
608
  return buildCanvasAxContext(options?.consumer);
593
609
  }
594
610
 
@@ -670,6 +686,218 @@ export class PmxCanvas extends EventEmitter {
670
686
  return canvasState.getAxTimeline(query);
671
687
  }
672
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
+
673
901
  listWorkItems(): PmxAxWorkItem[] {
674
902
  return canvasState.getWorkItems();
675
903
  }
@@ -1002,12 +1230,19 @@ export class PmxCanvas extends EventEmitter {
1002
1230
  return result;
1003
1231
  }
1004
1232
 
1005
- 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
+ }
1006
1239
  return canvasState.getLayout();
1007
1240
  }
1008
1241
 
1009
- getNode(id: string): SdkCanvasNode | undefined {
1010
- 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);
1011
1246
  return node ? toSdkNode(node) : undefined;
1012
1247
  }
1013
1248
 
@@ -1423,6 +1658,9 @@ export {
1423
1658
  screenshotCanvasAutomationWebView,
1424
1659
  } from './server.js';
1425
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';
1426
1664
  export type {
1427
1665
  CanvasAnnotation,
1428
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:
@@ -203,8 +223,10 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
203
223
  toolName: 'canvas_view',
204
224
  description:
205
225
  'Canvas viewport and layout control. Action "arrange" auto-lays-out nodes (grid/columns/etc.); "focus" pans/zooms the viewport to a node; "fit" zooms to fit all nodes in view; "clear" removes every node and edge from the canvas; "remove-annotation" deletes a human-drawn annotation by id.',
206
- actionSummary: 'arrange | focus | fit | clear | remove-annotation',
226
+ actionSummary: 'arrange | focus | fit | clear | remove-annotation | get-tour | set-tour',
207
227
  actions: {
228
+ 'get-tour': 'tour.get',
229
+ 'set-tour': 'tour.set',
208
230
  arrange: 'arrange',
209
231
  focus: 'node.focus',
210
232
  fit: 'view.fit',
@@ -215,9 +237,10 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
215
237
  {
216
238
  toolName: 'canvas_query',
217
239
  description:
218
- '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.',
219
- 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',
220
242
  actions: {
243
+ context: 'context.get',
221
244
  search: 'search',
222
245
  layout: 'layout.get',
223
246
  validate: 'validate.get',
@@ -299,13 +322,14 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
299
322
  {
300
323
  toolName: 'canvas_ax_timeline',
301
324
  description:
302
- '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.',
303
- 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',
304
327
  actions: {
305
328
  read: 'ax.timeline.get',
306
329
  'record-event': 'ax.event.record',
307
330
  'add-evidence': 'ax.evidence.add',
308
331
  'send-steering': 'ax.steer',
332
+ reads: 'ax.reads.list',
309
333
  },
310
334
  },
311
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
  }
@@ -29,14 +29,19 @@ function defaultPresenceSource(name: string, input: Record<string, unknown>, sou
29
29
  /** Runs operations in-process against the shared canvasState singleton. */
30
30
  export class LocalOperationInvoker implements OperationInvoker {
31
31
  private readonly source: string;
32
+ private readonly proxiedRead: boolean;
32
33
 
33
34
  /** `source` labels this caller's agent presence ('mcp', 'sdk', …). */
34
35
  constructor(source = 'api') {
35
36
  this.source = agentSourceLabel(source);
37
+ this.proxiedRead = source === 'mcp';
36
38
  }
37
39
 
38
40
  async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
39
- return await executeOperation(name, defaultPresenceSource(name, input, this.source), { source: this.source });
41
+ return await executeOperation(name, defaultPresenceSource(name, input, this.source), {
42
+ source: this.source,
43
+ proxiedRead: this.proxiedRead,
44
+ });
40
45
  }
41
46
  }
42
47
 
@@ -48,11 +53,13 @@ function toOperationErrorStatus(status: number): OperationErrorStatus {
48
53
  export class HttpOperationInvoker implements OperationInvoker {
49
54
  private readonly baseUrl: string;
50
55
  private readonly source: string;
56
+ private readonly proxiedRead: boolean;
51
57
 
52
58
  /** `source` labels this caller's agent presence on the server ('cli', 'mcp', …). */
53
59
  constructor(baseUrl: string, source = 'api') {
54
60
  this.baseUrl = baseUrl.replace(/\/$/, '');
55
61
  this.source = agentSourceLabel(source);
62
+ this.proxiedRead = source === 'mcp';
56
63
  }
57
64
 
58
65
  async invoke(name: string, rawInput: Record<string, unknown>): Promise<unknown> {
@@ -79,7 +86,11 @@ export class HttpOperationInvoker implements OperationInvoker {
79
86
  }
80
87
 
81
88
  let url = `${this.baseUrl}${path}`;
82
- const init: RequestInit = { method: route.method, headers: { 'x-pmx-source': this.source } };
89
+ const proxyHeaders: Record<string, string> = this.proxiedRead ? { 'x-pmx-proxied-read': '1' } : {};
90
+ const init: RequestInit = {
91
+ method: route.method,
92
+ headers: { 'x-pmx-source': this.source, ...proxyHeaders },
93
+ };
83
94
  if (route.method === 'GET' || route.method === 'DELETE') {
84
95
  const params = new URLSearchParams();
85
96
  for (const [key, value] of Object.entries(rest)) {
@@ -88,7 +99,7 @@ export class HttpOperationInvoker implements OperationInvoker {
88
99
  const query = params.toString();
89
100
  if (query) url += `?${query}`;
90
101
  } else {
91
- init.headers = { 'Content-Type': 'application/json', 'x-pmx-source': this.source };
102
+ init.headers = { 'Content-Type': 'application/json', 'x-pmx-source': this.source, ...proxyHeaders };
92
103
  init.body = JSON.stringify(rest);
93
104
  }
94
105
 
@@ -10,12 +10,59 @@
10
10
  import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
11
11
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
12
  import { getOperation, listOperations } from './registry.js';
13
- import type { OperationInvoker } from './invoker.js';
13
+ import { agentSourceLabel, type OperationInvoker } from './invoker.js';
14
14
  import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
15
+ import { CONTEXT_READ_OPS, contextReadFromPayload, type ContextReadInput } from '../context-reads.js';
15
16
  import { compositeFoldedOpNames, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
16
17
 
17
18
  export interface OperationToolHost extends OperationMcpToolHost {
18
19
  invoker(): OperationInvoker;
20
+ prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }>;
21
+ recordContextRead(read: ContextReadInput): Promise<void>;
22
+ }
23
+
24
+ async function invokeMcpOperation(
25
+ host: OperationToolHost,
26
+ op: Operation,
27
+ opInput: Record<string, unknown>,
28
+ formatInput: Record<string, unknown>,
29
+ consumer: string | null,
30
+ ) {
31
+ const board = typeof opInput.board === 'string' && opInput.board.trim() ? opInput.board.trim() : undefined;
32
+ const readTarget = CONTEXT_READ_OPS.has(op.name) ? await host.prepareContextRead(board) : null;
33
+ if (readTarget?.boardId && 'board' in op.inputShape) {
34
+ opInput = { ...opInput, board: readTarget.boardId };
35
+ formatInput = { ...formatInput, board: readTarget.boardId };
36
+ }
37
+ const result = await host.invoker().invoke(op.name, opInput);
38
+ const formatted = op.mcp?.formatResult
39
+ ? await op.mcp.formatResult(result, formatInput, host)
40
+ : { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
41
+ if (readTarget) {
42
+ const text = formatted.content
43
+ .filter((item): item is Extract<(typeof formatted.content)[number], { type: 'text' }> => item.type === 'text')
44
+ .map((item) => item.text)
45
+ .join('');
46
+ try {
47
+ await host.recordContextRead(
48
+ contextReadFromPayload(
49
+ {
50
+ channel: 'operation',
51
+ resource: op.name,
52
+ source: agentSourceLabel('mcp'),
53
+ consumer,
54
+ agentId: null,
55
+ pinnedNodeIds: readTarget.pinnedNodeIds,
56
+ boardId: readTarget.boardId,
57
+ },
58
+ text,
59
+ ),
60
+ );
61
+ } catch (error) {
62
+ console.error('[pmx-canvas mcp] recording a context read failed:', error);
63
+ }
64
+ }
65
+ return formatted;
19
66
  }
20
67
 
21
68
  export function registerOperationTools(server: McpServer, getHost: () => Promise<OperationToolHost>): void {
@@ -43,11 +90,7 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
43
90
  try {
44
91
  const host = await getHost();
45
92
  const opInput = tool.buildInput ? tool.buildInput(input) : input;
46
- const result = await host.invoker().invoke(op.name, opInput);
47
- if (tool.formatResult) {
48
- return await tool.formatResult(result, input, host);
49
- }
50
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
93
+ return await invokeMcpOperation(host, op, opInput, input, server.server.getClientVersion()?.name ?? null);
51
94
  } catch (error) {
52
95
  return {
53
96
  content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
@@ -261,11 +304,7 @@ export function registerCompositeTools(
261
304
  );
262
305
  }
263
306
  const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
264
- const result = await host.invoker().invoke(opName, opInput);
265
- if (op.mcp?.formatResult) {
266
- return await op.mcp.formatResult(result, rest, host);
267
- }
268
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
307
+ return await invokeMcpOperation(host, op, opInput, rest, server.server.getClientVersion()?.name ?? null);
269
308
  } catch (error) {
270
309
  return {
271
310
  content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],