pi-tldraw 0.1.0
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/LICENSE +21 -0
- package/README.md +222 -0
- package/bridge/app-bridge-entry.js +6 -0
- package/mcp-app/LICENSE.md +9 -0
- package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
- package/mcp-app/README.md +129 -0
- package/mcp-app/dev-tunnel.sh +51 -0
- package/mcp-app/dist/editor-api.json +8493 -0
- package/mcp-app/dist/mcp-app.html +643 -0
- package/mcp-app/dist/method-map.json +915 -0
- package/mcp-app/package.json +42 -0
- package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
- package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
- package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
- package/mcp-app/scripts/extract-editor-api.ts +1374 -0
- package/mcp-app/server.json +21 -0
- package/mcp-app/src/logger.ts +45 -0
- package/mcp-app/src/register-tools.ts +368 -0
- package/mcp-app/src/shared/generated-data.ts +160 -0
- package/mcp-app/src/shared/pending-requests.ts +69 -0
- package/mcp-app/src/shared/types.ts +76 -0
- package/mcp-app/src/shared/utils.ts +132 -0
- package/mcp-app/src/tools/exec.ts +120 -0
- package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
- package/mcp-app/src/tools/search.ts +150 -0
- package/mcp-app/src/widget/app-context.tsx +29 -0
- package/mcp-app/src/widget/dev-log.tsx +70 -0
- package/mcp-app/src/widget/exec-helpers.ts +232 -0
- package/mcp-app/src/widget/export-tldr.ts +35 -0
- package/mcp-app/src/widget/focused/defaults.ts +141 -0
- package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
- package/mcp-app/src/widget/focused/format.ts +366 -0
- package/mcp-app/src/widget/focused/to-focused.ts +258 -0
- package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
- package/mcp-app/src/widget/image-guard.tsx +106 -0
- package/mcp-app/src/widget/index.html +33 -0
- package/mcp-app/src/widget/mcp-app.css +113 -0
- package/mcp-app/src/widget/mcp-app.tsx +857 -0
- package/mcp-app/src/widget/persistence.ts +337 -0
- package/mcp-app/src/widget/snapshot.ts +157 -0
- package/mcp-app/src/worker.ts +305 -0
- package/mcp-app/tsconfig.json +23 -0
- package/mcp-app/vite.config.ts +13 -0
- package/mcp-app/wrangler.toml +45 -0
- package/mcp-app-source.json +36 -0
- package/package.json +90 -0
- package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
- package/scripts/assemble-mcp-app.mjs +193 -0
- package/scripts/build-bridge.mjs +74 -0
- package/scripts/e2e-mcp.mjs +69 -0
- package/scripts/e2e-packaged-mcp-app.mjs +79 -0
- package/scripts/run-mcp-app-dev.mjs +44 -0
- package/scripts/verify-bundle.mjs +41 -0
- package/scripts/verify-mcp-app-source.mjs +51 -0
- package/scripts/verify-mcp-app.mjs +38 -0
- package/scripts/verify-package-files.mjs +50 -0
- package/src/canvas/export.ts +164 -0
- package/src/canvas/state.ts +117 -0
- package/src/canvas/workflow.ts +105 -0
- package/src/commands/tldraw-command.ts +48 -0
- package/src/diagram/guidance.ts +44 -0
- package/src/host/local-host.ts +289 -0
- package/src/index.ts +762 -0
- package/src/mcp/client.ts +126 -0
- package/src/mcp/response.ts +74 -0
- package/src/semantic/layer.ts +309 -0
- package/src/server/server-manager.ts +153 -0
- package/src/store/export-store.ts +33 -0
- package/src/store/project-store.ts +251 -0
- package/src/ui/tldraw-status.ts +88 -0
- package/static/app-bridge-bundle.js +18114 -0
- package/static/app-bridge-bundle.meta.json +164 -0
- package/static/host.html +390 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { parseMcpResponse, type JsonRpcResponse } from './response'
|
|
2
|
+
|
|
3
|
+
export type McpTool = {
|
|
4
|
+
name: string
|
|
5
|
+
title?: string
|
|
6
|
+
description?: string
|
|
7
|
+
inputSchema?: unknown
|
|
8
|
+
_meta?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type McpResource = {
|
|
12
|
+
uri: string
|
|
13
|
+
name?: string
|
|
14
|
+
title?: string
|
|
15
|
+
description?: string
|
|
16
|
+
mimeType?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MCP_PROTOCOL_VERSION = '2025-06-18'
|
|
20
|
+
|
|
21
|
+
export class TldrawMcpClient {
|
|
22
|
+
private sessionId: string | null = null
|
|
23
|
+
private nextId = 1
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly endpoint: string,
|
|
27
|
+
private readonly ensureServer?: (signal?: AbortSignal) => Promise<void>
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
async initialize(signal?: AbortSignal) {
|
|
31
|
+
if (this.sessionId) return
|
|
32
|
+
await this.ensureServer?.(signal)
|
|
33
|
+
|
|
34
|
+
const { response, payload } = await this.post<JsonRpcResponse>(
|
|
35
|
+
{
|
|
36
|
+
jsonrpc: '2.0',
|
|
37
|
+
id: this.nextId++,
|
|
38
|
+
method: 'initialize',
|
|
39
|
+
params: {
|
|
40
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
41
|
+
capabilities: {},
|
|
42
|
+
clientInfo: { name: 'pi-tldraw', version: '0.0.1' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
signal
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const sessionId = response.headers.get('mcp-session-id')
|
|
49
|
+
if (!sessionId) {
|
|
50
|
+
throw new Error('tldraw MCP initialize succeeded but did not return mcp-session-id')
|
|
51
|
+
}
|
|
52
|
+
if (payload.error) throw new Error(payload.error.message)
|
|
53
|
+
|
|
54
|
+
this.sessionId = sessionId
|
|
55
|
+
await this.notifyInitialized(signal)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async listTools(signal?: AbortSignal): Promise<McpTool[]> {
|
|
59
|
+
const result = await this.request<{ tools?: McpTool[] }>('tools/list', {}, signal)
|
|
60
|
+
return result.tools ?? []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async listResources(signal?: AbortSignal): Promise<McpResource[]> {
|
|
64
|
+
const result = await this.request<{ resources?: McpResource[] }>('resources/list', {}, signal)
|
|
65
|
+
return result.resources ?? []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal) {
|
|
69
|
+
return this.request('tools/call', { name, arguments: args }, signal)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async readResource(uri: string, signal?: AbortSignal) {
|
|
73
|
+
return this.request('resources/read', { uri }, signal)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
reset() {
|
|
77
|
+
this.sessionId = null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async notifyInitialized(signal?: AbortSignal) {
|
|
81
|
+
await this.post(
|
|
82
|
+
{
|
|
83
|
+
jsonrpc: '2.0',
|
|
84
|
+
method: 'notifications/initialized',
|
|
85
|
+
},
|
|
86
|
+
signal,
|
|
87
|
+
{ sessionId: this.sessionId }
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async request<T = unknown>(method: string, params: unknown, signal?: AbortSignal): Promise<T> {
|
|
92
|
+
await this.initialize(signal)
|
|
93
|
+
const { payload } = await this.post<JsonRpcResponse<T>>(
|
|
94
|
+
{
|
|
95
|
+
jsonrpc: '2.0',
|
|
96
|
+
id: this.nextId++,
|
|
97
|
+
method,
|
|
98
|
+
params,
|
|
99
|
+
},
|
|
100
|
+
signal,
|
|
101
|
+
{ sessionId: this.sessionId }
|
|
102
|
+
)
|
|
103
|
+
if (payload.error) throw new Error(payload.error.message)
|
|
104
|
+
return payload.result as T
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async post<T>(body: unknown, signal?: AbortSignal, opts?: { sessionId?: string | null }) {
|
|
108
|
+
const headers: Record<string, string> = {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
Accept: 'application/json, text/event-stream',
|
|
111
|
+
}
|
|
112
|
+
if (opts?.sessionId) headers['mcp-session-id'] = opts.sessionId
|
|
113
|
+
|
|
114
|
+
const response = await fetch(this.endpoint, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify(body),
|
|
118
|
+
signal,
|
|
119
|
+
})
|
|
120
|
+
const text = await response.text()
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(`MCP HTTP ${response.status}: ${text}`)
|
|
123
|
+
}
|
|
124
|
+
return { response, payload: parseMcpResponse<T>(text) }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type JsonRpcResponse<T = unknown> = {
|
|
2
|
+
jsonrpc: '2.0'
|
|
3
|
+
id?: string | number
|
|
4
|
+
result?: T
|
|
5
|
+
error?: { code: number; message: string; data?: unknown }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type McpContentItem =
|
|
9
|
+
| { type: 'text'; text: string }
|
|
10
|
+
| Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
export type McpToolResult = {
|
|
13
|
+
content?: unknown
|
|
14
|
+
structuredContent?: unknown
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseMcpResponse<T>(text: string): T {
|
|
19
|
+
const trimmed = text.trim()
|
|
20
|
+
if (!trimmed) return undefined as T
|
|
21
|
+
if (isJsonPayload(trimmed)) return JSON.parse(trimmed) as T
|
|
22
|
+
|
|
23
|
+
const dataLine = trimmed.split(/\r?\n/).find((line) => line.startsWith('data:'))
|
|
24
|
+
if (dataLine) return JSON.parse(dataLine.slice('data:'.length).trim()) as T
|
|
25
|
+
|
|
26
|
+
throw new Error(`Could not parse MCP response: ${trimmed.slice(0, 200)}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isJsonPayload = (text: string): boolean => text.startsWith('{') || text.startsWith('[')
|
|
30
|
+
|
|
31
|
+
export function extractTextContent(result: McpToolResult): string {
|
|
32
|
+
const content = result.content
|
|
33
|
+
if (!Array.isArray(content)) return JSON.stringify(result, null, 2)
|
|
34
|
+
return content.map(contentItemText).join('\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function contentItemText(item: unknown): string {
|
|
38
|
+
if (isTextContent(item)) return item.text
|
|
39
|
+
return JSON.stringify(item)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isTextContent = (item: unknown): item is { type: 'text'; text: string } =>
|
|
43
|
+
Boolean(
|
|
44
|
+
item &&
|
|
45
|
+
typeof item === 'object' &&
|
|
46
|
+
(item as Record<string, unknown>).type === 'text' &&
|
|
47
|
+
typeof (item as Record<string, unknown>).text === 'string'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
export function parseCanvasIdFromText(text: string): string | undefined {
|
|
51
|
+
return text.match(/Canvas ID: ([^\s]+)/)?.[1]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseCanvasIdFromResult(result: McpToolResult): string | undefined {
|
|
55
|
+
return parseCanvasIdFromText(extractTextContent(result))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function extractReturnValue(result: McpToolResult): unknown {
|
|
59
|
+
const text = extractTextContent(result)
|
|
60
|
+
const marker = 'Return value:\n'
|
|
61
|
+
const start = text.indexOf(marker)
|
|
62
|
+
if (start === -1) return undefined
|
|
63
|
+
|
|
64
|
+
const afterMarker = text.slice(start + marker.length)
|
|
65
|
+
const end = afterMarker.indexOf('\n\nCanvas ID:')
|
|
66
|
+
const json = (end === -1 ? afterMarker : afterMarker.slice(0, end)).trim()
|
|
67
|
+
if (!json) return undefined
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(json)
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic layer for tldraw canvases.
|
|
3
|
+
*
|
|
4
|
+
* Turns the raw focused-shape "flood" into a compact, meaning-first view that is
|
|
5
|
+
* cheap for an LLM to read. The pipeline is a small set of pure functions over a
|
|
6
|
+
* uniform element table (the "tensor"):
|
|
7
|
+
*
|
|
8
|
+
* bundle ──assemble──▶ RawRow[] ──enrich──▶ ElementRow[] ──render──▶ string
|
|
9
|
+
*
|
|
10
|
+
* Rendering is parameterized by an *aperture* — a named reduction policy:
|
|
11
|
+
* - 'overview' : census + edge list + reading-order nodes. Right for small/medium
|
|
12
|
+
* canvases. Output grows with the canvas (O(nodes + edges)).
|
|
13
|
+
* - 'summary' : census + graph statistics (components, hubs) + size distribution.
|
|
14
|
+
* Bounded output (O(topK)) regardless of canvas size. Right for
|
|
15
|
+
* large/dense canvases where an edge list would itself be a flood.
|
|
16
|
+
*
|
|
17
|
+
* This module is deliberately a *walking skeleton*: the columns and renderers are
|
|
18
|
+
* the minimum that demonstrably reduces tokens while staying correct. Clustering,
|
|
19
|
+
* containment, role inference, handles, focus/halo scoping, and image rendering are
|
|
20
|
+
* later rings that enrich the same tensor — they are intentionally not here yet.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type ElementKind = 'node' | 'edge' | 'note' | 'text' | 'freehand' | 'frame' | 'group' | 'image'
|
|
24
|
+
|
|
25
|
+
/** A shape in the tldraw "focused" format (the shape that exec reads/writes). */
|
|
26
|
+
export interface FocusedShape {
|
|
27
|
+
_type?: string
|
|
28
|
+
type?: string
|
|
29
|
+
shapeId?: string
|
|
30
|
+
id?: string
|
|
31
|
+
x?: number
|
|
32
|
+
y?: number
|
|
33
|
+
w?: number
|
|
34
|
+
h?: number
|
|
35
|
+
text?: string
|
|
36
|
+
fromId?: string | null
|
|
37
|
+
toId?: string | null
|
|
38
|
+
[key: string]: unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CanvasBundle {
|
|
42
|
+
shapes: FocusedShape[]
|
|
43
|
+
assets?: unknown[]
|
|
44
|
+
/**
|
|
45
|
+
* Arrow→shape bindings. Intentionally ignored by the semantic layer: arrow
|
|
46
|
+
* `fromId`/`toId` already carry the graph, so bindings are pure redundancy for
|
|
47
|
+
* a reader. Kept in the type only so callers can pass a full bundle through.
|
|
48
|
+
*/
|
|
49
|
+
bindings?: unknown[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Raw columns: a uniform row per element, before any derivation. */
|
|
53
|
+
export interface RawRow {
|
|
54
|
+
id: string
|
|
55
|
+
kind: ElementKind
|
|
56
|
+
type: string
|
|
57
|
+
x: number
|
|
58
|
+
y: number
|
|
59
|
+
w: number
|
|
60
|
+
h: number
|
|
61
|
+
text: string
|
|
62
|
+
fromId: string | null
|
|
63
|
+
toId: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Enriched row: raw columns + the minimal derived columns the renderers use. */
|
|
67
|
+
export interface ElementRow extends RawRow {
|
|
68
|
+
cx: number
|
|
69
|
+
cy: number
|
|
70
|
+
area: number
|
|
71
|
+
degree: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Aperture = 'overview' | 'summary'
|
|
75
|
+
|
|
76
|
+
export interface RenderOptions {
|
|
77
|
+
aperture?: Aperture
|
|
78
|
+
/** Element-count threshold above which 'auto' picks 'summary'. */
|
|
79
|
+
autoThreshold?: number
|
|
80
|
+
/** Top-K hubs/largest nodes to list in the summary aperture. */
|
|
81
|
+
topK?: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function kindOf(type: string): ElementKind {
|
|
85
|
+
switch (type) {
|
|
86
|
+
case 'arrow':
|
|
87
|
+
return 'edge'
|
|
88
|
+
case 'note':
|
|
89
|
+
return 'note'
|
|
90
|
+
case 'text':
|
|
91
|
+
return 'text'
|
|
92
|
+
case 'line':
|
|
93
|
+
case 'draw':
|
|
94
|
+
return 'freehand'
|
|
95
|
+
case 'frame':
|
|
96
|
+
return 'frame'
|
|
97
|
+
case 'group':
|
|
98
|
+
return 'group'
|
|
99
|
+
case 'image':
|
|
100
|
+
return 'image'
|
|
101
|
+
default:
|
|
102
|
+
return 'node'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
|
107
|
+
|
|
108
|
+
/** Stage 1 — flatten every shape into a uniform raw row. Lossless w.r.t. the columns we keep. */
|
|
109
|
+
export function assemble(bundle: CanvasBundle): RawRow[] {
|
|
110
|
+
const shapes = Array.isArray(bundle.shapes) ? bundle.shapes : []
|
|
111
|
+
return shapes.map((s) => {
|
|
112
|
+
const type = s._type ?? s.type ?? 'unknown'
|
|
113
|
+
return {
|
|
114
|
+
id: String(s.shapeId ?? s.id ?? ''),
|
|
115
|
+
kind: kindOf(type),
|
|
116
|
+
type,
|
|
117
|
+
x: Math.round(num(s.x)),
|
|
118
|
+
y: Math.round(num(s.y)),
|
|
119
|
+
w: Math.round(num(s.w)),
|
|
120
|
+
h: Math.round(num(s.h)),
|
|
121
|
+
text: (typeof s.text === 'string' ? s.text : '').trim(),
|
|
122
|
+
fromId: s.fromId != null ? String(s.fromId) : null,
|
|
123
|
+
toId: s.toId != null ? String(s.toId) : null,
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Stage 2 — add the minimal derived columns (center, area, graph degree). */
|
|
129
|
+
export function enrich(rows: RawRow[]): ElementRow[] {
|
|
130
|
+
const degree = new Map<string, number>()
|
|
131
|
+
for (const r of rows) {
|
|
132
|
+
if (r.kind !== 'edge') continue
|
|
133
|
+
if (r.fromId) degree.set(r.fromId, (degree.get(r.fromId) ?? 0) + 1)
|
|
134
|
+
if (r.toId) degree.set(r.toId, (degree.get(r.toId) ?? 0) + 1)
|
|
135
|
+
}
|
|
136
|
+
return rows.map((r) => ({
|
|
137
|
+
...r,
|
|
138
|
+
cx: r.x + r.w / 2,
|
|
139
|
+
cy: r.y + r.h / 2,
|
|
140
|
+
area: r.w * r.h,
|
|
141
|
+
degree: degree.get(r.id) ?? 0,
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Build the full tensor in one call. */
|
|
146
|
+
export function buildTensor(bundle: CanvasBundle): ElementRow[] {
|
|
147
|
+
return enrich(assemble(bundle))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function splitNodesEdges(rows: ElementRow[]) {
|
|
151
|
+
const nodes = rows.filter((r) => r.kind !== 'edge')
|
|
152
|
+
const edges = rows.filter((r) => r.kind === 'edge')
|
|
153
|
+
return { nodes, edges }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function census(rows: ElementRow[]): string {
|
|
157
|
+
const counts: Record<string, number> = {}
|
|
158
|
+
for (const r of rows) counts[r.kind] = (counts[r.kind] ?? 0) + 1
|
|
159
|
+
return (
|
|
160
|
+
Object.entries(counts)
|
|
161
|
+
.sort(([, a], [, b]) => b - a)
|
|
162
|
+
.map(([k, v]) => `${v} ${k}${v > 1 ? 's' : ''}`)
|
|
163
|
+
.join(' · ') || 'empty'
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function medianArea(nodes: ElementRow[]): number {
|
|
168
|
+
const areas = nodes
|
|
169
|
+
.map((n) => n.area)
|
|
170
|
+
.filter((a) => a > 0)
|
|
171
|
+
.sort((a, b) => a - b)
|
|
172
|
+
return areas.length ? areas[Math.floor(areas.length / 2)] : 0
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sizeClass(area: number, median: number): string {
|
|
176
|
+
if (!area || !median) return '·'
|
|
177
|
+
if (area < median * 0.5) return 's'
|
|
178
|
+
if (area > median * 2) return 'L'
|
|
179
|
+
return 'm'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function nodeLabel(id: string | null, byId: Map<string, ElementRow>): string {
|
|
183
|
+
if (!id) return '∅'
|
|
184
|
+
const n = byId.get(id)
|
|
185
|
+
if (!n) return `${id}?` // unresolved endpoint (orphan arrow)
|
|
186
|
+
return n.text ? `"${n.text}"` : n.type
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 'overview' aperture — census + full edge list + reading-order node list.
|
|
191
|
+
* Output scales with the canvas; ideal for small/medium diagrams.
|
|
192
|
+
*/
|
|
193
|
+
export function renderOverview(rows: ElementRow[]): string {
|
|
194
|
+
const { nodes, edges } = splitNodesEdges(rows)
|
|
195
|
+
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
196
|
+
const median = medianArea(nodes)
|
|
197
|
+
|
|
198
|
+
const out: string[] = [`CANVAS · ${census(rows)}`, '']
|
|
199
|
+
|
|
200
|
+
if (edges.length) {
|
|
201
|
+
out.push('GRAPH')
|
|
202
|
+
for (const e of edges) {
|
|
203
|
+
const arrow = e.text ? ` —${e.text}→ ` : ' → '
|
|
204
|
+
out.push(` ${nodeLabel(e.fromId, byId)}${arrow}${nodeLabel(e.toId, byId)}`)
|
|
205
|
+
}
|
|
206
|
+
out.push('')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
out.push(`NODES (reading order, ${nodes.length})`)
|
|
210
|
+
const ordered = [...nodes].sort((a, b) => a.cy - b.cy || a.cx - b.cx)
|
|
211
|
+
for (const n of ordered) {
|
|
212
|
+
const loose = n.degree === 0 ? ' ·loose' : ''
|
|
213
|
+
out.push(` [${sizeClass(n.area, median)}] ${n.type}${n.text ? ` "${n.text}"` : ''}${loose}`)
|
|
214
|
+
}
|
|
215
|
+
return out.join('\n')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Weakly-connected components over the node/edge graph (union-find). */
|
|
219
|
+
function connectedComponents(nodes: ElementRow[], edges: ElementRow[]) {
|
|
220
|
+
const parent = new Map<string, string>()
|
|
221
|
+
const find = (x: string): string => {
|
|
222
|
+
let root = x
|
|
223
|
+
while (parent.get(root) !== root) root = parent.get(root)!
|
|
224
|
+
while (parent.get(x) !== root) {
|
|
225
|
+
const next = parent.get(x)!
|
|
226
|
+
parent.set(x, root)
|
|
227
|
+
x = next
|
|
228
|
+
}
|
|
229
|
+
return root
|
|
230
|
+
}
|
|
231
|
+
const union = (a: string, b: string) => {
|
|
232
|
+
const ra = find(a)
|
|
233
|
+
const rb = find(b)
|
|
234
|
+
if (ra !== rb) parent.set(ra, rb)
|
|
235
|
+
}
|
|
236
|
+
for (const n of nodes) parent.set(n.id, n.id)
|
|
237
|
+
for (const e of edges) {
|
|
238
|
+
if (e.fromId && e.toId && parent.has(e.fromId) && parent.has(e.toId)) union(e.fromId, e.toId)
|
|
239
|
+
}
|
|
240
|
+
const sizes = new Map<string, number>()
|
|
241
|
+
for (const n of nodes) {
|
|
242
|
+
const root = find(n.id)
|
|
243
|
+
sizes.set(root, (sizes.get(root) ?? 0) + 1)
|
|
244
|
+
}
|
|
245
|
+
return [...sizes.values()].sort((a, b) => b - a)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 'summary' aperture — census + graph statistics + size distribution. Bounded
|
|
250
|
+
* output (O(topK)) regardless of canvas size; ideal for large/dense diagrams
|
|
251
|
+
* where listing every edge would itself be a flood.
|
|
252
|
+
*/
|
|
253
|
+
export function renderSummary(rows: ElementRow[], topK = 8): string {
|
|
254
|
+
const { nodes, edges } = splitNodesEdges(rows)
|
|
255
|
+
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
256
|
+
const out: string[] = [`CANVAS · ${census(rows)}`, '']
|
|
257
|
+
|
|
258
|
+
// --- Graph shape ---
|
|
259
|
+
if (edges.length) {
|
|
260
|
+
const comps = connectedComponents(nodes, edges)
|
|
261
|
+
const isolated = nodes.filter((n) => n.degree === 0).length
|
|
262
|
+
const labeled = edges.filter((e) => e.text).length
|
|
263
|
+
out.push('GRAPH')
|
|
264
|
+
out.push(` ${edges.length} edges · ${comps.length} component${comps.length === 1 ? '' : 's'} (largest ${comps[0] ?? 0}) · ${isolated} isolated`)
|
|
265
|
+
out.push(` ${labeled}/${edges.length} edges labeled`)
|
|
266
|
+
const hubs = [...nodes]
|
|
267
|
+
.filter((n) => n.degree > 0)
|
|
268
|
+
.sort((a, b) => b.degree - a.degree)
|
|
269
|
+
.slice(0, topK)
|
|
270
|
+
if (hubs.length) {
|
|
271
|
+
out.push(' hubs:')
|
|
272
|
+
for (const h of hubs) out.push(` ${nodeLabel(h.id, byId)} (deg ${h.degree})`)
|
|
273
|
+
}
|
|
274
|
+
out.push('')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- Size distribution ---
|
|
278
|
+
const median = medianArea(nodes)
|
|
279
|
+
const buckets: Record<string, number> = { s: 0, m: 0, L: 0, '·': 0 }
|
|
280
|
+
for (const n of nodes) buckets[sizeClass(n.area, median)]++
|
|
281
|
+
out.push('SIZE')
|
|
282
|
+
out.push(` small ${buckets.s} · medium ${buckets.m} · large ${buckets.L} · sizeless ${buckets['·']}`)
|
|
283
|
+
const biggest = [...nodes]
|
|
284
|
+
.filter((n) => n.area > 0)
|
|
285
|
+
.sort((a, b) => b.area - a.area)
|
|
286
|
+
.slice(0, topK)
|
|
287
|
+
if (biggest.length) {
|
|
288
|
+
out.push(' largest:')
|
|
289
|
+
for (const n of biggest) out.push(` ${nodeLabel(n.id, byId)}`)
|
|
290
|
+
}
|
|
291
|
+
return out.join('\n')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Pick an aperture from canvas size when the caller asks for 'auto' (default). */
|
|
295
|
+
export function chooseAperture(rows: ElementRow[], threshold = 60): Aperture {
|
|
296
|
+
return rows.length > threshold ? 'summary' : 'overview'
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Top-level convenience: build the tensor and render at the chosen aperture. */
|
|
300
|
+
export function renderView(bundle: CanvasBundle, opts: RenderOptions = {}): string {
|
|
301
|
+
const rows = buildTensor(bundle)
|
|
302
|
+
const aperture = opts.aperture ?? chooseAperture(rows, opts.autoThreshold)
|
|
303
|
+
return aperture === 'summary' ? renderSummary(rows, opts.topK) : renderOverview(rows)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Rough token estimate (chars/4). Exported for metrics/tests. */
|
|
307
|
+
export function estimateTokens(text: string): number {
|
|
308
|
+
return Math.round(text.length / 4)
|
|
309
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
export function createMcpServerManager(endpoint: string) {
|
|
6
|
+
let proc: ChildProcess | null = null
|
|
7
|
+
let starting: Promise<void> | null = null
|
|
8
|
+
let startedAt: number | null = null
|
|
9
|
+
let lastError: string | null = null
|
|
10
|
+
const logs: string[] = []
|
|
11
|
+
|
|
12
|
+
const appDir = resolveAppDir()
|
|
13
|
+
const autoStart = process.env.TLDRAW_MCP_AUTO_START !== 'false' && isLocalEndpoint(endpoint)
|
|
14
|
+
|
|
15
|
+
async function ensure(signal?: AbortSignal) {
|
|
16
|
+
if (await isReachable(endpoint)) return
|
|
17
|
+
if (!autoStart) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`tldraw MCP server is not reachable at ${endpoint}. Start it manually or unset TLDRAW_MCP_AUTO_START=false.`
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
if (starting) return starting
|
|
23
|
+
starting = startAndWait(signal).finally(() => {
|
|
24
|
+
starting = null
|
|
25
|
+
})
|
|
26
|
+
return starting
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function start(signal?: AbortSignal) {
|
|
30
|
+
if (await isReachable(endpoint)) return
|
|
31
|
+
if (starting) return starting
|
|
32
|
+
starting = startAndWait(signal).finally(() => {
|
|
33
|
+
starting = null
|
|
34
|
+
})
|
|
35
|
+
return starting
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function startAndWait(signal?: AbortSignal) {
|
|
39
|
+
if (!existsSync(appDir)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`tldraw MCP app directory not found: ${appDir}. Set TLDRAW_MCP_APP_DIR to the packaged/local apps/mcp-app directory.`
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!proc || proc.exitCode !== null || proc.killed) {
|
|
46
|
+
lastError = null
|
|
47
|
+
appendLog(`starting: cd ${appDir} && yarn dev`)
|
|
48
|
+
const child = spawn('yarn', ['dev'], {
|
|
49
|
+
cwd: appDir,
|
|
50
|
+
env: { ...process.env },
|
|
51
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
52
|
+
detached: true,
|
|
53
|
+
})
|
|
54
|
+
proc = child
|
|
55
|
+
startedAt = Date.now()
|
|
56
|
+
child.stdout?.on('data', (chunk) => appendLog(String(chunk)))
|
|
57
|
+
child.stderr?.on('data', (chunk) => appendLog(String(chunk)))
|
|
58
|
+
child.on('exit', (code, sig) => {
|
|
59
|
+
appendLog(`process exited: code=${code ?? 'null'} signal=${sig ?? 'null'}`)
|
|
60
|
+
if (code && code !== 0) lastError = `tldraw MCP server exited with code ${code}`
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const started = Date.now()
|
|
65
|
+
while (Date.now() - started < 180_000) {
|
|
66
|
+
if (signal?.aborted) throw new Error('Cancelled')
|
|
67
|
+
if (await isReachable(endpoint)) return
|
|
68
|
+
if (proc.exitCode !== null) {
|
|
69
|
+
throw new Error(lastError ?? `tldraw MCP server exited before ${endpoint} became reachable`)
|
|
70
|
+
}
|
|
71
|
+
await delay(1000)
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Timed out waiting for tldraw MCP server at ${endpoint}. Recent logs:\n${logs.slice(-20).join('\n')}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function restart(signal?: AbortSignal) {
|
|
77
|
+
await stop()
|
|
78
|
+
return startAndWait(signal)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function stop() {
|
|
82
|
+
if (!proc || proc.exitCode !== null || proc.killed) return
|
|
83
|
+
appendLog('stopping managed server')
|
|
84
|
+
try {
|
|
85
|
+
// Kill the process group so the wrangler child exits too.
|
|
86
|
+
if (proc.pid) process.kill(-proc.pid, 'SIGTERM')
|
|
87
|
+
else proc.kill('SIGTERM')
|
|
88
|
+
} catch {
|
|
89
|
+
proc.kill('SIGTERM')
|
|
90
|
+
}
|
|
91
|
+
await delay(1000)
|
|
92
|
+
if (proc.exitCode === null && !proc.killed) {
|
|
93
|
+
try {
|
|
94
|
+
if (proc.pid) process.kill(-proc.pid, 'SIGKILL')
|
|
95
|
+
else proc.kill('SIGKILL')
|
|
96
|
+
} catch {
|
|
97
|
+
proc.kill('SIGKILL')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getStatus() {
|
|
103
|
+
return {
|
|
104
|
+
endpoint,
|
|
105
|
+
autoStart,
|
|
106
|
+
appDir,
|
|
107
|
+
managedPid: proc?.pid ?? null,
|
|
108
|
+
startedAt,
|
|
109
|
+
lastError,
|
|
110
|
+
logs: logs.slice(-50),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function appendLog(text: string) {
|
|
115
|
+
for (const line of text.split(/\r?\n/)) {
|
|
116
|
+
if (!line.trim()) continue
|
|
117
|
+
logs.push(`${new Date().toISOString()} ${line}`)
|
|
118
|
+
}
|
|
119
|
+
while (logs.length > 200) logs.shift()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ensure, start, restart, stop, getStatus }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function isReachable(endpoint: string) {
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(endpoint, {
|
|
128
|
+
method: 'OPTIONS',
|
|
129
|
+
signal: AbortSignal.timeout(1500),
|
|
130
|
+
})
|
|
131
|
+
return response.ok || response.status === 204 || response.status === 405
|
|
132
|
+
} catch {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isLocalEndpoint(endpoint: string) {
|
|
138
|
+
try {
|
|
139
|
+
const url = new URL(endpoint)
|
|
140
|
+
return ['127.0.0.1', 'localhost', '0.0.0.0'].includes(url.hostname)
|
|
141
|
+
} catch {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveAppDir() {
|
|
147
|
+
if (process.env.TLDRAW_MCP_APP_DIR) return process.env.TLDRAW_MCP_APP_DIR
|
|
148
|
+
return fileURLToPath(new URL('../../mcp-app', import.meta.url))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function delay(ms: number) {
|
|
152
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
153
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { CanvasExportFormat, CanvasExportScope } from '../canvas/export'
|
|
4
|
+
|
|
5
|
+
export interface SaveCanvasImageExportInput {
|
|
6
|
+
canvasId?: string
|
|
7
|
+
format: CanvasExportFormat
|
|
8
|
+
scope: CanvasExportScope
|
|
9
|
+
data: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getExportDir(cwd: string) {
|
|
13
|
+
return join(cwd, '.pi', 'tldraw-exports')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createExportFileName(input: Pick<SaveCanvasImageExportInput, 'canvasId' | 'format' | 'scope'>) {
|
|
17
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
18
|
+
const canvas = sanitizeFilePart(input.canvasId ?? 'canvas')
|
|
19
|
+
return `${stamp}-${canvas}-${input.scope}.${input.format}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function saveCanvasImageExport(cwd: string, input: SaveCanvasImageExportInput) {
|
|
23
|
+
const dir = getExportDir(cwd)
|
|
24
|
+
await mkdir(dir, { recursive: true })
|
|
25
|
+
const file = createExportFileName(input)
|
|
26
|
+
const path = join(dir, file)
|
|
27
|
+
await writeFile(path, Buffer.from(input.data, 'base64'))
|
|
28
|
+
return path
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sanitizeFilePart(value: string) {
|
|
32
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
33
|
+
}
|