@botlearn-course/daemon 0.0.1

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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/agent-service-client.d.ts +18 -0
  4. package/dist/agent-service-client.js +108 -0
  5. package/dist/auth-store.d.ts +16 -0
  6. package/dist/auth-store.js +106 -0
  7. package/dist/cli.d.ts +24 -0
  8. package/dist/cli.js +354 -0
  9. package/dist/course-client.d.ts +46 -0
  10. package/dist/course-client.js +143 -0
  11. package/dist/doctor.d.ts +15 -0
  12. package/dist/doctor.js +85 -0
  13. package/dist/file-candidates.d.ts +34 -0
  14. package/dist/file-candidates.js +173 -0
  15. package/dist/index.d.ts +19 -0
  16. package/dist/index.js +19 -0
  17. package/dist/log.d.ts +20 -0
  18. package/dist/log.js +154 -0
  19. package/dist/path-env.d.ts +8 -0
  20. package/dist/path-env.js +42 -0
  21. package/dist/redaction.d.ts +24 -0
  22. package/dist/redaction.js +158 -0
  23. package/dist/run-dispatcher.d.ts +43 -0
  24. package/dist/run-dispatcher.js +294 -0
  25. package/dist/run-queue.d.ts +11 -0
  26. package/dist/run-queue.js +26 -0
  27. package/dist/runtime-capabilities.d.ts +3 -0
  28. package/dist/runtime-capabilities.js +42 -0
  29. package/dist/runtime-profile.d.ts +8 -0
  30. package/dist/runtime-profile.js +213 -0
  31. package/dist/runtimes/acp-stream.d.ts +96 -0
  32. package/dist/runtimes/acp-stream.js +488 -0
  33. package/dist/runtimes/claude-code.d.ts +41 -0
  34. package/dist/runtimes/claude-code.js +353 -0
  35. package/dist/runtimes/codex.d.ts +44 -0
  36. package/dist/runtimes/codex.js +332 -0
  37. package/dist/runtimes/deepseek-tui.d.ts +50 -0
  38. package/dist/runtimes/deepseek-tui.js +701 -0
  39. package/dist/runtimes/engine.d.ts +52 -0
  40. package/dist/runtimes/engine.js +127 -0
  41. package/dist/runtimes/fake.d.ts +13 -0
  42. package/dist/runtimes/fake.js +45 -0
  43. package/dist/runtimes/gemini.d.ts +39 -0
  44. package/dist/runtimes/gemini.js +251 -0
  45. package/dist/runtimes/hermes-agent.d.ts +61 -0
  46. package/dist/runtimes/hermes-agent.js +173 -0
  47. package/dist/runtimes/index.d.ts +15 -0
  48. package/dist/runtimes/index.js +74 -0
  49. package/dist/runtimes/kimi.d.ts +35 -0
  50. package/dist/runtimes/kimi.js +335 -0
  51. package/dist/runtimes/ndjson-stream.d.ts +51 -0
  52. package/dist/runtimes/ndjson-stream.js +207 -0
  53. package/dist/runtimes/openclaw-acp.d.ts +52 -0
  54. package/dist/runtimes/openclaw-acp.js +872 -0
  55. package/dist/runtimes/probe.d.ts +17 -0
  56. package/dist/runtimes/probe.js +54 -0
  57. package/dist/runtimes/runtime-errors.d.ts +20 -0
  58. package/dist/runtimes/runtime-errors.js +95 -0
  59. package/dist/runtimes/text-cap.d.ts +7 -0
  60. package/dist/runtimes/text-cap.js +25 -0
  61. package/dist/transcript.d.ts +13 -0
  62. package/dist/transcript.js +46 -0
  63. package/dist/types.d.ts +199 -0
  64. package/dist/types.js +17 -0
  65. package/dist/workspace.d.ts +23 -0
  66. package/dist/workspace.js +54 -0
  67. package/package.json +40 -0
@@ -0,0 +1,701 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import net from "node:net";
5
+ import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
6
+ import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
7
+ const log = consoleLogger;
8
+ const DEEPSEEK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
9
+ const STARTUP_TIMEOUT_MS = 30_000;
10
+ const STARTUP_POLL_MS = 250;
11
+ /** 单轮流式 assistant 文本字节上限。 */
12
+ const SSE_TEXT_CAP = 1 * 1024 * 1024;
13
+ const PROCESS_POOL = new Map();
14
+ /** 单 daemon、单套 env 配置:池里最多一个 server。 */
15
+ const POOL_KEY = "default";
16
+ let exitCleanupHookInstalled = false;
17
+ /**
18
+ * daemon 退出时杀掉池中 deepseek server。池是内存态的,没有这个钩子
19
+ * daemon 重启会留下孤儿 server。首次 spawn 时惰性安装,保持模块导入无副作用。
20
+ */
21
+ function installExitCleanupHook() {
22
+ if (exitCleanupHookInstalled)
23
+ return;
24
+ exitCleanupHookInstalled = true;
25
+ process.once("exit", () => {
26
+ for (const [key, handle] of PROCESS_POOL.entries()) {
27
+ shutdownHandle(handle, "daemon-exit");
28
+ PROCESS_POOL.delete(key);
29
+ }
30
+ });
31
+ }
32
+ /** 解析 PATH 上的 `deepseek` dispatcher CLI。 */
33
+ export function resolveDeepseekCommand(deps = {}) {
34
+ const explicit = (deps.env ?? process.env).BOTLEARN_DEEPSEEK_TUI_BIN;
35
+ if (explicit && explicit.length > 0)
36
+ return explicit;
37
+ const onPath = resolveCommandOnPath("deepseek", deps);
38
+ if (!onPath)
39
+ return null;
40
+ return resolveDownloadedDeepseekBinary(onPath, deps) ?? onPath;
41
+ }
42
+ export function probeDeepseekTui(deps = {}) {
43
+ const command = resolveDeepseekCommand(deps);
44
+ if (!command)
45
+ return { available: false };
46
+ return {
47
+ available: true,
48
+ path: command,
49
+ version: readCommandVersion(command, [], deps) ?? undefined,
50
+ };
51
+ }
52
+ /**
53
+ * DeepSeek TUI 引擎。驱动 `deepseek serve --http` 暴露的 headless runtime
54
+ * API(HTTP/SSE),不是交互式 TUI 也不是 ACP —— HTTP/SSE 是其文档化的完整
55
+ * runtime 面;ACP 目前只是保守的编辑器基线。
56
+ *
57
+ * 权限姿态:course run 在 owner 本机的隔离工作区执行,线程一律
58
+ * `allow_shell/trust_mode/auto_approve = true`(owner 信任,无审批人可交互)。
59
+ */
60
+ export class DeepseekTuiAdapter {
61
+ id = "deepseek-tui";
62
+ explicitBinary;
63
+ explicitServerUrl;
64
+ explicitAuthToken;
65
+ fetchFn;
66
+ spawnFn;
67
+ resolvedBinary = null;
68
+ constructor(deps = {}) {
69
+ this.explicitBinary = deps.binary ?? process.env.BOTLEARN_DEEPSEEK_TUI_BIN;
70
+ this.explicitServerUrl = deps.serverUrl ?? process.env.BOTLEARN_DEEPSEEK_TUI_URL;
71
+ this.explicitAuthToken = deps.authToken ?? process.env.BOTLEARN_DEEPSEEK_TUI_TOKEN;
72
+ this.fetchFn = deps.fetchFn ?? fetch;
73
+ this.spawnFn = deps.spawnFn ?? spawn;
74
+ }
75
+ async run(opts) {
76
+ if (opts.signal.aborted) {
77
+ return {
78
+ text: "",
79
+ newSessionId: opts.sessionId ?? "",
80
+ error: "deepseek-tui aborted before start",
81
+ };
82
+ }
83
+ const handle = await this.acquireHandle(opts);
84
+ handle.inFlight += 1;
85
+ if (handle.idleTimer)
86
+ clearTimeout(handle.idleTimer);
87
+ const turnAbort = new AbortController();
88
+ const onAbort = () => turnAbort.abort();
89
+ opts.signal.addEventListener("abort", onAbort, { once: true });
90
+ try {
91
+ const headers = authHeaders(handle.token);
92
+ let threadId = opts.sessionId?.trim() || "";
93
+ if (threadId && !isValidThreadId(threadId)) {
94
+ return {
95
+ text: "",
96
+ newSessionId: "",
97
+ error: "deepseek-tui: invalid sessionId",
98
+ };
99
+ }
100
+ if (!threadId) {
101
+ threadId = await this.createThread(handle.baseUrl, headers, opts, turnAbort.signal);
102
+ }
103
+ else if (opts.systemContext !== undefined) {
104
+ await this.patchThreadSystemContext(handle.baseUrl, headers, threadId, opts.systemContext, turnAbort.signal);
105
+ }
106
+ const runResult = await this.startTurnAndReadEvents({
107
+ baseUrl: handle.baseUrl,
108
+ headers,
109
+ threadId,
110
+ opts,
111
+ signal: turnAbort.signal,
112
+ });
113
+ const text = runResult.text;
114
+ const error = runResult.error ?? (text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
115
+ return {
116
+ text,
117
+ newSessionId: threadId,
118
+ ...(error ? { error } : {}),
119
+ };
120
+ }
121
+ catch (err) {
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ // 服务端已丢失该线程(重启、GC)→ 清空 sessionId 让下次重建。
124
+ const staleSession = opts.sessionId && /404|not found|missing/i.test(message);
125
+ return {
126
+ text: "",
127
+ newSessionId: staleSession ? "" : (opts.sessionId ?? ""),
128
+ error: `deepseek-tui: ${message}`,
129
+ };
130
+ }
131
+ finally {
132
+ opts.signal.removeEventListener("abort", onAbort);
133
+ handle.inFlight -= 1;
134
+ if (!this.explicitServerUrl)
135
+ resetIdle(handle, POOL_KEY);
136
+ }
137
+ }
138
+ resolveBinary() {
139
+ if (this.explicitBinary)
140
+ return this.explicitBinary;
141
+ if (this.resolvedBinary)
142
+ return this.resolvedBinary;
143
+ this.resolvedBinary = resolveDeepseekCommand() ?? "deepseek";
144
+ return this.resolvedBinary;
145
+ }
146
+ async acquireHandle(opts) {
147
+ if (this.explicitServerUrl) {
148
+ return {
149
+ child: nullChild(),
150
+ baseUrl: trimTrailingSlash(this.explicitServerUrl),
151
+ token: this.explicitAuthToken ?? "",
152
+ closed: false,
153
+ inFlight: 0,
154
+ stderrTail: "",
155
+ };
156
+ }
157
+ const existing = PROCESS_POOL.get(POOL_KEY);
158
+ if (existing && !existing.closed)
159
+ return existing;
160
+ const port = await findFreePort();
161
+ const token = randomToken();
162
+ const baseUrl = `http://127.0.0.1:${port}`;
163
+ const child = this.spawnFn(this.resolveBinary(), ["serve", "--http", "--host", "127.0.0.1", "--port", String(port), "--auth-token", token], {
164
+ cwd: opts.cwd,
165
+ env: this.spawnEnv(),
166
+ stdio: ["ignore", "pipe", "pipe"],
167
+ // 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
168
+ // server 的 dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
169
+ detached: true,
170
+ });
171
+ installExitCleanupHook();
172
+ const handle = {
173
+ child,
174
+ baseUrl,
175
+ token,
176
+ closed: false,
177
+ inFlight: 0,
178
+ stderrTail: "",
179
+ };
180
+ child.stderr?.setEncoding("utf8");
181
+ child.stderr?.on("data", (chunk) => {
182
+ handle.stderrTail = (handle.stderrTail + chunk).slice(-4096);
183
+ });
184
+ child.on("close", () => {
185
+ handle.closed = true;
186
+ PROCESS_POOL.delete(POOL_KEY);
187
+ });
188
+ child.on("error", () => {
189
+ handle.closed = true;
190
+ PROCESS_POOL.delete(POOL_KEY);
191
+ });
192
+ await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS);
193
+ PROCESS_POOL.set(POOL_KEY, handle);
194
+ resetIdle(handle, POOL_KEY);
195
+ return handle;
196
+ }
197
+ /**
198
+ * 不设置 DEEPSEEK_RUNTIME_DIR:server 跨 run 池化共享,per-run 目录不成立;
199
+ * BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
200
+ */
201
+ spawnEnv() {
202
+ return {
203
+ ...process.env,
204
+ FORCE_COLOR: "0",
205
+ NO_COLOR: "1",
206
+ };
207
+ }
208
+ async createThread(baseUrl, headers, opts, signal) {
209
+ const body = {
210
+ workspace: opts.cwd,
211
+ mode: "agent",
212
+ allow_shell: true,
213
+ trust_mode: true,
214
+ auto_approve: true,
215
+ archived: false,
216
+ };
217
+ const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
218
+ if (selection.model)
219
+ body.model = selection.model;
220
+ if (selection.reasoningEffort)
221
+ body.reasoning_effort = selection.reasoningEffort;
222
+ if (opts.systemContext)
223
+ body.system_prompt = opts.systemContext;
224
+ const res = await this.requestJson(`${baseUrl}/v1/threads`, {
225
+ method: "POST",
226
+ headers,
227
+ body: JSON.stringify(body),
228
+ signal,
229
+ });
230
+ const id = stringField(res, "id") ?? stringField(res, "thread_id");
231
+ if (!id)
232
+ throw new Error("create thread response missing id");
233
+ return id;
234
+ }
235
+ async patchThreadSystemContext(baseUrl, headers, threadId, systemContext, signal) {
236
+ await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}`, {
237
+ method: "PATCH",
238
+ headers,
239
+ body: JSON.stringify({ system_prompt: systemContext ?? "" }),
240
+ signal,
241
+ });
242
+ }
243
+ async startTurnAndReadEvents(args) {
244
+ const { baseUrl, headers, threadId, opts, signal } = args;
245
+ // 事件流必须先于 turn 打开,否则 turn 早期事件会丢。
246
+ const eventsUrl = `${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=0`;
247
+ const eventsAbort = new AbortController();
248
+ const onAbort = () => eventsAbort.abort();
249
+ signal.addEventListener("abort", onAbort, { once: true });
250
+ let eventsError;
251
+ const eventsReaderPromise = this.readEvents(eventsUrl, headers, opts, eventsAbort.signal).catch((err) => {
252
+ eventsError = err;
253
+ return null;
254
+ });
255
+ try {
256
+ const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
257
+ const body = {
258
+ prompt: opts.text,
259
+ mode: "agent",
260
+ allow_shell: true,
261
+ trust_mode: true,
262
+ auto_approve: true,
263
+ };
264
+ if (selection.model)
265
+ body.model = selection.model;
266
+ if (selection.reasoningEffort)
267
+ body.reasoning_effort = selection.reasoningEffort;
268
+ const started = await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`, {
269
+ method: "POST",
270
+ headers,
271
+ body: JSON.stringify(body),
272
+ signal,
273
+ });
274
+ const turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
275
+ const eventsReader = await eventsReaderPromise;
276
+ if (!eventsReader)
277
+ throw eventsError ?? new Error("events stream failed");
278
+ return await eventsReader(turnId);
279
+ }
280
+ finally {
281
+ eventsAbort.abort();
282
+ signal.removeEventListener("abort", onAbort);
283
+ }
284
+ }
285
+ async readEvents(url, headers, opts, signal) {
286
+ const res = await this.fetchFn(url, { method: "GET", headers, signal });
287
+ if (!res.ok)
288
+ throw new Error(`events stream failed HTTP ${res.status}`);
289
+ if (!res.body)
290
+ throw new Error("events stream response missing body");
291
+ const reader = res.body.getReader();
292
+ return async (turnId) => {
293
+ const decoder = new TextDecoder();
294
+ let buf = "";
295
+ let seq = 0;
296
+ let text = "";
297
+ let errorText = "";
298
+ let capped = false;
299
+ const append = (chunk) => {
300
+ if (!chunk || capped)
301
+ return;
302
+ const budget = SSE_TEXT_CAP - Buffer.byteLength(text, "utf8");
303
+ if (budget <= 0) {
304
+ capped = true;
305
+ return;
306
+ }
307
+ if (Buffer.byteLength(chunk, "utf8") > budget) {
308
+ text += chunk.slice(0, budget);
309
+ capped = true;
310
+ return;
311
+ }
312
+ text += chunk;
313
+ };
314
+ const emit = (eventName, payload) => {
315
+ // 其他 turn 的事件(并发轮次、历史重放)一律过滤。
316
+ const eventTurnId = stringField(payload, "turn_id") ?? stringField(payload?.payload, "turn_id");
317
+ if (turnId && eventTurnId && eventTurnId !== turnId)
318
+ return false;
319
+ seq += 1;
320
+ const block = normalizeDeepseekEvent(eventName, payload, seq);
321
+ if (block)
322
+ opts.onBlock?.(block);
323
+ const extractedError = extractDeepseekError(eventName, payload);
324
+ if (extractedError)
325
+ errorText = extractedError;
326
+ if (eventName === "message.delta") {
327
+ append(stringField(payload, "content") ?? "");
328
+ }
329
+ else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
330
+ append(extractDeepseekDelta(payload));
331
+ }
332
+ if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
333
+ opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
334
+ }
335
+ else if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
336
+ const label = stringField(payload, "name") ??
337
+ stringField(payload?.tool, "name") ??
338
+ stringField(payload?.payload?.tool, "name") ??
339
+ inferDeepseekToolName(payload?.item ?? payload?.payload?.item) ??
340
+ "tool";
341
+ opts.onStatus?.({ kind: "thinking", phase: "updated", label });
342
+ }
343
+ else if (isDeepseekTerminalEvent(eventName, payload)) {
344
+ opts.onStatus?.({ kind: "thinking", phase: "stopped" });
345
+ return true;
346
+ }
347
+ return false;
348
+ };
349
+ while (true) {
350
+ const { value, done } = await reader.read();
351
+ if (done)
352
+ break;
353
+ buf += decoder.decode(value, { stream: true });
354
+ let idx;
355
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
356
+ const frame = parseSseFrame(buf.slice(0, idx));
357
+ buf = buf.slice(idx + 2);
358
+ if (!frame)
359
+ continue;
360
+ if (emit(frame.event, frame.data)) {
361
+ await reader.cancel().catch(() => undefined);
362
+ return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
363
+ }
364
+ }
365
+ }
366
+ if (buf.trim()) {
367
+ const frame = parseSseFrame(buf);
368
+ if (frame)
369
+ emit(frame.event, frame.data);
370
+ }
371
+ return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
372
+ };
373
+ }
374
+ async requestJson(url, init) {
375
+ const headers = new Headers(init.headers);
376
+ if (!headers.has("content-type") && init.body)
377
+ headers.set("content-type", "application/json");
378
+ const res = await this.fetchFn(url, { ...init, headers });
379
+ if (!res.ok) {
380
+ let detail = "";
381
+ try {
382
+ detail = await res.text();
383
+ }
384
+ catch {
385
+ // ignore
386
+ }
387
+ throw new Error(`HTTP ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
388
+ }
389
+ return (await res.json());
390
+ }
391
+ }
392
+ /** 仅测试用:清空进程池。 */
393
+ export function __resetDeepseekTuiPoolForTests() {
394
+ for (const [key, handle] of PROCESS_POOL.entries()) {
395
+ shutdownHandle(handle, "test-reset");
396
+ PROCESS_POOL.delete(key);
397
+ }
398
+ }
399
+ function normalizeDeepseekEvent(eventName, payload, seq) {
400
+ if (eventName === "message.delta") {
401
+ return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
402
+ }
403
+ if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
404
+ return { raw: { event: eventName, payload }, kind: "tool_use", seq };
405
+ }
406
+ if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
407
+ return { raw: { event: eventName, payload }, kind: "tool_result", seq };
408
+ }
409
+ if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
410
+ return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
411
+ }
412
+ if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
413
+ return { raw: { event: eventName, payload }, kind: "thinking", seq };
414
+ }
415
+ if (eventName === "turn.started" ||
416
+ eventName === "status" ||
417
+ embeddedDeepseekEvent(payload) === "turn.started") {
418
+ return { raw: { event: eventName, payload }, kind: "system", seq };
419
+ }
420
+ if (eventName === "error" || isDeepseekTerminalEvent(eventName, payload)) {
421
+ return { raw: { event: eventName, payload }, kind: "other", seq };
422
+ }
423
+ return null;
424
+ }
425
+ function embeddedDeepseekEvent(payload) {
426
+ return stringField(payload, "event") ?? stringField(payload?.payload, "event");
427
+ }
428
+ function isDeepseekTerminalEvent(eventName, payload) {
429
+ const embedded = embeddedDeepseekEvent(payload);
430
+ return (eventName === "turn.completed" ||
431
+ eventName === "turn.finished" ||
432
+ eventName === "turn.done" ||
433
+ eventName === "done" ||
434
+ embedded === "turn.completed" ||
435
+ embedded === "turn.finished" ||
436
+ embedded === "turn.done" ||
437
+ embedded === "done");
438
+ }
439
+ function isToolStarted(eventName, payload) {
440
+ const itemKind = payload?.payload?.item?.kind ?? payload?.item?.kind;
441
+ return ((eventName === "item.started" &&
442
+ (!!payload?.tool ||
443
+ itemKind === "tool_call" ||
444
+ itemKind === "command_execution" ||
445
+ itemKind === "file_change")) ||
446
+ (payload?.event === "item.started" && !!payload?.payload?.tool));
447
+ }
448
+ function isToolCompleted(eventName, payload) {
449
+ const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
450
+ return ((eventName === "item.completed" ||
451
+ eventName === "item.failed" ||
452
+ payload?.event === "item.completed" ||
453
+ payload?.event === "item.failed") &&
454
+ (kind === "tool_call" || kind === "file_change" || kind === "command_execution"));
455
+ }
456
+ function isAgentMessageDelta(payload) {
457
+ return payload?.kind === "agent_message" || payload?.payload?.kind === "agent_message";
458
+ }
459
+ function isAgentReasoningItem(payload) {
460
+ return (payload?.item?.kind === "agent_reasoning" ||
461
+ payload?.payload?.item?.kind === "agent_reasoning");
462
+ }
463
+ function extractDeepseekDelta(payload) {
464
+ return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
465
+ }
466
+ function inferDeepseekToolName(item) {
467
+ const candidates = [stringField(item, "summary"), stringField(item, "detail")];
468
+ for (const candidate of candidates) {
469
+ if (!candidate)
470
+ continue;
471
+ const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
472
+ if (match?.[1] && match[1] !== "tool_call")
473
+ return match[1];
474
+ }
475
+ return undefined;
476
+ }
477
+ function emptyCompletionError(stderrTail) {
478
+ const tail = stderrTail.trim();
479
+ if (!tail) {
480
+ return "deepseek runtime completed with no assistant_message (check DEEPSEEK_API_KEY / model availability)";
481
+ }
482
+ const lines = tail.split(/\r?\n/).filter((line) => line.trim().length > 0);
483
+ const lastLines = lines.slice(-5).join("\n").slice(-500);
484
+ return `deepseek runtime completed with no assistant_message; stderr tail: ${lastLines}`;
485
+ }
486
+ function extractDeepseekError(eventName, payload) {
487
+ if (eventName === "error") {
488
+ return (stringField(payload, "message") ??
489
+ stringField(payload, "error") ??
490
+ stringField(payload?.payload, "message") ??
491
+ stringField(payload?.payload, "error"));
492
+ }
493
+ if (eventName === "item.failed") {
494
+ return (stringField(payload?.payload?.item, "detail") ??
495
+ stringField(payload?.payload?.item, "summary") ??
496
+ stringField(payload?.payload, "error"));
497
+ }
498
+ if (isDeepseekTerminalEvent(eventName, payload)) {
499
+ const turn = payload?.payload?.turn ?? payload?.turn;
500
+ const status = stringField(turn, "status");
501
+ const err = stringField(turn, "error");
502
+ if (err)
503
+ return err;
504
+ if (status && status !== "completed")
505
+ return `DeepSeek turn ${status}`;
506
+ }
507
+ return undefined;
508
+ }
509
+ function parseSseFrame(raw) {
510
+ let event = "message";
511
+ const dataLines = [];
512
+ for (const line of raw.split(/\r?\n/)) {
513
+ if (line.startsWith("event:"))
514
+ event = line.slice("event:".length).trim();
515
+ else if (line.startsWith("data:"))
516
+ dataLines.push(line.slice("data:".length).trimStart());
517
+ }
518
+ if (dataLines.length === 0)
519
+ return null;
520
+ try {
521
+ return { event, data: JSON.parse(dataLines.join("\n")) };
522
+ }
523
+ catch {
524
+ return { event, data: { content: dataLines.join("\n") } };
525
+ }
526
+ }
527
+ function authHeaders(token) {
528
+ return token ? { authorization: `Bearer ${token}` } : {};
529
+ }
530
+ function parseDeepseekRuntimeSelection(extraArgs) {
531
+ const out = {};
532
+ if (!extraArgs?.length)
533
+ return out;
534
+ for (let i = 0; i < extraArgs.length; i += 1) {
535
+ const arg = extraArgs[i];
536
+ if (arg === "--model") {
537
+ const value = nextArgValue(extraArgs, i);
538
+ if (value !== undefined) {
539
+ out.model = value;
540
+ i += 1;
541
+ }
542
+ }
543
+ else if (arg.startsWith("--model=")) {
544
+ out.model = arg.slice("--model=".length);
545
+ }
546
+ else if (arg === "--reasoning-effort") {
547
+ const value = nextArgValue(extraArgs, i);
548
+ if (value !== undefined) {
549
+ out.reasoningEffort = value;
550
+ i += 1;
551
+ }
552
+ }
553
+ else if (arg.startsWith("--reasoning-effort=")) {
554
+ out.reasoningEffort = arg.slice("--reasoning-effort=".length);
555
+ }
556
+ }
557
+ return out;
558
+ }
559
+ function nextArgValue(args, index) {
560
+ const next = args[index + 1];
561
+ if (typeof next !== "string")
562
+ return undefined;
563
+ if (!next.startsWith("-"))
564
+ return next;
565
+ return /^-\d/.test(next) ? next : undefined;
566
+ }
567
+ function resetIdle(handle, key) {
568
+ if (handle.idleTimer)
569
+ clearTimeout(handle.idleTimer);
570
+ if (handle.inFlight > 0 || handle.closed)
571
+ return;
572
+ handle.idleTimer = setTimeout(() => {
573
+ if (handle.inFlight === 0 && !handle.closed) {
574
+ log.info("deepseek-tui.idle-timeout", { key });
575
+ shutdownHandle(handle, "idle-timeout");
576
+ PROCESS_POOL.delete(key);
577
+ }
578
+ }, DEEPSEEK_IDLE_TIMEOUT_MS);
579
+ handle.idleTimer.unref?.();
580
+ }
581
+ function shutdownHandle(handle, reason) {
582
+ if (handle.closed)
583
+ return;
584
+ handle.closed = true;
585
+ if (handle.idleTimer)
586
+ clearTimeout(handle.idleTimer);
587
+ try {
588
+ const pid = handle.child.pid;
589
+ if (typeof pid === "number" && pid > 0) {
590
+ // 负 pid 对整个进程组发信号(对应 spawn 的 detached),同时杀掉
591
+ // dispatcher 与它再 spawn 的 deepseek-tui server。
592
+ process.kill(-pid, "SIGTERM");
593
+ }
594
+ else {
595
+ handle.child.kill("SIGTERM");
596
+ }
597
+ }
598
+ catch {
599
+ try {
600
+ handle.child.kill("SIGTERM");
601
+ }
602
+ catch {
603
+ // no-op
604
+ }
605
+ }
606
+ try {
607
+ handle.child.stdout?.destroy();
608
+ handle.child.stderr?.destroy();
609
+ handle.child.stdin?.destroy();
610
+ handle.child.unref();
611
+ }
612
+ catch {
613
+ // no-op
614
+ }
615
+ log.debug("deepseek-tui.shutdown", { reason });
616
+ }
617
+ async function waitForHealth(baseUrl, fetchFn, child, timeoutMs) {
618
+ const deadline = Date.now() + timeoutMs;
619
+ let lastError = "";
620
+ while (Date.now() < deadline) {
621
+ if (child.exitCode !== null) {
622
+ throw new Error(`deepseek serve exited with code ${child.exitCode}`);
623
+ }
624
+ try {
625
+ const res = await fetchFn(`${baseUrl}/health`, { method: "GET" });
626
+ if (res.ok)
627
+ return;
628
+ lastError = `HTTP ${res.status}`;
629
+ }
630
+ catch (err) {
631
+ lastError = err instanceof Error ? err.message : String(err);
632
+ }
633
+ await sleep(STARTUP_POLL_MS);
634
+ }
635
+ throw new Error(`deepseek serve did not become healthy: ${lastError}`);
636
+ }
637
+ async function findFreePort() {
638
+ return new Promise((resolve, reject) => {
639
+ const srv = net.createServer();
640
+ srv.on("error", reject);
641
+ srv.listen(0, "127.0.0.1", () => {
642
+ const addr = srv.address();
643
+ srv.close(() => {
644
+ if (typeof addr === "object" && addr?.port)
645
+ resolve(addr.port);
646
+ else
647
+ reject(new Error("failed to allocate port"));
648
+ });
649
+ });
650
+ });
651
+ }
652
+ function randomToken() {
653
+ return `blc_ds_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
654
+ }
655
+ function sleep(ms) {
656
+ return new Promise((resolve) => setTimeout(resolve, ms));
657
+ }
658
+ function stringField(obj, key) {
659
+ const v = obj?.[key];
660
+ return typeof v === "string" ? v : undefined;
661
+ }
662
+ function trimTrailingSlash(value) {
663
+ return value.replace(/\/+$/, "");
664
+ }
665
+ function isValidThreadId(id) {
666
+ return id.length > 0 && id.length <= 256 && !/[\u0000-\u001f\u007f]/.test(id);
667
+ }
668
+ /**
669
+ * PATH 上的 `deepseek` 可能是负责下载真实二进制的 dispatcher shim:
670
+ * realpath 后同目录 `downloads/deepseek` 存在则优先用它。
671
+ */
672
+ function resolveDownloadedDeepseekBinary(onPath, deps = {}) {
673
+ const exists = deps.existsSyncFn ?? existsSync;
674
+ try {
675
+ const resolved = realpathSync(onPath);
676
+ const candidate = path.join(path.dirname(resolved), "downloads", "deepseek");
677
+ return exists(candidate) ? candidate : null;
678
+ }
679
+ catch {
680
+ return null;
681
+ }
682
+ }
683
+ function nullChild() {
684
+ return {
685
+ kill: () => true,
686
+ on: () => nullChild(),
687
+ stderr: { setEncoding: () => undefined, on: () => undefined },
688
+ stdout: { setEncoding: () => undefined, on: () => undefined },
689
+ stdin: { write: () => true },
690
+ exitCode: null,
691
+ };
692
+ }
693
+ export const deepseekTuiModule = {
694
+ id: "deepseek-tui",
695
+ displayName: "DeepSeek TUI",
696
+ binary: "deepseek",
697
+ envVar: "BOTLEARN_DEEPSEEK_TUI_BIN",
698
+ installHint: "Install DeepSeek TUI (`deepseek` on PATH, or set BOTLEARN_DEEPSEEK_TUI_BIN). `deepseek serve` needs a logged-in account / DEEPSEEK_API_KEY to answer prompts.",
699
+ probe: async () => probeDeepseekTui(),
700
+ create: () => wrapEngineAdapter("deepseek-tui", new DeepseekTuiAdapter()),
701
+ };