@theprototype/agent 1.48.0 → 1.50.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theprototype/agent",
3
- "version": "1.48.0",
3
+ "version": "1.50.0",
4
4
  "description": "Let an AI agent (Claude Code, Claude Desktop, any MCP host) join a theprototype.app session from its link and build the 3D scene with you.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "files": [
30
30
  "src/",
31
+ "catalog.json",
31
32
  "README.md",
32
33
  "LICENSE"
33
34
  ],
@@ -38,7 +39,7 @@
38
39
  "access": "public"
39
40
  },
40
41
  "scripts": {
41
- "test": "node test/messages.test.mjs && node test/bridge.test.mjs && node test/link.test.mjs && node test/mcp.test.mjs && node test/wire.test.mjs",
42
+ "test": "node test/chat.test.mjs && node test/messages.test.mjs && node test/bridge.test.mjs && node test/link.test.mjs && node test/mcp.test.mjs && node test/repl.test.mjs && node test/wire.test.mjs && node test/catalog.test.mjs",
42
43
  "spike": "node src/spike.js",
43
44
  "smoke": "node src/cli.js smoke",
44
45
  "repl": "node src/cli.js repl",
@@ -237,6 +237,8 @@ export class BrowserEngine extends EventEmitter {
237
237
  const ctx = await this.browser.newContext({ ignoreHTTPSErrors: this.insecure, viewport: { width: 1280, height: 720 } });
238
238
  await ctx.addInitScript(agentStorage, { name: this.name, compat: this.compat });
239
239
  if (this.compat) await ctx.addInitScript(compatShim);
240
+ // a diagnostic / test seam: one more init script in the agent page (the e2e gives it roles this way)
241
+ if (process.env.TP_AGENT_INIT_SCRIPT) await ctx.addInitScript({ path: process.env.TP_AGENT_INIT_SCRIPT });
240
242
  this.page = await ctx.newPage();
241
243
  this.page.on('pageerror', (/** @type {any} */ e) => this.log('page error: ' + firstLine(e)));
242
244
  if (this.verbose) this.page.on('console', (/** @type {any} */ m) => this.log('console.' + m.type() + ': ' + m.text().slice(0, 300)));
@@ -317,6 +319,16 @@ export class BrowserEngine extends EventEmitter {
317
319
  return joinedOf(await this.status(), this.hostId);
318
320
  }
319
321
 
322
+ /**
323
+ * 56: a chat method of tpAgent 1.1.0 (nextMessage, abortWait, requeue, reply, ask, chatStatus, thread).
324
+ * @param {string} method @param {...any} args
325
+ */
326
+ async chat(method, ...args) {
327
+ const r = await this.call(method, ...args);
328
+ if (r && r.__missing) throw new Error('this app build has no agent chat (tpAgent 1.1.0, theprototype 1.46+): people cannot type to you here');
329
+ return r;
330
+ }
331
+
320
332
  /** @param {number} ms */
321
333
  async waitForEdit(ms) {
322
334
  const r = await this.call('waitForEdit', ms);
package/src/catalog.js ADDED
@@ -0,0 +1,114 @@
1
+ // Roadmap 86 T4 (+ 56 C19) — THE STATIC TOOL CATALOG, one builder for every MCP surface that must list
2
+ // tools before a page is up: the Claude connector's Worker (claude.ai reads tools/list ONCE, at chat
3
+ // start, and ignores list_changed) and `serve --static-tools`. Pure: no SDK, no browser.
4
+ //
5
+ // The host half is whatever the app's `tpAgent.tools()` answers (core `scripts/agent-tool-catalog.mjs`
6
+ // loads the app's own modules to get it), mapped by mcpTools.js exactly as a live session maps it, plus
7
+ // the bridge tools a tab answers itself. Nobody hand-writes a tool. The instructions (86 T3) are built
8
+ // from the same list, so a line about a tool appears only while that tool exists.
9
+
10
+ import { mapHostTools, bridgeTools } from './mcpTools.js';
11
+
12
+ /** Bump when the catalog's SHAPE changes (not when tools change). 2 = titles + annotations. */
13
+ export const CATALOG_FORMAT = 2;
14
+
15
+ /**
16
+ * Per tool: a human title, whether it only reads, and whether an edit may overwrite or remove what is
17
+ * there (MCP `destructiveHint`; false = only adds). The Claude connector directory requires a title and
18
+ * a read-only / destructive hint on EVERY tool. Everything an agent does is undoable, but the hints say
19
+ * what a call does, not whether it can be taken back. A tool missing here is treated as a destructive
20
+ * edit (fail closed) with a title made from its name.
21
+ */
22
+ export const TOOL_ANNOTATIONS = /** @type {Record<string, {title: string, read?: true, destructive?: boolean, openWorld?: true}>} */ ({
23
+ list_scene: { title: 'Read the scene', read: true },
24
+ describe_node_types: { title: 'Look up flow node types', read: true },
25
+ what_can_you_do: { title: 'What can you do here', read: true },
26
+ status: { title: 'Session status', read: true },
27
+ screenshot: { title: 'Take a picture of the scene', read: true },
28
+ wait_for_edit: { title: 'Wait for someone to edit', read: true },
29
+ pending_offers: { title: 'List questions the app is asking', read: true },
30
+ create_objects: { title: 'Add objects', destructive: false },
31
+ create_flow_nodes: { title: 'Add behaviour (flow) nodes', destructive: false },
32
+ create_behaviour: { title: 'Add a code behaviour', destructive: false },
33
+ create_joints: { title: 'Add physics joints', destructive: false },
34
+ group_objects: { title: 'Group objects', destructive: false },
35
+ generate_mesh: { title: 'Generate a 3D model', destructive: false, openWorld: true },
36
+ update_objects: { title: 'Change objects', destructive: true },
37
+ update_flow_nodes: { title: 'Change or remove flow nodes', destructive: true },
38
+ edit_behaviour: { title: 'Edit a code behaviour', destructive: true },
39
+ set_physics: { title: 'Set physics', destructive: true },
40
+ control_simulation: { title: 'Run or stop the simulation', destructive: true },
41
+ delete_objects: { title: 'Delete objects', destructive: true },
42
+ clear_scene: { title: 'Clear the scene', destructive: true },
43
+ install_modules: { title: 'Install modules', destructive: false, openWorld: true },
44
+ accept_offer: { title: 'Answer a question the app asks', destructive: true },
45
+ connect: { title: 'Join a session', destructive: false, openWorld: true }
46
+ });
47
+
48
+ /** a title from a tool name: `create_objects` → `Create objects` @param {string} name */
49
+ const titleOf = (name) => (name.charAt(0).toUpperCase() + name.slice(1)).replace(/_/g, ' ');
50
+
51
+ /**
52
+ * A catalog tool with `title` + `annotations` (MCP 2025-06-18 shapes).
53
+ * @param {{name: string, description: string, inputSchema: any}} t
54
+ */
55
+ export function annotate(t) {
56
+ const a = TOOL_ANNOTATIONS[t.name];
57
+ const title = a?.title ?? titleOf(t.name);
58
+ /** @type {Record<string, any>} */
59
+ const annotations = { title, readOnlyHint: !!a?.read };
60
+ if (!a?.read) annotations.destructiveHint = a?.destructive ?? true;
61
+ annotations.openWorldHint = !!a?.openWorld;
62
+ return { name: t.name, title, description: t.description, inputSchema: t.inputSchema, annotations };
63
+ }
64
+
65
+ /**
66
+ * The server instructions for a voice-first client (claude.ai chat, phone and voice mode). Short,
67
+ * spoken-style, and only about tools in `names`.
68
+ * @param {string[]} names the tool names the client will see
69
+ * @returns {string}
70
+ */
71
+ export function connectorInstructions(names) {
72
+ const has = new Set(names);
73
+ const lines = [
74
+ 'You are building in the person\'s own open theprototype.app tab: a live, shared 3D scene. Every change shows for everyone in the session and can be undone.',
75
+ 'This may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words ("Added three trees by the fire"). Never read out uuids, lists of numbers or JSON.',
76
+ 'If a tool says the tab is closed or not connected, tell the person to open theprototype.app and press Connect Claude, then wait; do not retry in a loop.'
77
+ ];
78
+ if (has.has('status')) lines.push('Call status first: it says whether you may edit (a viewer can only look).');
79
+ if (has.has('list_scene')) lines.push('Call list_scene before editing so you use real uuids; batch many objects into one call.');
80
+ if (has.has('where_are_users'))
81
+ lines.push('For "in front of me", "here" or "this", call where_are_users and build at the spots it gives, facing the person.');
82
+ if (has.has('attach_to_user')) lines.push('For "give me a flashlight" or another prop, make the object, then attach_to_user to the person who asked.');
83
+ if (has.has('create_flow_nodes'))
84
+ lines.push(
85
+ 'Motion and interaction are flow nodes (create_flow_nodes)' + (has.has('describe_node_types') ? '; call describe_node_types before wiring edges.' : '.')
86
+ );
87
+ if (has.has('screenshot')) lines.push('Use screenshot to check a result when it matters, not after every step.');
88
+ if (has.has('what_can_you_do')) lines.push('When asked what you can do here, call what_can_you_do and say its "say" line.');
89
+ return lines.join('\n');
90
+ }
91
+
92
+ /**
93
+ * Host tools (tpAgent.tools()'s shape) → the static catalog.
94
+ * @param {any[]} hostTools
95
+ * @param {{provenance?: Record<string, any>}} [opts]
96
+ * @returns {{format: number, generator: Record<string, any>, instructions: string, tools: {name: string, title: string, description: string, inputSchema: any, annotations: Record<string, any>}[]}}
97
+ */
98
+ export function buildCatalog(hostTools, opts = {}) {
99
+ if (!Array.isArray(hostTools) || !hostTools.length) throw new Error('the host tool list is empty');
100
+ const mapped = mapHostTools(hostTools);
101
+ // the bridge tools a page answers itself; `connect` is the agent CLI's (the Worker drops it, serve keeps it)
102
+ const tools = [...mapped.tools, ...bridgeTools('browser')].map(annotate);
103
+ return {
104
+ format: CATALOG_FORMAT,
105
+ generator: {
106
+ source: 'tpAgent.tools() via core scripts/agent-tool-catalog.cjs, mapped by tools/agent/src/catalog.js',
107
+ hostTools: hostTools.length,
108
+ dropped: mapped.dropped,
109
+ ...(opts.provenance ?? {})
110
+ },
111
+ instructions: connectorInstructions(tools.map((t) => t.name)),
112
+ tools
113
+ };
114
+ }
package/src/chat.js ADDED
@@ -0,0 +1,303 @@
1
+ // The chat half of the bridge (roadmap 56 C2-C4, C6, C8, C11). Pure apart from the image spool's disk writes.
2
+ //
3
+ // An MCP host cannot be woken by a server, so a message typed in the app's AI assistant window waits in the
4
+ // agent page's inbox until the agent PICKS IT UP: `wait_for_message` long-polls `tpAgent.nextMessage`, the
5
+ // agent acts, `reply` streams the answer back into the window, and it waits again. The page owns every rule
6
+ // (the sender's role, viewers refused, Stop → `cancelled`, history on a chat switch); this file shapes what
7
+ // the model sees: the message as JSON, each image as an MCP image block AND a temp file whose path rides in
8
+ // the JSON (so a skill that wants a file — img2threejs — can take it as its reference).
9
+ import { mkdirSync, writeFileSync, rmSync, readFileSync, statSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join, basename } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+
14
+ export const CHAT_TOOL_NAMES = ['wait_for_message', 'reply', 'ask', 'get_thread'];
15
+
16
+ /** wait_for_message: default and longest wait (seconds) */
17
+ export const WAIT_DEFAULT_S = 240;
18
+ export const WAIT_MAX_S = 600;
19
+ /** import_model: the largest local GLB the bridge reads (the app's own import cap) */
20
+ export const IMPORT_MAX_BYTES = 25 * 1024 * 1024;
21
+ /** import_file (roadmap 66 S4b): the app's caps by kind, checked here so a wrong file never crosses the wire */
22
+ export const IMPORT_FILE_CAPS = { image: 16 * 1024 * 1024, json: 2 * 1024 * 1024, clip: 25 * 1024 * 1024 };
23
+ /** import_file: base64 characters per exec call (a multiple of 4, so every part but the last is whole quads) */
24
+ export const IMPORT_CHUNK_CHARS = 4 * 1024 * 1024;
25
+
26
+ /** The chat tools (browser engine only: the wire peer has no inbox). */
27
+ export function chatTools() {
28
+ return [
29
+ {
30
+ name: 'wait_for_message',
31
+ description:
32
+ 'Listen for the people in the session: blocks until someone types to you in the app\'s AI assistant window, then returns that message ' +
33
+ '(id, chatId, from, name, role, text, images with a local file path each, answers = the ask it answers). Returns null when the timeout passes ' +
34
+ 'with no message — call it again to keep listening. Loop: wait_for_message → do the work with the scene tools → reply(final) → wait_for_message. ' +
35
+ 'role "viewer" = that person may not change the scene: answer, but do not edit for them (edits are refused). A message in a chat you have not ' +
36
+ 'seen carries history (its newest messages) + summary; get_thread pages further back.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: { timeoutS: { type: 'number', description: 'seconds to wait (default ' + WAIT_DEFAULT_S + ', at most ' + WAIT_MAX_S + ')' } }
40
+ }
41
+ },
42
+ {
43
+ name: 'reply',
44
+ description:
45
+ 'Answer in the app\'s AI assistant window (everyone in the session sees the thread). Each call adds a paragraph to ONE streamed reply ' +
46
+ '(people watch it grow); final: true finishes it and ends your "working" state. Without `to` it answers the message you are working on. ' +
47
+ 'When the user typed to you in THIS terminal instead, pass their words as `prompt` so the app shows that turn too ("via terminal"). ' +
48
+ 'After Stop, reply answers cancelled until you send final.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ text: { type: 'string', description: 'what to say (a paragraph added to your reply)' },
53
+ final: { type: 'boolean', description: 'true = this reply is complete' },
54
+ to: { type: 'string', description: 'peer id to answer (default: the sender of the message you are working on)' },
55
+ chatId: { type: 'string', description: 'default: the chat of the message you are working on' },
56
+ prompt: { type: 'string', description: 'the user\'s own words typed in the terminal, echoed into the app before your reply' }
57
+ },
58
+ required: ['text']
59
+ }
60
+ },
61
+ {
62
+ name: 'ask',
63
+ description:
64
+ 'Ask the person a question with buttons in the window ("Pine or oak?"). Their click arrives through wait_for_message as a message whose ' +
65
+ '`answers` is the id this returns and whose text is the option chosen.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ question: { type: 'string' },
70
+ options: { type: 'array', items: { type: 'string' }, description: 'button labels (at most 8)' },
71
+ to: { type: 'string', description: 'peer id (default: the sender of the message you are working on)' },
72
+ chatId: { type: 'string' }
73
+ },
74
+ required: ['question', 'options']
75
+ }
76
+ },
77
+ {
78
+ name: 'get_thread',
79
+ description:
80
+ 'Read a chat in the app, oldest first: the `limit` messages before message id `before` (omit for the newest). `more` says older ones exist. ' +
81
+ 'Use it when a message\'s history is not enough.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ chatId: { type: 'string', description: 'default: the chat you are in' },
86
+ before: { type: 'string', description: 'a message id; returns the messages before it' },
87
+ limit: { type: 'number', description: 'default 20, at most 100' }
88
+ }
89
+ }
90
+ }
91
+ ];
92
+ }
93
+
94
+ /** seconds → the clamped wait in ms @param {any} s */
95
+ export function waitMs(s) {
96
+ const n = Number(s);
97
+ const sec = Number.isFinite(n) && n > 0 ? Math.min(WAIT_MAX_S, Math.max(1, n)) : WAIT_DEFAULT_S;
98
+ return Math.round(sec * 1000);
99
+ }
100
+
101
+ const EXT = /** @type {Record<string, string>} */ ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' });
102
+
103
+ /** a filename-safe id @param {any} s */
104
+ const safe = (s) => String(s ?? 'x').replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64) || 'x';
105
+
106
+ /**
107
+ * The temp files the images go to: `<tmp>/theprototype-agent/<session>/<chatId>/<msgId>-<n>.<ext>`, the
108
+ * directories 0700 and the files 0600 (photos are private), removed when the bridge exits.
109
+ */
110
+ export class ImageSpool {
111
+ /** @param {{root?: string}} [o] */
112
+ constructor(o = {}) {
113
+ this.root = o.root ?? join(tmpdir(), 'theprototype-agent', process.pid + '-' + randomBytes(4).toString('hex'));
114
+ this.made = false;
115
+ }
116
+
117
+ /**
118
+ * @param {string} chatId @param {string} msgId @param {number} n @param {{mimeType: string, data: string, name?: string}} im
119
+ * @returns {string} the file's path
120
+ */
121
+ write(chatId, msgId, n, im) {
122
+ const dir = join(this.root, safe(chatId));
123
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
124
+ this.made = true;
125
+ const file = join(dir, safe(msgId) + '-' + n + '.' + (EXT[im.mimeType] ?? 'bin'));
126
+ writeFileSync(file, Buffer.from(im.data, 'base64'), { mode: 0o600 });
127
+ return file;
128
+ }
129
+
130
+ cleanup() {
131
+ if (!this.made) return;
132
+ try {
133
+ rmSync(this.root, { recursive: true, force: true });
134
+ } catch {}
135
+ this.made = false;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * A delivered message → MCP content: the JSON (image bytes replaced by their file paths) first, then one
141
+ * image block per image (Claude sees them).
142
+ * @param {any} msg @param {ImageSpool | null} spool
143
+ */
144
+ export function messageContent(msg, spool) {
145
+ const images = Array.isArray(msg.images) ? msg.images : [];
146
+ /** @type {any[]} */
147
+ const blocks = [];
148
+ /** @type {any[]} */
149
+ const listed = [];
150
+ images.forEach((/** @type {any} */ im, /** @type {number} */ i) => {
151
+ if (!im || typeof im.data !== 'string') return;
152
+ /** @type {any} */
153
+ const row = { mimeType: im.mimeType };
154
+ if (im.name) row.name = im.name;
155
+ if (im.w && im.h) row.size = im.w + 'x' + im.h;
156
+ if (spool) {
157
+ try {
158
+ row.path = spool.write(msg.chatId, msg.id, i + 1, im);
159
+ } catch (e) {
160
+ row.pathError = String(/** @type {any} */ (e).message || e).split('\n')[0];
161
+ }
162
+ }
163
+ listed.push(row);
164
+ blocks.push({ type: 'image', data: im.data, mimeType: im.mimeType });
165
+ });
166
+ const { images: _drop, ...rest } = msg;
167
+ /** @type {any} */
168
+ const out = { ...rest };
169
+ if (listed.length) out.images = listed;
170
+ if (msg.role === 'viewer') out.note = 'This person is a VIEWER: they may read and ask, but not change the scene. Edits for this request are refused — say so in your reply.';
171
+ if (msg.imagesDropped) out.note = (out.note ? out.note + ' ' : '') + 'They attached images; viewers cannot attach, so they were dropped.';
172
+ if (msg.history?.length) out.historyNote = 'You have not seen this chat in this session: history = its newest messages, summary = the whole of it; get_thread pages back.';
173
+ return [{ type: 'text', text: JSON.stringify(out) }, ...blocks];
174
+ }
175
+
176
+ /** the host's `import_model` schema plus the bridge's `path` (a local file) @param {any} tool */
177
+ export function withImportPath(tool) {
178
+ if (!tool || tool.name !== 'import_model') return tool;
179
+ const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema : { type: 'object' };
180
+ const properties = { ...(schema.properties ?? {}) };
181
+ if (properties.path) return tool;
182
+ properties.path = {
183
+ type: 'string',
184
+ description: 'a self-contained .glb on the machine running this bridge (≤ 25 MB); the bridge reads it and sends the bytes — use instead of data/url'
185
+ };
186
+ return {
187
+ ...tool,
188
+ description: tool.description + (tool.description ? ' ' : '') + 'Local files: pass `path` (the bridge reads it).',
189
+ inputSchema: { ...schema, properties }
190
+ };
191
+ }
192
+
193
+ /**
194
+ * `import_model` with a `path`: read the local file (≤ 25 MB, glTF binary magic), hand the page `data`.
195
+ * The path never reaches the page. Throws a readable error.
196
+ * @param {any} args @param {{read?: (p: string) => Buffer, size?: (p: string) => number, maxBytes?: number}} [io]
197
+ */
198
+ export function importModelArgs(args, io = {}) {
199
+ if (!args || typeof args.path !== 'string' || !args.path) return args;
200
+ const { path, ...rest } = args;
201
+ if (rest.data || rest.url) throw new Error('import_model: pass one of path, data or url');
202
+ const max = io.maxBytes ?? IMPORT_MAX_BYTES;
203
+ let size;
204
+ try {
205
+ size = (io.size ?? ((p) => statSync(p).size))(path);
206
+ } catch (e) {
207
+ throw new Error('import_model: cannot read ' + path + ' (' + String(/** @type {any} */ (e).code || /** @type {any} */ (e).message) + ')');
208
+ }
209
+ if (size > max) throw new Error('import_model: ' + basename(path) + ' is ' + (size / 1048576).toFixed(1) + ' MB; the limit is ' + max / 1048576 + ' MB');
210
+ const buf = (io.read ?? ((p) => readFileSync(p)))(path);
211
+ if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'glTF')
212
+ throw new Error('import_model: ' + basename(path) + ' is not a binary glTF (.glb) — export a self-contained GLB (no external files)');
213
+ return { ...rest, data: buf.toString('base64'), name: rest.name ?? basename(path).replace(/\.glb$/i, '') };
214
+ }
215
+
216
+ /**
217
+ * What a local file for `import_file` is, by its first bytes (the page sniffs again and owns the rules;
218
+ * this is the early refusal so 16 MB of the wrong thing never crosses the wire).
219
+ * @param {Buffer} b @returns {'image' | 'json' | 'clip' | null}
220
+ */
221
+ export function sniffImportKind(b) {
222
+ if (b.length >= 8 && b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG') return 'image';
223
+ if (b.length >= 12 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image';
224
+ if (b.length >= 12 && b.toString('latin1', 0, 4) === 'glTF') return 'clip';
225
+ const head = b.toString('utf8', 0, Math.min(b.length, 64)).replace(/^\uFEFF/, '').trimStart();
226
+ if (head[0] === '{' || head[0] === '[') return 'json';
227
+ return null;
228
+ }
229
+
230
+ /**
231
+ * `import_file` with a `path` (and/or `spec_path`): read the local files, check kind and size, and return
232
+ * the exec calls to make — ONE call, or the base64 split into `chunk: {id, index, total}` parts with every
233
+ * other argument riding the last one (the page imports when the last part lands). Paths never reach the
234
+ * page. Throws a readable error. Without a path the args pass through as the only call.
235
+ * @param {any} args
236
+ * @param {{read?: (p: string) => Buffer, size?: (p: string) => number, chunkChars?: number, id?: string}} [io]
237
+ * @returns {any[]}
238
+ */
239
+ export function importFileCalls(args, io = {}) {
240
+ if (!args || typeof args !== 'object') return [args];
241
+ const { path, spec_path: specPath, ...rest } = args;
242
+ const size = io.size ?? ((/** @type {string} */ p) => statSync(p).size);
243
+ const read = io.read ?? ((/** @type {string} */ p) => readFileSync(p));
244
+ /** @param {string} p @param {number} max @param {string} what */
245
+ const load = (p, max, what) => {
246
+ let n;
247
+ try {
248
+ n = size(p);
249
+ } catch (e) {
250
+ throw new Error('import_file: cannot read ' + p + ' (' + String(/** @type {any} */ (e).code || /** @type {any} */ (e).message) + ')');
251
+ }
252
+ if (n > max) throw new Error('import_file: ' + basename(p) + ' is ' + (n / 1048576).toFixed(1) + ' MB; the limit for ' + what + ' is ' + max / 1048576 + ' MB');
253
+ return read(p);
254
+ };
255
+ if (typeof specPath === 'string' && specPath) {
256
+ if (rest.spec != null) throw new Error('import_file: pass one of spec or spec_path');
257
+ const text = load(specPath, IMPORT_FILE_CAPS.json, 'a spec').toString('utf8').replace(/^\uFEFF/, '');
258
+ try {
259
+ rest.spec = JSON.parse(text);
260
+ } catch (e) {
261
+ throw new Error('import_file: ' + basename(specPath) + ' is not JSON (' + String(/** @type {any} */ (e).message).slice(0, 80) + ')');
262
+ }
263
+ }
264
+ if (typeof path !== 'string' || !path) return [rest];
265
+ if (rest.data || rest.chunk) throw new Error('import_file: pass one of path or data');
266
+ const max = Math.max(...Object.values(IMPORT_FILE_CAPS));
267
+ const buf = load(path, max, 'any file');
268
+ const kind = sniffImportKind(buf);
269
+ if (!kind) throw new Error('import_file: ' + basename(path) + ' is not a PNG, WebP, JSON or .tpanim file (read from its bytes)');
270
+ if (buf.length > IMPORT_FILE_CAPS[kind])
271
+ throw new Error('import_file: ' + basename(path) + ' is ' + (buf.length / 1048576).toFixed(1) + ' MB; the limit for ' + (kind === 'image' ? 'an image' : kind === 'json' ? 'a JSON file' : 'a clip') + ' is ' + IMPORT_FILE_CAPS[kind] / 1048576 + ' MB');
272
+ if (rest.name == null) rest.name = basename(path).replace(/\.[^.]+$/, '');
273
+ const data = buf.toString('base64');
274
+ const step = Math.max(4, Math.floor((io.chunkChars ?? IMPORT_CHUNK_CHARS) / 4) * 4);
275
+ if (data.length <= step) return [{ ...rest, data }];
276
+ const total = Math.ceil(data.length / step);
277
+ const id = io.id ?? 'b' + randomBytes(6).toString('hex');
278
+ /** @type {any[]} */
279
+ const calls = [];
280
+ for (let i = 0; i < total; i++) {
281
+ const part = { data: data.slice(i * step, (i + 1) * step), chunk: { id, index: i, total } };
282
+ calls.push(i === total - 1 ? { ...rest, ...part } : part);
283
+ }
284
+ return calls;
285
+ }
286
+
287
+ /**
288
+ * One scene-tool call through the engine, with the bridge's local-file arguments resolved: `import_model`
289
+ * reads its `path`; `import_file` reads `path`/`spec_path` and may take several exec calls (chunks) — the
290
+ * first error stops it, else the last result is the answer.
291
+ * @param {{exec: (name: string, args: any) => Promise<any>}} engine @param {string} name @param {any} args
292
+ */
293
+ export async function execBridged(engine, name, args) {
294
+ if (name === 'import_model') return engine.exec(name, importModelArgs(args));
295
+ if (name !== 'import_file') return engine.exec(name, args);
296
+ const calls = importFileCalls(args);
297
+ let r;
298
+ for (const c of calls) {
299
+ r = await engine.exec(name, c);
300
+ if (!r || r.error || r.__missing) return r;
301
+ }
302
+ return r;
303
+ }
package/src/llmClient.js CHANGED
@@ -1,12 +1,65 @@
1
- // Minimal OpenAI-compatible client for the REPL (roadmap #10 B4). Non-streaming
2
- // (simpler in a terminal), tool-call loop. Node 20 has global fetch.
1
+ // Minimal OpenAI-compatible client for the REPL (roadmap #10 B4 → 56 C3), tool-call loop. Node 20 has
2
+ // global fetch. With `onDelta` it streams (SSE, `stream: true`) so the app's AI assistant window watches the
3
+ // reply grow; an endpoint that answers a stream request with plain JSON still works.
4
+
5
+ /**
6
+ * Read an SSE body into one assistant message, calling onDelta per content chunk.
7
+ * @param {any} res @param {(t: string) => void} onDelta
8
+ * @returns {Promise<{content: string, toolCalls: any[]}>}
9
+ */
10
+ async function readStream(res, onDelta) {
11
+ let content = '';
12
+ /** index -> {id, type, function: {name, arguments}} @type {Map<number, any>} */
13
+ const calls = new Map();
14
+ const decoder = new TextDecoder();
15
+ let buf = '';
16
+ /** @param {string} line */
17
+ const take = (line) => {
18
+ const t = line.trim();
19
+ if (!t.startsWith('data:')) return;
20
+ const data = t.slice(5).trim();
21
+ if (!data || data === '[DONE]') return;
22
+ let j;
23
+ try {
24
+ j = JSON.parse(data);
25
+ } catch {
26
+ return;
27
+ }
28
+ const d = j.choices?.[0]?.delta ?? j.choices?.[0]?.message;
29
+ if (!d) return;
30
+ if (typeof d.content === 'string' && d.content) {
31
+ content += d.content;
32
+ onDelta(d.content);
33
+ }
34
+ for (const tc of d.tool_calls ?? []) {
35
+ const i = Number.isInteger(tc.index) ? tc.index : calls.size;
36
+ const c = calls.get(i) ?? { id: '', type: 'function', function: { name: '', arguments: '' } };
37
+ if (tc.id) c.id = tc.id;
38
+ if (tc.function?.name) c.function.name += tc.function.name;
39
+ if (tc.function?.arguments) c.function.arguments += tc.function.arguments;
40
+ calls.set(i, c);
41
+ }
42
+ };
43
+ for await (const chunk of res.body) {
44
+ buf += decoder.decode(chunk, { stream: true });
45
+ let nl;
46
+ while ((nl = buf.indexOf('\n')) >= 0) {
47
+ take(buf.slice(0, nl));
48
+ buf = buf.slice(nl + 1);
49
+ }
50
+ }
51
+ if (buf) take(buf);
52
+ const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([i, c]) => ({ ...c, id: c.id || 'call_' + i }));
53
+ return { content, toolCalls };
54
+ }
3
55
 
4
56
  /**
5
57
  * @param {Object} cfg @param {string} cfg.apiUrl @param {string} cfg.apiKey @param {string} cfg.model
6
58
  * @param {any[]} messages @param {any[]} tools
59
+ * @param {((t: string) => void) | undefined} [onDelta] stream, calling this per content chunk
7
60
  * @returns {Promise<{content:string, toolCalls:any[]}>}
8
61
  */
9
- async function chatOnce(cfg, messages, tools) {
62
+ export async function chatOnce(cfg, messages, tools, onDelta) {
10
63
  const base = cfg.apiUrl.replace(/\/+$/, '');
11
64
  const res = await fetch(base + '/chat/completions', {
12
65
  method: 'POST',
@@ -18,16 +71,20 @@ async function chatOnce(cfg, messages, tools) {
18
71
  model: cfg.model,
19
72
  messages,
20
73
  tools: tools && tools.length ? tools.map((t) => ({ type: 'function', function: t })) : undefined,
21
- tool_choice: tools && tools.length ? 'auto' : undefined
74
+ tool_choice: tools && tools.length ? 'auto' : undefined,
75
+ stream: onDelta ? true : undefined
22
76
  })
23
77
  });
24
78
  if (!res.ok) {
25
79
  const body = await res.text().catch(() => '');
26
80
  throw new Error('API ' + res.status + ': ' + body.slice(0, 300));
27
81
  }
82
+ if (onDelta && /text\/event-stream/i.test(res.headers.get('content-type') || '')) return readStream(res, onDelta);
28
83
  const json = await res.json();
29
84
  const msg = json.choices && json.choices[0] && json.choices[0].message;
30
- return { content: (msg && msg.content) || '', toolCalls: (msg && msg.tool_calls) || [] };
85
+ const content = (msg && msg.content) || '';
86
+ if (onDelta && content) onDelta(content);
87
+ return { content, toolCalls: (msg && msg.tool_calls) || [] };
31
88
  }
32
89
 
33
90
  /**
@@ -38,17 +95,27 @@ async function chatOnce(cfg, messages, tools) {
38
95
  * @param {any[]} opts.tools
39
96
  * @param {(name:string, args:any)=>any} opts.executeTool may return a Promise
40
97
  * @param {(name:string, args:any)=>void} [opts.onTool]
98
+ * @param {(t: string, round: number) => void} [opts.onDelta] stream the reply (round = which model call)
99
+ * @param {(result: any) => boolean} [opts.stopOn] a tool result that ends the turn (56 C6: Stop → cancelled)
41
100
  * @param {number} [opts.maxIterations]
42
101
  * @returns {Promise<string>}
43
102
  */
44
- export async function runChat({ cfg, messages, tools, executeTool, onTool, maxIterations = 12 }) {
103
+ export async function runChat({ cfg, messages, tools, executeTool, onTool, onDelta, stopOn, maxIterations = 12 }) {
45
104
  let final = '';
46
105
  for (let i = 0; i < maxIterations; i++) {
47
- const { content, toolCalls } = await chatOnce(cfg, messages, tools);
106
+ const { content, toolCalls } = await chatOnce(cfg, messages, tools, onDelta ? (t) => onDelta(t, i) : undefined);
48
107
  final = content;
49
- if (!toolCalls.length) break;
108
+ if (!toolCalls.length) {
109
+ if (content) messages.push({ role: 'assistant', content });
110
+ break;
111
+ }
50
112
  messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls });
113
+ let stopped = false;
51
114
  for (const call of toolCalls) {
115
+ if (stopped) {
116
+ messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify({ error: 'cancelled', cancelled: true }) });
117
+ continue;
118
+ }
52
119
  let args = {};
53
120
  try {
54
121
  args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
@@ -61,9 +128,14 @@ export async function runChat({ cfg, messages, tools, executeTool, onTool, maxIt
61
128
  try {
62
129
  result = await executeTool(call.function.name, args);
63
130
  } catch (e) {
64
- result = { error: e.message };
131
+ result = { error: /** @type {any} */ (e).message };
65
132
  }
66
133
  messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result ?? { ok: true }) });
134
+ if (stopOn && stopOn(result)) stopped = true;
135
+ }
136
+ if (stopped) {
137
+ final = '';
138
+ break;
67
139
  }
68
140
  }
69
141
  return final;