pmx-canvas 0.1.17 → 0.1.18

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  All notable changes to `pmx-canvas` are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.1.18] - 2026-05-05
7
+
8
+ Token-budget polish on top of 0.1.17. Full-mode MCP responses for
9
+ hosted external-MCP-app nodes now elide the rendered shell HTML in
10
+ favor of a compact `{ omitted, resourceUri, bytes, sha256 }` summary,
11
+ so an agent that asks for `full: true` no longer re-receives the same
12
+ ext-app shell HTML on every read. Adds a dedicated
13
+ `excalidraw-diagram-authoring.md` skill reference and folds the
14
+ freehand annotation feature into the README's main feature list.
15
+
16
+ ### Added
17
+
18
+ - **`serializeCanvasNodeForAgent` / `serializeCanvasLayoutForAgent`.**
19
+ New agent-facing serializers wrap the existing
20
+ `serializeCanvasNode` / `serializeCanvasLayout` helpers and replace
21
+ hosted ext-app shell HTML (`mcp-app` nodes in `ext-app` mode that
22
+ carry a `resourceUri`) with a `{ omitted: 'external-mcp-app-html',
23
+ resourceUri, bytes, sha256 }` descriptor. The MCP server uses
24
+ these wrappers for `canvas_get_node` (full), `canvas_get_layout`
25
+ (full), and the full-payload branch of every add-style response.
26
+ Non-external-app HTML — `html` nodes, bundled web-artifact
27
+ output — is preserved exactly as before.
28
+ - **`skills/pmx-canvas/references/excalidraw-diagram-authoring.md`.**
29
+ A 145-line authoring guide for `canvas_add_diagram` covering shape-
30
+ level `label` format, sizing and camera rules, the pastel palette,
31
+ and common pitfalls. The SKILL points to it from the diagram
32
+ guidance section.
33
+
34
+ ### Changed
35
+
36
+ - **README adds an `03 / Annotate` section.** The annotation feature
37
+ shipped in 0.1.17 is now part of the main README feature list
38
+ alongside Curate / Mix / Control / Save / Any agent. Subsequent
39
+ sections were renumbered (Control your context → 04, Save → 05,
40
+ Any agent → 06).
41
+
42
+ ### Internal
43
+
44
+ - Regression coverage for: agent-mode node serialization eliding
45
+ hosted ext-app shell HTML, agent-mode layout serialization not
46
+ repeating the ext-app shell across multiple nodes, non-external-
47
+ app HTML payloads being preserved unchanged, and `canvas_get_node`
48
+ / `canvas_get_layout` full-mode elision through the MCP server.
49
+
6
50
  ## [0.1.17] - 2026-05-04
7
51
 
8
52
  Adds a freehand annotation layer so humans can draw directly on the
@@ -727,6 +771,7 @@ otherwise have to discover by trial and error.
727
771
  - Regression coverage for snapshot flat-`id` aliases on both MCP and
728
772
  HTTP surfaces, plus async / top-level-`await` WebView script bodies.
729
773
 
774
+ [0.1.18]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.18
730
775
  [0.1.17]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.17
731
776
  [0.1.16]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.16
732
777
  [0.1.15]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.15
package/Readme.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # pmx-canvas
2
2
 
3
3
  **A moldable canvas for agent-assisted thinking.** An infinite 2D surface
4
- where files, plans, status, charts, fetched web pages, and hand-drawn
5
- diagrams live side by side. Every node carries its own renderer; agents
4
+ where files, plans, status, charts, fetched web pages, annotations, and
5
+ hand-drawn diagrams live side by side. Every node carries its own renderer; agents
6
6
  (and you) build new views in the middle of a session, not as a separate
7
7
  tooling project. Pin what matters and the agent reads your spatial
8
8
  curation as structured context.
@@ -41,14 +41,21 @@ surface. The reach of the canvas is the union of its
41
41
  already has access to** — MCP servers, CLIs, file reads, web fetch, anything
42
42
  on its toolbelt.
43
43
 
44
- ### 03 / Control your context
44
+ ### 03 / Annotate
45
+
46
+ Draw freehand marks directly on the canvas to circle, underline, connect, or
47
+ call out what matters without turning the markup into another node. Annotations
48
+ persist with state and snapshots, can be erased in the browser, and appear to
49
+ agents as compact spatial context: target, bounds, and nearby canvas content.
50
+
51
+ ### 04 / Control your context
45
52
 
46
53
  Pinning is an explicit, low-noise control over what the agent sees next. No
47
54
  prompt engineering, no copy-paste — pin a node in the browser and the MCP
48
55
  server fires a `notifications/resources/updated` event the agent's harness
49
56
  picks up immediately.
50
57
 
51
- ### 04 / Save
58
+ ### 05 / Save
52
59
 
53
60
  Spatial state auto-saves to `.pmx-canvas/state.json` (debounced ~500 ms) —
54
61
  git-committable, shareable across machines, and survives both browser
@@ -56,7 +63,7 @@ refresh and server restart. Named [snapshots](docs/mcp.md#tools), full
56
63
  undo/redo, and an auto-detected code graph (JS/TS, Python, Go, Rust) make
57
64
  the canvas durable rather than throwaway.
58
65
 
59
- ### 05 / Any agent
66
+ ### 06 / Any agent
60
67
 
61
68
  Harness-agnostic. Drive the canvas from [MCP](docs/mcp.md) (41 tools,
62
69
  8 resources, change notifications), the [CLI](docs/cli.md), the
@@ -33,8 +33,10 @@ export declare function getCanvasNodeKind(node: CanvasNodeState, data: Record<st
33
33
  export declare function getCanvasNodeTitle(node: CanvasNodeState): string | null;
34
34
  export declare function getCanvasNodeContent(node: CanvasNodeState): string | null;
35
35
  export declare function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode;
36
+ export declare function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode;
36
37
  export declare function serializeCanvasNodeWithBlobSummaries(node: CanvasNodeState): SerializedCanvasNode;
37
38
  export declare function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLayout;
39
+ export declare function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout;
38
40
  export declare function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout;
39
41
  export declare function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary;
40
42
  export declare function summarizeCanvasAnnotationForContext(annotation: CanvasAnnotation, nodes: CanvasNodeState[]): CanvasAnnotationContextSummary;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -622,6 +622,8 @@ canvas_webview_stop();
622
622
  - Do not use separate `text` elements with `containerId`/`boundElements` to place centered text
623
623
  inside shapes. The hosted SVG preview does not auto-position those; PMX normalizes imported
624
624
  canonical bound text back into shape labels for hosted app calls.
625
+ - For detailed sizing, camera, and label-fit rules, read `references/excalidraw-diagram-authoring.md`
626
+ before creating dense diagrams.
625
627
  - Prefer the pastel fill palette in the Excalidraw `read_me` (light blue/green/orange/...) for
626
628
  a consistent look across diagrams
627
629
 
@@ -0,0 +1,145 @@
1
+ # Excalidraw Diagram Authoring
2
+
3
+ Use this guide when creating diagrams through PMX Canvas with `canvas_add_diagram` or
4
+ `pmx-canvas external-app add --kind excalidraw`.
5
+
6
+ ## Why Text Can Still Drift
7
+
8
+ PMX normalizes canonical Excalidraw bound text (`containerId` / `boundElements`) into the hosted
9
+ app's supported shape-level `label` format before calling Excalidraw. That fixes the payload
10
+ format mismatch, but it does not fix poor diagram geometry.
11
+
12
+ Text can still appear clipped or misplaced when:
13
+
14
+ - A label is too long for its shape.
15
+ - A diamond or ellipse is too small for the label's usable center area.
16
+ - The `cameraUpdate` viewport is too tight or not 4:3.
17
+ - Title, footer, or notes are placed near the camera edge.
18
+ - The caller bypasses PMX and sends raw elements directly to Excalidraw MCP.
19
+
20
+ ## Text Format Rules
21
+
22
+ For text inside a rectangle, ellipse, or diamond, use shape-level `label`:
23
+
24
+ ```json
25
+ {
26
+ "type": "rectangle",
27
+ "id": "step-a",
28
+ "x": 100,
29
+ "y": 100,
30
+ "width": 260,
31
+ "height": 90,
32
+ "label": { "text": "Step A", "fontSize": 18 }
33
+ }
34
+ ```
35
+
36
+ Do not create separate centered text elements for shape labels:
37
+
38
+ ```json
39
+ {
40
+ "type": "text",
41
+ "containerId": "step-a",
42
+ "text": "Step A"
43
+ }
44
+ ```
45
+
46
+ Standalone text is fine for titles, notes, captions, and free-floating annotations. For standalone
47
+ text, `x` is the left edge; `textAlign` does not center it on a point.
48
+
49
+ ## Label Length Rules
50
+
51
+ - Use 1-4 words inside shapes.
52
+ - Put detailed explanations in nearby standalone text annotations.
53
+ - Prefer `Bound Text` over `Pattern B: containerId+boundElements only`.
54
+ - If a label needs more than 4 words, either widen the shape or split the idea into a label plus an annotation.
55
+
56
+ ## Shape Sizing Rules
57
+
58
+ - Minimum labeled rectangle or ellipse: `180x80`.
59
+ - For 3-5 word labels: `240x90` or larger.
60
+ - For long labels: `320+` width or use an external annotation.
61
+ - Diamonds need more room than rectangles because the usable center area is smaller.
62
+ - Leave at least `30px` gap between shapes and labels/arrows.
63
+
64
+ ## Camera Rules
65
+
66
+ Always start with a `cameraUpdate` as the first element.
67
+
68
+ Use 4:3 camera sizes only:
69
+
70
+ - `400x300`
71
+ - `600x450`
72
+ - `800x600`
73
+ - `1200x900`
74
+ - `1600x1200`
75
+
76
+ Camera bounds must include the full diagram plus padding. Leave at least `80px` padding around all
77
+ visible content.
78
+
79
+ Example:
80
+
81
+ ```json
82
+ { "type": "cameraUpdate", "x": 20, "y": 0, "width": 1200, "height": 900 }
83
+ ```
84
+
85
+ If a title, footer, or rightmost label is clipped, the camera is wrong even if the elements are valid.
86
+
87
+ ## Good Pattern
88
+
89
+ ```json
90
+ [
91
+ { "type": "cameraUpdate", "x": 20, "y": 0, "width": 1200, "height": 900 },
92
+ {
93
+ "type": "rectangle",
94
+ "id": "a",
95
+ "x": 120,
96
+ "y": 160,
97
+ "width": 260,
98
+ "height": 90,
99
+ "backgroundColor": "#a5d8ff",
100
+ "fillStyle": "solid",
101
+ "label": { "text": "Short Label", "fontSize": 18 }
102
+ },
103
+ {
104
+ "type": "rectangle",
105
+ "id": "b",
106
+ "x": 520,
107
+ "y": 160,
108
+ "width": 280,
109
+ "height": 90,
110
+ "backgroundColor": "#b2f2bb",
111
+ "fillStyle": "solid",
112
+ "label": { "text": "Next Step", "fontSize": 18 }
113
+ },
114
+ {
115
+ "type": "arrow",
116
+ "id": "a-to-b",
117
+ "x": 390,
118
+ "y": 205,
119
+ "width": 110,
120
+ "height": 0,
121
+ "points": [[0, 0], [110, 0]],
122
+ "endArrowhead": "arrow",
123
+ "label": { "text": "then", "fontSize": 14 }
124
+ },
125
+ {
126
+ "type": "text",
127
+ "id": "note",
128
+ "x": 120,
129
+ "y": 290,
130
+ "text": "Longer explanation goes here, outside the shape.",
131
+ "fontSize": 16
132
+ }
133
+ ]
134
+ ```
135
+
136
+ ## Preflight Checklist
137
+
138
+ - Shape text uses `label`, not separate `text` elements.
139
+ - Shape labels are short enough to fit.
140
+ - Long explanations are outside shapes.
141
+ - The first element is a 4:3 `cameraUpdate`.
142
+ - Camera has at least `80px` padding around all visible content.
143
+ - Titles and footers are not near the camera edge.
144
+ - Arrows have explicit `points` and enough space for labels.
145
+ - Calls go through PMX (`canvas_add_diagram` or `external-app add --kind excalidraw`) unless you manually apply these rules to raw Excalidraw MCP input.
package/src/mcp/server.ts CHANGED
@@ -29,7 +29,13 @@ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './ca
29
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
30
  import { wrapCanvasAutomationScript } from '../server/server.js';
31
31
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
32
- import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode, summarizeCanvasAnnotationForContext } from '../server/canvas-serialization.js';
32
+ import {
33
+ getCanvasNodeTitle,
34
+ serializeCanvasLayoutForAgent,
35
+ serializeCanvasNode,
36
+ serializeCanvasNodeForAgent,
37
+ summarizeCanvasAnnotationForContext,
38
+ } from '../server/canvas-serialization.js';
33
39
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
34
40
 
35
41
  let canvas: CanvasAccess | null = null;
@@ -207,7 +213,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
207
213
 
208
214
  function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
209
215
  return {
210
- ...serializeCanvasLayout(layout),
216
+ ...serializeCanvasLayoutForAgent(layout),
211
217
  annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
212
218
  };
213
219
  }
@@ -240,7 +246,7 @@ async function createdNodePayload(c: CanvasAccess, id: string, options: { full?:
240
246
  if (!wantsFullPayload(options)) {
241
247
  return { ok: true, node: compactNodePayload(node), id };
242
248
  }
243
- const serialized = serializeCanvasNode(node);
249
+ const serialized = serializeCanvasNodeForAgent(node);
244
250
  return { ok: true, node: serialized, ...serialized };
245
251
  }
246
252
 
@@ -324,7 +330,7 @@ export async function startMcpServer(): Promise<void> {
324
330
  isError: true,
325
331
  };
326
332
  }
327
- const payload = wantsFullPayload(input) ? serializeCanvasNode(node) : compactNodePayload(node);
333
+ const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
328
334
  return {
329
335
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
330
336
  };
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { canvasState } from './canvas-state.js';
2
3
  import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
3
4
  import {
@@ -47,6 +48,13 @@ interface BlobSummary {
47
48
  sha256: string;
48
49
  }
49
50
 
51
+ interface ExternalMcpAppHtmlSummary {
52
+ omitted: 'external-mcp-app-html';
53
+ resourceUri: string;
54
+ bytes: number;
55
+ sha256: string;
56
+ }
57
+
50
58
  function pickString(value: unknown): string | null {
51
59
  return typeof value === 'string' && value.length > 0 ? value : null;
52
60
  }
@@ -90,6 +98,39 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
90
98
  };
91
99
  }
92
100
 
101
+ function summarizeExternalMcpAppHtml(node: SerializedCanvasNode): Record<string, unknown> {
102
+ const html = node.data.html;
103
+ const resourceUri = node.data.resourceUri;
104
+ if (
105
+ node.type !== 'mcp-app' ||
106
+ node.data.mode !== 'ext-app' ||
107
+ typeof html !== 'string' ||
108
+ html.length === 0 ||
109
+ typeof resourceUri !== 'string' ||
110
+ resourceUri.length === 0
111
+ ) {
112
+ return node.data;
113
+ }
114
+
115
+ return {
116
+ ...node.data,
117
+ html: {
118
+ omitted: 'external-mcp-app-html',
119
+ resourceUri,
120
+ bytes: Buffer.byteLength(html, 'utf-8'),
121
+ sha256: createHash('sha256').update(html).digest('hex'),
122
+ } satisfies ExternalMcpAppHtmlSummary,
123
+ };
124
+ }
125
+
126
+ export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode {
127
+ const serialized = serializeCanvasNode(node);
128
+ return {
129
+ ...serialized,
130
+ data: summarizeExternalMcpAppHtml(serialized),
131
+ };
132
+ }
133
+
93
134
  function summarizeBlobValue(value: unknown): unknown {
94
135
  if (!canvasState.isBlobReference(value)) return value;
95
136
  return {
@@ -117,6 +158,13 @@ export function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLay
117
158
  };
118
159
  }
119
160
 
161
+ export function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout {
162
+ return {
163
+ ...layout,
164
+ nodes: layout.nodes.map(serializeCanvasNodeForAgent),
165
+ };
166
+ }
167
+
120
168
  export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout {
121
169
  return {
122
170
  ...layout,