pmx-canvas 0.1.20 → 0.1.22

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 (38) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/dist/canvas/global.css +71 -0
  3. package/dist/canvas/index.js +94 -60
  4. package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
  5. package/dist/types/client/types.d.ts +1 -1
  6. package/dist/types/server/canvas-serialization.d.ts +1 -0
  7. package/dist/types/server/html-node-summary.d.ts +2 -0
  8. package/dist/types/server/html-primitives.d.ts +9 -1
  9. package/dist/types/server/index.d.ts +8 -1
  10. package/docs/http-api.md +1 -1
  11. package/docs/mcp.md +4 -0
  12. package/docs/node-types.md +27 -5
  13. package/docs/screenshot.png +0 -0
  14. package/docs/sdk.md +1 -0
  15. package/package.json +1 -1
  16. package/skills/pmx-canvas/SKILL.md +10 -4
  17. package/skills/pmx-canvas/references/html-primitives.md +132 -0
  18. package/src/cli/agent.ts +34 -1
  19. package/src/cli/index.ts +3 -1
  20. package/src/client/App.tsx +1 -1
  21. package/src/client/canvas/CommandPalette.tsx +1 -1
  22. package/src/client/canvas/ExpandedNodeOverlay.tsx +115 -2
  23. package/src/client/canvas/auto-fit.ts +5 -1
  24. package/src/client/nodes/HtmlNode.tsx +125 -13
  25. package/src/client/state/sse-bridge.ts +1 -1
  26. package/src/client/theme/global.css +71 -0
  27. package/src/mcp/canvas-access.ts +31 -1
  28. package/src/mcp/server.ts +17 -3
  29. package/src/server/agent-context.ts +23 -1
  30. package/src/server/canvas-operations.ts +18 -5
  31. package/src/server/canvas-provenance.ts +8 -6
  32. package/src/server/canvas-schema.ts +11 -0
  33. package/src/server/canvas-serialization.ts +36 -5
  34. package/src/server/html-node-summary.ts +141 -0
  35. package/src/server/html-primitives.ts +328 -8
  36. package/src/server/index.ts +22 -3
  37. package/src/server/server.ts +27 -9
  38. package/src/server/spatial-analysis.ts +4 -2
@@ -1,4 +1,5 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import { normalizeHtmlNodeSemanticData } from './html-node-summary.js';
2
3
 
3
4
  export type CanvasNodeType =
4
5
  | 'markdown'
@@ -228,17 +229,18 @@ export function normalizeCanvasNodeData<T extends Record<string, unknown>>(
228
229
  nodeType: CanvasNodeType,
229
230
  data: T,
230
231
  ): T {
231
- const existing = normalizeExistingProvenance(data.provenance);
232
- const inferred = inferCanvasNodeProvenance(nodeType, data);
232
+ const semanticData = nodeType === 'html' ? normalizeHtmlNodeSemanticData(data) : data;
233
+ const existing = normalizeExistingProvenance(semanticData.provenance);
234
+ const inferred = inferCanvasNodeProvenance(nodeType, semanticData);
233
235
  const provenance = mergeProvenance(existing, inferred);
234
236
 
235
237
  if (provenance) {
236
- return { ...data, provenance } as T;
238
+ return { ...semanticData, provenance } as T;
237
239
  }
238
- if ('provenance' in data) {
239
- const nextData = { ...data };
240
+ if ('provenance' in semanticData) {
241
+ const nextData = { ...semanticData };
240
242
  delete nextData.provenance;
241
243
  return nextData as T;
242
244
  }
243
- return data;
245
+ return semanticData;
244
246
  }
@@ -243,6 +243,12 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
243
243
  mcpTool: 'canvas_add_html_node',
244
244
  fields: [
245
245
  { name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
246
+ { name: 'summary', type: 'string', required: false, description: 'Explicit agent-readable summary. If omitted, PMX derives one from visible HTML text.' },
247
+ { name: 'agentSummary', type: 'string', required: false, description: 'Explicit semantic sidecar used by search, pinned context, and spatial context.', aliases: ['agent-summary'] },
248
+ { name: 'embeddedNodeIds', type: 'string[]', required: false, description: 'Canvas node IDs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-node-id'] },
249
+ { name: 'embeddedUrls', type: 'string[]', required: false, description: 'URLs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-url'] },
250
+ { name: 'presentation', type: 'boolean', required: false, description: 'Marks this HTML surface as a fullscreen presentation/deck.' },
251
+ { name: 'slideTitles', type: 'string[]', required: false, description: 'Agent-readable slide titles for presentation HTML.', aliases: ['slide-title'] },
246
252
  { name: 'primitive', type: 'HtmlPrimitiveKind', required: false, description: 'Generate HTML from a built-in communication primitive instead of passing raw HTML.', aliases: ['kind'] },
247
253
  { name: 'data', type: 'record<string, unknown>', required: false, description: 'Primitive data when --primitive is used, or arbitrary node metadata.' },
248
254
  { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
@@ -259,6 +265,9 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
259
265
  },
260
266
  notes: [
261
267
  'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
268
+ 'Normal html nodes are the default. Presentation mode is opt-in via presentation:true or the presentation primitive.',
269
+ 'HTML nodes persist data.contentSummary and data.agentSummary so agents can understand rich visual HTML without parsing the full iframe payload.',
270
+ 'Only presentation-marked HTML nodes expose a browser Present button for fullscreen review; use the presentation primitive for PowerPoint-like decks.',
262
271
  'Use `primitive` / `kind` with `data` to create reusable agent communication artifacts such as choice grids, plans, review sheets, explainers, and editors.',
263
272
  'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
264
273
  ],
@@ -291,6 +300,8 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
291
300
  },
292
301
  notes: [
293
302
  'HTTP callers may POST { type: "html-primitive", kind, data } or { type: "html", primitive: kind, data }; both create a normal html node with primitive metadata.',
303
+ 'Use kind "presentation" only when a PowerPoint-like deck is requested; created nodes persist presentation, slideCount, slideTitles, and optional presentationTheme metadata for agents.',
304
+ 'Presentation primitive data supports theme: "canvas" | "midnight" | "paper" | "aurora" or a custom color object with bg, panel, surface, border, text, textSecondary, textMuted, accent, and colorScheme.',
294
305
  'Interactive editor primitives include copy/export controls so the human can send edited state back to the agent.',
295
306
  ],
296
307
  },
@@ -56,6 +56,13 @@ interface ExternalMcpAppHtmlSummary {
56
56
  sha256: string;
57
57
  }
58
58
 
59
+ interface FileContentSummary {
60
+ omitted: 'file-content';
61
+ bytes: number;
62
+ lineCount: number;
63
+ sha256: string;
64
+ }
65
+
59
66
  function pickString(value: unknown): string | null {
60
67
  return typeof value === 'string' && value.length > 0 ? value : null;
61
68
  }
@@ -76,10 +83,14 @@ export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
76
83
  }
77
84
 
78
85
  export function getCanvasNodeContent(node: CanvasNodeState): string | null {
79
- if (node.type === 'html' && typeof node.data.htmlPrimitive === 'string') {
80
- const primitive = node.data.htmlPrimitive;
86
+ if (node.type === 'html') {
87
+ const primitive = typeof node.data.htmlPrimitive === 'string' ? node.data.htmlPrimitive : null;
81
88
  const description = pickString(node.data.description);
82
- return description ? `${primitive}: ${description}` : primitive;
89
+ return pickString(node.data.agentSummary)
90
+ ?? pickString(node.data.contentSummary)
91
+ ?? (primitive
92
+ ? (description ? `${primitive}: ${description}` : primitive)
93
+ : null);
83
94
  }
84
95
  return pickString(node.data.content)
85
96
  ?? pickString(node.data.fileContent)
@@ -92,12 +103,13 @@ export function getCanvasNodeContent(node: CanvasNodeState): string | null {
92
103
 
93
104
  export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode {
94
105
  const data = normalizeCanvasNodeData(node.type, node.data);
106
+ const normalizedNode = { ...node, data };
95
107
  return {
96
108
  ...node,
97
109
  data,
98
110
  kind: getCanvasNodeKind(node, data),
99
- title: getCanvasNodeTitle(node),
100
- content: getCanvasNodeContent(node),
111
+ title: getCanvasNodeTitle(normalizedNode),
112
+ content: getCanvasNodeContent(normalizedNode),
101
113
  path: pickString(data.path),
102
114
  url: pickString(data.url),
103
115
  provenance: pickProvenance(data.provenance),
@@ -137,6 +149,25 @@ export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCa
137
149
  };
138
150
  }
139
151
 
152
+ export function serializeCanvasNodeCompact(node: CanvasNodeState): SerializedCanvasNode {
153
+ const serialized = serializeCanvasNode(node);
154
+ if (serialized.type !== 'file' || typeof serialized.data.fileContent !== 'string') return serialized;
155
+ const fileContent = serialized.data.fileContent;
156
+ return {
157
+ ...serialized,
158
+ content: serialized.path,
159
+ data: {
160
+ ...serialized.data,
161
+ fileContent: {
162
+ omitted: 'file-content',
163
+ bytes: Buffer.byteLength(fileContent, 'utf-8'),
164
+ lineCount: fileContent.split('\n').length,
165
+ sha256: createHash('sha256').update(fileContent).digest('hex'),
166
+ } satisfies FileContentSummary,
167
+ },
168
+ };
169
+ }
170
+
140
171
  function summarizeBlobValue(value: unknown): unknown {
141
172
  if (!canvasState.isBlobReference(value)) return value;
142
173
  return {
@@ -0,0 +1,141 @@
1
+ const HTML_CONTENT_SUMMARY_MAX_LENGTH = 900;
2
+ const HTML_AGENT_SUMMARY_MAX_LENGTH = 1200;
3
+ const HTML_REFERENCE_LIMIT = 12;
4
+
5
+ function pickString(value: unknown): string | null {
6
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
7
+ }
8
+
9
+ function strings(value: unknown): string[] {
10
+ return Array.isArray(value)
11
+ ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
12
+ : [];
13
+ }
14
+
15
+ function truncateText(value: string, maxLength: number): string {
16
+ if (value.length <= maxLength) return value;
17
+ return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
18
+ }
19
+
20
+ function normalizeWhitespace(value: string): string {
21
+ return value.replace(/\s+/g, ' ').trim();
22
+ }
23
+
24
+ function decodeHtmlEntities(value: string): string {
25
+ const named: Record<string, string> = {
26
+ amp: '&',
27
+ gt: '>',
28
+ lt: '<',
29
+ nbsp: ' ',
30
+ quot: '"',
31
+ apos: "'",
32
+ };
33
+ return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity) => {
34
+ const lower = entity.toLowerCase();
35
+ if (lower.startsWith('#x')) {
36
+ const codePoint = Number.parseInt(lower.slice(2), 16);
37
+ return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
38
+ }
39
+ if (lower.startsWith('#')) {
40
+ const codePoint = Number.parseInt(lower.slice(1), 10);
41
+ return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
42
+ }
43
+ return named[lower] ?? match;
44
+ });
45
+ }
46
+
47
+ export function summarizeHtmlText(html: string): string | null {
48
+ const withoutNoise = html
49
+ .replace(/<!--[\s\S]*?-->/g, ' ')
50
+ .replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
51
+ .replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
52
+ .replace(/<noscript\b[\s\S]*?<\/noscript>/gi, ' ');
53
+ const text = withoutNoise
54
+ .replace(/<(?:h[1-6]|p|li|br|div|section|article|header|footer|main|aside|summary|figcaption|blockquote|tr|td|th)\b[^>]*>/gi, '\n')
55
+ .replace(/<[^>]+>/g, ' ');
56
+ const normalized = normalizeWhitespace(decodeHtmlEntities(text));
57
+ return normalized.length > 0 ? truncateText(normalized, HTML_CONTENT_SUMMARY_MAX_LENGTH) : null;
58
+ }
59
+
60
+ function uniqueLimited(values: string[]): string[] {
61
+ const seen = new Set<string>();
62
+ const unique: string[] = [];
63
+ for (const value of values) {
64
+ const trimmed = value.trim();
65
+ if (!trimmed || seen.has(trimmed)) continue;
66
+ seen.add(trimmed);
67
+ unique.push(trimmed);
68
+ if (unique.length >= HTML_REFERENCE_LIMIT) break;
69
+ }
70
+ return unique;
71
+ }
72
+
73
+ function extractHtmlNodeIds(html: string): string[] {
74
+ const ids: string[] = [];
75
+ for (const match of html.matchAll(/\b(?:node|graph|json-render|web-artifact|mcp-app|group)-[a-z0-9-]+\b/gi)) {
76
+ ids.push(match[0]);
77
+ }
78
+ return uniqueLimited(ids);
79
+ }
80
+
81
+ function extractHtmlUrls(html: string): string[] {
82
+ const urls: string[] = [];
83
+ for (const match of html.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
84
+ const url = match[1]?.trim();
85
+ if (!url) continue;
86
+ if (/^(?:https?:)?\/\//i.test(url) || url.startsWith('/') || url.startsWith('ui://')) {
87
+ urls.push(url);
88
+ }
89
+ }
90
+ return uniqueLimited(urls);
91
+ }
92
+
93
+ function joinSummaryParts(parts: string[]): string | null {
94
+ const summary = parts
95
+ .map((part) => part.trim())
96
+ .filter(Boolean)
97
+ .filter((part, index, all) => all.findIndex((candidate) => candidate === part) === index)
98
+ .join('\n');
99
+ return summary ? truncateText(summary, HTML_AGENT_SUMMARY_MAX_LENGTH) : null;
100
+ }
101
+
102
+ export function normalizeHtmlNodeSemanticData<T extends Record<string, unknown>>(data: T): T {
103
+ const {
104
+ agentSummary: _agentSummary,
105
+ contentSummary: _contentSummary,
106
+ embeddedNodeIds: _embeddedNodeIds,
107
+ embeddedUrls: _embeddedUrls,
108
+ embeddedNodeId: _embeddedNodeId,
109
+ embeddedGraphId: _embeddedGraphId,
110
+ sourceNodeId: _sourceNodeId,
111
+ ...base
112
+ } = data;
113
+
114
+ const html = pickString(base.html);
115
+ const explicitSummary = pickString(base.summary) ?? pickString(base.description);
116
+ const primitive = pickString(base.htmlPrimitive);
117
+ const contentSummary = html ? summarizeHtmlText(html) : null;
118
+ const explicitNodeIds = [
119
+ ...strings(data.embeddedNodeIds),
120
+ pickString(data.embeddedNodeId),
121
+ pickString(data.embeddedGraphId),
122
+ pickString(data.sourceNodeId),
123
+ ].filter((value): value is string => value !== null);
124
+ const embeddedNodeIds = uniqueLimited([...explicitNodeIds, ...(html ? extractHtmlNodeIds(html) : [])]);
125
+ const embeddedUrls = uniqueLimited([...strings(data.embeddedUrls), ...(html ? extractHtmlUrls(html) : [])]);
126
+ const agentSummary = pickString(data.agentSummary) ?? joinSummaryParts([
127
+ primitive ? `HTML primitive: ${primitive}` : '',
128
+ explicitSummary ?? '',
129
+ contentSummary ?? '',
130
+ embeddedNodeIds.length > 0 ? `Embedded canvas nodes: ${embeddedNodeIds.join(', ')}` : '',
131
+ embeddedUrls.length > 0 ? `Embedded URLs: ${embeddedUrls.join(', ')}` : '',
132
+ ]);
133
+
134
+ return {
135
+ ...base,
136
+ ...(contentSummary ? { contentSummary } : {}),
137
+ ...(agentSummary ? { agentSummary } : {}),
138
+ ...(embeddedNodeIds.length > 0 ? { embeddedNodeIds } : {}),
139
+ ...(embeddedUrls.length > 0 ? { embeddedUrls } : {}),
140
+ } as T;
141
+ }