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