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 +45 -0
- package/Readme.md +12 -5
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +2 -0
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +145 -0
- package/src/mcp/server.ts +10 -4
- package/src/server/canvas-serialization.ts +48 -0
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
|
|
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 /
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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.
|
|
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 {
|
|
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
|
-
...
|
|
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 =
|
|
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) ?
|
|
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,
|