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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/bridge/app-bridge-entry.js +6 -0
  4. package/mcp-app/LICENSE.md +9 -0
  5. package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
  6. package/mcp-app/README.md +129 -0
  7. package/mcp-app/dev-tunnel.sh +51 -0
  8. package/mcp-app/dist/editor-api.json +8493 -0
  9. package/mcp-app/dist/mcp-app.html +643 -0
  10. package/mcp-app/dist/method-map.json +915 -0
  11. package/mcp-app/package.json +42 -0
  12. package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
  13. package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
  14. package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
  15. package/mcp-app/scripts/extract-editor-api.ts +1374 -0
  16. package/mcp-app/server.json +21 -0
  17. package/mcp-app/src/logger.ts +45 -0
  18. package/mcp-app/src/register-tools.ts +368 -0
  19. package/mcp-app/src/shared/generated-data.ts +160 -0
  20. package/mcp-app/src/shared/pending-requests.ts +69 -0
  21. package/mcp-app/src/shared/types.ts +76 -0
  22. package/mcp-app/src/shared/utils.ts +132 -0
  23. package/mcp-app/src/tools/exec.ts +120 -0
  24. package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
  25. package/mcp-app/src/tools/search.ts +150 -0
  26. package/mcp-app/src/widget/app-context.tsx +29 -0
  27. package/mcp-app/src/widget/dev-log.tsx +70 -0
  28. package/mcp-app/src/widget/exec-helpers.ts +232 -0
  29. package/mcp-app/src/widget/export-tldr.ts +35 -0
  30. package/mcp-app/src/widget/focused/defaults.ts +141 -0
  31. package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
  32. package/mcp-app/src/widget/focused/format.ts +366 -0
  33. package/mcp-app/src/widget/focused/to-focused.ts +258 -0
  34. package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
  35. package/mcp-app/src/widget/image-guard.tsx +106 -0
  36. package/mcp-app/src/widget/index.html +33 -0
  37. package/mcp-app/src/widget/mcp-app.css +113 -0
  38. package/mcp-app/src/widget/mcp-app.tsx +857 -0
  39. package/mcp-app/src/widget/persistence.ts +337 -0
  40. package/mcp-app/src/widget/snapshot.ts +157 -0
  41. package/mcp-app/src/worker.ts +305 -0
  42. package/mcp-app/tsconfig.json +23 -0
  43. package/mcp-app/vite.config.ts +13 -0
  44. package/mcp-app/wrangler.toml +45 -0
  45. package/mcp-app-source.json +36 -0
  46. package/package.json +90 -0
  47. package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
  48. package/scripts/assemble-mcp-app.mjs +193 -0
  49. package/scripts/build-bridge.mjs +74 -0
  50. package/scripts/e2e-mcp.mjs +69 -0
  51. package/scripts/e2e-packaged-mcp-app.mjs +79 -0
  52. package/scripts/run-mcp-app-dev.mjs +44 -0
  53. package/scripts/verify-bundle.mjs +41 -0
  54. package/scripts/verify-mcp-app-source.mjs +51 -0
  55. package/scripts/verify-mcp-app.mjs +38 -0
  56. package/scripts/verify-package-files.mjs +50 -0
  57. package/src/canvas/export.ts +164 -0
  58. package/src/canvas/state.ts +117 -0
  59. package/src/canvas/workflow.ts +105 -0
  60. package/src/commands/tldraw-command.ts +48 -0
  61. package/src/diagram/guidance.ts +44 -0
  62. package/src/host/local-host.ts +289 -0
  63. package/src/index.ts +762 -0
  64. package/src/mcp/client.ts +126 -0
  65. package/src/mcp/response.ts +74 -0
  66. package/src/semantic/layer.ts +309 -0
  67. package/src/server/server-manager.ts +153 -0
  68. package/src/store/export-store.ts +33 -0
  69. package/src/store/project-store.ts +251 -0
  70. package/src/ui/tldraw-status.ts +88 -0
  71. package/static/app-bridge-bundle.js +18114 -0
  72. package/static/app-bridge-bundle.meta.json +164 -0
  73. package/static/host.html +390 -0
  74. 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
+ }