pmx-canvas 0.1.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 (226) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +865 -0
  4. package/dist/canvas/global.css +3173 -0
  5. package/dist/canvas/index.js +183 -0
  6. package/dist/json-render/index.css +2 -0
  7. package/dist/json-render/index.js +389 -0
  8. package/dist/types/cli/agent.d.ts +13 -0
  9. package/dist/types/cli/index.d.ts +2 -0
  10. package/dist/types/cli/watch.d.ts +5 -0
  11. package/dist/types/client/App.d.ts +1 -0
  12. package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
  13. package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
  14. package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
  15. package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
  16. package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
  18. package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
  19. package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
  20. package/dist/types/client/canvas/DockedNode.d.ts +4 -0
  21. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
  23. package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
  24. package/dist/types/client/canvas/Minimap.d.ts +23 -0
  25. package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
  26. package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
  27. package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
  28. package/dist/types/client/canvas/snap-guides.d.ts +23 -0
  29. package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
  30. package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
  31. package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
  32. package/dist/types/client/ext-app/bridge.d.ts +161 -0
  33. package/dist/types/client/icons.d.ts +70 -0
  34. package/dist/types/client/index.d.ts +1 -0
  35. package/dist/types/client/nodes/ContextNode.d.ts +34 -0
  36. package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
  37. package/dist/types/client/nodes/FileNode.d.ts +5 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -0
  39. package/dist/types/client/nodes/ImageNode.d.ts +10 -0
  40. package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
  41. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
  42. package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
  43. package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
  44. package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
  45. package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
  46. package/dist/types/client/nodes/PromptNode.d.ts +5 -0
  47. package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
  48. package/dist/types/client/nodes/StatusNode.d.ts +4 -0
  49. package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
  50. package/dist/types/client/nodes/TraceNode.d.ts +4 -0
  51. package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
  52. package/dist/types/client/nodes/image-warnings.d.ts +6 -0
  53. package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
  54. package/dist/types/client/nodes/md-format.d.ts +25 -0
  55. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  56. package/dist/types/client/state/attention-store.d.ts +25 -0
  57. package/dist/types/client/state/canvas-store.d.ts +74 -0
  58. package/dist/types/client/state/intent-bridge.d.ts +158 -0
  59. package/dist/types/client/state/sse-bridge.d.ts +5 -0
  60. package/dist/types/client/theme/tokens.d.ts +27 -0
  61. package/dist/types/client/types.d.ts +40 -0
  62. package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
  63. package/dist/types/client/utils/placement.d.ts +1 -0
  64. package/dist/types/client/utils/platform.d.ts +2 -0
  65. package/dist/types/json-render/catalog.d.ts +815 -0
  66. package/dist/types/json-render/charts/components.d.ts +54 -0
  67. package/dist/types/json-render/charts/definitions.d.ts +103 -0
  68. package/dist/types/json-render/charts/extra-components.d.ts +58 -0
  69. package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
  70. package/dist/types/json-render/renderer/index.d.ts +16 -0
  71. package/dist/types/json-render/schema.d.ts +46 -0
  72. package/dist/types/json-render/server.d.ts +55 -0
  73. package/dist/types/mcp/server.d.ts +22 -0
  74. package/dist/types/server/agent-context.d.ts +21 -0
  75. package/dist/types/server/artifact-paths.d.ts +3 -0
  76. package/dist/types/server/canvas-operations.d.ts +154 -0
  77. package/dist/types/server/canvas-provenance.d.ts +13 -0
  78. package/dist/types/server/canvas-schema.d.ts +49 -0
  79. package/dist/types/server/canvas-serialization.d.ts +25 -0
  80. package/dist/types/server/canvas-state.d.ts +174 -0
  81. package/dist/types/server/canvas-validation.d.ts +33 -0
  82. package/dist/types/server/chart-template.d.ts +29 -0
  83. package/dist/types/server/code-graph.d.ts +67 -0
  84. package/dist/types/server/context-cards.d.ts +24 -0
  85. package/dist/types/server/diagram-presets.d.ts +28 -0
  86. package/dist/types/server/ext-app-call-registry.d.ts +16 -0
  87. package/dist/types/server/ext-app-tool-result.d.ts +1 -0
  88. package/dist/types/server/file-watcher.d.ts +16 -0
  89. package/dist/types/server/index.d.ts +243 -0
  90. package/dist/types/server/mcp-app-candidate.d.ts +25 -0
  91. package/dist/types/server/mcp-app-host.d.ts +65 -0
  92. package/dist/types/server/mcp-app-runtime.d.ts +47 -0
  93. package/dist/types/server/mutation-history.d.ts +105 -0
  94. package/dist/types/server/placement.d.ts +37 -0
  95. package/dist/types/server/server.d.ts +103 -0
  96. package/dist/types/server/spatial-analysis.d.ts +87 -0
  97. package/dist/types/server/trace-manager.d.ts +48 -0
  98. package/dist/types/server/web-artifacts.d.ts +50 -0
  99. package/dist/types/server/webpage-node.d.ts +25 -0
  100. package/dist/types/shared/auto-arrange.d.ts +29 -0
  101. package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
  102. package/dist/types/shared/placement.d.ts +26 -0
  103. package/dist/types/shared/semantic-attention.d.ts +97 -0
  104. package/package.json +109 -0
  105. package/skills/data-analysis/SKILL.md +324 -0
  106. package/skills/doc-coauthoring/SKILL.md +375 -0
  107. package/skills/frontend-design/SKILL.md +45 -0
  108. package/skills/json-render-codegen/SKILL.md +112 -0
  109. package/skills/json-render-core/SKILL.md +265 -0
  110. package/skills/json-render-ink/SKILL.md +273 -0
  111. package/skills/json-render-mcp/SKILL.md +132 -0
  112. package/skills/json-render-react/SKILL.md +264 -0
  113. package/skills/json-render-shadcn/SKILL.md +159 -0
  114. package/skills/playwright-cli/SKILL.md +67 -0
  115. package/skills/pmx-canvas/SKILL.md +668 -0
  116. package/skills/pmx-canvas/evals/evals.json +186 -0
  117. package/skills/pmx-canvas-testing/SKILL.md +78 -0
  118. package/skills/published-consumer-e2e/SKILL.md +43 -0
  119. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
  120. package/skills/web-artifacts-builder/SKILL.md +80 -0
  121. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
  122. package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
  123. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  124. package/skills/web-design-guidelines/SKILL.md +39 -0
  125. package/src/cli/agent.ts +2144 -0
  126. package/src/cli/index.ts +622 -0
  127. package/src/cli/watch.ts +88 -0
  128. package/src/client/App.tsx +507 -0
  129. package/src/client/canvas/AttentionHistory.tsx +81 -0
  130. package/src/client/canvas/AttentionToast.tsx +19 -0
  131. package/src/client/canvas/CanvasNode.tsx +363 -0
  132. package/src/client/canvas/CanvasViewport.tsx +590 -0
  133. package/src/client/canvas/CommandPalette.tsx +302 -0
  134. package/src/client/canvas/ContextMenu.tsx +601 -0
  135. package/src/client/canvas/ContextPinBar.tsx +25 -0
  136. package/src/client/canvas/ContextPinHud.tsx +22 -0
  137. package/src/client/canvas/DockedNode.tsx +66 -0
  138. package/src/client/canvas/EdgeLayer.tsx +280 -0
  139. package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
  140. package/src/client/canvas/FocusFieldLayer.tsx +107 -0
  141. package/src/client/canvas/Minimap.tsx +301 -0
  142. package/src/client/canvas/SelectionBar.tsx +69 -0
  143. package/src/client/canvas/ShortcutOverlay.tsx +69 -0
  144. package/src/client/canvas/SnapshotPanel.tsx +236 -0
  145. package/src/client/canvas/snap-guides.ts +170 -0
  146. package/src/client/canvas/use-node-drag.ts +51 -0
  147. package/src/client/canvas/use-node-resize.ts +59 -0
  148. package/src/client/canvas/use-pan-zoom.ts +191 -0
  149. package/src/client/ext-app/bridge.ts +542 -0
  150. package/src/client/icons.tsx +424 -0
  151. package/src/client/index.tsx +7 -0
  152. package/src/client/nodes/ContextNode.tsx +412 -0
  153. package/src/client/nodes/ExtAppFrame.tsx +509 -0
  154. package/src/client/nodes/FileNode.tsx +256 -0
  155. package/src/client/nodes/GroupNode.tsx +39 -0
  156. package/src/client/nodes/ImageNode.tsx +160 -0
  157. package/src/client/nodes/InlineFormatBar.tsx +169 -0
  158. package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
  159. package/src/client/nodes/LedgerNode.tsx +37 -0
  160. package/src/client/nodes/MarkdownNode.tsx +359 -0
  161. package/src/client/nodes/McpAppNode.tsx +85 -0
  162. package/src/client/nodes/MdFormatBar.tsx +109 -0
  163. package/src/client/nodes/PromptNode.tsx +597 -0
  164. package/src/client/nodes/ResponseNode.tsx +153 -0
  165. package/src/client/nodes/StatusNode.tsx +84 -0
  166. package/src/client/nodes/StatusSummary.tsx +38 -0
  167. package/src/client/nodes/TraceNode.tsx +120 -0
  168. package/src/client/nodes/WebpageNode.tsx +288 -0
  169. package/src/client/nodes/image-warnings.ts +95 -0
  170. package/src/client/nodes/inline-editor-commands.ts +37 -0
  171. package/src/client/nodes/md-format.ts +206 -0
  172. package/src/client/state/attention-bridge.ts +328 -0
  173. package/src/client/state/attention-store.ts +73 -0
  174. package/src/client/state/canvas-store.ts +631 -0
  175. package/src/client/state/intent-bridge.ts +315 -0
  176. package/src/client/state/sse-bridge.ts +965 -0
  177. package/src/client/theme/global.css +3173 -0
  178. package/src/client/theme/tokens.ts +72 -0
  179. package/src/client/types-shims.d.ts +5 -0
  180. package/src/client/types.ts +81 -0
  181. package/src/client/utils/ext-app-tool-result.ts +4 -0
  182. package/src/client/utils/placement.ts +4 -0
  183. package/src/client/utils/platform.ts +2 -0
  184. package/src/json-render/catalog.ts +256 -0
  185. package/src/json-render/charts/components.tsx +198 -0
  186. package/src/json-render/charts/definitions.ts +81 -0
  187. package/src/json-render/charts/extra-components.tsx +267 -0
  188. package/src/json-render/charts/extra-definitions.ts +145 -0
  189. package/src/json-render/renderer/index.css +174 -0
  190. package/src/json-render/renderer/index.tsx +86 -0
  191. package/src/json-render/schema.ts +62 -0
  192. package/src/json-render/server.ts +597 -0
  193. package/src/mcp/server.ts +1377 -0
  194. package/src/server/agent-context.ts +242 -0
  195. package/src/server/artifact-paths.ts +17 -0
  196. package/src/server/canvas-operations.ts +1279 -0
  197. package/src/server/canvas-provenance.ts +243 -0
  198. package/src/server/canvas-schema.ts +432 -0
  199. package/src/server/canvas-serialization.ts +95 -0
  200. package/src/server/canvas-state.ts +1134 -0
  201. package/src/server/canvas-validation.ts +114 -0
  202. package/src/server/chart-template.ts +449 -0
  203. package/src/server/code-graph.ts +370 -0
  204. package/src/server/context-cards.ts +31 -0
  205. package/src/server/diagram-presets.ts +71 -0
  206. package/src/server/ext-app-call-registry.ts +77 -0
  207. package/src/server/ext-app-tool-result.ts +4 -0
  208. package/src/server/file-watcher.ts +121 -0
  209. package/src/server/index.ts +647 -0
  210. package/src/server/mcp-app-candidate.ts +174 -0
  211. package/src/server/mcp-app-host.ts +814 -0
  212. package/src/server/mcp-app-runtime.ts +459 -0
  213. package/src/server/mutation-history.ts +350 -0
  214. package/src/server/placement.ts +125 -0
  215. package/src/server/server.ts +3846 -0
  216. package/src/server/spatial-analysis.ts +356 -0
  217. package/src/server/trace-manager.ts +333 -0
  218. package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
  219. package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
  220. package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
  221. package/src/server/web-artifacts.ts +442 -0
  222. package/src/server/webpage-node.ts +328 -0
  223. package/src/shared/auto-arrange.ts +439 -0
  224. package/src/shared/ext-app-tool-result.ts +76 -0
  225. package/src/shared/placement.ts +81 -0
  226. package/src/shared/semantic-attention.ts +598 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Spatial Semantics Layer for PMX Canvas
3
+ *
4
+ * Analyzes the spatial arrangement of nodes on the canvas to extract
5
+ * meaningful relationships: proximity clusters, reading order, and
6
+ * neighborhood context around pinned nodes.
7
+ *
8
+ * This makes the canvas promise — "spatial arrangement is communication" —
9
+ * actually real for agents. Instead of raw x/y coordinates, agents get
10
+ * semantic clusters, ordered context, and implicit human intent.
11
+ */
12
+
13
+ import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
14
+ import { summarizeNodeForAgentContext } from './agent-context.js';
15
+
16
+ // ── Types ────────────────────────────────────────────────────────────
17
+
18
+ export interface SpatialCluster {
19
+ /** Auto-generated cluster ID */
20
+ id: string;
21
+ /** Node IDs in this cluster */
22
+ nodeIds: string[];
23
+ /** Human-readable label derived from node titles/types */
24
+ label: string;
25
+ /** Centroid of the cluster */
26
+ centroid: { x: number; y: number };
27
+ /** Bounding box of all nodes in the cluster */
28
+ bounds: { x: number; y: number; width: number; height: number };
29
+ }
30
+
31
+ export interface SpatialNeighbor {
32
+ id: string;
33
+ type: string;
34
+ title: string | null;
35
+ distance: number;
36
+ }
37
+
38
+ export interface NodeSpatialInfo {
39
+ id: string;
40
+ type: string;
41
+ title: string | null;
42
+ content: string | null;
43
+ clusterId: string | null;
44
+ /** Reading order index (top-left to bottom-right) */
45
+ readingOrder: number;
46
+ }
47
+
48
+ export interface SpatialContext {
49
+ /** Total nodes on canvas */
50
+ totalNodes: number;
51
+ /** Detected proximity clusters */
52
+ clusters: SpatialCluster[];
53
+ /** All nodes in spatial reading order (top-left to bottom-right) */
54
+ nodesInReadingOrder: NodeSpatialInfo[];
55
+ /** For each pinned node, nearby unpinned nodes (the implicit context) */
56
+ pinnedNeighborhoods: {
57
+ pinnedNodeId: string;
58
+ pinnedNodeTitle: string | null;
59
+ neighbors: SpatialNeighbor[];
60
+ }[];
61
+ }
62
+
63
+ // ── Helpers ──────────────────────────────────────────────────────────
64
+
65
+ /** Euclidean distance between two node centers */
66
+ function centerDistance(a: CanvasNodeState, b: CanvasNodeState): number {
67
+ const ax = a.position.x + a.size.width / 2;
68
+ const ay = a.position.y + a.size.height / 2;
69
+ const bx = b.position.x + b.size.width / 2;
70
+ const by = b.position.y + b.size.height / 2;
71
+ return Math.sqrt((ax - bx) ** 2 + (ay - by) ** 2);
72
+ }
73
+
74
+ /** Gap distance — how far apart two nodes are edge-to-edge (0 if overlapping) */
75
+ function gapDistance(a: CanvasNodeState, b: CanvasNodeState): number {
76
+ const aRight = a.position.x + a.size.width;
77
+ const aBottom = a.position.y + a.size.height;
78
+ const bRight = b.position.x + b.size.width;
79
+ const bBottom = b.position.y + b.size.height;
80
+
81
+ const gapX = Math.max(0, Math.max(a.position.x, b.position.x) - Math.min(aRight, bRight));
82
+ const gapY = Math.max(0, Math.max(a.position.y, b.position.y) - Math.min(aBottom, bBottom));
83
+
84
+ return Math.sqrt(gapX ** 2 + gapY ** 2);
85
+ }
86
+
87
+ /** Reading-order sort: top-to-bottom, then left-to-right (with row tolerance) */
88
+ function readingOrderSort(nodes: CanvasNodeState[]): CanvasNodeState[] {
89
+ const sorted = [...nodes];
90
+ // Row tolerance: nodes within 100px vertical are considered the same row
91
+ const ROW_TOLERANCE = 100;
92
+ sorted.sort((a, b) => {
93
+ const rowA = Math.floor(a.position.y / ROW_TOLERANCE);
94
+ const rowB = Math.floor(b.position.y / ROW_TOLERANCE);
95
+ if (rowA !== rowB) return rowA - rowB;
96
+ return a.position.x - b.position.x;
97
+ });
98
+ return sorted;
99
+ }
100
+
101
+ /** Derive a human-readable label for a cluster from its nodes */
102
+ function deriveClusterLabel(nodes: CanvasNodeState[]): string {
103
+ // Use the first node with a title, or fall back to type summary
104
+ const titled = nodes.find((n) => n.data.title && typeof n.data.title === 'string');
105
+ if (titled && nodes.length <= 3) {
106
+ const titles = nodes
107
+ .filter((n) => n.data.title)
108
+ .map((n) => n.data.title as string)
109
+ .slice(0, 3);
110
+ return titles.join(', ');
111
+ }
112
+
113
+ // Summarize by type counts
114
+ const typeCounts: Record<string, number> = {};
115
+ for (const n of nodes) {
116
+ typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1;
117
+ }
118
+ const parts = Object.entries(typeCounts).map(([type, count]) =>
119
+ count === 1 ? type : `${count} ${type}`,
120
+ );
121
+
122
+ if (titled) {
123
+ return `${titled.data.title} + ${parts.join(', ')}`;
124
+ }
125
+ return parts.join(', ');
126
+ }
127
+
128
+ // ── Core Analysis ────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Detect proximity clusters using single-linkage clustering.
132
+ * Two nodes are "close" if their edge-to-edge gap is within the threshold.
133
+ *
134
+ * Default threshold: 200px (roughly "visually grouped" on a typical canvas).
135
+ */
136
+ export function detectClusters(
137
+ nodes: CanvasNodeState[],
138
+ proximityThreshold = 200,
139
+ ): SpatialCluster[] {
140
+ if (nodes.length === 0) return [];
141
+
142
+ // Union-Find for clustering
143
+ const parent = new Map<string, string>();
144
+ const find = (id: string): string => {
145
+ while (parent.get(id) !== id) {
146
+ const p = parent.get(id)!;
147
+ parent.set(id, parent.get(p)!); // path compression
148
+ id = p;
149
+ }
150
+ return id;
151
+ };
152
+ const union = (a: string, b: string): void => {
153
+ const ra = find(a);
154
+ const rb = find(b);
155
+ if (ra !== rb) parent.set(ra, rb);
156
+ };
157
+
158
+ // Initialize each node as its own cluster
159
+ for (const node of nodes) {
160
+ parent.set(node.id, node.id);
161
+ }
162
+
163
+ // Compare all pairs (fine for canvas-scale node counts, typically < 200)
164
+ for (let i = 0; i < nodes.length; i++) {
165
+ for (let j = i + 1; j < nodes.length; j++) {
166
+ if (gapDistance(nodes[i], nodes[j]) <= proximityThreshold) {
167
+ union(nodes[i].id, nodes[j].id);
168
+ }
169
+ }
170
+ }
171
+
172
+ // Group by root
173
+ const groups = new Map<string, CanvasNodeState[]>();
174
+ for (const node of nodes) {
175
+ const root = find(node.id);
176
+ if (!groups.has(root)) groups.set(root, []);
177
+ groups.get(root)!.push(node);
178
+ }
179
+
180
+ // Build cluster objects (only clusters with 2+ nodes are interesting)
181
+ const clusters: SpatialCluster[] = [];
182
+ let clusterIdx = 0;
183
+ for (const [, members] of groups) {
184
+ if (members.length < 2) continue;
185
+
186
+ const xs = members.map((n) => n.position.x);
187
+ const ys = members.map((n) => n.position.y);
188
+ const rights = members.map((n) => n.position.x + n.size.width);
189
+ const bottoms = members.map((n) => n.position.y + n.size.height);
190
+
191
+ const minX = Math.min(...xs);
192
+ const minY = Math.min(...ys);
193
+ const maxRight = Math.max(...rights);
194
+ const maxBottom = Math.max(...bottoms);
195
+
196
+ clusters.push({
197
+ id: `cluster-${clusterIdx++}`,
198
+ nodeIds: members.map((n) => n.id),
199
+ label: deriveClusterLabel(members),
200
+ centroid: {
201
+ x: Math.round((minX + maxRight) / 2),
202
+ y: Math.round((minY + maxBottom) / 2),
203
+ },
204
+ bounds: {
205
+ x: minX,
206
+ y: minY,
207
+ width: maxRight - minX,
208
+ height: maxBottom - minY,
209
+ },
210
+ });
211
+ }
212
+
213
+ // Sort clusters by reading order (top-left centroid first)
214
+ clusters.sort((a, b) => {
215
+ const rowA = Math.floor(a.centroid.y / 200);
216
+ const rowB = Math.floor(b.centroid.y / 200);
217
+ if (rowA !== rowB) return rowA - rowB;
218
+ return a.centroid.x - b.centroid.x;
219
+ });
220
+
221
+ return clusters;
222
+ }
223
+
224
+ /**
225
+ * Find the nearest unpinned nodes to each pinned node.
226
+ */
227
+ export function findNeighborhoods(
228
+ nodes: CanvasNodeState[],
229
+ pinnedIds: Set<string>,
230
+ maxNeighbors = 5,
231
+ maxDistance = 600,
232
+ ): SpatialContext['pinnedNeighborhoods'] {
233
+ const pinned = nodes.filter((n) => pinnedIds.has(n.id));
234
+ const unpinned = nodes.filter((n) => !pinnedIds.has(n.id));
235
+
236
+ return pinned.map((pin) => {
237
+ const withDist = unpinned
238
+ .map((n) => ({ node: n, distance: centerDistance(pin, n) }))
239
+ .filter((d) => d.distance <= maxDistance)
240
+ .sort((a, b) => a.distance - b.distance)
241
+ .slice(0, maxNeighbors);
242
+
243
+ return {
244
+ pinnedNodeId: pin.id,
245
+ pinnedNodeTitle: (pin.data.title as string) ?? null,
246
+ neighbors: withDist.map((d) => ({
247
+ id: d.node.id,
248
+ type: d.node.type,
249
+ title: (d.node.data.title as string) ?? null,
250
+ distance: Math.round(d.distance),
251
+ })),
252
+ };
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Full-text search across node titles and content.
258
+ * Returns matching nodes with relevance score.
259
+ */
260
+ export function searchNodes(
261
+ nodes: CanvasNodeState[],
262
+ query: string,
263
+ ): { id: string; type: string; title: string | null; snippet: string; score: number }[] {
264
+ const q = query.toLowerCase().trim();
265
+ if (!q) return [];
266
+
267
+ const terms = q.split(/\s+/);
268
+ const results: { id: string; type: string; title: string | null; snippet: string; score: number }[] = [];
269
+
270
+ for (const node of nodes) {
271
+ const title = ((node.data.title as string) ?? '').toLowerCase();
272
+ const content = ((node.data.content as string) ?? (node.data.fileContent as string) ?? '').toLowerCase();
273
+ const path = ((node.data.path as string) ?? '').toLowerCase();
274
+ const description = ((node.data.description as string) ?? '').toLowerCase();
275
+ const url = ((node.data.url as string) ?? '').toLowerCase();
276
+
277
+ let score = 0;
278
+ for (const term of terms) {
279
+ // Title matches are worth more
280
+ if (title.includes(term)) score += 3;
281
+ if (path.includes(term)) score += 2;
282
+ if (url.includes(term)) score += 2;
283
+ if (description.includes(term)) score += 1;
284
+ if (content.includes(term)) score += 1;
285
+ }
286
+
287
+ if (score === 0) continue;
288
+
289
+ // Extract a snippet around the first match in content
290
+ let snippet = '';
291
+ const fullContent = (node.data.content as string) ?? (node.data.fileContent as string) ?? '';
292
+ const matchIdx = fullContent.toLowerCase().indexOf(terms[0]);
293
+ if (matchIdx >= 0) {
294
+ const start = Math.max(0, matchIdx - 40);
295
+ const end = Math.min(fullContent.length, matchIdx + 80);
296
+ snippet = (start > 0 ? '...' : '') +
297
+ fullContent.slice(start, end).replace(/\n/g, ' ') +
298
+ (end < fullContent.length ? '...' : '');
299
+ } else if (title) {
300
+ snippet = (node.data.title as string) ?? '';
301
+ }
302
+
303
+ results.push({
304
+ id: node.id,
305
+ type: node.type,
306
+ title: (node.data.title as string) ?? null,
307
+ snippet,
308
+ score,
309
+ });
310
+ }
311
+
312
+ results.sort((a, b) => b.score - a.score);
313
+ return results;
314
+ }
315
+
316
+ /**
317
+ * Build the complete spatial context for the canvas.
318
+ */
319
+ export function buildSpatialContext(
320
+ nodes: CanvasNodeState[],
321
+ _edges: CanvasEdge[],
322
+ pinnedIds: Set<string>,
323
+ ): SpatialContext {
324
+ const clusters = detectClusters(nodes);
325
+
326
+ // Build a lookup: nodeId → clusterId
327
+ const nodeToCluster = new Map<string, string>();
328
+ for (const cluster of clusters) {
329
+ for (const nid of cluster.nodeIds) {
330
+ nodeToCluster.set(nid, cluster.id);
331
+ }
332
+ }
333
+
334
+ const ordered = readingOrderSort(nodes);
335
+
336
+ const nodesInReadingOrder: NodeSpatialInfo[] = ordered.map((n, i) => ({
337
+ id: n.id,
338
+ type: n.type,
339
+ title: (n.data.title as string) ?? null,
340
+ content: summarizeNodeForAgentContext(n, {
341
+ defaultTextLength: 320,
342
+ webpageTextLength: 640,
343
+ }) || null,
344
+ clusterId: nodeToCluster.get(n.id) ?? null,
345
+ readingOrder: i,
346
+ }));
347
+
348
+ const pinnedNeighborhoods = findNeighborhoods(nodes, pinnedIds);
349
+
350
+ return {
351
+ totalNodes: nodes.length,
352
+ clusters,
353
+ nodesInReadingOrder,
354
+ pinnedNeighborhoods,
355
+ };
356
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * TraceManager — creates trace nodes and flow edges on the canvas
3
+ * as the agent calls tools and spawns subagents.
4
+ *
5
+ * Server-side singleton consumed by chat-view event wiring.
6
+ */
7
+
8
+ import { type CanvasEdge, type CanvasNodeState, canvasState } from './canvas-state.js';
9
+ import { emitPrimaryWorkbenchEvent } from './server.js';
10
+
11
+ const MAX_TRACE_NODES = 120;
12
+ const TRACE_NODE_WIDTH = 200;
13
+ const TRACE_NODE_HEIGHT = 56;
14
+ const TRACE_GAP_X = 24;
15
+ const TRACE_GAP_Y = 80;
16
+ const TRACE_MARGIN_TOP = 80;
17
+ const TRACE_MAX_COLS = 6; // 6 x (200+24) = 1344px fits standard 1440px viewport
18
+
19
+ // ── Category color coding ─────────────────────────────────────
20
+ type TraceCategory = 'mcp' | 'file' | 'subagent' | 'other';
21
+
22
+ function categorize(name: string, mcpServerName?: string | null): TraceCategory {
23
+ if (mcpServerName) return 'mcp';
24
+ const lower = name.toLowerCase();
25
+ if (
26
+ lower.includes('read') ||
27
+ lower.includes('write') ||
28
+ lower.includes('edit') ||
29
+ lower.includes('glob') ||
30
+ lower.includes('grep') ||
31
+ lower.includes('bash')
32
+ )
33
+ return 'file';
34
+ return 'other';
35
+ }
36
+
37
+ // ── ID generation ─────────────────────────────────────────────
38
+ let traceCounter = 0;
39
+
40
+ function nextTraceNodeId(): string {
41
+ return `trace-${Date.now().toString(36)}-${(traceCounter++).toString(36)}`;
42
+ }
43
+
44
+ function nextTraceEdgeId(): string {
45
+ return `tedge-${Date.now().toString(36)}-${(traceCounter++).toString(36)}`;
46
+ }
47
+
48
+ // ── Positioning ───────────────────────────────────────────────
49
+
50
+ function computeTraceOrigin(): { x: number; y: number } {
51
+ const layout = canvasState.getLayout();
52
+ let maxY = 0;
53
+ for (const node of layout.nodes) {
54
+ if (node.type === 'trace') continue;
55
+ const bottom = node.position.y + node.size.height;
56
+ if (bottom > maxY) maxY = bottom;
57
+ }
58
+ return { x: 40, y: maxY + TRACE_MARGIN_TOP };
59
+ }
60
+
61
+ // ── TraceManager class ────────────────────────────────────────
62
+
63
+ class TraceManager {
64
+ private _enabled = false;
65
+ private traceNodeIds: string[] = [];
66
+ private lastTraceNodeId: string | null = null;
67
+ private toolCallToNodeId = new Map<string, string>();
68
+ private traceOrigin: { x: number; y: number } | null = null;
69
+ private chainIndex = 0;
70
+
71
+ get enabled(): boolean {
72
+ return this._enabled;
73
+ }
74
+
75
+ setEnabled(value: boolean): void {
76
+ this._enabled = value;
77
+ if (value) {
78
+ this.traceOrigin = null; // recompute on next trace node
79
+ this.chainIndex = 0;
80
+ }
81
+ }
82
+
83
+ onToolStart(payload: {
84
+ name: string;
85
+ toolCallId?: string;
86
+ activity?: string;
87
+ mcpServerName?: string | null;
88
+ mcpToolName?: string | null;
89
+ }): void {
90
+ if (!this._enabled) return;
91
+
92
+ const id = nextTraceNodeId();
93
+ const category = categorize(payload.name, payload.mcpServerName);
94
+ const pos = this.nextPosition();
95
+
96
+ const node: CanvasNodeState = {
97
+ id,
98
+ type: 'trace',
99
+ position: pos,
100
+ size: { width: TRACE_NODE_WIDTH, height: TRACE_NODE_HEIGHT },
101
+ zIndex: 0,
102
+ collapsed: false,
103
+ pinned: true,
104
+ dockPosition: null,
105
+ data: {
106
+ toolName: payload.name,
107
+ category,
108
+ status: 'running',
109
+ activity: payload.activity ?? payload.name,
110
+ startedAt: Date.now(),
111
+ },
112
+ };
113
+
114
+ this.evictIfNeeded();
115
+ canvasState.addNode(node);
116
+ this.traceNodeIds.push(id);
117
+
118
+ // Flow edge from previous trace node
119
+ if (this.lastTraceNodeId) {
120
+ const edge: CanvasEdge = {
121
+ id: nextTraceEdgeId(),
122
+ from: this.lastTraceNodeId,
123
+ to: id,
124
+ type: 'flow',
125
+ animated: true,
126
+ };
127
+ canvasState.addEdge(edge);
128
+ }
129
+
130
+ if (payload.toolCallId) {
131
+ this.toolCallToNodeId.set(payload.toolCallId, id);
132
+ }
133
+
134
+ this.lastTraceNodeId = id;
135
+ this.broadcastUpdate();
136
+ }
137
+
138
+ onToolComplete(payload: {
139
+ name: string;
140
+ toolCallId?: string;
141
+ success?: boolean;
142
+ activity?: string;
143
+ error?: string;
144
+ }): void {
145
+ const nodeId = payload.toolCallId ? this.toolCallToNodeId.get(payload.toolCallId) : null;
146
+ if (!nodeId) return;
147
+
148
+ if (payload.toolCallId) {
149
+ this.toolCallToNodeId.delete(payload.toolCallId);
150
+ }
151
+
152
+ const node = canvasState.getNode(nodeId);
153
+ if (!node || node.type !== 'trace') return;
154
+
155
+ const startedAt = (node.data.startedAt as number) || Date.now();
156
+ const durationMs = Math.max(0, Date.now() - startedAt);
157
+ const durationText =
158
+ durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
159
+
160
+ canvasState.updateNode(nodeId, {
161
+ data: {
162
+ ...node.data,
163
+ status: payload.success === false ? 'failed' : 'success',
164
+ resultSummary: payload.activity ?? '',
165
+ error: payload.error,
166
+ duration: durationText,
167
+ },
168
+ });
169
+
170
+ // Stop edge animation for completed edges ending at this node
171
+ for (const edge of canvasState.getEdgesForNode(nodeId)) {
172
+ if (edge.to === nodeId && edge.animated) {
173
+ canvasState.removeEdge(edge.id);
174
+ canvasState.addEdge({ ...edge, animated: false });
175
+ }
176
+ }
177
+
178
+ this.broadcastUpdate();
179
+ }
180
+
181
+ onSubagentStarted(payload: {
182
+ agentName: string;
183
+ agentDisplayName?: string;
184
+ }): void {
185
+ if (!this._enabled) return;
186
+
187
+ const id = nextTraceNodeId();
188
+ const origin = this.getOrigin();
189
+ // Subagent branch: offset below the full trace grid to avoid overlap
190
+ const parentNode = this.lastTraceNodeId ? canvasState.getNode(this.lastTraceNodeId) : null;
191
+ const gridRows = Math.floor(this.chainIndex / TRACE_MAX_COLS) + 1;
192
+ const gridBottomY = origin.y + gridRows * (TRACE_NODE_HEIGHT + TRACE_GAP_Y);
193
+ const pos = {
194
+ x: parentNode ? parentNode.position.x : origin.x,
195
+ y: gridBottomY,
196
+ };
197
+
198
+ const node: CanvasNodeState = {
199
+ id,
200
+ type: 'trace',
201
+ position: pos,
202
+ size: { width: TRACE_NODE_WIDTH, height: TRACE_NODE_HEIGHT },
203
+ zIndex: 0,
204
+ collapsed: false,
205
+ pinned: true,
206
+ dockPosition: null,
207
+ data: {
208
+ toolName: payload.agentDisplayName ?? payload.agentName,
209
+ category: 'subagent' as TraceCategory,
210
+ status: 'running',
211
+ activity: `Subagent: ${payload.agentDisplayName ?? payload.agentName}`,
212
+ startedAt: Date.now(),
213
+ },
214
+ };
215
+
216
+ this.evictIfNeeded();
217
+ canvasState.addNode(node);
218
+ this.traceNodeIds.push(id);
219
+
220
+ // Spawn edge from parent
221
+ if (this.lastTraceNodeId) {
222
+ const edge: CanvasEdge = {
223
+ id: nextTraceEdgeId(),
224
+ from: this.lastTraceNodeId,
225
+ to: id,
226
+ type: 'flow',
227
+ label: 'spawn',
228
+ animated: true,
229
+ };
230
+ canvasState.addEdge(edge);
231
+ }
232
+
233
+ this.toolCallToNodeId.set(`subagent:${payload.agentName}`, id);
234
+ this.broadcastUpdate();
235
+ }
236
+
237
+ onSubagentCompleted(payload: {
238
+ agentName: string;
239
+ agentDisplayName?: string;
240
+ durationMs?: number;
241
+ failed?: boolean;
242
+ }): void {
243
+ const nodeId = this.toolCallToNodeId.get(`subagent:${payload.agentName}`);
244
+ if (!nodeId) return;
245
+ this.toolCallToNodeId.delete(`subagent:${payload.agentName}`);
246
+
247
+ const node = canvasState.getNode(nodeId);
248
+ if (!node || node.type !== 'trace') return;
249
+
250
+ const startedAt = (node.data.startedAt as number) || Date.now();
251
+ const durationMs = payload.durationMs ?? Math.max(0, Date.now() - startedAt);
252
+ const durationText =
253
+ durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
254
+
255
+ canvasState.updateNode(nodeId, {
256
+ data: {
257
+ ...node.data,
258
+ status: payload.failed ? 'failed' : 'success',
259
+ duration: durationText,
260
+ },
261
+ });
262
+
263
+ // Stop edge animation
264
+ for (const edge of canvasState.getEdgesForNode(nodeId)) {
265
+ if (edge.to === nodeId && edge.animated) {
266
+ canvasState.removeEdge(edge.id);
267
+ canvasState.addEdge({ ...edge, animated: false });
268
+ }
269
+ }
270
+
271
+ this.broadcastUpdate();
272
+ }
273
+
274
+ clearTrace(): void {
275
+ const traceNodeIds = new Set(this.traceNodeIds);
276
+ for (const node of canvasState.getLayout().nodes) {
277
+ if (node.type === 'trace') {
278
+ traceNodeIds.add(node.id);
279
+ }
280
+ }
281
+
282
+ for (const id of traceNodeIds) {
283
+ canvasState.removeNode(id); // removeNode cascades edge deletion
284
+ }
285
+ this.traceNodeIds = [];
286
+ this.lastTraceNodeId = null;
287
+ this.toolCallToNodeId.clear();
288
+ this.traceOrigin = null;
289
+ this.chainIndex = 0;
290
+ this.broadcastUpdate();
291
+ }
292
+
293
+ getTraceNodeCount(): number {
294
+ return canvasState.getLayout().nodes.filter((node) => node.type === 'trace').length;
295
+ }
296
+
297
+ // ── Private helpers ───────────────────────────────────────
298
+
299
+ private getOrigin(): { x: number; y: number } {
300
+ if (!this.traceOrigin) {
301
+ this.traceOrigin = computeTraceOrigin();
302
+ }
303
+ return this.traceOrigin;
304
+ }
305
+
306
+ private nextPosition(): { x: number; y: number } {
307
+ const origin = this.getOrigin();
308
+ const col = this.chainIndex % TRACE_MAX_COLS;
309
+ const row = Math.floor(this.chainIndex / TRACE_MAX_COLS);
310
+ const x = origin.x + col * (TRACE_NODE_WIDTH + TRACE_GAP_X);
311
+ const y = origin.y + row * (TRACE_NODE_HEIGHT + TRACE_GAP_Y);
312
+ this.chainIndex++;
313
+ return { x, y };
314
+ }
315
+
316
+ private evictIfNeeded(): void {
317
+ while (this.traceNodeIds.length >= MAX_TRACE_NODES) {
318
+ const oldest = this.traceNodeIds.shift();
319
+ if (oldest) {
320
+ canvasState.removeNode(oldest); // cascades edge deletion
321
+ }
322
+ }
323
+ }
324
+
325
+ private broadcastUpdate(): void {
326
+ emitPrimaryWorkbenchEvent('canvas-layout-update', {
327
+ layout: canvasState.getLayout(),
328
+ });
329
+ }
330
+ }
331
+
332
+ // Module-level singleton
333
+ export const traceManager = new TraceManager();