pmx-canvas 0.6.2 → 0.6.4
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 +31 -0
- package/Readme.md +30 -3
- package/dist/canvas/global.css +32 -0
- package/dist/canvas/index.js +68 -66
- package/dist/json-render/index.js +57 -57
- package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
- package/dist/types/client/canvas/auto-fit.d.ts +3 -0
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +3 -3
- package/dist/types/client/state/human-store.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +20 -20
- package/dist/types/json-render/schema.d.ts +40 -0
- package/dist/types/mcp/skills.d.ts +13 -0
- package/dist/types/server/bundled-skills.d.ts +32 -4
- package/docs/cli.md +7 -2
- package/docs/http-api.md +5 -1
- package/docs/mcp.md +35 -1
- package/docs/node-types.md +43 -1
- package/docs/product-context-vision-2026-09.md +17 -6
- package/docs/product-vision-2026-09.md +136 -41
- package/docs/product-vision-review-2026-09.md +277 -0
- package/package.json +11 -11
- package/skills/json-render-mcp/SKILL.md +118 -2
- package/skills/pmx-canvas/SKILL.md +43 -13
- package/skills/pmx-canvas/evals/evals.json +46 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
- package/src/client/canvas/FeedbackDialog.tsx +86 -0
- package/src/client/canvas/ToolRail.tsx +225 -189
- package/src/client/canvas/auto-fit.ts +20 -1
- package/src/client/nodes/use-iframe-content-height.ts +44 -24
- package/src/client/state/human-store.ts +6 -6
- package/src/client/theme/global.css +32 -0
- package/src/json-render/catalog.ts +6 -0
- package/src/json-render/renderer/index.tsx +17 -0
- package/src/json-render/schema.ts +4 -0
- package/src/mcp/server.ts +9 -4
- package/src/mcp/skills.ts +47 -0
- package/src/server/bundled-skills.ts +92 -53
- package/src/server/operations/ops/nodes.ts +43 -11
- package/src/shared/content-height-reporter.ts +4 -2
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { effect } from '@preact/signals';
|
|
2
|
+
import { useEffect } from 'preact/hooks';
|
|
3
|
+
import { nodes, updateNode } from '../state/canvas-store';
|
|
4
|
+
import { grabbingNodeId, otherHumans } from '../state/human-store';
|
|
5
|
+
import { pushCanvasUpdate } from '../state/intent-bridge';
|
|
6
|
+
import { computeContentGrowHeight, contentFitPosition } from '../canvas/auto-fit';
|
|
4
7
|
import type { CanvasNodeState } from '../types';
|
|
5
8
|
|
|
6
9
|
/**
|
|
@@ -11,43 +14,60 @@ import type { CanvasNodeState } from '../types';
|
|
|
11
14
|
* because growth is monotonic with a dead-band — cannot oscillate. This is the
|
|
12
15
|
* fix for iframe nodes whose body scrollHeight the parent can't measure.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Debounce before changing geometry, retaining the latest measurement during a
|
|
18
|
+
* human grab. Read current geometry at application time; persist only this node.
|
|
19
|
+
* Relocations are undoable, height-only adjustments do not fill the undo stack.
|
|
17
20
|
*/
|
|
18
21
|
export function useIframeContentHeight(
|
|
19
22
|
node: CanvasNodeState,
|
|
20
23
|
iframeRef: { current: HTMLIFrameElement | null },
|
|
21
24
|
frameToken: string,
|
|
22
25
|
): void {
|
|
23
|
-
const nodeRef = useRef(node);
|
|
24
|
-
nodeRef.current = node;
|
|
25
|
-
const persistTimer = useRef<number | null>(null);
|
|
26
|
-
|
|
27
26
|
useEffect(() => {
|
|
28
27
|
if (!frameToken) return undefined;
|
|
28
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
let pendingHeight: number | null = null;
|
|
30
|
+
const held = () =>
|
|
31
|
+
grabbingNodeId.value === node.id || otherHumans.value.some((human) => human.grabbingNodeId === node.id);
|
|
32
|
+
function apply() {
|
|
33
|
+
timer = null;
|
|
34
|
+
if (held() || pendingHeight === null) return;
|
|
35
|
+
const reported = pendingHeight;
|
|
36
|
+
pendingHeight = null;
|
|
37
|
+
const current = nodes.value.get(node.id);
|
|
38
|
+
if (!current) return;
|
|
39
|
+
const target = computeContentGrowHeight(current, reported);
|
|
40
|
+
if (target === null) return;
|
|
41
|
+
const position = contentFitPosition(current, target, Array.from(nodes.value.values()));
|
|
42
|
+
const moved = position.x !== current.position.x || position.y !== current.position.y;
|
|
43
|
+
const patch = {
|
|
44
|
+
size: { width: current.size.width, height: target },
|
|
45
|
+
...(moved ? { position } : {}),
|
|
46
|
+
};
|
|
47
|
+
updateNode(current.id, patch);
|
|
48
|
+
void pushCanvasUpdate([{ id: current.id, ...patch }], { recordHistory: moved });
|
|
49
|
+
}
|
|
50
|
+
function schedule() {
|
|
51
|
+
if (timer !== null) clearTimeout(timer);
|
|
52
|
+
timer = pendingHeight !== null && !held() ? setTimeout(apply, 300) : null;
|
|
53
|
+
}
|
|
54
|
+
const dispose = effect(() => {
|
|
55
|
+
held();
|
|
56
|
+
schedule();
|
|
57
|
+
});
|
|
29
58
|
function onMessage(event: MessageEvent) {
|
|
30
59
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
31
60
|
const d = event.data as { source?: string; type?: string; token?: string; height?: unknown } | null;
|
|
32
61
|
if (!d || d.source !== 'pmx-canvas-frame' || d.type !== 'content-height' || d.token !== frameToken) return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (target === null) return;
|
|
37
|
-
resizeNode(current.id, { width: current.size.width, height: target });
|
|
38
|
-
if (persistTimer.current !== null) window.clearTimeout(persistTimer.current);
|
|
39
|
-
persistTimer.current = window.setTimeout(() => {
|
|
40
|
-
persistLayout({ recordHistory: false });
|
|
41
|
-
persistTimer.current = null;
|
|
42
|
-
}, 300);
|
|
62
|
+
if (typeof d.height !== 'number' || !Number.isFinite(d.height) || d.height <= 0) return;
|
|
63
|
+
pendingHeight = d.height;
|
|
64
|
+
schedule();
|
|
43
65
|
}
|
|
44
66
|
window.addEventListener('message', onMessage);
|
|
45
67
|
return () => {
|
|
46
68
|
window.removeEventListener('message', onMessage);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
persistTimer.current = null;
|
|
50
|
-
}
|
|
69
|
+
dispose();
|
|
70
|
+
if (timer !== null) clearTimeout(timer);
|
|
51
71
|
};
|
|
52
72
|
}, [node.id, frameToken]);
|
|
53
73
|
}
|
|
@@ -58,7 +58,7 @@ export function applyHumanSnapshot(snapshot: Partial<HumanPresenceSnapshot> | nu
|
|
|
58
58
|
humans.value = snapshot.humans;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
export const grabbingNodeId = signal<string | null>(null);
|
|
62
62
|
let lastCursor: { x: number; y: number } | null = null;
|
|
63
63
|
let lastSentAt = 0;
|
|
64
64
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -74,7 +74,7 @@ function post(body: Record<string, unknown>): void {
|
|
|
74
74
|
function flushCursor(): void {
|
|
75
75
|
pending = null;
|
|
76
76
|
lastSentAt = Date.now();
|
|
77
|
-
post({ cursor: lastCursor, grabbingNodeId:
|
|
77
|
+
post({ cursor: lastCursor, grabbingNodeId: grabbingNodeId.value });
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** Report the pointer in world coordinates (throttled); null when it leaves the canvas. */
|
|
@@ -91,7 +91,7 @@ export function reportHumanCursor(cursor: { x: number; y: number } | null): void
|
|
|
91
91
|
|
|
92
92
|
/** Hold / release a node (drag, rename): the edit lock agents must respect. */
|
|
93
93
|
export function reportHumanGrab(nodeId: string | null): void {
|
|
94
|
-
|
|
94
|
+
grabbingNodeId.value = nodeId;
|
|
95
95
|
post({ cursor: lastCursor, grabbingNodeId: nodeId });
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -147,8 +147,8 @@ let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
|
147
147
|
|
|
148
148
|
/** Announce this tab and keep it alive; renews a held grab. Call once per connection. */
|
|
149
149
|
export function startHumanPresence(): () => void {
|
|
150
|
-
post({ cursor: lastCursor, grabbingNodeId:
|
|
151
|
-
heartbeat = setInterval(() => post({ grabbingNodeId:
|
|
150
|
+
post({ cursor: lastCursor, grabbingNodeId: grabbingNodeId.value });
|
|
151
|
+
heartbeat = setInterval(() => post({ grabbingNodeId: grabbingNodeId.value }), 3000);
|
|
152
152
|
const leave = () => {
|
|
153
153
|
try {
|
|
154
154
|
navigator.sendBeacon?.(
|
|
@@ -170,6 +170,6 @@ export function startHumanPresence(): () => void {
|
|
|
170
170
|
export function resetHumanPresence(): void {
|
|
171
171
|
humans.value = [];
|
|
172
172
|
yieldedNodes.value = new Map();
|
|
173
|
-
|
|
173
|
+
grabbingNodeId.value = null;
|
|
174
174
|
lastCursor = null;
|
|
175
175
|
}
|
|
@@ -4474,6 +4474,38 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
4474
4474
|
font-weight: 600;
|
|
4475
4475
|
}
|
|
4476
4476
|
|
|
4477
|
+
.feedback-backdrop {
|
|
4478
|
+
position: fixed;
|
|
4479
|
+
padding: 24px 0;
|
|
4480
|
+
align-items: center;
|
|
4481
|
+
}
|
|
4482
|
+
.feedback-dialog {
|
|
4483
|
+
width: min(480px, calc(100% - 32px));
|
|
4484
|
+
max-height: calc(100dvh - 48px);
|
|
4485
|
+
overflow-y: auto;
|
|
4486
|
+
color: var(--c-text);
|
|
4487
|
+
}
|
|
4488
|
+
.feedback-dialog label {
|
|
4489
|
+
display: flex;
|
|
4490
|
+
flex-direction: column;
|
|
4491
|
+
gap: 6px;
|
|
4492
|
+
font-size: 12px;
|
|
4493
|
+
}
|
|
4494
|
+
.feedback-note {
|
|
4495
|
+
margin: 0;
|
|
4496
|
+
color: var(--c-muted);
|
|
4497
|
+
font-size: 12px;
|
|
4498
|
+
line-height: 1.5;
|
|
4499
|
+
}
|
|
4500
|
+
.feedback-dialog textarea {
|
|
4501
|
+
resize: vertical;
|
|
4502
|
+
min-height: 100px;
|
|
4503
|
+
}
|
|
4504
|
+
.feedback-dialog button:disabled {
|
|
4505
|
+
opacity: 0.5;
|
|
4506
|
+
cursor: not-allowed;
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4477
4509
|
/* Selection action bar — floating bottom-center of the canvas region
|
|
4478
4510
|
(rail-chrome-v2 phase 7, item 13). */
|
|
4479
4511
|
/* In a session the command bar owns the bottom-center slot — lift the
|
|
@@ -20,6 +20,12 @@ const buttonDefinition = shadcnComponentDefinitions.Button;
|
|
|
20
20
|
|
|
21
21
|
export const allComponentDefinitions = {
|
|
22
22
|
...shadcnComponentDefinitions,
|
|
23
|
+
Card: {
|
|
24
|
+
...shadcnComponentDefinitions.Card,
|
|
25
|
+
slots: ['default', 'header', 'footer'],
|
|
26
|
+
description:
|
|
27
|
+
'Card container. Use children for its body and optional header/footer slots for custom headings or actions. A header slot replaces the title and description props.',
|
|
28
|
+
},
|
|
23
29
|
Badge: {
|
|
24
30
|
...badgeDefinition,
|
|
25
31
|
props: badgeDefinition.props.extend({
|
|
@@ -69,9 +69,26 @@ function Button({ props, emit }: { props: ButtonProps; emit: (event: string) =>
|
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function Card({ props, children, slots, ...rest }: Parameters<typeof shadcnComponents.Card>[0]) {
|
|
73
|
+
const header = slots?.header;
|
|
74
|
+
const footer = slots?.footer;
|
|
75
|
+
return (
|
|
76
|
+
<shadcnComponents.Card {...rest} props={header ? { ...props, title: null, description: null } : props}>
|
|
77
|
+
{header && <header data-pmx-card-slot="header">{header}</header>}
|
|
78
|
+
{children}
|
|
79
|
+
{footer && (
|
|
80
|
+
<footer data-pmx-card-slot="footer" className="flex flex-wrap items-center gap-3">
|
|
81
|
+
{footer}
|
|
82
|
+
</footer>
|
|
83
|
+
)}
|
|
84
|
+
</shadcnComponents.Card>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
const { registry } = defineRegistry(catalog as never, {
|
|
73
89
|
components: {
|
|
74
90
|
...shadcnComponents,
|
|
91
|
+
Card,
|
|
75
92
|
Badge,
|
|
76
93
|
Button,
|
|
77
94
|
...chartComponents,
|
|
@@ -4,12 +4,16 @@ export const schema = defineSchema(
|
|
|
4
4
|
(s) => ({
|
|
5
5
|
spec: s.object({
|
|
6
6
|
root: s.string(),
|
|
7
|
+
state: { ...s.record(s.any()), ...s.optional() },
|
|
7
8
|
elements: s.record(
|
|
8
9
|
s.object({
|
|
9
10
|
type: s.ref('catalog.components'),
|
|
10
11
|
props: s.propsOf('catalog.components'),
|
|
11
12
|
children: s.array(s.string()),
|
|
12
13
|
visible: s.any(),
|
|
14
|
+
repeat: { ...s.any(), ...s.optional() },
|
|
15
|
+
watch: { ...s.record(s.any()), ...s.optional() },
|
|
16
|
+
slots: { ...s.record(s.array(s.string())), ...s.optional() },
|
|
13
17
|
// Event→action bindings (on.press, on.change, …). Preserved through
|
|
14
18
|
// validation so spec authors can wire actions — including the ax.*
|
|
15
19
|
// handlers the viewer forwards to the canvas AX bridge.
|
package/src/mcp/server.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
summarizeCanvasAnnotationForContext,
|
|
42
42
|
} from '../server/canvas-serialization.js';
|
|
43
43
|
import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
|
|
44
|
+
import { registerSkillExtension } from './skills.js';
|
|
44
45
|
|
|
45
46
|
let canvas: CanvasAccess | null = null;
|
|
46
47
|
let resourceNotificationServer: McpServer | null = null;
|
|
@@ -212,11 +213,15 @@ function buildSummaryFromLayout(
|
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
export async function startMcpServer(): Promise<void> {
|
|
215
|
-
const server = new McpServer(
|
|
216
|
-
name: 'pmx-canvas',
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
const server = new McpServer(
|
|
217
|
+
{ name: 'pmx-canvas', version: readPackageVersion() },
|
|
218
|
+
{
|
|
219
|
+
instructions:
|
|
220
|
+
'Bundled workbench guidance: skill://pmx-canvas/SKILL.md. Discover skills via skills/list when supported, or canvas://skills; read files through resources/read. Skill delivery does not grant permission to execute bundled scripts.',
|
|
221
|
+
},
|
|
222
|
+
);
|
|
219
223
|
resourceNotificationServer = server;
|
|
224
|
+
registerSkillExtension(server);
|
|
220
225
|
|
|
221
226
|
// ── Operation-registry tools (plan-005) ────────────────────────
|
|
222
227
|
// Standalone tools are registered from the shared operation registry for
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { createBundledSkillCatalog } from '../server/bundled-skills.js';
|
|
5
|
+
|
|
6
|
+
/** SEP-2640 methods are protocol requests, not model-facing canvas tools. */
|
|
7
|
+
export function registerSkillExtension(server: McpServer, catalog = createBundledSkillCatalog()): void {
|
|
8
|
+
// The v1 SDK accepts custom methods and transmits capabilities unchanged,
|
|
9
|
+
// but its ServerCapabilities type predates the extensions field.
|
|
10
|
+
const capabilities = { resources: {}, extensions: { 'io.modelcontextprotocol/skills': {} } };
|
|
11
|
+
server.server.registerCapabilities(capabilities);
|
|
12
|
+
server.server.setRequestHandler(
|
|
13
|
+
z.object({ method: z.literal('skills/list'), params: z.object({ cursor: z.string().optional() }).optional() }),
|
|
14
|
+
async ({ params }) => {
|
|
15
|
+
// The bounded package catalog is a single page, so no cursor is issued.
|
|
16
|
+
if (params?.cursor !== undefined) throw new McpError(ErrorCode.InvalidParams, 'Unknown skills cursor');
|
|
17
|
+
return { resultType: 'complete', skills: catalog.skills };
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
server.server.setRequestHandler(
|
|
21
|
+
z.object({ method: z.literal('skills/get'), params: z.object({ uri: z.string() }) }),
|
|
22
|
+
async ({ params }) => {
|
|
23
|
+
const skill = catalog.skills.find((entry) => entry.uri === params.uri);
|
|
24
|
+
if (!skill) throw new McpError(ErrorCode.InvalidParams, `No skill is served at ${params.uri}`);
|
|
25
|
+
return { resultType: 'complete', skill };
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
server.registerResource(
|
|
29
|
+
'skill-files',
|
|
30
|
+
new ResourceTemplate('skill://{name}/{+path}', { list: undefined }),
|
|
31
|
+
{ description: 'Packaged skill files. Discover complete manifests through skills/list or skills/get.' },
|
|
32
|
+
async (uri) => {
|
|
33
|
+
// Exact catalog lookup: a URI never becomes a filesystem path.
|
|
34
|
+
const content = catalog.files.get(uri.href);
|
|
35
|
+
if (!content) throw new McpError(ErrorCode.InvalidParams, `No skill file is served at ${uri.href}`);
|
|
36
|
+
return { contents: [content] };
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
for (const skill of catalog.skills) {
|
|
40
|
+
server.registerResource(
|
|
41
|
+
skill.frontmatter.name,
|
|
42
|
+
skill.uri,
|
|
43
|
+
{ mimeType: 'text/markdown', description: skill.frontmatter.description },
|
|
44
|
+
async () => ({ contents: [catalog.files.get(skill.uri)!] }),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -17,15 +17,29 @@
|
|
|
17
17
|
* - `canvas://skills/<name>` → full markdown content
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
22
|
import { dirname, join, resolve } from 'node:path';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
|
|
26
|
+
const skillFrontmatterSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
name: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(64)
|
|
32
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
33
|
+
description: z.string().min(1).max(1024),
|
|
34
|
+
})
|
|
35
|
+
.passthrough();
|
|
23
36
|
|
|
24
37
|
export interface BundledSkill {
|
|
25
38
|
name: string;
|
|
26
39
|
description: string;
|
|
27
40
|
uri: string;
|
|
28
41
|
filePath: string;
|
|
42
|
+
frontmatter: z.infer<typeof skillFrontmatterSchema>;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
const MAX_DESCRIPTION_LENGTH = 400;
|
|
@@ -65,52 +79,18 @@ export function findBundledSkillsRoot(): string | null {
|
|
|
65
79
|
return null;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const markdown = rawMarkdown.replace(/\r\n/g, '\n');
|
|
73
|
-
// YAML frontmatter lives between two `---` fences at the very top.
|
|
74
|
-
if (!markdown.startsWith('---')) return '';
|
|
75
|
-
const end = markdown.indexOf('\n---', 3);
|
|
76
|
-
if (end === -1) return '';
|
|
77
|
-
const frontmatter = markdown.slice(3, end);
|
|
78
|
-
const lines = frontmatter.split('\n');
|
|
79
|
-
|
|
80
|
-
// Support both single-line `description: ...` and block-scalar `description: >` /
|
|
81
|
-
// `description: |` forms (indented continuation lines).
|
|
82
|
-
for (let i = 0; i < lines.length; i++) {
|
|
83
|
-
const line = lines[i] ?? '';
|
|
84
|
-
const match = /^description:\s*(.*)$/.exec(line);
|
|
85
|
-
if (!match) continue;
|
|
86
|
-
const first = (match[1] ?? '').trim();
|
|
87
|
-
if (first && first !== '>' && first !== '|' && first !== '>-' && first !== '|-') {
|
|
88
|
-
return first.slice(0, MAX_DESCRIPTION_LENGTH);
|
|
89
|
-
}
|
|
90
|
-
// Block scalar — concatenate indented follow-on lines.
|
|
91
|
-
const parts: string[] = [];
|
|
92
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
93
|
-
const follow = lines[j] ?? '';
|
|
94
|
-
if (follow.length === 0) {
|
|
95
|
-
parts.push('');
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (!/^\s/.test(follow)) break;
|
|
99
|
-
parts.push(follow.trim());
|
|
100
|
-
}
|
|
101
|
-
return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, MAX_DESCRIPTION_LENGTH);
|
|
102
|
-
}
|
|
103
|
-
return '';
|
|
82
|
+
function parseFrontmatter(markdown: string): BundledSkill['frontmatter'] {
|
|
83
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
|
|
84
|
+
if (!match) throw new Error('Bundled skill is missing YAML frontmatter');
|
|
85
|
+
return skillFrontmatterSchema.parse(Bun.YAML.parse(match[1]!));
|
|
104
86
|
}
|
|
105
87
|
|
|
106
88
|
/**
|
|
107
89
|
* Enumerate every `<name>/SKILL.md` under the bundled skills root and return
|
|
108
|
-
* a compact index.
|
|
109
|
-
*
|
|
110
|
-
* break the MCP server's resource listing.
|
|
90
|
+
* a compact index. Invalid packaged skills fail explicitly rather than being
|
|
91
|
+
* silently omitted from the Skills extension's integrity manifest.
|
|
111
92
|
*/
|
|
112
|
-
export function listBundledSkills(): BundledSkill[] {
|
|
113
|
-
const root = findBundledSkillsRoot();
|
|
93
|
+
export function listBundledSkills(root = findBundledSkillsRoot()): BundledSkill[] {
|
|
114
94
|
if (!root) return [];
|
|
115
95
|
const entries = readdirSync(root);
|
|
116
96
|
const skills: BundledSkill[] = [];
|
|
@@ -118,18 +98,19 @@ export function listBundledSkills(): BundledSkill[] {
|
|
|
118
98
|
if (entry.startsWith('.')) continue;
|
|
119
99
|
const skillFile = join(root, entry, 'SKILL.md');
|
|
120
100
|
if (!existsSync(skillFile)) continue;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const description = parseFrontmatterDescription(markdown);
|
|
124
|
-
skills.push({
|
|
125
|
-
name: entry,
|
|
126
|
-
description,
|
|
127
|
-
uri: `canvas://skills/${entry}`,
|
|
128
|
-
filePath: skillFile,
|
|
129
|
-
});
|
|
130
|
-
} catch {
|
|
131
|
-
// skip unreadable files
|
|
101
|
+
if (lstatSync(join(root, entry)).isSymbolicLink() || lstatSync(skillFile).isSymbolicLink()) {
|
|
102
|
+
throw new Error(`Bundled skill contains a symbolic link: ${entry}`);
|
|
132
103
|
}
|
|
104
|
+
if (!lstatSync(skillFile).isFile()) throw new Error(`Bundled skill contains a non-regular file: ${skillFile}`);
|
|
105
|
+
const frontmatter = parseFrontmatter(readFileSync(skillFile, 'utf-8'));
|
|
106
|
+
if (frontmatter.name !== entry) throw new Error(`Bundled skill name does not match directory: ${entry}`);
|
|
107
|
+
skills.push({
|
|
108
|
+
name: entry,
|
|
109
|
+
description: frontmatter.description.slice(0, MAX_DESCRIPTION_LENGTH),
|
|
110
|
+
uri: `canvas://skills/${entry}`,
|
|
111
|
+
filePath: skillFile,
|
|
112
|
+
frontmatter,
|
|
113
|
+
});
|
|
133
114
|
}
|
|
134
115
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
135
116
|
return skills;
|
|
@@ -145,3 +126,61 @@ export function readBundledSkill(name: string): string | null {
|
|
|
145
126
|
return null;
|
|
146
127
|
}
|
|
147
128
|
}
|
|
129
|
+
|
|
130
|
+
export interface SkillEntry {
|
|
131
|
+
uri: string;
|
|
132
|
+
frontmatter: BundledSkill['frontmatter'];
|
|
133
|
+
resources: Array<{ uri: string; digest: string; size: number }>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type SkillContent = { uri: string; mimeType: string } & ({ text: string } | { blob: string });
|
|
137
|
+
|
|
138
|
+
/** Capture immutable package bytes once per MCP process, never read caller-supplied paths. */
|
|
139
|
+
export function createBundledSkillCatalog(root = findBundledSkillsRoot()) {
|
|
140
|
+
const skills: SkillEntry[] = [];
|
|
141
|
+
const files = new Map<string, SkillContent>();
|
|
142
|
+
for (const skill of listBundledSkills(root)) {
|
|
143
|
+
const entry: SkillEntry = { uri: `skill://${skill.name}/SKILL.md`, frontmatter: skill.frontmatter, resources: [] };
|
|
144
|
+
let totalBytes = 0;
|
|
145
|
+
function walk(directory: string, prefix = ''): void {
|
|
146
|
+
for (const child of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
147
|
+
a.name.localeCompare(b.name),
|
|
148
|
+
)) {
|
|
149
|
+
const filePath = join(directory, child.name);
|
|
150
|
+
const relativePath = prefix + encodeURIComponent(child.name);
|
|
151
|
+
if (child.isSymbolicLink()) throw new Error(`Bundled skill contains a symbolic link: ${filePath}`);
|
|
152
|
+
if (child.isDirectory()) {
|
|
153
|
+
walk(filePath, `${relativePath}/`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!child.isFile()) throw new Error(`Bundled skill contains a non-regular file: ${filePath}`);
|
|
157
|
+
totalBytes += lstatSync(filePath).size;
|
|
158
|
+
if (entry.resources.length >= 512 || totalBytes > 16 * 1024 * 1024) {
|
|
159
|
+
throw new Error(`Bundled skill exceeds SEP-2640 file/byte limits: ${skill.name}`);
|
|
160
|
+
}
|
|
161
|
+
const bytes = readFileSync(filePath);
|
|
162
|
+
const uri = `skill://${skill.name}/${relativePath}`;
|
|
163
|
+
const mimeType = child.name.endsWith('.md') ? 'text/markdown' : Bun.file(filePath).type;
|
|
164
|
+
// Round-trip detection preserves invalid UTF-8 and binary assets as blobs.
|
|
165
|
+
const text = bytes.toString('utf8');
|
|
166
|
+
const content =
|
|
167
|
+
!bytes.includes(0) && Buffer.from(text).equals(bytes)
|
|
168
|
+
? { uri, mimeType, text }
|
|
169
|
+
: { uri, mimeType, blob: bytes.toString('base64') };
|
|
170
|
+
files.set(uri, content);
|
|
171
|
+
entry.resources.push({
|
|
172
|
+
uri,
|
|
173
|
+
size: bytes.length,
|
|
174
|
+
digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`,
|
|
175
|
+
});
|
|
176
|
+
if (uri === entry.uri) entry.frontmatter = parseFrontmatter(text);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
walk(dirname(skill.filePath));
|
|
180
|
+
if (entry.frontmatter.name !== skill.name)
|
|
181
|
+
throw new Error(`Bundled skill name changed during loading: ${skill.name}`);
|
|
182
|
+
entry.resources.sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
|
183
|
+
skills.push(entry);
|
|
184
|
+
}
|
|
185
|
+
return { skills, files };
|
|
186
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This module must never import server.ts or index.ts.
|
|
10
10
|
*/
|
|
11
|
+
import { resolve } from 'node:path';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { canvasState, type CanvasAnnotation, type CanvasLayout, type CanvasNodeState } from '../../canvas-state.js';
|
|
13
14
|
import {
|
|
@@ -1145,7 +1146,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1145
1146
|
},
|
|
1146
1147
|
},
|
|
1147
1148
|
handler: async (input, ctx) => {
|
|
1148
|
-
|
|
1149
|
+
let body: Record<string, unknown> = input;
|
|
1149
1150
|
const id = input.id;
|
|
1150
1151
|
const existing = canvasState.getNode(id);
|
|
1151
1152
|
if (!existing) throw new OperationError(`Node "${id}" not found.`, 404);
|
|
@@ -1162,7 +1163,48 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1162
1163
|
const result = await refreshCanvasWebpageNode(id, { ...(url ? { url } : {}) });
|
|
1163
1164
|
return result as unknown as Record<string, unknown>;
|
|
1164
1165
|
}
|
|
1166
|
+
|
|
1167
|
+
let nextFilePath: string | null = null;
|
|
1168
|
+
let nextFileContent: ReturnType<typeof readFileNodeContent> | null = null;
|
|
1169
|
+
if (existing.type === 'file') {
|
|
1170
|
+
const nestedData = isRecord(body.data) ? body.data : undefined;
|
|
1171
|
+
const hasTopLevelPath = body.path !== undefined;
|
|
1172
|
+
const hasNestedPath = nestedData?.path !== undefined;
|
|
1173
|
+
if (hasTopLevelPath || hasNestedPath) {
|
|
1174
|
+
const normalizePath = (value: unknown): string => {
|
|
1175
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
1176
|
+
throw new OperationError('File node "path" must be a non-empty string.');
|
|
1177
|
+
}
|
|
1178
|
+
return resolve(canvasState.getWorkspaceRoot(), value.trim());
|
|
1179
|
+
};
|
|
1180
|
+
const normalizedTopLevelPath = hasTopLevelPath ? normalizePath(body.path) : undefined;
|
|
1181
|
+
const normalizedNestedPath = hasNestedPath ? normalizePath(nestedData?.path) : undefined;
|
|
1182
|
+
body = {
|
|
1183
|
+
...body,
|
|
1184
|
+
...(normalizedTopLevelPath ? { path: normalizedTopLevelPath } : {}),
|
|
1185
|
+
...(nestedData
|
|
1186
|
+
? { data: { ...nestedData, ...(normalizedNestedPath ? { path: normalizedNestedPath } : {}) } }
|
|
1187
|
+
: {}),
|
|
1188
|
+
};
|
|
1189
|
+
nextFilePath = normalizedNestedPath ?? normalizedTopLevelPath ?? null;
|
|
1190
|
+
nextFileContent = nextFilePath ? readFileNodeContent(nextFilePath) : null;
|
|
1191
|
+
if (!nextFileContent || nextFileContent.kind === 'unavailable') {
|
|
1192
|
+
throw new OperationError(`File node target "${nextFilePath}" is missing or unreadable.`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1165
1197
|
const { patch, groupChildIds } = buildNodePatch(existing, body);
|
|
1198
|
+
const prevPath = existing.type === 'file' && typeof existing.data.path === 'string' ? existing.data.path : null;
|
|
1199
|
+
if (nextFilePath && nextFileContent && patch.data) {
|
|
1200
|
+
const data = { ...patch.data };
|
|
1201
|
+
const prevBase = prevPath?.split(/[\\/]/).pop();
|
|
1202
|
+
if (typeof body.title !== 'string' && (!data.title || data.title === prevBase)) {
|
|
1203
|
+
data.title = nextFilePath.split(/[\\/]/).pop();
|
|
1204
|
+
}
|
|
1205
|
+
applyFileContentToNodeData(data, nextFileContent);
|
|
1206
|
+
patch.data = data;
|
|
1207
|
+
}
|
|
1166
1208
|
canvasState.updateNode(id, patch);
|
|
1167
1209
|
if (groupChildIds !== undefined && !setGroupChildrenFromApi(id, groupChildIds)) {
|
|
1168
1210
|
throw new OperationError(`Group "${id}" not found.`, 404);
|
|
@@ -1171,20 +1213,10 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1171
1213
|
// watcher IN PLACE — before this, pointing a file node elsewhere meant
|
|
1172
1214
|
// recreating it, which lost its edges, pins, and position.
|
|
1173
1215
|
if (existing.type === 'file') {
|
|
1174
|
-
const prevPath = typeof existing.data.path === 'string' ? existing.data.path : null;
|
|
1175
1216
|
const repointed = canvasState.getNode(id);
|
|
1176
1217
|
const nextPath = repointed && typeof repointed.data.path === 'string' ? repointed.data.path : null;
|
|
1177
1218
|
if (nextPath && nextPath !== prevPath) {
|
|
1178
1219
|
if (prevPath) unwatchFileForNode(id, prevPath);
|
|
1179
|
-
const data = { ...repointed!.data };
|
|
1180
|
-
// The title follows the file unless the caller pinned one in this
|
|
1181
|
-
// patch or had renamed the node away from the old filename.
|
|
1182
|
-
const prevBase = prevPath?.split(/[\\/]/).pop();
|
|
1183
|
-
if (typeof body.title !== 'string' && (!data.title || data.title === prevBase)) {
|
|
1184
|
-
data.title = nextPath.split(/[\\/]/).pop();
|
|
1185
|
-
}
|
|
1186
|
-
applyFileContentToNodeData(data, readFileNodeContent(nextPath));
|
|
1187
|
-
canvasState.updateNode(id, { data });
|
|
1188
1220
|
watchFileForNode(id, nextPath);
|
|
1189
1221
|
scheduleCodeGraphRecompute(() => {
|
|
1190
1222
|
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
@@ -26,8 +26,10 @@ export function contentHeightReporterSource(frameToken: string): string {
|
|
|
26
26
|
`function m(){var d=document.documentElement;return Math.max(d?d.scrollHeight:0,document.body?document.body.scrollHeight:0);}` +
|
|
27
27
|
`function r(){var h=m();if(Math.abs(h-last)<=4)return;last=h;window.parent.postMessage({source:'pmx-canvas-frame',type:'content-height',token:T,height:h},'*');}` +
|
|
28
28
|
`function s(){if(timer)return;timer=setTimeout(function(){timer=null;r();},100);}` +
|
|
29
|
-
`if(document.readyState!=='loading')s();window.addEventListener('load',s);` +
|
|
30
|
-
`try{new ResizeObserver(s).observe(document.documentElement);}catch(e){}
|
|
29
|
+
`if(document.readyState!=='loading')s();window.addEventListener('load',s,true);` +
|
|
30
|
+
`try{new ResizeObserver(s).observe(document.documentElement);}catch(e){}` +
|
|
31
|
+
`new MutationObserver(s).observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true});` +
|
|
32
|
+
`if(document.fonts)document.fonts.ready.then(s);setTimeout(s,60);})();`
|
|
31
33
|
);
|
|
32
34
|
}
|
|
33
35
|
|