@addai/node 0.21.0 → 0.23.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/dist/codex-spawn.js +44 -21
- package/dist/desktop/relay-client.d.ts +5 -0
- package/dist/desktop/relay-client.js +57 -21
- package/dist/gemini-spawn.d.ts +15 -0
- package/dist/gemini-spawn.js +30 -2
- package/dist/grok-spawn.js +66 -23
- package/dist/index.js +6 -1
- package/dist/install.d.ts +1 -0
- package/dist/install.js +13 -19
- package/dist/kimi-spawn.d.ts +7 -0
- package/dist/kimi-spawn.js +29 -1
- package/dist/mcp-headers.d.ts +15 -0
- package/dist/mcp-headers.js +49 -0
- package/dist/request-pump.d.ts +10 -0
- package/dist/request-pump.js +15 -0
- package/dist/think-split.d.ts +24 -0
- package/dist/think-split.js +103 -0
- package/package.json +1 -1
package/dist/codex-spawn.js
CHANGED
|
@@ -47,10 +47,12 @@ const child_process_1 = require("child_process");
|
|
|
47
47
|
const fs = __importStar(require("fs"));
|
|
48
48
|
const os = __importStar(require("os"));
|
|
49
49
|
const path = __importStar(require("path"));
|
|
50
|
+
const think_split_1 = require("./think-split");
|
|
50
51
|
const codex_binary_1 = require("./codex-binary");
|
|
51
52
|
const diskguard_1 = require("./diskguard");
|
|
52
53
|
const win_1 = require("./win");
|
|
53
54
|
const events_1 = require("./events");
|
|
55
|
+
const mcp_headers_1 = require("./mcp-headers");
|
|
54
56
|
/** Quote a string as a TOML basic string (double-quoted, escape backslash + quote). */
|
|
55
57
|
function tomlString(s) {
|
|
56
58
|
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
|
|
@@ -59,6 +61,11 @@ function tomlString(s) {
|
|
|
59
61
|
* Only the NAME goes in config.toml — the value rides on the child env, so
|
|
60
62
|
* the token never lands on disk. */
|
|
61
63
|
const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
|
|
64
|
+
/** Env var name carrying one HTTP header's value into the codex process.
|
|
65
|
+
* config.toml references it by name via env_http_headers, so the value
|
|
66
|
+
* itself never lands on disk. */
|
|
67
|
+
const headerEnvVar = (slug, header) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}` +
|
|
68
|
+
`_H_${header.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`;
|
|
62
69
|
/** Build a config.toml that declares only the supplied MCP servers and
|
|
63
70
|
* return the CODEX_HOME directory plus any env vars the child needs
|
|
64
71
|
* (bearer tokens for remote servers). Caller is responsible for cleanup
|
|
@@ -73,12 +80,16 @@ function writeCodexHome(servers, workingDirectory) {
|
|
|
73
80
|
// Remote MCP servers. Registry rows store command='http'|'sse' with
|
|
74
81
|
// args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
|
|
75
82
|
// the row verbatim made codex try to exec a binary called `http`, so the
|
|
76
|
-
// server never started and its tools silently vanished
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
// server never started and its tools silently vanished.
|
|
84
|
+
//
|
|
85
|
+
// Credentials go through env_http_headers, which maps a header name to the
|
|
86
|
+
// NAME of an env var holding its value — so the whole credential set is
|
|
87
|
+
// forwarded and config.toml still never contains a secret. This adapter
|
|
88
|
+
// used to send only ONE credential as a bearer token, on the belief that
|
|
89
|
+
// codex could not send custom headers; it can, and a server needing more
|
|
90
|
+
// than one header (addai-drive carries three) was arriving half-authorised.
|
|
91
|
+
if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
|
|
92
|
+
const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
|
|
82
93
|
if (!url) {
|
|
83
94
|
console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
84
95
|
continue;
|
|
@@ -86,22 +97,24 @@ function writeCodexHome(servers, workingDirectory) {
|
|
|
86
97
|
const name = tomlString(s.slug).slice(1, -1);
|
|
87
98
|
toml += `[mcp_servers.${name}]\n`;
|
|
88
99
|
toml += `url = ${tomlString(url)}\n`;
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
|
|
100
|
+
const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
|
|
101
|
+
// An OAuth access token is spelled as codex's native bearer rather than
|
|
102
|
+
// a hand-rolled Authorization header, so its OAuth handling still applies.
|
|
103
|
+
const bearer = headers.Authorization;
|
|
94
104
|
if (bearer) {
|
|
105
|
+
delete headers.Authorization;
|
|
95
106
|
const varName = bearerEnvVar(s.slug);
|
|
96
|
-
extraEnv[varName] =
|
|
107
|
+
extraEnv[varName] = bearer.replace(/^Bearer\s+/i, '');
|
|
97
108
|
toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
}
|
|
110
|
+
const entries = Object.entries(headers);
|
|
111
|
+
if (entries.length > 0) {
|
|
112
|
+
// Sub-table must follow the parent table's scalar keys.
|
|
113
|
+
toml += `[mcp_servers.${name}.env_http_headers]\n`;
|
|
114
|
+
for (const [header, value] of entries) {
|
|
115
|
+
const varName = headerEnvVar(s.slug, header);
|
|
116
|
+
extraEnv[varName] = value;
|
|
117
|
+
toml += `${tomlString(header)} = ${tomlString(varName)}\n`;
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
toml += '\n';
|
|
@@ -318,6 +331,10 @@ function spawnCodex(input) {
|
|
|
318
331
|
kill() { },
|
|
319
332
|
};
|
|
320
333
|
}
|
|
334
|
+
// Models do not reliably keep reasoning on their own stream — they drop
|
|
335
|
+
// back to ordinary text mid-run and carry on thinking inside a <think>
|
|
336
|
+
// tag. Split it back out before it reaches the room. See think-split.ts.
|
|
337
|
+
const emitSplit = (0, think_split_1.splittingEmitter)(emit);
|
|
321
338
|
proc.stdout?.on('data', (chunk) => {
|
|
322
339
|
touchActivity();
|
|
323
340
|
buffer += chunk.toString('utf8');
|
|
@@ -338,7 +355,7 @@ function spawnCodex(input) {
|
|
|
338
355
|
threadId = parsed.thread_id;
|
|
339
356
|
}
|
|
340
357
|
for (const ev of lineToEvents(parsed))
|
|
341
|
-
|
|
358
|
+
emitSplit(ev);
|
|
342
359
|
}
|
|
343
360
|
});
|
|
344
361
|
proc.stderr?.on('data', (chunk) => {
|
|
@@ -354,7 +371,13 @@ function spawnCodex(input) {
|
|
|
354
371
|
// SIGINT from kill()/timeout, or async spawn error). DiskGuard is the
|
|
355
372
|
// backstop for the paths this can't cover — SIGKILL and crashes.
|
|
356
373
|
proc.once('error', (err) => { void guardSession.dispose(true); reject(err); });
|
|
357
|
-
proc.once('exit', (code) => {
|
|
374
|
+
proc.once('exit', (code) => {
|
|
375
|
+
// Release anything the splitter is still holding — a run that ends
|
|
376
|
+
// mid-tag must not lose the tail of its answer.
|
|
377
|
+
emitSplit.flush();
|
|
378
|
+
void guardSession.dispose(true);
|
|
379
|
+
resolve(code ?? -1);
|
|
380
|
+
});
|
|
358
381
|
});
|
|
359
382
|
return {
|
|
360
383
|
pid: proc.pid,
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
+
/** What to run when the server says there is work. Injected by index.ts so
|
|
2
|
+
* this module stays ignorant of the pumps it is nudging. */
|
|
3
|
+
type WakeKind = 'command' | 'request';
|
|
4
|
+
export declare function onWake(kind: WakeKind, fn: () => void): void;
|
|
1
5
|
export declare function startRelayClient(): void;
|
|
2
6
|
export declare function stopRelayClient(): void;
|
|
7
|
+
export {};
|
|
@@ -36,16 +36,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.onWake = onWake;
|
|
39
40
|
exports.startRelayClient = startRelayClient;
|
|
40
41
|
exports.stopRelayClient = stopRelayClient;
|
|
41
|
-
// Dial-out socket to the
|
|
42
|
+
// Dial-out socket to the relay — this node's live link to the server.
|
|
42
43
|
//
|
|
43
|
-
// The daemon connects OUT
|
|
44
|
-
//
|
|
45
|
-
//
|
|
44
|
+
// The daemon connects OUT, so there is no inbound port and no NAT problem.
|
|
45
|
+
// It is held open ALWAYS, not only while a desktop is running, because it is
|
|
46
|
+
// how the server reaches this machine without being asked: work queued in
|
|
47
|
+
// Studio is pushed down here the moment it exists instead of waiting for the
|
|
48
|
+
// 30s heartbeat to notice it. That heartbeat remains the fallback for a node
|
|
49
|
+
// whose socket is down; this is the fast path, not the only path.
|
|
46
50
|
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
51
|
+
// Two kinds of traffic share it:
|
|
52
|
+
// - RAW RFB bytes for desktop viewers. The container serves RFB directly
|
|
53
|
+
// (no websockify), so noVNC in the browser speaks RFB across it unchanged.
|
|
54
|
+
// - `wake` control frames, which cost nothing when idle and turn a ten
|
|
55
|
+
// second wait into a round trip.
|
|
49
56
|
const ws_1 = __importDefault(require("ws"));
|
|
50
57
|
const net = __importStar(require("net"));
|
|
51
58
|
const store_1 = require("../store");
|
|
@@ -55,7 +62,7 @@ const RELAY_URL = process.env.AINODE_RELAY_URL
|
|
|
55
62
|
?? 'wss://desktop-relay-29522465016.europe-west2.run.app';
|
|
56
63
|
const RECONNECT_MIN_MS = 2_000;
|
|
57
64
|
const RECONNECT_MAX_MS = 60_000;
|
|
58
|
-
/** Re-check
|
|
65
|
+
/** Re-check for a pairing token this often when there is not one yet. */
|
|
59
66
|
const IDLE_CHECK_MS = 60_000;
|
|
60
67
|
/** How often to prove the socket is still alive. A machine that suspends
|
|
61
68
|
* leaves a half-open socket that never fires 'close', so without this the
|
|
@@ -73,6 +80,10 @@ let missedPongs = 0;
|
|
|
73
80
|
* people watching the same screen each need their own connection and their
|
|
74
81
|
* own handshake. x11vnc runs with -shared precisely so it will serve them. */
|
|
75
82
|
const bridges = new Map();
|
|
83
|
+
const wakeHooks = new Map();
|
|
84
|
+
function onWake(kind, fn) {
|
|
85
|
+
wakeHooks.set(kind, fn);
|
|
86
|
+
}
|
|
76
87
|
function dropBridges() {
|
|
77
88
|
for (const s of bridges.values()) {
|
|
78
89
|
try {
|
|
@@ -82,18 +93,28 @@ function dropBridges() {
|
|
|
82
93
|
}
|
|
83
94
|
bridges.clear();
|
|
84
95
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Start a connection attempt that CANNOT take the daemon down with it.
|
|
98
|
+
*
|
|
99
|
+
* `connect` is fire-and-forget. While the socket was gated on having a
|
|
100
|
+
* running desktop, most nodes returned early and never reached the pairing
|
|
101
|
+
* read or the WebSocket construction — so a throw there was unreachable in
|
|
102
|
+
* practice. Making the socket unconditional put every node on that path, and
|
|
103
|
+
* an unhandled rejection in Node terminates the process. At boot. In a loop.
|
|
104
|
+
* A relay that cannot connect must cost this node its push channel, nothing
|
|
105
|
+
* more.
|
|
106
|
+
*/
|
|
107
|
+
function connectSafely() {
|
|
108
|
+
connect().catch(err => {
|
|
109
|
+
ws = null;
|
|
110
|
+
console.error('[relay] connect failed:', err?.message ?? String(err));
|
|
111
|
+
scheduleReconnect();
|
|
112
|
+
});
|
|
92
113
|
}
|
|
93
114
|
function scheduleReconnect() {
|
|
94
115
|
if (stopped)
|
|
95
116
|
return;
|
|
96
|
-
setTimeout(
|
|
117
|
+
setTimeout(connectSafely, backoff);
|
|
97
118
|
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
|
|
98
119
|
}
|
|
99
120
|
async function connect() {
|
|
@@ -104,11 +125,6 @@ async function connect() {
|
|
|
104
125
|
scheduleReconnect();
|
|
105
126
|
return;
|
|
106
127
|
}
|
|
107
|
-
// No point holding a socket open for a machine with nothing to show.
|
|
108
|
-
if (!(await anyDesktopRunning())) {
|
|
109
|
-
idleTimer = setTimeout(() => { void connect(); }, IDLE_CHECK_MS);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
128
|
const sock = new ws_1.default(`${RELAY_URL}/node?token=${encodeURIComponent(token)}`);
|
|
113
129
|
ws = sock;
|
|
114
130
|
sock.on('open', () => {
|
|
@@ -173,6 +189,26 @@ async function connect() {
|
|
|
173
189
|
bridges.get(msg.viewerId)?.write(Buffer.from(msg.b64, 'base64'));
|
|
174
190
|
return;
|
|
175
191
|
}
|
|
192
|
+
if (msg.type === 'wake') {
|
|
193
|
+
// The whole point of the socket. Costs one function call; saves the
|
|
194
|
+
// wait for the next heartbeat.
|
|
195
|
+
const kind = msg.what;
|
|
196
|
+
const hook = kind ? wakeHooks.get(kind) : undefined;
|
|
197
|
+
if (hook) {
|
|
198
|
+
try {
|
|
199
|
+
hook();
|
|
200
|
+
}
|
|
201
|
+
catch { /* a pump must never kill the link */ }
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
for (const h of wakeHooks.values()) {
|
|
205
|
+
try {
|
|
206
|
+
h();
|
|
207
|
+
}
|
|
208
|
+
catch { /* as above */ }
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
176
212
|
if (msg.type === 'detach') {
|
|
177
213
|
bridges.get(msg.viewerId)?.destroy();
|
|
178
214
|
bridges.delete(msg.viewerId);
|
|
@@ -197,7 +233,7 @@ function startRelayClient() {
|
|
|
197
233
|
return;
|
|
198
234
|
stopped = false;
|
|
199
235
|
backoff = RECONNECT_MIN_MS;
|
|
200
|
-
|
|
236
|
+
connectSafely();
|
|
201
237
|
}
|
|
202
238
|
function stopRelayClient() {
|
|
203
239
|
stopped = true;
|
package/dist/gemini-spawn.d.ts
CHANGED
|
@@ -26,6 +26,21 @@ export interface GeminiHandle {
|
|
|
26
26
|
onEvent(cb: (e: RuntimeEvent) => void): void;
|
|
27
27
|
done: Promise<number>;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Write a workspace-scope `<cwd>/.gemini/settings.json` for THIS spawn.
|
|
31
|
+
*
|
|
32
|
+
* gemini-cli's settings hierarchy is workspace > user — a workspace
|
|
33
|
+
* settings file wins over `~/.gemini/settings.json` field-by-field, so
|
|
34
|
+
* we can override `mcpServers` (the field we care about) without
|
|
35
|
+
* touching the user's home dir.
|
|
36
|
+
*
|
|
37
|
+
* The OLD approach pointed HOME at a temp dir to suppress the host's
|
|
38
|
+
* `~/.gemini/settings.json`. That was destructive: gemini-cli shells out
|
|
39
|
+
* to git, npm, node, and those tools read `~/.gitconfig`, `~/.npmrc`,
|
|
40
|
+
* `~/.netrc` from HOME. Pointing HOME at an empty dir blew them all
|
|
41
|
+
* away → silent tool failures (git push without creds, etc.).
|
|
42
|
+
*/
|
|
43
|
+
export declare function writeGeminiWorkspaceSettings(cwd: string, servers: GeminiMcpServer[]): string;
|
|
29
44
|
/** gemini-cli session ids are v4 UUIDs (createSessionId → node randomUUID).
|
|
30
45
|
* Only pass a plausibly-gemini id to `--resume`; a foreign id would just be
|
|
31
46
|
* reported "Invalid session identifier" and the run degrades to fresh via the
|
package/dist/gemini-spawn.js
CHANGED
|
@@ -44,6 +44,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
44
44
|
};
|
|
45
45
|
})();
|
|
46
46
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.writeGeminiWorkspaceSettings = writeGeminiWorkspaceSettings;
|
|
47
48
|
exports.isGeminiSessionId = isGeminiSessionId;
|
|
48
49
|
exports.lineToEvents = lineToEvents;
|
|
49
50
|
exports.spawnGemini = spawnGemini;
|
|
@@ -54,6 +55,8 @@ const path = __importStar(require("path"));
|
|
|
54
55
|
const gemini_binary_1 = require("./gemini-binary");
|
|
55
56
|
const win_1 = require("./win");
|
|
56
57
|
const events_1 = require("./events");
|
|
58
|
+
const think_split_1 = require("./think-split");
|
|
59
|
+
const mcp_headers_1 = require("./mcp-headers");
|
|
57
60
|
/**
|
|
58
61
|
* Write a workspace-scope `<cwd>/.gemini/settings.json` for THIS spawn.
|
|
59
62
|
*
|
|
@@ -75,6 +78,24 @@ function writeGeminiWorkspaceSettings(cwd, servers) {
|
|
|
75
78
|
for (const s of servers) {
|
|
76
79
|
if (!s.slug || !s.command)
|
|
77
80
|
continue;
|
|
81
|
+
// Remote MCP servers. gemini's settings schema spells streamable HTTP as
|
|
82
|
+
// `httpUrl` and SSE as `url` + `type: "sse"`, both with a `headers` map.
|
|
83
|
+
// Writing the registry row verbatim made gemini try to exec a binary
|
|
84
|
+
// called `http`, so the server never started and its tools were simply
|
|
85
|
+
// absent from the run — no error, nothing in the DB.
|
|
86
|
+
if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
|
|
87
|
+
const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
|
|
88
|
+
if (!url) {
|
|
89
|
+
console.error(`[gemini] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
|
|
93
|
+
const hasHeaders = Object.keys(headers).length > 0;
|
|
94
|
+
mcpServers[s.slug] = s.command === 'sse'
|
|
95
|
+
? { url, type: 'sse', ...(hasHeaders ? { headers } : {}) }
|
|
96
|
+
: { httpUrl: url, ...(hasHeaders ? { headers } : {}) };
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
78
99
|
// `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
|
|
79
100
|
const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args : []);
|
|
80
101
|
mcpServers[s.slug] = {
|
|
@@ -300,6 +321,10 @@ function spawnGemini(input) {
|
|
|
300
321
|
done: Promise.resolve(-1),
|
|
301
322
|
};
|
|
302
323
|
}
|
|
324
|
+
// Models do not reliably keep reasoning on their own stream — they drop
|
|
325
|
+
// back to ordinary text mid-run and carry on thinking inside a <think>
|
|
326
|
+
// tag. Split it back out before it reaches the room. See think-split.ts.
|
|
327
|
+
const emitSplit = (0, think_split_1.splittingEmitter)(emit);
|
|
303
328
|
proc.stdout?.on('data', (chunk) => {
|
|
304
329
|
buffer += chunk.toString('utf8');
|
|
305
330
|
let nl;
|
|
@@ -318,7 +343,7 @@ function spawnGemini(input) {
|
|
|
318
343
|
if (typeof parsed.session_id === 'string' && !sessionId)
|
|
319
344
|
sessionId = parsed.session_id;
|
|
320
345
|
for (const ev of lineToEvents(parsed))
|
|
321
|
-
|
|
346
|
+
emitSplit(ev);
|
|
322
347
|
}
|
|
323
348
|
});
|
|
324
349
|
proc.stderr?.on('data', (chunk) => {
|
|
@@ -349,10 +374,13 @@ function spawnGemini(input) {
|
|
|
349
374
|
if (typeof parsed.session_id === 'string' && !sessionId)
|
|
350
375
|
sessionId = parsed.session_id;
|
|
351
376
|
for (const ev of lineToEvents(parsed))
|
|
352
|
-
|
|
377
|
+
emitSplit(ev);
|
|
353
378
|
}
|
|
354
379
|
catch { /* ignore a partial/non-JSON tail */ }
|
|
355
380
|
}
|
|
381
|
+
// Release anything the splitter is still holding — a run that ends
|
|
382
|
+
// mid-tag must not lose the tail of its answer.
|
|
383
|
+
emitSplit.flush();
|
|
356
384
|
// No HOME-temp-dir to clean up anymore. The workspace settings file in
|
|
357
385
|
// <cwd>/.gemini/settings.json sits with the rest of the ephemeral cwd
|
|
358
386
|
// and is cleaned by the session dispose path.
|
package/dist/grok-spawn.js
CHANGED
|
@@ -73,6 +73,8 @@ const path = __importStar(require("path"));
|
|
|
73
73
|
const grok_binary_1 = require("./grok-binary");
|
|
74
74
|
const win_1 = require("./win");
|
|
75
75
|
const events_1 = require("./events");
|
|
76
|
+
const think_split_1 = require("./think-split");
|
|
77
|
+
const mcp_headers_1 = require("./mcp-headers");
|
|
76
78
|
/** Real config dir for the logged-in account. GROK_HOME points here so the
|
|
77
79
|
* entity uses the same account and token refresh works. */
|
|
78
80
|
function realGrokHome() {
|
|
@@ -159,16 +161,30 @@ function writeProjectMcpConfig(workingDirectory, servers) {
|
|
|
159
161
|
}
|
|
160
162
|
const lines = [];
|
|
161
163
|
for (const s of usable) {
|
|
162
|
-
// Remote MCP servers
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
164
|
+
// Remote MCP servers. xAI documents [mcp_servers.<name>] with `url` and a
|
|
165
|
+
// `headers` inline table, so grok now gets them properly instead of the
|
|
166
|
+
// skip this used to do — a skip that was safe (better than emitting
|
|
167
|
+
// `command = "http"`, which grok would try to exec) but meant an entity
|
|
168
|
+
// running on grok silently had none of its remote servers.
|
|
169
|
+
//
|
|
170
|
+
// Folder trust still gates repo-local config.toml servers, so the --trust
|
|
171
|
+
// launch flag remains load-bearing for these entries to start at all.
|
|
172
|
+
if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
|
|
173
|
+
const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
|
|
174
|
+
if (!url) {
|
|
175
|
+
console.error(`[grok] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
lines.push(`[mcp_servers.${tomlStr(s.slug)}]`);
|
|
179
|
+
lines.push(`url = ${tomlStr(url)}`);
|
|
180
|
+
const headers = Object.entries((0, mcp_headers_1.remoteMcpHeaders)(s.env));
|
|
181
|
+
if (headers.length > 0) {
|
|
182
|
+
const kv = headers.map(([k, v]) => `${tomlStr(k)} = ${tomlStr(v)}`).join(', ');
|
|
183
|
+
lines.push(`headers = { ${kv} }`);
|
|
184
|
+
}
|
|
185
|
+
lines.push('enabled = true');
|
|
186
|
+
lines.push('startup_timeout_sec = 30');
|
|
187
|
+
lines.push('');
|
|
172
188
|
continue;
|
|
173
189
|
}
|
|
174
190
|
// `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
|
|
@@ -415,25 +431,52 @@ function spawnGrok(input) {
|
|
|
415
431
|
emit({ type: 'assistant_text', delta });
|
|
416
432
|
}
|
|
417
433
|
};
|
|
434
|
+
// grok does not always keep reasoning on the `thought` stream: mid-run it
|
|
435
|
+
// will drop back to ordinary text and carry on thinking inside a <think>
|
|
436
|
+
// tag. Everything visible therefore goes through the splitter first, so
|
|
437
|
+
// private working never lands in the room. See think-split.ts.
|
|
438
|
+
const splitter = (0, think_split_1.createThinkSplitter)();
|
|
439
|
+
/** Visible answer text, already known not to be reasoning. */
|
|
440
|
+
const appendText = (delta) => {
|
|
441
|
+
// Thoughts precede the text they produced — flush them first.
|
|
442
|
+
flushThinking();
|
|
443
|
+
textBuf += delta;
|
|
444
|
+
if (textBuf.length >= MAX_BUF)
|
|
445
|
+
flushText();
|
|
446
|
+
else if (!flushTimer)
|
|
447
|
+
flushTimer = setTimeout(flushText, FLUSH_MS);
|
|
448
|
+
};
|
|
449
|
+
const appendThinking = (delta) => {
|
|
450
|
+
thinkBuf += delta;
|
|
451
|
+
if (thinkBuf.length >= THINK_MAX_BUF)
|
|
452
|
+
flushThinking();
|
|
453
|
+
else if (!thinkTimer)
|
|
454
|
+
thinkTimer = setTimeout(flushThinking, THINK_FLUSH_MS);
|
|
455
|
+
};
|
|
418
456
|
const emitCoalesced = (e) => {
|
|
419
457
|
if (e.type === 'assistant_text') {
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
458
|
+
// A delta may be part answer and part reasoning, or may end halfway
|
|
459
|
+
// through a tag — the splitter holds back whatever is still ambiguous.
|
|
460
|
+
for (const part of splitter.push(e.delta)) {
|
|
461
|
+
if (part.type === 'thinking')
|
|
462
|
+
appendThinking(part.delta);
|
|
463
|
+
else if (part.type === 'assistant_text')
|
|
464
|
+
appendText(part.delta);
|
|
465
|
+
}
|
|
427
466
|
}
|
|
428
467
|
else if (e.type === 'thinking') {
|
|
429
|
-
|
|
430
|
-
if (thinkBuf.length >= THINK_MAX_BUF)
|
|
431
|
-
flushThinking();
|
|
432
|
-
else if (!thinkTimer)
|
|
433
|
-
thinkTimer = setTimeout(flushThinking, THINK_FLUSH_MS);
|
|
468
|
+
appendThinking(e.delta);
|
|
434
469
|
}
|
|
435
470
|
else {
|
|
436
|
-
// Any other event must appear AFTER the stream so far
|
|
471
|
+
// Any other event must appear AFTER the stream so far. Release whatever
|
|
472
|
+
// the splitter is still holding: a run that ends mid-tag must not eat
|
|
473
|
+
// the tail of the answer.
|
|
474
|
+
for (const part of splitter.flush()) {
|
|
475
|
+
if (part.type === 'thinking')
|
|
476
|
+
appendThinking(part.delta);
|
|
477
|
+
else if (part.type === 'assistant_text')
|
|
478
|
+
appendText(part.delta);
|
|
479
|
+
}
|
|
437
480
|
flushThinking();
|
|
438
481
|
flushText();
|
|
439
482
|
emit(e);
|
package/dist/index.js
CHANGED
|
@@ -210,7 +210,12 @@ async function start(argv = []) {
|
|
|
210
210
|
(0, projects_1.start)();
|
|
211
211
|
// …and to keep desktops and the containers behind them in agreement.
|
|
212
212
|
(0, manager_1.startDesktopManager)();
|
|
213
|
-
// …and dial out to the relay
|
|
213
|
+
// …and dial out to the relay. That socket is this node's live link: the
|
|
214
|
+
// server pushes work down it the instant it exists, so a command no longer
|
|
215
|
+
// waits up to 30s for the next heartbeat to notice it. The heartbeat and
|
|
216
|
+
// the request pump still run — this makes them the fallback, not the clock.
|
|
217
|
+
(0, relay_client_1.onWake)('command', command_runner_1.wake);
|
|
218
|
+
(0, relay_client_1.onWake)('request', request_pump_1.pumpNow);
|
|
214
219
|
(0, relay_client_1.startRelayClient)();
|
|
215
220
|
let stopped = false;
|
|
216
221
|
// Outcome of the last drain, so a remote roll can report whether it left
|
package/dist/install.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface InstallResult {
|
|
|
42
42
|
/** Cleanup callback — removes everything this install wrote. */
|
|
43
43
|
dispose: () => Promise<void>;
|
|
44
44
|
}
|
|
45
|
+
export declare function writeMcpConfig(cwd: string, mcps: ResolvedMcp[]): string | null;
|
|
45
46
|
/**
|
|
46
47
|
* Run install side-effects against `cwd` and return paths to clean up
|
|
47
48
|
* later. Safe to call with an empty set — returns a no-op result.
|
package/dist/install.js
CHANGED
|
@@ -47,12 +47,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
47
47
|
})();
|
|
48
48
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
49
|
exports.LEGACY_MCP_CONFIG_FILENAME = exports.MCP_CONFIG_FILENAME = void 0;
|
|
50
|
+
exports.writeMcpConfig = writeMcpConfig;
|
|
50
51
|
exports.install = install;
|
|
51
52
|
const fs = __importStar(require("fs"));
|
|
52
53
|
const path = __importStar(require("path"));
|
|
53
54
|
const supabase_client_1 = require("./supabase-client");
|
|
54
55
|
const store_1 = require("./store");
|
|
55
56
|
const win_1 = require("./win");
|
|
57
|
+
const mcp_headers_1 = require("./mcp-headers");
|
|
56
58
|
/** Hidden per-session MCP config written next to the agent's working dir. */
|
|
57
59
|
exports.MCP_CONFIG_FILENAME = '.ainode.mcp.json';
|
|
58
60
|
/** Pre-rename filename; removed when found so agents don't read a stale copy. */
|
|
@@ -111,27 +113,19 @@ function writeMcpConfig(cwd, mcps) {
|
|
|
111
113
|
if (!m.slug || !m.command)
|
|
112
114
|
continue;
|
|
113
115
|
// Remote MCP servers: registry rows use command='http' (or 'sse') with
|
|
114
|
-
// args=[url].
|
|
115
|
-
//
|
|
116
|
-
// headless sessions use account-authenticated remote
|
|
117
|
-
// official Higgsfield server) without an interactive OAuth
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
// args=[url]. Credentials ride as request headers — see mcp-headers.ts for
|
|
117
|
+
// the naming rule and why OAUTH_* keys other than the access token are
|
|
118
|
+
// withheld. This is how headless sessions use account-authenticated remote
|
|
119
|
+
// MCPs (e.g. the official Higgsfield server) without an interactive OAuth
|
|
120
|
+
// dance.
|
|
121
|
+
if ((0, mcp_headers_1.isRemoteMcp)(m.command)) {
|
|
122
|
+
const url = (0, mcp_headers_1.remoteMcpUrl)(m.args);
|
|
123
|
+
if (!url) {
|
|
124
|
+
console.error(`[install] MCP ${m.slug}: ${m.command} server has no URL — skipped`);
|
|
121
125
|
continue;
|
|
122
|
-
const entry = { type: m.command, url };
|
|
123
|
-
const headers = {};
|
|
124
|
-
const token = m.env?.OAUTH_ACCESS_TOKEN;
|
|
125
|
-
if (token)
|
|
126
|
-
headers.Authorization = `Bearer ${token}`;
|
|
127
|
-
// Other env keys become literal headers (so remote MCPs with custom
|
|
128
|
-
// header auth work), but OAUTH_* keys are token bookkeeping (refresh
|
|
129
|
-
// token, expiry) — never send them as request headers.
|
|
130
|
-
for (const [k, v] of Object.entries(m.env || {})) {
|
|
131
|
-
if (!v || k.startsWith('OAUTH_'))
|
|
132
|
-
continue;
|
|
133
|
-
headers[k.replace(/_/g, '-')] = v;
|
|
134
126
|
}
|
|
127
|
+
const entry = { type: m.command, url };
|
|
128
|
+
const headers = (0, mcp_headers_1.remoteMcpHeaders)(m.env);
|
|
135
129
|
if (Object.keys(headers).length > 0)
|
|
136
130
|
entry.headers = headers;
|
|
137
131
|
servers[m.slug] = entry;
|
package/dist/kimi-spawn.d.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface KimiHandle {
|
|
|
39
39
|
onActivity(cb: () => void): void;
|
|
40
40
|
done: Promise<number>;
|
|
41
41
|
}
|
|
42
|
+
/** Write a sandbox $HOME with an empty .kimi dir so kimi's default
|
|
43
|
+
* `~/.kimi/mcp.json` resolves to nothing. Caller is responsible for
|
|
44
|
+
* cleanup (we drop it under os.tmpdir() so the OS reaps it). */
|
|
45
|
+
export declare function writeKimiSandboxHome(servers: KimiMcpServer[]): {
|
|
46
|
+
homeDir: string;
|
|
47
|
+
mcpConfigPath: string;
|
|
48
|
+
};
|
|
42
49
|
/** Parse one stream-json line into 0+ RuntimeEvents. Kimi's shapes are
|
|
43
50
|
* similar in spirit to claude's: assistant text deltas, tool calls,
|
|
44
51
|
* turn completion, and session metadata. Anything we don't recognise
|
package/dist/kimi-spawn.js
CHANGED
|
@@ -42,6 +42,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
42
42
|
};
|
|
43
43
|
})();
|
|
44
44
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.writeKimiSandboxHome = writeKimiSandboxHome;
|
|
45
46
|
exports.lineToEvents = lineToEvents;
|
|
46
47
|
exports.spawnKimi = spawnKimi;
|
|
47
48
|
const child_process_1 = require("child_process");
|
|
@@ -51,6 +52,8 @@ const path = __importStar(require("path"));
|
|
|
51
52
|
const kimi_binary_1 = require("./kimi-binary");
|
|
52
53
|
const win_1 = require("./win");
|
|
53
54
|
const events_1 = require("./events");
|
|
55
|
+
const think_split_1 = require("./think-split");
|
|
56
|
+
const mcp_headers_1 = require("./mcp-headers");
|
|
54
57
|
/** Write a sandbox $HOME with an empty .kimi dir so kimi's default
|
|
55
58
|
* `~/.kimi/mcp.json` resolves to nothing. Caller is responsible for
|
|
56
59
|
* cleanup (we drop it under os.tmpdir() so the OS reaps it). */
|
|
@@ -61,6 +64,24 @@ function writeKimiSandboxHome(servers) {
|
|
|
61
64
|
for (const s of servers) {
|
|
62
65
|
if (!s.slug || !s.command)
|
|
63
66
|
continue;
|
|
67
|
+
// Remote MCP servers. kimi's mcp.json takes `url` and infers the transport
|
|
68
|
+
// from url-vs-command, so no `transport` key is needed for streamable HTTP;
|
|
69
|
+
// SSE is stated explicitly. Headers carry the credential set. Writing the
|
|
70
|
+
// registry row verbatim made kimi try to exec a binary called `http`.
|
|
71
|
+
if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
|
|
72
|
+
const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
|
|
73
|
+
if (!url) {
|
|
74
|
+
console.error(`[kimi] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
|
|
78
|
+
mcpConfig.mcpServers[s.slug] = {
|
|
79
|
+
url,
|
|
80
|
+
...(s.command === 'sse' ? { transport: 'sse' } : {}),
|
|
81
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
82
|
+
};
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
64
85
|
// `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
|
|
65
86
|
const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args : []);
|
|
66
87
|
mcpConfig.mcpServers[s.slug] = {
|
|
@@ -269,6 +290,10 @@ function spawnKimi(input) {
|
|
|
269
290
|
done: Promise.resolve(-1),
|
|
270
291
|
};
|
|
271
292
|
}
|
|
293
|
+
// Models do not reliably keep reasoning on their own stream — they drop
|
|
294
|
+
// back to ordinary text mid-run and carry on thinking inside a <think>
|
|
295
|
+
// tag. Split it back out before it reaches the room. See think-split.ts.
|
|
296
|
+
const emitSplit = (0, think_split_1.splittingEmitter)(emit);
|
|
272
297
|
proc.stdout?.on('data', (chunk) => {
|
|
273
298
|
touchActivity();
|
|
274
299
|
buffer += chunk.toString('utf8');
|
|
@@ -288,7 +313,7 @@ function spawnKimi(input) {
|
|
|
288
313
|
if (typeof parsed.session_id === 'string' && !sessionId)
|
|
289
314
|
sessionId = parsed.session_id;
|
|
290
315
|
for (const ev of lineToEvents(parsed))
|
|
291
|
-
|
|
316
|
+
emitSplit(ev);
|
|
292
317
|
}
|
|
293
318
|
});
|
|
294
319
|
proc.stderr?.on('data', (chunk) => {
|
|
@@ -300,6 +325,9 @@ function spawnKimi(input) {
|
|
|
300
325
|
const done = new Promise((resolve, reject) => {
|
|
301
326
|
proc.once('error', reject);
|
|
302
327
|
proc.once('exit', (code) => {
|
|
328
|
+
// Release anything the splitter is still holding — a run that ends
|
|
329
|
+
// mid-tag must not lose the tail of its answer.
|
|
330
|
+
emitSplit.flush();
|
|
303
331
|
// Best-effort cleanup of sandbox tmp dir
|
|
304
332
|
try {
|
|
305
333
|
fs.rmSync(kimiSandboxDir, { recursive: true, force: true });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** True when a registry row describes a remote MCP server rather than a
|
|
2
|
+
* local process to spawn. */
|
|
3
|
+
export declare function isRemoteMcp(command: string): boolean;
|
|
4
|
+
/** The endpoint a remote row points at. Returns null for a malformed row so
|
|
5
|
+
* callers can skip it loudly rather than emit a broken config entry. */
|
|
6
|
+
export declare function remoteMcpUrl(args?: string[] | null): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Convert a registry env map into the HTTP headers the remote server expects.
|
|
9
|
+
*
|
|
10
|
+
* Underscores become hyphens (ENTITY_API_KEY -> ENTITY-API-KEY), matching the
|
|
11
|
+
* deployed github-mcp server. OAUTH_ACCESS_TOKEN becomes an Authorization
|
|
12
|
+
* bearer; every OTHER OAUTH_* key is token bookkeeping (refresh token, expiry)
|
|
13
|
+
* and must never be sent as a header.
|
|
14
|
+
*/
|
|
15
|
+
export declare function remoteMcpHeaders(env?: Record<string, string> | null): Record<string, string>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Shared handling for remote (http/sse) MCP registry rows.
|
|
3
|
+
//
|
|
4
|
+
// Registry rows store remote servers as command='http'|'sse' with args=[url]
|
|
5
|
+
// and the credential set in env. Each agent CLI spells the config differently,
|
|
6
|
+
// but all five make the same three decisions first — is this row remote, what
|
|
7
|
+
// URL does it point at, and what headers does its env map become. That lived
|
|
8
|
+
// inline in install.ts (the claude path) and the other four adapters each got
|
|
9
|
+
// it wrong in their own way, so it lives here now and they all call it.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.isRemoteMcp = isRemoteMcp;
|
|
12
|
+
exports.remoteMcpUrl = remoteMcpUrl;
|
|
13
|
+
exports.remoteMcpHeaders = remoteMcpHeaders;
|
|
14
|
+
/** True when a registry row describes a remote MCP server rather than a
|
|
15
|
+
* local process to spawn. */
|
|
16
|
+
function isRemoteMcp(command) {
|
|
17
|
+
return command === 'http' || command === 'sse';
|
|
18
|
+
}
|
|
19
|
+
/** The endpoint a remote row points at. Returns null for a malformed row so
|
|
20
|
+
* callers can skip it loudly rather than emit a broken config entry. */
|
|
21
|
+
function remoteMcpUrl(args) {
|
|
22
|
+
if (!Array.isArray(args))
|
|
23
|
+
return null;
|
|
24
|
+
const first = args[0];
|
|
25
|
+
return typeof first === 'string' && first.length > 0 ? first : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Convert a registry env map into the HTTP headers the remote server expects.
|
|
29
|
+
*
|
|
30
|
+
* Underscores become hyphens (ENTITY_API_KEY -> ENTITY-API-KEY), matching the
|
|
31
|
+
* deployed github-mcp server. OAUTH_ACCESS_TOKEN becomes an Authorization
|
|
32
|
+
* bearer; every OTHER OAUTH_* key is token bookkeeping (refresh token, expiry)
|
|
33
|
+
* and must never be sent as a header.
|
|
34
|
+
*/
|
|
35
|
+
function remoteMcpHeaders(env) {
|
|
36
|
+
const headers = {};
|
|
37
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
38
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
39
|
+
continue;
|
|
40
|
+
if (key === 'OAUTH_ACCESS_TOKEN') {
|
|
41
|
+
headers.Authorization = `Bearer ${value}`;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (key.startsWith('OAUTH_'))
|
|
45
|
+
continue;
|
|
46
|
+
headers[key.replace(/_/g, '-')] = value;
|
|
47
|
+
}
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
package/dist/request-pump.d.ts
CHANGED
|
@@ -2,6 +2,16 @@ export declare function inflightCount(): number;
|
|
|
2
2
|
export declare function activeRequestIdList(): string[];
|
|
3
3
|
export declare function start(): void;
|
|
4
4
|
export declare function stop(): void;
|
|
5
|
+
/**
|
|
6
|
+
* Pick up work right now, because the server said there is some.
|
|
7
|
+
*
|
|
8
|
+
* Called from the relay socket's `wake`. Deliberately does NOT clear
|
|
9
|
+
* `nextPickAllowedAt`: if the pump is backing off because Supabase is
|
|
10
|
+
* unhealthy, a push is no reason to start hammering it again — the backoff
|
|
11
|
+
* exists to protect the connection pool, and a wake that ignored it would
|
|
12
|
+
* reintroduce exactly the pile-up it was added to stop.
|
|
13
|
+
*/
|
|
14
|
+
export declare function pumpNow(): void;
|
|
5
15
|
/**
|
|
6
16
|
* Wait for all in-flight requests to finish, with a hard ceiling so a
|
|
7
17
|
* hung spawn can't block daemon shutdown forever. Resolves when either
|
package/dist/request-pump.js
CHANGED
|
@@ -8,6 +8,7 @@ exports.inflightCount = inflightCount;
|
|
|
8
8
|
exports.activeRequestIdList = activeRequestIdList;
|
|
9
9
|
exports.start = start;
|
|
10
10
|
exports.stop = stop;
|
|
11
|
+
exports.pumpNow = pumpNow;
|
|
11
12
|
exports.drain = drain;
|
|
12
13
|
const supabase_client_1 = require("./supabase-client");
|
|
13
14
|
const store_1 = require("./store");
|
|
@@ -236,6 +237,20 @@ function stop() {
|
|
|
236
237
|
timer = null;
|
|
237
238
|
}
|
|
238
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Pick up work right now, because the server said there is some.
|
|
242
|
+
*
|
|
243
|
+
* Called from the relay socket's `wake`. Deliberately does NOT clear
|
|
244
|
+
* `nextPickAllowedAt`: if the pump is backing off because Supabase is
|
|
245
|
+
* unhealthy, a push is no reason to start hammering it again — the backoff
|
|
246
|
+
* exists to protect the connection pool, and a wake that ignored it would
|
|
247
|
+
* reintroduce exactly the pile-up it was added to stop.
|
|
248
|
+
*/
|
|
249
|
+
function pumpNow() {
|
|
250
|
+
if (stopped)
|
|
251
|
+
return;
|
|
252
|
+
void tick();
|
|
253
|
+
}
|
|
239
254
|
/**
|
|
240
255
|
* Wait for all in-flight requests to finish, with a hard ceiling so a
|
|
241
256
|
* hung spawn can't block daemon shutdown forever. Resolves when either
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RuntimeEvent } from './types';
|
|
2
|
+
export interface ThinkSplitter {
|
|
3
|
+
/** Feed one visible-text delta; get back correctly-typed events. */
|
|
4
|
+
push(delta: string): RuntimeEvent[];
|
|
5
|
+
/** End of stream: release anything held back. */
|
|
6
|
+
flush(): RuntimeEvent[];
|
|
7
|
+
/** True while inside a reasoning region — the run ended mid-thought. */
|
|
8
|
+
get open(): boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function createThinkSplitter(): ThinkSplitter;
|
|
11
|
+
/**
|
|
12
|
+
* Wrap a harness's `emit` so visible text is split before it leaves.
|
|
13
|
+
*
|
|
14
|
+
* Harnesses map one JSON line to events with a pure function and emit them in
|
|
15
|
+
* a loop, so there is no natural place to keep splitter state. This holds it,
|
|
16
|
+
* and releases anything held back as soon as a non-text event arrives — the
|
|
17
|
+
* tail of an answer must never sit hostage behind a tool call.
|
|
18
|
+
*/
|
|
19
|
+
export interface SplittingEmit {
|
|
20
|
+
(e: RuntimeEvent): void;
|
|
21
|
+
/** End of stream: release whatever is still held. */
|
|
22
|
+
flush(): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function splittingEmitter(emit: (e: RuntimeEvent) => void): SplittingEmit;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createThinkSplitter = createThinkSplitter;
|
|
4
|
+
exports.splittingEmitter = splittingEmitter;
|
|
5
|
+
/** Openers seen in the wild across grok, qwen, deepseek-style outputs. The
|
|
6
|
+
* closer is the same word, so one list drives both. */
|
|
7
|
+
const TAGS = ['think', 'thinking', 'thought', 'reason', 'reasoning', 'antml'];
|
|
8
|
+
/** Longest string we might have to hold back: `</reasoning>` plus slack. */
|
|
9
|
+
const MAX_HOLD = 14;
|
|
10
|
+
function openerAt(buf, i) {
|
|
11
|
+
if (buf[i] !== '<')
|
|
12
|
+
return null;
|
|
13
|
+
for (const tag of TAGS) {
|
|
14
|
+
const open = `<${tag}>`;
|
|
15
|
+
if (buf.slice(i, i + open.length).toLowerCase() === open)
|
|
16
|
+
return { tag, len: open.length };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function closerAt(buf, i, tag) {
|
|
21
|
+
const close = `</${tag}>`;
|
|
22
|
+
return buf.slice(i, i + close.length).toLowerCase() === close ? close.length : null;
|
|
23
|
+
}
|
|
24
|
+
/** True when the tail of `buf` from `i` could still grow into a tag. */
|
|
25
|
+
function couldBecomeTag(buf, i) {
|
|
26
|
+
const tail = buf.slice(i).toLowerCase();
|
|
27
|
+
if (!tail.startsWith('<'))
|
|
28
|
+
return false;
|
|
29
|
+
const body = tail.startsWith('</') ? tail.slice(2) : tail.slice(1);
|
|
30
|
+
return TAGS.some(t => t.startsWith(body)) || body.length === 0;
|
|
31
|
+
}
|
|
32
|
+
function createThinkSplitter() {
|
|
33
|
+
let buf = '';
|
|
34
|
+
let inside = null;
|
|
35
|
+
function drain(final) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let text = '';
|
|
38
|
+
let think = '';
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < buf.length) {
|
|
41
|
+
if (inside === null) {
|
|
42
|
+
const open = openerAt(buf, i);
|
|
43
|
+
if (open) {
|
|
44
|
+
inside = open.tag;
|
|
45
|
+
i += open.len;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!final && couldBecomeTag(buf, i) && buf.length - i < MAX_HOLD)
|
|
49
|
+
break;
|
|
50
|
+
text += buf[i];
|
|
51
|
+
i += 1;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const close = closerAt(buf, i, inside);
|
|
55
|
+
if (close !== null) {
|
|
56
|
+
inside = null;
|
|
57
|
+
i += close;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!final && couldBecomeTag(buf, i) && buf.length - i < MAX_HOLD)
|
|
61
|
+
break;
|
|
62
|
+
think += buf[i];
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
buf = buf.slice(i);
|
|
67
|
+
if (think)
|
|
68
|
+
out.push({ type: 'thinking', delta: think });
|
|
69
|
+
if (text)
|
|
70
|
+
out.push({ type: 'assistant_text', delta: text });
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
push(delta) {
|
|
75
|
+
if (!delta)
|
|
76
|
+
return [];
|
|
77
|
+
buf += delta;
|
|
78
|
+
return drain(false);
|
|
79
|
+
},
|
|
80
|
+
flush() {
|
|
81
|
+
// Whatever is left is real output, tag or not — never silently swallow
|
|
82
|
+
// the tail of an answer because it happened to look like markup.
|
|
83
|
+
return drain(true);
|
|
84
|
+
},
|
|
85
|
+
get open() { return inside !== null; },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function splittingEmitter(emit) {
|
|
89
|
+
const splitter = createThinkSplitter();
|
|
90
|
+
const release = () => { for (const part of splitter.flush())
|
|
91
|
+
emit(part); };
|
|
92
|
+
const out = ((e) => {
|
|
93
|
+
if (e.type === 'assistant_text') {
|
|
94
|
+
for (const part of splitter.push(e.delta))
|
|
95
|
+
emit(part);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
release();
|
|
99
|
+
emit(e);
|
|
100
|
+
});
|
|
101
|
+
out.flush = release;
|
|
102
|
+
return out;
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addai/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|