pmx-canvas 0.4.6 → 0.4.7

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 (70) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +87 -83
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  10. package/dist/types/client/state/canvas-store.d.ts +10 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  12. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  13. package/dist/types/server/ax-interaction.d.ts +21 -2
  14. package/dist/types/server/canvas-operations.d.ts +5 -0
  15. package/dist/types/server/canvas-state.d.ts +8 -0
  16. package/dist/types/server/canvas-validation.d.ts +15 -21
  17. package/dist/types/server/file-content.d.ts +46 -0
  18. package/dist/types/server/html-primitives.d.ts +13 -1
  19. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  20. package/dist/types/server/server.d.ts +1 -0
  21. package/dist/types/server/web-artifacts.d.ts +14 -0
  22. package/dist/types/shared/ax-flow.d.ts +51 -0
  23. package/dist/types/shared/node-sizes.d.ts +33 -0
  24. package/docs/cli.md +19 -2
  25. package/docs/environment.md +1 -0
  26. package/docs/http-api.md +17 -0
  27. package/docs/mcp.md +12 -0
  28. package/docs/node-types.md +45 -1
  29. package/docs/screenshot.png +0 -0
  30. package/package.json +2 -2
  31. package/skills/pmx-canvas/SKILL.md +68 -7
  32. package/skills/pmx-canvas/evals/evals.json +54 -3
  33. package/skills/pmx-canvas/references/full-reference.md +11 -6
  34. package/src/cli/commands/history.ts +1 -1
  35. package/src/cli/commands/nodes.ts +2 -2
  36. package/src/cli/commands/query.ts +1 -1
  37. package/src/cli/commands/view.ts +1 -1
  38. package/src/cli/index.ts +6 -0
  39. package/src/cli/shared.ts +34 -5
  40. package/src/client/App.tsx +28 -15
  41. package/src/client/canvas/CanvasNode.tsx +54 -5
  42. package/src/client/canvas/EdgeLayer.tsx +44 -24
  43. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  44. package/src/client/canvas/auto-fit.ts +10 -1
  45. package/src/client/canvas/use-pan-zoom.ts +16 -1
  46. package/src/client/nodes/AxStepControls.tsx +226 -0
  47. package/src/client/nodes/FileNode.tsx +249 -51
  48. package/src/client/state/canvas-store.ts +19 -0
  49. package/src/client/state/intent-bridge.ts +32 -0
  50. package/src/client/state/sse-bridge.ts +4 -1
  51. package/src/client/theme/global.css +101 -3
  52. package/src/server/ax-flow-loop.ts +114 -0
  53. package/src/server/ax-interaction.ts +299 -4
  54. package/src/server/canvas-operations.ts +44 -17
  55. package/src/server/canvas-state.ts +20 -10
  56. package/src/server/canvas-validation.ts +50 -41
  57. package/src/server/demo-state.json +4605 -523
  58. package/src/server/demo.ts +24 -0
  59. package/src/server/file-content.ts +152 -0
  60. package/src/server/file-watcher.ts +7 -10
  61. package/src/server/html-primitives.ts +636 -0
  62. package/src/server/operations/ops/ax-work.ts +5 -0
  63. package/src/server/operations/ops/nodes.ts +15 -1
  64. package/src/server/operations/ops/viewport.ts +7 -1
  65. package/src/server/server.ts +106 -5
  66. package/src/server/web-artifacts.ts +109 -0
  67. package/src/shared/ax-flow.ts +102 -0
  68. package/src/shared/node-sizes.ts +52 -0
  69. package/dist/types/server/chart-template.d.ts +0 -29
  70. package/src/server/chart-template.ts +0 -448
@@ -13,6 +13,14 @@ interface DemoStateFixture {
13
13
  edges: CanvasEdge[];
14
14
  annotations?: CanvasAnnotation[];
15
15
  contextPins?: string[];
16
+ /**
17
+ * Canvas-bound AX state (work items and friends), restored verbatim so the
18
+ * `data.axStep.workItemId` stamped on the ⑧ agent-at-work step nodes resolves
19
+ * to a real work item — without it the seeded Start/Done controls 404.
20
+ * Timeline rows (events/evidence/steering) are deliberately absent: snapshots
21
+ * do not restore them either.
22
+ */
23
+ ax?: unknown;
16
24
  }
17
25
 
18
26
  const demoState = demoStateJson as DemoStateFixture;
@@ -29,6 +37,22 @@ export function seedDemoCanvas(): { nodes: number; edges: number; groups: number
29
37
  for (const annotation of annotations) canvasState.addAnnotation(annotation);
30
38
  canvasState.setContextPins(pins);
31
39
  canvasState.setViewport(demoState.viewport);
40
+ // After the nodes exist: the AX partition is normalized against the current
41
+ // node set, so applying it first would strip every work item's `nodeIds`.
42
+ //
43
+ // Only when the canvas-bound AX partition is EMPTY. applyPersistedAxState
44
+ // REPLACES it (work items, gates, review annotations, and the policy
45
+ // singleton) and the seed is flushed to disk — and the caller's
46
+ // only-seed-when-empty guard counts NODES only. Work items need no nodes,
47
+ // so an agent tracking a plan on a bare board would have it destroyed by
48
+ // `--demo`.
49
+ const axPartitionEmpty =
50
+ canvasState.getWorkItems().length === 0 &&
51
+ canvasState.getApprovalGates().length === 0 &&
52
+ canvasState.getReviewAnnotations().length === 0;
53
+ if (demoState.ax !== undefined && axPartitionEmpty) {
54
+ canvasState.applyPersistedAxState(structuredClone(demoState.ax));
55
+ }
32
56
  });
33
57
  canvasState.flushToDisk();
34
58
 
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared disk reader for `file` canvas nodes.
3
+ *
4
+ * Both entry points that put a file on the canvas — `buildFileNodeData`
5
+ * (creation) and the file watcher (live updates) — go through here so their
6
+ * notion of "what this file is" can never drift. Reading a file blind with
7
+ * `readFileSync(path, 'utf-8')` turned binaries into mojibake and had no size
8
+ * ceiling at all, so a multi-GB file was pulled into memory as a string.
9
+ */
10
+
11
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
12
+ import { extname } from 'node:path';
13
+
14
+ /** Text past this many bytes is stored truncated instead of whole. */
15
+ export const MAX_FILE_TEXT_BYTES = 2 * 1024 * 1024;
16
+
17
+ /** Bytes sniffed to classify a file without reading all of it. */
18
+ const SNIFF_BYTES = 8192;
19
+
20
+ const PDF_MAGIC = Buffer.from('%PDF-', 'ascii');
21
+
22
+ export type FileNodeContent =
23
+ /** Missing, unreadable, or not a regular file — the node stays path-backed. */
24
+ | { kind: 'unavailable' }
25
+ | { kind: 'binary'; byteSize: number; mtimeMs: number; mimeType?: string }
26
+ | { kind: 'text'; byteSize: number; mtimeMs: number; text: string; lineCount: number; truncated: boolean };
27
+
28
+ function isPdfFile(prefix: Buffer, filePath: string): boolean {
29
+ if (prefix.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC)) return true;
30
+ return extname(filePath).toLowerCase() === '.pdf';
31
+ }
32
+
33
+ /**
34
+ * PDF check straight from a path, for the byte route's server-derived
35
+ * Content-Type. Reads only the magic-byte prefix. Any read failure is "not a
36
+ * PDF" — the caller then serves an opaque download rather than an inline type.
37
+ */
38
+ export function isPdfFilePath(filePath: string): boolean {
39
+ let fd: number | null = null;
40
+ try {
41
+ fd = openSync(filePath, 'r');
42
+ const prefix = Buffer.alloc(PDF_MAGIC.length);
43
+ const read = readSync(fd, prefix, 0, PDF_MAGIC.length, 0);
44
+ return isPdfFile(prefix.subarray(0, read), filePath);
45
+ } catch {
46
+ return false;
47
+ } finally {
48
+ if (fd !== null) {
49
+ try {
50
+ closeSync(fd);
51
+ } catch {
52
+ // best effort
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Classify a file and read at most `MAX_FILE_TEXT_BYTES` of it.
60
+ *
61
+ * Never throws: an unreadable path is reported as `unavailable`, matching the
62
+ * pre-existing behavior where a bad path still rendered as a file node.
63
+ */
64
+ export function readFileNodeContent(filePath: string): FileNodeContent {
65
+ let fd: number | null = null;
66
+ try {
67
+ const stat = statSync(filePath);
68
+ if (!stat.isFile()) return { kind: 'unavailable' };
69
+ const byteSize = stat.size;
70
+ const mtimeMs = stat.mtimeMs;
71
+
72
+ fd = openSync(filePath, 'r');
73
+
74
+ // Classify from a prefix only — never read a huge file just to type it.
75
+ const sniff = Buffer.alloc(Math.min(SNIFF_BYTES, byteSize));
76
+ const prefix = sniff.subarray(0, sniff.length > 0 ? readSync(fd, sniff, 0, sniff.length, 0) : 0);
77
+
78
+ // A PDF need not carry a NUL byte in its header, so the magic bytes decide
79
+ // first; `.pdf` is the secondary signal for the same call.
80
+ if (isPdfFile(prefix, filePath)) {
81
+ return { kind: 'binary', byteSize, mtimeMs, mimeType: 'application/pdf' };
82
+ }
83
+ if (prefix.includes(0x00)) {
84
+ return { kind: 'binary', byteSize, mtimeMs };
85
+ }
86
+
87
+ const readLength = Math.min(byteSize, MAX_FILE_TEXT_BYTES);
88
+ const buffer = Buffer.alloc(readLength);
89
+ let filled = 0;
90
+ while (filled < readLength) {
91
+ const read = readSync(fd, buffer, filled, readLength - filled, filled);
92
+ if (read <= 0) break;
93
+ filled += read;
94
+ }
95
+
96
+ // `stream: true` makes the decoder hold back a multi-byte sequence the cap
97
+ // sliced in half instead of emitting U+FFFD; we never flush, so the partial
98
+ // trailing bytes are simply dropped and the text ends on a real character.
99
+ const text = new TextDecoder('utf-8').decode(buffer.subarray(0, filled), { stream: true });
100
+
101
+ return {
102
+ kind: 'text',
103
+ byteSize,
104
+ mtimeMs,
105
+ text,
106
+ lineCount: text.split('\n').length,
107
+ truncated: byteSize > MAX_FILE_TEXT_BYTES,
108
+ };
109
+ } catch {
110
+ return { kind: 'unavailable' };
111
+ } finally {
112
+ if (fd !== null) closeSync(fd);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Map a read result onto `file` node data in place, clearing the fields the new
118
+ * state contradicts (a file that turns binary must not keep stale decoded text).
119
+ */
120
+ export function applyFileContentToNodeData(
121
+ data: Record<string, unknown>,
122
+ content: FileNodeContent,
123
+ ): Record<string, unknown> {
124
+ if (content.kind === 'unavailable') return data;
125
+
126
+ data.byteSize = content.byteSize;
127
+ data.updatedAt = new Date(content.mtimeMs).toISOString();
128
+
129
+ if (content.kind === 'binary') {
130
+ data.binary = true;
131
+ delete data.fileContent;
132
+ delete data.lineCount;
133
+ delete data.truncated;
134
+ if (content.mimeType) {
135
+ data.mimeType = content.mimeType;
136
+ } else {
137
+ delete data.mimeType;
138
+ }
139
+ return data;
140
+ }
141
+
142
+ data.fileContent = content.text;
143
+ data.lineCount = content.lineCount;
144
+ delete data.binary;
145
+ delete data.mimeType;
146
+ if (content.truncated) {
147
+ data.truncated = true;
148
+ } else {
149
+ delete data.truncated;
150
+ }
151
+ return data;
152
+ }
@@ -6,9 +6,10 @@
6
6
  * edits a file, the canvas node updates automatically.
7
7
  */
8
8
 
9
- import { watch, existsSync, readFileSync, statSync } from 'node:fs';
9
+ import { watch, existsSync, statSync } from 'node:fs';
10
10
  import type { FSWatcher } from 'node:fs';
11
11
  import { canvasState } from './canvas-state.js';
12
+ import { applyFileContentToNodeData, readFileNodeContent } from './file-content.js';
12
13
 
13
14
  function logFileWatcherWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
14
15
  console.warn(`[file-watcher] ${action}`, { error, ...(details ?? {}) });
@@ -97,21 +98,17 @@ function handleFileChange(filePath: string): void {
97
98
  if (stat.mtimeMs === entry.lastMtime) return;
98
99
  entry.lastMtime = stat.mtimeMs;
99
100
 
100
- const content = readFileSync(filePath, 'utf-8');
101
- const lineCount = content.split('\n').length;
102
- const updatedAt = new Date(stat.mtimeMs).toISOString();
101
+ // Shared with node creation, so a file that becomes binary or crosses the
102
+ // text cap updates the same fields here (and drops stale decoded text).
103
+ const content = readFileNodeContent(filePath);
104
+ if (content.kind === 'unavailable') return;
103
105
 
104
106
  // Update all nodes watching this file
105
107
  for (const nodeId of entry.nodeIds) {
106
108
  const node = canvasState.getNode(nodeId);
107
109
  if (!node) continue;
108
110
  canvasState.updateNode(nodeId, {
109
- data: {
110
- ...node.data,
111
- fileContent: content,
112
- lineCount,
113
- updatedAt,
114
- },
111
+ data: applyFileContentToNodeData({ ...node.data }, content),
115
112
  });
116
113
  onFileChanged?.(nodeId);
117
114
  }