@botcord/daemon 0.2.49 → 0.2.50
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/gateway/dispatcher.js +31 -6
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/runtimes/deepseek-tui.d.ts +44 -0
- package/dist/gateway/runtimes/deepseek-tui.js +560 -0
- package/dist/gateway/runtimes/kimi.d.ts +32 -0
- package/dist/gateway/runtimes/kimi.js +204 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +23 -0
- package/package.json +1 -1
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +212 -0
- package/src/gateway/__tests__/dispatcher.test.ts +53 -3
- package/src/gateway/__tests__/kimi-adapter.test.ts +174 -0
- package/src/gateway/dispatcher.ts +31 -6
- package/src/gateway/index.ts +6 -0
- package/src/gateway/runtimes/deepseek-tui.ts +640 -0
- package/src/gateway/runtimes/kimi.ts +245 -0
- package/src/gateway/runtimes/registry.ts +26 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import { buildCliEnv } from "../cli-resolver.js";
|
|
7
|
+
import { consoleLogger } from "../log.js";
|
|
8
|
+
import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
|
|
9
|
+
const log = consoleLogger;
|
|
10
|
+
const DEEPSEEK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
11
|
+
const STARTUP_TIMEOUT_MS = 30_000;
|
|
12
|
+
const STARTUP_POLL_MS = 250;
|
|
13
|
+
const SSE_TEXT_CAP = 1 * 1024 * 1024;
|
|
14
|
+
const PROCESS_POOL = new Map();
|
|
15
|
+
/** Resolve the `deepseek` dispatcher CLI on PATH. */
|
|
16
|
+
export function resolveDeepseekCommand(deps = {}) {
|
|
17
|
+
const explicit = (deps.env ?? process.env).BOTCORD_DEEPSEEK_TUI_BIN;
|
|
18
|
+
if (explicit && explicit.length > 0)
|
|
19
|
+
return explicit;
|
|
20
|
+
const onPath = resolveCommandOnPath("deepseek", deps);
|
|
21
|
+
if (!onPath)
|
|
22
|
+
return null;
|
|
23
|
+
return resolveDownloadedDeepseekBinary(onPath, deps) ?? onPath;
|
|
24
|
+
}
|
|
25
|
+
/** Probe whether DeepSeek TUI is installed and report its version. */
|
|
26
|
+
export function probeDeepseekTui(deps = {}) {
|
|
27
|
+
const command = resolveDeepseekCommand(deps);
|
|
28
|
+
if (!command)
|
|
29
|
+
return { available: false };
|
|
30
|
+
return {
|
|
31
|
+
available: true,
|
|
32
|
+
path: command,
|
|
33
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* DeepSeek TUI adapter.
|
|
38
|
+
*
|
|
39
|
+
* Drives the headless runtime API exposed by `deepseek serve --http`, not the
|
|
40
|
+
* interactive TUI and not ACP. The HTTP/SSE API is the documented complete
|
|
41
|
+
* runtime surface; ACP is currently a conservative editor baseline.
|
|
42
|
+
*/
|
|
43
|
+
export class DeepseekTuiAdapter {
|
|
44
|
+
id = "deepseek-tui";
|
|
45
|
+
explicitBinary;
|
|
46
|
+
explicitServerUrl;
|
|
47
|
+
explicitAuthToken;
|
|
48
|
+
fetchFn;
|
|
49
|
+
spawnFn;
|
|
50
|
+
resolvedBinary = null;
|
|
51
|
+
constructor(deps = {}) {
|
|
52
|
+
this.explicitBinary = deps.binary ?? process.env.BOTCORD_DEEPSEEK_TUI_BIN;
|
|
53
|
+
this.explicitServerUrl = deps.serverUrl ?? process.env.BOTCORD_DEEPSEEK_TUI_URL;
|
|
54
|
+
this.explicitAuthToken = deps.authToken ?? process.env.BOTCORD_DEEPSEEK_TUI_TOKEN;
|
|
55
|
+
this.fetchFn = deps.fetchFn ?? fetch;
|
|
56
|
+
this.spawnFn = deps.spawnFn ?? spawn;
|
|
57
|
+
}
|
|
58
|
+
probe() {
|
|
59
|
+
return probeDeepseekTui();
|
|
60
|
+
}
|
|
61
|
+
async run(opts) {
|
|
62
|
+
if (opts.signal.aborted) {
|
|
63
|
+
return {
|
|
64
|
+
text: "",
|
|
65
|
+
newSessionId: opts.sessionId ?? "",
|
|
66
|
+
error: "deepseek-tui aborted before start",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const handle = await this.acquireHandle(opts);
|
|
70
|
+
handle.inFlight += 1;
|
|
71
|
+
if (handle.idleTimer)
|
|
72
|
+
clearTimeout(handle.idleTimer);
|
|
73
|
+
const turnAbort = new AbortController();
|
|
74
|
+
const onAbort = () => turnAbort.abort();
|
|
75
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
76
|
+
try {
|
|
77
|
+
const headers = authHeaders(handle.token);
|
|
78
|
+
let threadId = opts.sessionId?.trim() || "";
|
|
79
|
+
if (threadId && !isValidThreadId(threadId)) {
|
|
80
|
+
return {
|
|
81
|
+
text: "",
|
|
82
|
+
newSessionId: "",
|
|
83
|
+
error: "deepseek-tui: invalid sessionId",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!threadId) {
|
|
87
|
+
threadId = await this.createThread(handle.baseUrl, headers, opts, turnAbort.signal);
|
|
88
|
+
}
|
|
89
|
+
else if (opts.systemContext !== undefined) {
|
|
90
|
+
await this.patchThreadSystemContext(handle.baseUrl, headers, threadId, opts.systemContext, turnAbort.signal);
|
|
91
|
+
}
|
|
92
|
+
const runResult = await this.startTurnAndReadEvents({
|
|
93
|
+
baseUrl: handle.baseUrl,
|
|
94
|
+
headers,
|
|
95
|
+
threadId,
|
|
96
|
+
opts,
|
|
97
|
+
signal: turnAbort.signal,
|
|
98
|
+
});
|
|
99
|
+
const text = runResult.text;
|
|
100
|
+
return {
|
|
101
|
+
text,
|
|
102
|
+
newSessionId: threadId,
|
|
103
|
+
...(runResult.error ? { error: runResult.error } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
108
|
+
const staleSession = opts.sessionId && /404|not found|missing/i.test(message);
|
|
109
|
+
return {
|
|
110
|
+
text: "",
|
|
111
|
+
newSessionId: staleSession ? "" : opts.sessionId ?? "",
|
|
112
|
+
error: `deepseek-tui: ${message}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
117
|
+
handle.inFlight -= 1;
|
|
118
|
+
if (!this.explicitServerUrl)
|
|
119
|
+
resetIdle(handle, poolKey(opts));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
resolveBinary() {
|
|
123
|
+
if (this.explicitBinary)
|
|
124
|
+
return this.explicitBinary;
|
|
125
|
+
if (this.resolvedBinary)
|
|
126
|
+
return this.resolvedBinary;
|
|
127
|
+
this.resolvedBinary = resolveDeepseekCommand() ?? "deepseek";
|
|
128
|
+
return this.resolvedBinary;
|
|
129
|
+
}
|
|
130
|
+
async acquireHandle(opts) {
|
|
131
|
+
if (this.explicitServerUrl) {
|
|
132
|
+
return {
|
|
133
|
+
child: nullChild(),
|
|
134
|
+
baseUrl: trimTrailingSlash(this.explicitServerUrl),
|
|
135
|
+
token: this.explicitAuthToken ?? "",
|
|
136
|
+
closed: false,
|
|
137
|
+
inFlight: 0,
|
|
138
|
+
stderrTail: "",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const key = poolKey(opts);
|
|
142
|
+
const existing = PROCESS_POOL.get(key);
|
|
143
|
+
if (existing && !existing.closed)
|
|
144
|
+
return existing;
|
|
145
|
+
const port = await findFreePort();
|
|
146
|
+
const token = randomToken();
|
|
147
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
148
|
+
const child = this.spawnFn(this.resolveBinary(), ["serve", "--http", "--host", "127.0.0.1", "--port", String(port), "--auth-token", token], {
|
|
149
|
+
cwd: opts.cwd,
|
|
150
|
+
env: this.spawnEnv(opts),
|
|
151
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
152
|
+
});
|
|
153
|
+
const handle = {
|
|
154
|
+
child,
|
|
155
|
+
baseUrl,
|
|
156
|
+
token,
|
|
157
|
+
closed: false,
|
|
158
|
+
inFlight: 0,
|
|
159
|
+
stderrTail: "",
|
|
160
|
+
};
|
|
161
|
+
child.stderr?.setEncoding("utf8");
|
|
162
|
+
child.stderr?.on("data", (chunk) => {
|
|
163
|
+
handle.stderrTail = (handle.stderrTail + chunk).slice(-4096);
|
|
164
|
+
});
|
|
165
|
+
child.on("close", () => {
|
|
166
|
+
handle.closed = true;
|
|
167
|
+
PROCESS_POOL.delete(key);
|
|
168
|
+
});
|
|
169
|
+
child.on("error", () => {
|
|
170
|
+
handle.closed = true;
|
|
171
|
+
PROCESS_POOL.delete(key);
|
|
172
|
+
});
|
|
173
|
+
await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS);
|
|
174
|
+
PROCESS_POOL.set(key, handle);
|
|
175
|
+
resetIdle(handle, key);
|
|
176
|
+
return handle;
|
|
177
|
+
}
|
|
178
|
+
spawnEnv(opts) {
|
|
179
|
+
const env = {
|
|
180
|
+
...process.env,
|
|
181
|
+
...buildCliEnv({
|
|
182
|
+
hubUrl: opts.hubUrl,
|
|
183
|
+
accountId: opts.accountId,
|
|
184
|
+
basePath: process.env.PATH,
|
|
185
|
+
}),
|
|
186
|
+
FORCE_COLOR: "0",
|
|
187
|
+
NO_COLOR: "1",
|
|
188
|
+
};
|
|
189
|
+
if (opts.accountId) {
|
|
190
|
+
const runtimeDir = path.join(agentDeepseekHomeDir(opts.accountId), "runtime");
|
|
191
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
192
|
+
env.DEEPSEEK_RUNTIME_DIR = runtimeDir;
|
|
193
|
+
}
|
|
194
|
+
return env;
|
|
195
|
+
}
|
|
196
|
+
async createThread(baseUrl, headers, opts, signal) {
|
|
197
|
+
const body = {
|
|
198
|
+
workspace: opts.cwd,
|
|
199
|
+
mode: "agent",
|
|
200
|
+
allow_shell: opts.trustLevel !== "public",
|
|
201
|
+
trust_mode: opts.trustLevel !== "public",
|
|
202
|
+
auto_approve: opts.trustLevel !== "public",
|
|
203
|
+
archived: false,
|
|
204
|
+
};
|
|
205
|
+
if (opts.systemContext)
|
|
206
|
+
body.system_prompt = opts.systemContext;
|
|
207
|
+
const res = await this.requestJson(`${baseUrl}/v1/threads`, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers,
|
|
210
|
+
body: JSON.stringify(body),
|
|
211
|
+
signal,
|
|
212
|
+
});
|
|
213
|
+
const id = stringField(res, "id") ?? stringField(res, "thread_id");
|
|
214
|
+
if (!id)
|
|
215
|
+
throw new Error("create thread response missing id");
|
|
216
|
+
return id;
|
|
217
|
+
}
|
|
218
|
+
async patchThreadSystemContext(baseUrl, headers, threadId, systemContext, signal) {
|
|
219
|
+
await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}`, {
|
|
220
|
+
method: "PATCH",
|
|
221
|
+
headers,
|
|
222
|
+
body: JSON.stringify({ system_prompt: systemContext ?? "" }),
|
|
223
|
+
signal,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async startTurnAndReadEvents(args) {
|
|
227
|
+
const { baseUrl, headers, threadId, opts, signal } = args;
|
|
228
|
+
const eventsUrl = `${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=0`;
|
|
229
|
+
const eventsAbort = new AbortController();
|
|
230
|
+
const onAbort = () => eventsAbort.abort();
|
|
231
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
232
|
+
let eventsError;
|
|
233
|
+
const eventsReaderPromise = this.readEvents(eventsUrl, headers, opts, eventsAbort.signal).catch((err) => {
|
|
234
|
+
eventsError = err;
|
|
235
|
+
return null;
|
|
236
|
+
});
|
|
237
|
+
let turnId = "";
|
|
238
|
+
try {
|
|
239
|
+
const started = await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers,
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
prompt: opts.text,
|
|
244
|
+
mode: "agent",
|
|
245
|
+
allow_shell: opts.trustLevel !== "public",
|
|
246
|
+
trust_mode: opts.trustLevel !== "public",
|
|
247
|
+
auto_approve: opts.trustLevel !== "public",
|
|
248
|
+
}),
|
|
249
|
+
signal,
|
|
250
|
+
});
|
|
251
|
+
turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
|
|
252
|
+
const eventsReader = await eventsReaderPromise;
|
|
253
|
+
if (!eventsReader)
|
|
254
|
+
throw eventsError ?? new Error("events stream failed");
|
|
255
|
+
return await eventsReader(turnId);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
eventsAbort.abort();
|
|
262
|
+
signal.removeEventListener("abort", onAbort);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async readEvents(url, headers, opts, signal) {
|
|
266
|
+
const res = await this.fetchFn(url, { method: "GET", headers, signal });
|
|
267
|
+
if (!res.ok)
|
|
268
|
+
throw new Error(`events stream failed HTTP ${res.status}`);
|
|
269
|
+
if (!res.body)
|
|
270
|
+
throw new Error("events stream response missing body");
|
|
271
|
+
const reader = res.body.getReader();
|
|
272
|
+
return async (turnId) => {
|
|
273
|
+
const decoder = new TextDecoder();
|
|
274
|
+
let buf = "";
|
|
275
|
+
let seq = 0;
|
|
276
|
+
let text = "";
|
|
277
|
+
let errorText = "";
|
|
278
|
+
let capped = false;
|
|
279
|
+
const append = (chunk) => {
|
|
280
|
+
if (!chunk || capped)
|
|
281
|
+
return;
|
|
282
|
+
const budget = SSE_TEXT_CAP - Buffer.byteLength(text, "utf8");
|
|
283
|
+
if (budget <= 0) {
|
|
284
|
+
capped = true;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (Buffer.byteLength(chunk, "utf8") > budget) {
|
|
288
|
+
text += chunk.slice(0, budget);
|
|
289
|
+
capped = true;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
text += chunk;
|
|
293
|
+
};
|
|
294
|
+
const emit = (eventName, payload) => {
|
|
295
|
+
const eventTurnId = stringField(payload, "turn_id") ?? stringField(payload?.payload, "turn_id");
|
|
296
|
+
if (turnId && eventTurnId && eventTurnId !== turnId)
|
|
297
|
+
return false;
|
|
298
|
+
seq += 1;
|
|
299
|
+
const block = normalizeDeepseekEvent(eventName, payload, seq);
|
|
300
|
+
if (block)
|
|
301
|
+
opts.onBlock?.(block);
|
|
302
|
+
const extractedError = extractDeepseekError(eventName, payload);
|
|
303
|
+
if (extractedError)
|
|
304
|
+
errorText = extractedError;
|
|
305
|
+
if (eventName === "message.delta") {
|
|
306
|
+
append(stringField(payload, "content") ?? "");
|
|
307
|
+
}
|
|
308
|
+
else if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
|
|
309
|
+
append(stringField(payload.payload, "delta") ?? "");
|
|
310
|
+
}
|
|
311
|
+
if (eventName === "turn.started") {
|
|
312
|
+
opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
|
|
313
|
+
}
|
|
314
|
+
else if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
315
|
+
const label = stringField(payload, "name") ?? stringField(payload?.payload?.tool, "name") ?? "tool";
|
|
316
|
+
opts.onStatus?.({ kind: "thinking", phase: "updated", label });
|
|
317
|
+
}
|
|
318
|
+
else if (eventName === "turn.completed" || eventName === "done") {
|
|
319
|
+
opts.onStatus?.({ kind: "thinking", phase: "stopped" });
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
};
|
|
324
|
+
while (true) {
|
|
325
|
+
const { value, done } = await reader.read();
|
|
326
|
+
if (done)
|
|
327
|
+
break;
|
|
328
|
+
buf += decoder.decode(value, { stream: true });
|
|
329
|
+
let idx;
|
|
330
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
331
|
+
const frame = parseSseFrame(buf.slice(0, idx));
|
|
332
|
+
buf = buf.slice(idx + 2);
|
|
333
|
+
if (!frame)
|
|
334
|
+
continue;
|
|
335
|
+
if (emit(frame.event, frame.data)) {
|
|
336
|
+
await reader.cancel().catch(() => undefined);
|
|
337
|
+
return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (buf.trim()) {
|
|
342
|
+
const frame = parseSseFrame(buf);
|
|
343
|
+
if (frame)
|
|
344
|
+
emit(frame.event, frame.data);
|
|
345
|
+
}
|
|
346
|
+
return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async requestJson(url, init) {
|
|
350
|
+
const headers = new Headers(init.headers);
|
|
351
|
+
if (!headers.has("content-type") && init.body)
|
|
352
|
+
headers.set("content-type", "application/json");
|
|
353
|
+
const res = await this.fetchFn(url, { ...init, headers });
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
let detail = "";
|
|
356
|
+
try {
|
|
357
|
+
detail = await res.text();
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// ignore
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`HTTP ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
|
|
363
|
+
}
|
|
364
|
+
return (await res.json());
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
export function __resetDeepseekTuiPoolForTests() {
|
|
368
|
+
for (const [key, handle] of PROCESS_POOL.entries()) {
|
|
369
|
+
shutdownHandle(handle, "test-reset");
|
|
370
|
+
PROCESS_POOL.delete(key);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function normalizeDeepseekEvent(eventName, payload, seq) {
|
|
374
|
+
if (eventName === "message.delta") {
|
|
375
|
+
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
376
|
+
}
|
|
377
|
+
if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
378
|
+
return { raw: { event: eventName, payload }, kind: "tool_use", seq };
|
|
379
|
+
}
|
|
380
|
+
if (eventName === "tool.completed" || isToolCompleted(payload)) {
|
|
381
|
+
return { raw: { event: eventName, payload }, kind: "tool_result", seq };
|
|
382
|
+
}
|
|
383
|
+
if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
|
|
384
|
+
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
385
|
+
}
|
|
386
|
+
if (eventName === "turn.started" || eventName === "status") {
|
|
387
|
+
return { raw: { event: eventName, payload }, kind: "system", seq };
|
|
388
|
+
}
|
|
389
|
+
if (eventName === "error" || eventName === "turn.completed" || eventName === "done") {
|
|
390
|
+
return { raw: { event: eventName, payload }, kind: "other", seq };
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
function isToolStarted(payload) {
|
|
395
|
+
return payload?.event === "item.started" && !!payload?.payload?.tool;
|
|
396
|
+
}
|
|
397
|
+
function isToolCompleted(payload) {
|
|
398
|
+
const kind = payload?.payload?.item?.kind;
|
|
399
|
+
return ((payload?.event === "item.completed" || payload?.event === "item.failed") &&
|
|
400
|
+
(kind === "tool_call" || kind === "file_change" || kind === "command_execution"));
|
|
401
|
+
}
|
|
402
|
+
function extractDeepseekError(eventName, payload) {
|
|
403
|
+
if (eventName === "error") {
|
|
404
|
+
return (stringField(payload, "message") ??
|
|
405
|
+
stringField(payload, "error") ??
|
|
406
|
+
stringField(payload?.payload, "message") ??
|
|
407
|
+
stringField(payload?.payload, "error"));
|
|
408
|
+
}
|
|
409
|
+
if (eventName === "item.failed") {
|
|
410
|
+
return (stringField(payload?.payload?.item, "detail") ??
|
|
411
|
+
stringField(payload?.payload?.item, "summary") ??
|
|
412
|
+
stringField(payload?.payload, "error"));
|
|
413
|
+
}
|
|
414
|
+
if (eventName === "turn.completed") {
|
|
415
|
+
const turn = payload?.payload?.turn ?? payload?.turn;
|
|
416
|
+
const status = stringField(turn, "status");
|
|
417
|
+
const err = stringField(turn, "error");
|
|
418
|
+
if (err)
|
|
419
|
+
return err;
|
|
420
|
+
if (status && status !== "completed")
|
|
421
|
+
return `DeepSeek turn ${status}`;
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
function parseSseFrame(raw) {
|
|
426
|
+
let event = "message";
|
|
427
|
+
const dataLines = [];
|
|
428
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
429
|
+
if (line.startsWith("event:"))
|
|
430
|
+
event = line.slice("event:".length).trim();
|
|
431
|
+
else if (line.startsWith("data:"))
|
|
432
|
+
dataLines.push(line.slice("data:".length).trimStart());
|
|
433
|
+
}
|
|
434
|
+
if (dataLines.length === 0)
|
|
435
|
+
return null;
|
|
436
|
+
try {
|
|
437
|
+
return { event, data: JSON.parse(dataLines.join("\n")) };
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return { event, data: { content: dataLines.join("\n") } };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function authHeaders(token) {
|
|
444
|
+
return token ? { authorization: `Bearer ${token}` } : {};
|
|
445
|
+
}
|
|
446
|
+
function poolKey(opts) {
|
|
447
|
+
return opts.accountId || "default";
|
|
448
|
+
}
|
|
449
|
+
function resetIdle(handle, key) {
|
|
450
|
+
if (handle.idleTimer)
|
|
451
|
+
clearTimeout(handle.idleTimer);
|
|
452
|
+
if (handle.inFlight > 0 || handle.closed)
|
|
453
|
+
return;
|
|
454
|
+
handle.idleTimer = setTimeout(() => {
|
|
455
|
+
if (handle.inFlight === 0 && !handle.closed) {
|
|
456
|
+
log.info("deepseek-tui.idle-timeout", { key });
|
|
457
|
+
shutdownHandle(handle, "idle-timeout");
|
|
458
|
+
PROCESS_POOL.delete(key);
|
|
459
|
+
}
|
|
460
|
+
}, DEEPSEEK_IDLE_TIMEOUT_MS);
|
|
461
|
+
handle.idleTimer.unref?.();
|
|
462
|
+
}
|
|
463
|
+
function shutdownHandle(handle, reason) {
|
|
464
|
+
if (handle.closed)
|
|
465
|
+
return;
|
|
466
|
+
handle.closed = true;
|
|
467
|
+
if (handle.idleTimer)
|
|
468
|
+
clearTimeout(handle.idleTimer);
|
|
469
|
+
try {
|
|
470
|
+
handle.child.kill("SIGTERM");
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
// no-op
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
handle.child.stdout?.destroy();
|
|
477
|
+
handle.child.stderr?.destroy();
|
|
478
|
+
handle.child.stdin?.destroy();
|
|
479
|
+
handle.child.unref();
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// no-op
|
|
483
|
+
}
|
|
484
|
+
log.debug("deepseek-tui.shutdown", { reason });
|
|
485
|
+
}
|
|
486
|
+
async function waitForHealth(baseUrl, fetchFn, child, timeoutMs) {
|
|
487
|
+
const deadline = Date.now() + timeoutMs;
|
|
488
|
+
let lastError = "";
|
|
489
|
+
while (Date.now() < deadline) {
|
|
490
|
+
if (child.exitCode !== null) {
|
|
491
|
+
throw new Error(`deepseek serve exited with code ${child.exitCode}`);
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const res = await fetchFn(`${baseUrl}/health`, { method: "GET" });
|
|
495
|
+
if (res.ok)
|
|
496
|
+
return;
|
|
497
|
+
lastError = `HTTP ${res.status}`;
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
501
|
+
}
|
|
502
|
+
await sleep(STARTUP_POLL_MS);
|
|
503
|
+
}
|
|
504
|
+
throw new Error(`deepseek serve did not become healthy: ${lastError}`);
|
|
505
|
+
}
|
|
506
|
+
async function findFreePort() {
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
const srv = net.createServer();
|
|
509
|
+
srv.on("error", reject);
|
|
510
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
511
|
+
const addr = srv.address();
|
|
512
|
+
srv.close(() => {
|
|
513
|
+
if (typeof addr === "object" && addr?.port)
|
|
514
|
+
resolve(addr.port);
|
|
515
|
+
else
|
|
516
|
+
reject(new Error("failed to allocate port"));
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
function randomToken() {
|
|
522
|
+
return `bc_ds_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
523
|
+
}
|
|
524
|
+
function sleep(ms) {
|
|
525
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
526
|
+
}
|
|
527
|
+
function stringField(obj, key) {
|
|
528
|
+
const v = obj?.[key];
|
|
529
|
+
return typeof v === "string" ? v : undefined;
|
|
530
|
+
}
|
|
531
|
+
function trimTrailingSlash(value) {
|
|
532
|
+
return value.replace(/\/+$/, "");
|
|
533
|
+
}
|
|
534
|
+
function isValidThreadId(id) {
|
|
535
|
+
return id.length > 0 && id.length <= 256 && !/[\u0000-\u001f\u007f]/.test(id);
|
|
536
|
+
}
|
|
537
|
+
function resolveDownloadedDeepseekBinary(onPath, deps = {}) {
|
|
538
|
+
const exists = deps.existsSyncFn ?? existsSync;
|
|
539
|
+
try {
|
|
540
|
+
const resolved = realpathSync(onPath);
|
|
541
|
+
const candidate = path.join(path.dirname(resolved), "downloads", "deepseek");
|
|
542
|
+
return exists(candidate) ? candidate : null;
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function agentDeepseekHomeDir(accountId) {
|
|
549
|
+
return path.join(homedir(), ".botcord", "agents", accountId, "deepseek-tui");
|
|
550
|
+
}
|
|
551
|
+
function nullChild() {
|
|
552
|
+
return {
|
|
553
|
+
kill: () => true,
|
|
554
|
+
on: () => nullChild(),
|
|
555
|
+
stderr: { setEncoding: () => undefined, on: () => undefined },
|
|
556
|
+
stdout: { setEncoding: () => undefined, on: () => undefined },
|
|
557
|
+
stdin: { write: () => true },
|
|
558
|
+
exitCode: null,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
|
|
2
|
+
import { type ProbeDeps } from "./probe.js";
|
|
3
|
+
import type { RuntimeProbeResult, RuntimeRunOptions } from "../types.js";
|
|
4
|
+
/** Resolve the Kimi CLI executable on PATH. */
|
|
5
|
+
export declare function resolveKimiCommand(deps?: ProbeDeps): string | null;
|
|
6
|
+
/** Probe whether the Kimi CLI is installed and report its version. */
|
|
7
|
+
export declare function probeKimi(deps?: ProbeDeps): RuntimeProbeResult;
|
|
8
|
+
/**
|
|
9
|
+
* Kimi CLI adapter — spawns:
|
|
10
|
+
*
|
|
11
|
+
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
|
|
12
|
+
*
|
|
13
|
+
* `--session <sid>` resumes an existing session or creates a new session with
|
|
14
|
+
* that id, so the adapter generates a UUID on first turn and persists it for
|
|
15
|
+
* later turns. Kimi does not expose a Codex-style per-invocation AGENTS.md
|
|
16
|
+
* carrier, so dynamic `systemContext` is sent as a system-reminder prefix on
|
|
17
|
+
* the user prompt.
|
|
18
|
+
*/
|
|
19
|
+
export declare class KimiAdapter extends NdjsonStreamAdapter {
|
|
20
|
+
readonly id: "kimi-cli";
|
|
21
|
+
private readonly explicitBinary;
|
|
22
|
+
private resolvedBinary;
|
|
23
|
+
constructor(opts?: {
|
|
24
|
+
binary?: string;
|
|
25
|
+
});
|
|
26
|
+
probe(): RuntimeProbeResult;
|
|
27
|
+
run(opts: RuntimeRunOptions): Promise<import("../types.js").RuntimeRunResult>;
|
|
28
|
+
protected resolveBinary(): string;
|
|
29
|
+
protected buildArgs(opts: RuntimeRunOptions): string[];
|
|
30
|
+
protected spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv;
|
|
31
|
+
protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void;
|
|
32
|
+
}
|