@theprototype/agent 1.48.0 → 1.51.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/README.md +54 -5
- package/catalog.json +1317 -0
- package/package.json +3 -2
- package/src/browserEngine.js +12 -0
- package/src/catalog.js +114 -0
- package/src/chat.js +303 -0
- package/src/llmClient.js +81 -9
- package/src/mcpServer.js +62 -9
- package/src/repl.js +243 -27
package/src/mcpServer.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// is up (never a hardcoded catalog: it is the live app's set, physics and mesh gen included
|
|
5
5
|
// when the host offers them) and re-read on a poll and after every call, so a host that turns
|
|
6
6
|
// a provider on, or a role change, reaches the client as `notifications/tools/list_changed`.
|
|
7
|
-
// Beside them sit the bridge's own four: connect, status, screenshot, wait_for_edit
|
|
7
|
+
// Beside them sit the bridge's own four: connect, status, screenshot, wait_for_edit — and (roadmap 56,
|
|
8
|
+
// browser engine) the chat: wait_for_message, reply, ask, get_thread (./chat.js), so people can type to the
|
|
9
|
+
// agent in the app's AI assistant window while the terminal stays a second window onto the same thread.
|
|
8
10
|
//
|
|
9
11
|
// The server answers IMMEDIATELY and connects in the background: approval is a human's, so no
|
|
10
12
|
// tool registration ever waits on it — scene tools refuse with "not in the session yet" until
|
|
@@ -14,6 +16,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
14
16
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
17
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
16
18
|
import { bridgeTools, mapHostTools, toolSignature, textResult, resultIsError } from './mcpTools.js';
|
|
19
|
+
import { chatTools, waitMs, ImageSpool, messageContent, withImportPath, execBridged } from './chat.js';
|
|
17
20
|
import { parseLink } from './link.js';
|
|
18
21
|
|
|
19
22
|
export const PKG_VERSION = (() => {
|
|
@@ -27,7 +30,10 @@ export const PKG_VERSION = (() => {
|
|
|
27
30
|
const INSTRUCTIONS = [
|
|
28
31
|
'theprototype.app session bridge: you are a peer in a live, shared 3D scene. Everything you change replicates to everyone in the session, with undo.',
|
|
29
32
|
'Start with status (or connect with the session link the host gave you). The scene tools are the host app\'s own and appear once you are in;',
|
|
30
|
-
'call list_scene first so you use real uuids, batch many objects into one call, and use screenshot to look at the result. wait_for_edit waits for the humans.'
|
|
33
|
+
'call list_scene first so you use real uuids, batch many objects into one call, and use screenshot to look at the result. wait_for_edit waits for the humans.',
|
|
34
|
+
'People can also TYPE TO YOU in the app\'s AI assistant window: when the user asks you to listen (e.g. "listen to my theprototype session"), loop',
|
|
35
|
+
'wait_for_message → act → reply({text, final: true}) → wait_for_message (null = nobody wrote yet: wait again). Respect each message\'s role (viewers may not edit).',
|
|
36
|
+
'When the user talks to you in this terminal while you are in a session, echo it: reply({prompt: <their words>, text: <your answer>, final: true}), so the app\'s thread is complete.'
|
|
31
37
|
].join(' ');
|
|
32
38
|
|
|
33
39
|
const CONNECT_WAIT_MS = 45000;
|
|
@@ -44,11 +50,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
44
50
|
* @param {any[]} [opts.staticTools] host tools known before connecting (the wire engine's catalog)
|
|
45
51
|
* @param {number} [opts.pollMs] how often to re-read the host's tool list (default 5000)
|
|
46
52
|
* @param {number} [opts.connectWaitMs]
|
|
53
|
+
* @param {ImageSpool | null} [opts.spool] where chat images are written (null = no temp files)
|
|
47
54
|
* @param {(m: string) => void} [opts.log]
|
|
48
55
|
*/
|
|
49
56
|
export function createBridgeServer(opts) {
|
|
50
57
|
const log = opts.log || (() => {});
|
|
51
|
-
const builtins = bridgeTools(opts.kind);
|
|
58
|
+
const builtins = [...bridgeTools(opts.kind), ...(opts.kind === 'browser' ? chatTools() : [])];
|
|
59
|
+
const spool = opts.spool === undefined ? new ImageSpool() : opts.spool;
|
|
52
60
|
const reserved = builtins.map((t) => t.name);
|
|
53
61
|
/** @type {any} */
|
|
54
62
|
let engine = null;
|
|
@@ -66,7 +74,7 @@ export function createBridgeServer(opts) {
|
|
|
66
74
|
|
|
67
75
|
if (opts.staticTools) {
|
|
68
76
|
const m = mapHostTools(opts.staticTools, reserved);
|
|
69
|
-
hostTools = m.tools;
|
|
77
|
+
hostTools = m.tools.map(withImportPath);
|
|
70
78
|
dropped = m.dropped;
|
|
71
79
|
signature = toolSignature(hostTools);
|
|
72
80
|
}
|
|
@@ -94,10 +102,11 @@ export function createBridgeServer(opts) {
|
|
|
94
102
|
return;
|
|
95
103
|
}
|
|
96
104
|
const m = mapHostTools(raw, reserved);
|
|
97
|
-
const
|
|
105
|
+
const mapped = m.tools.map(withImportPath);
|
|
106
|
+
const sig = toolSignature(mapped);
|
|
98
107
|
if (sig !== signature) {
|
|
99
108
|
signature = sig;
|
|
100
|
-
hostTools =
|
|
109
|
+
hostTools = mapped;
|
|
101
110
|
dropped = m.dropped;
|
|
102
111
|
log('host tool list: ' + hostTools.length + ' tools');
|
|
103
112
|
notifyChanged();
|
|
@@ -131,7 +140,11 @@ export function createBridgeServer(opts) {
|
|
|
131
140
|
const mine = engine;
|
|
132
141
|
starting = engine
|
|
133
142
|
.start()
|
|
134
|
-
.then(() =>
|
|
143
|
+
.then(() => {
|
|
144
|
+
// 56: the window labels the agent "via MCP" (an older page has no chat: fine)
|
|
145
|
+
if (engine === mine && mine.kind === 'browser') mine.chat?.('chatStatus', null, { mode: 'mcp' }).catch(() => {});
|
|
146
|
+
return refreshTools();
|
|
147
|
+
})
|
|
135
148
|
.catch((/** @type {any} */ e) => log('connect failed: ' + e.message));
|
|
136
149
|
if (!poll) {
|
|
137
150
|
poll = setInterval(() => refreshTools().catch(() => {}), opts.pollMs ?? 5000);
|
|
@@ -178,7 +191,7 @@ export function createBridgeServer(opts) {
|
|
|
178
191
|
|
|
179
192
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...builtins, ...hostTools] }));
|
|
180
193
|
|
|
181
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
194
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
182
195
|
const name = req.params.name;
|
|
183
196
|
const a = /** @type {any} */ (req.params.arguments || {});
|
|
184
197
|
try {
|
|
@@ -210,6 +223,10 @@ export function createBridgeServer(opts) {
|
|
|
210
223
|
const r = await engine.waitForEdit(ms);
|
|
211
224
|
return textResult(r, resultIsError(r));
|
|
212
225
|
}
|
|
226
|
+
if (name === 'wait_for_message' || name === 'reply' || name === 'ask' || name === 'get_thread') {
|
|
227
|
+
if (!engineReady()) return textResult({ error: 'the agent page is not up yet (' + engine.state + ')' }, true);
|
|
228
|
+
return await chatCall(name, a, extra?.signal);
|
|
229
|
+
}
|
|
213
230
|
if (!hostTools.some((t) => t.name === name)) {
|
|
214
231
|
return textResult({ error: 'unknown tool: ' + name, note: engineReady() ? 'call status for the tools this session offers' : 'the scene tools appear once connected' }, true);
|
|
215
232
|
}
|
|
@@ -223,7 +240,7 @@ export function createBridgeServer(opts) {
|
|
|
223
240
|
true
|
|
224
241
|
);
|
|
225
242
|
}
|
|
226
|
-
const r = await engine
|
|
243
|
+
const r = await execBridged(engine, name, a);
|
|
227
244
|
refreshTools().catch(() => {});
|
|
228
245
|
if (r && r.__missing) return textResult({ error: 'this app build has no exec()' }, true);
|
|
229
246
|
return textResult(r, resultIsError(r));
|
|
@@ -232,9 +249,45 @@ export function createBridgeServer(opts) {
|
|
|
232
249
|
}
|
|
233
250
|
});
|
|
234
251
|
|
|
252
|
+
/**
|
|
253
|
+
* 56: the chat tools. wait_for_message long-polls the page's inbox; if the client gives up on the call
|
|
254
|
+
* (cancel / timeout) the page's wait is released, and a message that arrived in that instant goes back.
|
|
255
|
+
* @param {string} name @param {any} a @param {AbortSignal | undefined} signal
|
|
256
|
+
*/
|
|
257
|
+
async function chatCall(name, a, signal) {
|
|
258
|
+
const e = engine;
|
|
259
|
+
if (name === 'wait_for_message') {
|
|
260
|
+
const token = 'w' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
261
|
+
const release = () => e.chat('abortWait', token).catch(() => {});
|
|
262
|
+
signal?.addEventListener('abort', release, { once: true });
|
|
263
|
+
try {
|
|
264
|
+
const msg = await e.chat('nextMessage', waitMs(a.timeoutS ?? a.timeout_s), token);
|
|
265
|
+
if (!msg) return textResult(null);
|
|
266
|
+
if (signal?.aborted) {
|
|
267
|
+
await e.chat('requeue', msg).catch(() => {});
|
|
268
|
+
return textResult({ error: 'cancelled' }, true);
|
|
269
|
+
}
|
|
270
|
+
return { content: messageContent(msg, spool) };
|
|
271
|
+
} finally {
|
|
272
|
+
signal?.removeEventListener('abort', release);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (name === 'reply') {
|
|
276
|
+
const r = await e.chat('reply', { text: a.text, final: a.final, to: a.to, chatId: a.chatId ?? a.chat_id, prompt: a.prompt, replyTo: a.replyTo });
|
|
277
|
+
return textResult(r, !r?.ok);
|
|
278
|
+
}
|
|
279
|
+
if (name === 'ask') {
|
|
280
|
+
const r = await e.chat('ask', { question: a.question, options: a.options, to: a.to, chatId: a.chatId ?? a.chat_id });
|
|
281
|
+
return textResult(r, !r?.ok);
|
|
282
|
+
}
|
|
283
|
+
const r = await e.chat('thread', { chatId: a.chatId ?? a.chat_id, before: a.before, limit: a.limit });
|
|
284
|
+
return textResult(r);
|
|
285
|
+
}
|
|
286
|
+
|
|
235
287
|
async function shutdown() {
|
|
236
288
|
if (closed) return;
|
|
237
289
|
closed = true;
|
|
290
|
+
spool?.cleanup();
|
|
238
291
|
if (poll) clearInterval(poll);
|
|
239
292
|
const e = engine;
|
|
240
293
|
engine = null;
|
package/src/repl.js
CHANGED
|
@@ -1,11 +1,226 @@
|
|
|
1
|
-
// REPL mode (roadmap 10 B4 → 55 A3):
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// REPL mode (roadmap 10 B4 → 55 A3 → 56 C3/C4): your own OpenAI-compatible model drives the scene. Either
|
|
2
|
+
// engine: the browser engine hands the model the host app's own tools and system prompt; the wire engine its
|
|
3
|
+
// fixed object catalog. Slash commands: /status /tools /raw <tool> <json> /quit.
|
|
4
|
+
//
|
|
5
|
+
// 56: the bridge owns the model loop here, so a message typed in the app's AI assistant window goes STRAIGHT
|
|
6
|
+
// IN as a user turn — no terminal needed after launch. Terminal lines and app messages share ONE transcript,
|
|
7
|
+
// run one at a time; the reply streams into the window (and the terminal), terminal turns show "via
|
|
8
|
+
// terminal", and Stop in the window ends the turn (the page answers `cancelled` to the next tool call).
|
|
5
9
|
import readline from 'node:readline';
|
|
6
10
|
import { runChat } from './llmClient.js';
|
|
7
11
|
import { mapHostTools } from './mcpTools.js';
|
|
8
12
|
import { redactLink } from './link.js';
|
|
13
|
+
import { execBridged } from './chat.js';
|
|
14
|
+
|
|
15
|
+
/** how often a streamed reply is pushed to the window (each push carries the full text so far) */
|
|
16
|
+
const FLUSH_MS = 250;
|
|
17
|
+
/** how long one app wait lasts before it re-arms */
|
|
18
|
+
const APP_WAIT_MS = 25000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One REPL conversation over an engine. Exported for the tests (a fake engine + a stub endpoint).
|
|
22
|
+
* @param {Object} o
|
|
23
|
+
* @param {any} o.engine
|
|
24
|
+
* @param {{apiUrl: string, apiKey: string, model: string}} o.cfg
|
|
25
|
+
* @param {(s: string) => void} [o.out] terminal output
|
|
26
|
+
* @param {(s: string) => void} [o.log] stderr notes
|
|
27
|
+
* @param {number} [o.flushMs]
|
|
28
|
+
* @param {number} [o.appWaitMs]
|
|
29
|
+
*/
|
|
30
|
+
export function createReplSession(o) {
|
|
31
|
+
const { engine, cfg } = o;
|
|
32
|
+
const out = o.out ?? ((s) => process.stdout.write(s));
|
|
33
|
+
const log = o.log ?? ((s) => console.error('[agent] ' + s));
|
|
34
|
+
const flushMs = o.flushMs ?? FLUSH_MS;
|
|
35
|
+
/** does this page take app messages? (browser engine on tpAgent 1.1.0) */
|
|
36
|
+
let chatOn = engine.kind === 'browser' && typeof engine.chat === 'function';
|
|
37
|
+
/** @type {any[]} */
|
|
38
|
+
const history = [];
|
|
39
|
+
/** @type {Promise<any>} */
|
|
40
|
+
let busy = Promise.resolve();
|
|
41
|
+
let pendingTerminal = 0;
|
|
42
|
+
let closed = false;
|
|
43
|
+
/** @type {string | null} */
|
|
44
|
+
let waitToken = null;
|
|
45
|
+
|
|
46
|
+
const toolsFor = async () => mapHostTools(await engine.tools(), []).tools.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }));
|
|
47
|
+
|
|
48
|
+
/** @param {string} method @param {...any} args */
|
|
49
|
+
const chat = (method, ...args) => (chatOn ? engine.chat(method, ...args) : Promise.resolve(null));
|
|
50
|
+
|
|
51
|
+
/** @param {() => Promise<any>} fn */
|
|
52
|
+
function enqueue(fn) {
|
|
53
|
+
const job = busy.then(fn);
|
|
54
|
+
busy = job.catch(() => {});
|
|
55
|
+
return job;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A streamed reply into the window: deltas batched every flushMs, the last push final.
|
|
60
|
+
* @param {any} ids replyTo/to/chatId for an app request, or {via:'cli'} for a terminal turn
|
|
61
|
+
*/
|
|
62
|
+
function streamer(ids) {
|
|
63
|
+
let pending = '';
|
|
64
|
+
let any = false;
|
|
65
|
+
let lastRound = 0;
|
|
66
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
67
|
+
let timer = null;
|
|
68
|
+
let sending = Promise.resolve();
|
|
69
|
+
const push = (/** @type {boolean} */ final) => {
|
|
70
|
+
const delta = pending;
|
|
71
|
+
pending = '';
|
|
72
|
+
sending = sending.then(() => chat('reply', { ...ids, delta, final }).catch(() => null));
|
|
73
|
+
return sending;
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
/** @param {string} t @param {number} round */
|
|
77
|
+
delta(t, round) {
|
|
78
|
+
if (any && round !== lastRound) {
|
|
79
|
+
pending += '\n\n';
|
|
80
|
+
out('\n\n');
|
|
81
|
+
}
|
|
82
|
+
lastRound = round;
|
|
83
|
+
any = true;
|
|
84
|
+
pending += t;
|
|
85
|
+
out(t);
|
|
86
|
+
if (!timer) timer = setTimeout(() => ((timer = null), push(false)), flushMs);
|
|
87
|
+
},
|
|
88
|
+
/** @param {string} [tail] */
|
|
89
|
+
async end(tail) {
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
91
|
+
timer = null;
|
|
92
|
+
if (tail) {
|
|
93
|
+
pending += (any ? '\n\n' : '') + tail;
|
|
94
|
+
out((any ? '\n\n' : '') + tail);
|
|
95
|
+
}
|
|
96
|
+
out('\n');
|
|
97
|
+
return push(true);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run one turn of the model with streaming. Returns 'done' | 'stopped'.
|
|
104
|
+
* @param {any} user the user message (text or content parts) @param {any} ids
|
|
105
|
+
*/
|
|
106
|
+
async function turn(user, ids) {
|
|
107
|
+
if (!history.length) history.push({ role: 'system', content: await engine.systemPrompt() });
|
|
108
|
+
history.push({ role: 'user', content: user });
|
|
109
|
+
const s = streamer(ids);
|
|
110
|
+
let stopped = false;
|
|
111
|
+
try {
|
|
112
|
+
await runChat({
|
|
113
|
+
cfg,
|
|
114
|
+
messages: history,
|
|
115
|
+
tools: await toolsFor(),
|
|
116
|
+
executeTool: (name, a) => execBridged(engine, name, a), // local `path`s for import_model / import_file
|
|
117
|
+
onTool: (name, a) => out('\n · ' + name + ' ' + summarizeArgs(a) + '\n'),
|
|
118
|
+
onDelta: (t, round) => s.delta(t, round),
|
|
119
|
+
stopOn: (r) => {
|
|
120
|
+
if (r && r.cancelled) stopped = true;
|
|
121
|
+
return stopped;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const msg = String(/** @type {any} */ (e).message || e).split('\n')[0];
|
|
126
|
+
// an endpoint without vision: say so once and go on in text
|
|
127
|
+
if (Array.isArray(user) && /image|vision|content/i.test(msg) && !/^API 401/.test(msg)) {
|
|
128
|
+
history.pop();
|
|
129
|
+
const text = user.filter((p) => p.type === 'text').map((p) => p.text).join('\n');
|
|
130
|
+
log('this model refused the images (' + msg.slice(0, 80) + '); retrying without them');
|
|
131
|
+
return turn(text + '\n(The person attached images, but this model cannot see images.)', ids);
|
|
132
|
+
}
|
|
133
|
+
await s.end('Error: ' + msg);
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
await s.end(stopped ? '(stopped)' : undefined);
|
|
137
|
+
if (stopped) history.push({ role: 'system', content: 'The person pressed Stop: that request was cancelled. Do not continue it.' });
|
|
138
|
+
return stopped ? 'stopped' : 'done';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A line typed in the terminal: echoed into the app's thread as "via terminal", then answered. @param {string} text */
|
|
142
|
+
function terminal(text) {
|
|
143
|
+
pendingTerminal++;
|
|
144
|
+
if (waitToken) chat('abortWait', waitToken).catch(() => {});
|
|
145
|
+
return enqueue(async () => {
|
|
146
|
+
try {
|
|
147
|
+
await chat('reply', { prompt: text, via: 'cli' }).catch(() => null);
|
|
148
|
+
return await turn(text, { via: 'cli' });
|
|
149
|
+
} finally {
|
|
150
|
+
pendingTerminal--;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A message typed in the app's window, as a user turn. @param {any} msg */
|
|
156
|
+
function appTurn(msg) {
|
|
157
|
+
return enqueue(async () => {
|
|
158
|
+
const who = (msg.name || String(msg.from || '').toUpperCase() || 'someone') + ' (' + (msg.role || 'editor') + ')';
|
|
159
|
+
out('\n[app] ' + who + ': ' + (msg.text || '') + (msg.images?.length ? ' [' + msg.images.length + ' image' + (msg.images.length > 1 ? 's' : '') + ']' : '') + '\n');
|
|
160
|
+
if (msg.history?.length) {
|
|
161
|
+
const lines = msg.history.map((/** @type {any} */ h) => (h.name || h.from) + (h.via === 'cli' ? ' (terminal)' : '') + ': ' + String(h.text || '').slice(0, 400));
|
|
162
|
+
history.push({ role: 'system', content: 'Earlier in this chat (' + msg.chatId + '). ' + (msg.summary || '') + '\n' + lines.join('\n') });
|
|
163
|
+
}
|
|
164
|
+
let text = '[' + who + ' in the app' + (msg.answers ? ', answering your question' : '') + ']: ' + (msg.text || '');
|
|
165
|
+
if (msg.role === 'viewer') text += '\n(This person is a VIEWER: answer, but do not change the scene for them — edits are refused.)';
|
|
166
|
+
const user = msg.images?.length
|
|
167
|
+
? [{ type: 'text', text }, ...msg.images.map((/** @type {any} */ im) => ({ type: 'image_url', image_url: { url: 'data:' + im.mimeType + ';base64,' + im.data } }))]
|
|
168
|
+
: text;
|
|
169
|
+
return turn(user, { replyTo: msg.id, to: msg.from, chatId: msg.chatId }).catch((e) => log('turn failed: ' + e.message));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pick up app messages until stopped (each one waits for the turn before it). */
|
|
174
|
+
async function appLoop() {
|
|
175
|
+
if (!chatOn) return;
|
|
176
|
+
await chat('chatStatus', null, { mode: 'repl', model: cfg.model }).catch(() => {});
|
|
177
|
+
while (!closed) {
|
|
178
|
+
await busy;
|
|
179
|
+
if (closed) break;
|
|
180
|
+
const token = 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
181
|
+
waitToken = token;
|
|
182
|
+
let msg;
|
|
183
|
+
try {
|
|
184
|
+
msg = await engine.chat('nextMessage', o.appWaitMs ?? APP_WAIT_MS, token);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
waitToken = null;
|
|
187
|
+
if (/no agent chat/.test(String(/** @type {any} */ (e).message))) {
|
|
188
|
+
log('this app build has no agent chat: only the terminal talks to the model');
|
|
189
|
+
chatOn = false;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
waitToken = null;
|
|
196
|
+
if (!msg) continue;
|
|
197
|
+
if (pendingTerminal > 0) {
|
|
198
|
+
// a terminal line got in first: this one goes back and waits its turn
|
|
199
|
+
await chat('requeue', msg).catch(() => {});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
appTurn(msg);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
history,
|
|
208
|
+
terminal,
|
|
209
|
+
appTurn,
|
|
210
|
+
appLoop,
|
|
211
|
+
toolsFor,
|
|
212
|
+
get busy() {
|
|
213
|
+
return busy;
|
|
214
|
+
},
|
|
215
|
+
get chatOn() {
|
|
216
|
+
return chatOn;
|
|
217
|
+
},
|
|
218
|
+
stop() {
|
|
219
|
+
closed = true;
|
|
220
|
+
if (waitToken) chat('abortWait', waitToken).catch(() => {});
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
9
224
|
|
|
10
225
|
/**
|
|
11
226
|
* @param {Record<string, any>} args
|
|
@@ -21,11 +236,15 @@ export async function runRepl(args, link, makeEngine) {
|
|
|
21
236
|
if (!cfg.apiUrl || !cfg.model) throw new Error('repl needs --api-url and --model (or env AGENT_API_URL / AGENT_MODEL)');
|
|
22
237
|
|
|
23
238
|
const engine = makeEngine(link);
|
|
239
|
+
/** @type {ReturnType<typeof createReplSession> | null} */
|
|
240
|
+
let session = null;
|
|
24
241
|
const quit = async (/** @type {number} */ code) => {
|
|
242
|
+
session?.stop();
|
|
25
243
|
await engine.close().catch(() => {});
|
|
26
244
|
process.exit(code);
|
|
27
245
|
};
|
|
28
246
|
process.on('SIGINT', () => quit(0));
|
|
247
|
+
process.on('SIGTERM', () => quit(0));
|
|
29
248
|
console.error('[agent] joining ' + redactLink(link) + ' (' + engine.kind + ' engine)…');
|
|
30
249
|
await engine.start();
|
|
31
250
|
console.error('[agent] waiting for the host to let us in (Approve, or an invite link)…');
|
|
@@ -37,39 +256,36 @@ export async function runRepl(args, link, makeEngine) {
|
|
|
37
256
|
}
|
|
38
257
|
await new Promise((r) => setTimeout(r, 500));
|
|
39
258
|
}
|
|
40
|
-
|
|
259
|
+
session = createReplSession({ engine, cfg });
|
|
260
|
+
const s = session;
|
|
261
|
+
console.error(
|
|
262
|
+
'[agent] in the session. Type a request, or /help. Ctrl+C to quit.' +
|
|
263
|
+
(s.chatOn ? ' People can also type to ' + cfg.model + ' in the app\'s AI assistant window.' : '') +
|
|
264
|
+
'\n'
|
|
265
|
+
);
|
|
41
266
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'scene> ' });
|
|
267
|
+
const interactive = !!process.stdin.isTTY;
|
|
268
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'scene> ', terminal: interactive });
|
|
269
|
+
s.appLoop().catch((e) => console.error('[agent] app messages stopped: ' + e.message));
|
|
47
270
|
rl.prompt();
|
|
48
271
|
|
|
49
272
|
rl.on('line', async (line) => {
|
|
50
273
|
const text = line.trim();
|
|
51
274
|
if (!text) return rl.prompt();
|
|
52
|
-
rl.pause();
|
|
53
275
|
try {
|
|
54
|
-
if (text.startsWith('/'))
|
|
55
|
-
|
|
56
|
-
history.push({ role: 'user', content: text });
|
|
57
|
-
const reply = await runChat({
|
|
58
|
-
cfg,
|
|
59
|
-
messages: history,
|
|
60
|
-
tools: await toolsFor(),
|
|
61
|
-
executeTool: (name, a) => engine.exec(name, a),
|
|
62
|
-
onTool: (name, a) => console.log(' · ' + name + ' ' + summarizeArgs(a))
|
|
63
|
-
});
|
|
64
|
-
if (reply) console.log('\n' + reply + '\n');
|
|
276
|
+
if (text.startsWith('/')) {
|
|
277
|
+
if ((await handleSlash(text, engine, s.toolsFor)) === 'quit') return quit(0);
|
|
65
278
|
}
|
|
279
|
+
else await s.terminal(text);
|
|
66
280
|
} catch (e) {
|
|
67
281
|
console.error('[agent] error: ' + /** @type {any} */ (e).message + '\n');
|
|
68
282
|
}
|
|
69
|
-
rl.resume();
|
|
70
283
|
rl.prompt();
|
|
71
284
|
});
|
|
72
|
-
|
|
285
|
+
// a closed stdin (a script, `--repl < /dev/null`) keeps the agent answering the app; Ctrl+C quits
|
|
286
|
+
rl.on('close', () => {
|
|
287
|
+
if (interactive || !s.chatOn) quit(0);
|
|
288
|
+
});
|
|
73
289
|
}
|
|
74
290
|
|
|
75
291
|
/** @param {any} a */
|
|
@@ -78,10 +294,10 @@ function summarizeArgs(a) {
|
|
|
78
294
|
return '';
|
|
79
295
|
}
|
|
80
296
|
|
|
81
|
-
/** @param {string} text @param {any} engine @param {
|
|
82
|
-
async function handleSlash(text, engine,
|
|
297
|
+
/** @param {string} text @param {any} engine @param {() => Promise<any[]>} toolsFor @returns {Promise<'quit' | void>} */
|
|
298
|
+
async function handleSlash(text, engine, toolsFor) {
|
|
83
299
|
const [cmd, ...rest] = text.slice(1).split(/\s+/);
|
|
84
|
-
if (cmd === 'quit' || cmd === 'q' || cmd === 'exit') return
|
|
300
|
+
if (cmd === 'quit' || cmd === 'q' || cmd === 'exit') return 'quit';
|
|
85
301
|
if (cmd === 'status') console.log(JSON.stringify({ ...engine.describe(), session: await engine.status() }, null, 2));
|
|
86
302
|
else if (cmd === 'tools') for (const t of await toolsFor()) console.log(' ' + t.name);
|
|
87
303
|
else if (cmd === 'raw') {
|