pmx-canvas 0.6.1 → 0.6.3
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 +41 -0
- package/Readme.md +14 -2
- package/dist/canvas/global.css +22 -3
- package/dist/canvas/index.js +64 -64
- package/dist/json-render/index.js +57 -57
- package/dist/types/client/canvas/auto-fit.d.ts +3 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- 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/http-api.md +5 -1
- package/docs/mcp.md +35 -1
- package/docs/node-types.md +1 -1
- package/docs/product-context-vision-2026-09.md +16 -5
- package/docs/product-vision-2026-09.md +4 -2
- package/package.json +11 -11
- package/skills/json-render-mcp/SKILL.md +118 -2
- package/skills/pmx-canvas/SKILL.md +42 -16
- package/skills/pmx-canvas/evals/evals.json +1 -1
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
- package/skills/pmx-canvas/references/full-reference.md +5 -4
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
- package/skills/pmx-canvas-testing/SKILL.md +13 -4
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +11 -12
- package/src/client/canvas/CanvasNode.tsx +9 -12
- package/src/client/canvas/CanvasViewport.tsx +10 -34
- package/src/client/canvas/ContextMenu.tsx +13 -0
- package/src/client/canvas/EdgeLayer.tsx +2 -2
- package/src/client/canvas/SessionPanel.tsx +23 -4
- package/src/client/canvas/ShortcutOverlay.tsx +2 -2
- package/src/client/canvas/auto-fit.ts +20 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -3
- package/src/client/nodes/ExtAppFrame.tsx +7 -1
- package/src/client/nodes/use-iframe-content-height.ts +44 -24
- package/src/client/state/human-store.ts +6 -6
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/client/theme/global.css +22 -3
- 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/canvas-operations.ts +2 -2
- package/src/server/operations/ops/nodes.ts +43 -11
- package/src/server/operations/ops/viewport.ts +6 -1
- package/src/server/server.ts +9 -9
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
- package/src/shared/content-height-reporter.ts +4 -2
|
@@ -1467,7 +1467,13 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1467
1467
|
minHeight: 0,
|
|
1468
1468
|
border: 'none',
|
|
1469
1469
|
background: 'var(--c-panel)',
|
|
1470
|
-
|
|
1470
|
+
// Keep the expanded frame in Chromium's pointer hit-test tree from
|
|
1471
|
+
// its first paint. Toggling none → auto when boot completes can
|
|
1472
|
+
// leave the compositor routing the first click to the parent even
|
|
1473
|
+
// though DOM hit testing and Playwright both resolve the button.
|
|
1474
|
+
// The loading sibling is non-interactive; inline frames remain
|
|
1475
|
+
// guarded by the preview catcher below.
|
|
1476
|
+
pointerEvents: isExpanded ? 'auto' : 'none',
|
|
1471
1477
|
}}
|
|
1472
1478
|
title={`Ext App: ${toolName}`}
|
|
1473
1479
|
/>
|
|
@@ -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
|
}
|
|
@@ -922,7 +922,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
922
922
|
}
|
|
923
923
|
: undefined;
|
|
924
924
|
|
|
925
|
-
|
|
925
|
+
// Routine node/layout broadcasts do not replace the camera, so must not
|
|
926
|
+
// interrupt a focus animation either (view.focus also emits a layout).
|
|
927
|
+
if (shouldApplyViewport) cancelViewportAnimation();
|
|
926
928
|
applyServerCanvasLayout(
|
|
927
929
|
{
|
|
928
930
|
...(nextViewport ? { viewport: nextViewport } : {}),
|
|
@@ -1377,7 +1377,7 @@ body,
|
|
|
1377
1377
|
animation: session-panel-in 180ms ease;
|
|
1378
1378
|
}
|
|
1379
1379
|
.session-panel.is-collapsed {
|
|
1380
|
-
width:
|
|
1380
|
+
width: 40px;
|
|
1381
1381
|
align-items: center;
|
|
1382
1382
|
padding-top: 10px;
|
|
1383
1383
|
gap: 10px;
|
|
@@ -1390,6 +1390,17 @@ body,
|
|
|
1390
1390
|
text-transform: uppercase;
|
|
1391
1391
|
color: var(--c-dim);
|
|
1392
1392
|
}
|
|
1393
|
+
.session-collapsed-live {
|
|
1394
|
+
display: inline-flex;
|
|
1395
|
+
flex-direction: column;
|
|
1396
|
+
align-items: center;
|
|
1397
|
+
gap: 4px;
|
|
1398
|
+
font: 9px var(--mono);
|
|
1399
|
+
color: var(--c-dim);
|
|
1400
|
+
}
|
|
1401
|
+
.session-collapsed-live .session-live-dot {
|
|
1402
|
+
background: var(--c-ok);
|
|
1403
|
+
}
|
|
1393
1404
|
|
|
1394
1405
|
.session-panel-header {
|
|
1395
1406
|
height: var(--hud-bar-height);
|
|
@@ -1717,7 +1728,7 @@ body,
|
|
|
1717
1728
|
}
|
|
1718
1729
|
.session-timeline-filters .activity-filter {
|
|
1719
1730
|
flex-shrink: 0;
|
|
1720
|
-
padding: 2px
|
|
1731
|
+
padding: 2px 3px;
|
|
1721
1732
|
}
|
|
1722
1733
|
|
|
1723
1734
|
.session-timeline-row {
|
|
@@ -3745,6 +3756,12 @@ body,
|
|
|
3745
3756
|
}
|
|
3746
3757
|
|
|
3747
3758
|
/* Node resize handle */
|
|
3759
|
+
.canvas-node:has(.node-resize-handle) {
|
|
3760
|
+
/* Keep the four-pixel corner grab inside the shell's inner clip, not on
|
|
3761
|
+
its rounded border. The other corners retain the standard card radius. */
|
|
3762
|
+
border-bottom-right-radius: 8px;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3748
3765
|
.canvas-node .node-resize-handle {
|
|
3749
3766
|
position: absolute;
|
|
3750
3767
|
bottom: 0;
|
|
@@ -3789,7 +3806,9 @@ html.is-node-dragging .attention-field-layer {
|
|
|
3789
3806
|
}
|
|
3790
3807
|
|
|
3791
3808
|
html.is-node-dragging,
|
|
3792
|
-
html.is-node-dragging
|
|
3809
|
+
html.is-node-dragging *,
|
|
3810
|
+
html.is-canvas-panning,
|
|
3811
|
+
html.is-canvas-panning * {
|
|
3793
3812
|
cursor: grabbing !important;
|
|
3794
3813
|
user-select: none !important;
|
|
3795
3814
|
-webkit-user-select: none !important;
|
|
@@ -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
|
+
}
|
|
@@ -726,7 +726,7 @@ function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
|
|
|
726
726
|
const content = input.content?.trim() ?? '';
|
|
727
727
|
if (!content || content.includes('\n') || content.includes('\r')) return false;
|
|
728
728
|
if (typeof input.data?.path === 'string' && input.data.path.length > 0) return true;
|
|
729
|
-
if (existsSync(resolve(content))) return true;
|
|
729
|
+
if (existsSync(resolve(canvasState.getWorkspaceRoot(), content))) return true;
|
|
730
730
|
if (!input.title) return true;
|
|
731
731
|
return content.startsWith('/') || content.startsWith('./') || content.startsWith('../') || content.includes('/');
|
|
732
732
|
}
|
|
@@ -748,7 +748,7 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
|
748
748
|
|
|
749
749
|
const rawPath =
|
|
750
750
|
typeof input.data?.path === 'string' && input.data.path.length > 0 ? input.data.path : (input.content ?? '');
|
|
751
|
-
const resolved = resolve(rawPath);
|
|
751
|
+
const resolved = resolve(canvasState.getWorkspaceRoot(), rawPath);
|
|
752
752
|
const fileName = basename(resolved) || rawPath;
|
|
753
753
|
const data: Record<string, unknown> = {
|
|
754
754
|
...(input.data ?? {}),
|
|
@@ -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() });
|