dsh-advisor-group 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.
package/lib/index.mjs ADDED
@@ -0,0 +1,3871 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync, promises, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join } from "node:path";
6
+ import { KNOWN_SESSION_EVENT_TYPES, Session } from "@deepseek-ai/dsh-session";
7
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
8
+ import { defineTool } from "@deepseek-ai/dsh-tools";
9
+ import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
10
+ //#region src/defaults.ts
11
+ const DEFAULT_ADVISOR_PROMPT = `
12
+ 你是顾问群中的资深专家。你的任务不是替用户执行,而是提供高质量的专业意见供主模型参考。
13
+
14
+ 回答要求:
15
+ 1. 先给出明确结论,再说明理由、依据或经验;
16
+ 2. 内容要具体、可执行,避免空话套话;
17
+ 3. 明确区分事实、推测和建议;
18
+ 4. 如果信息不足,明确说明缺少什么、需要什么信息,不要强行编造;
19
+ 5. 涉及专业风险时,主动提示风险点和适用边界;
20
+ 6. 在讨论轮次中:
21
+ - 第 1 轮:独立给出你的专业意见;
22
+ - 后续轮次:参考其他顾问的观点,明确表示同意/补充/反对,不要简单重复;
23
+ 7. 尽量用分点/结构化输出,方便主模型综合。
24
+
25
+ 结尾按约定标注置信度或不确定性。
26
+ `.trim();
27
+ //#endregion
28
+ //#region src/config.ts
29
+ const ProviderProtocol = Schema.union([
30
+ Schema.const("openai"),
31
+ Schema.const("anthropic"),
32
+ Schema.const("gemini")
33
+ ]);
34
+ const ProviderAuthMode = Schema.union([Schema.const("x-api-key"), Schema.const("bearer")]);
35
+ const AdvisorConfig = Schema.object({
36
+ id: Schema.string().required().description("Unique advisor id used by ask_advisors."),
37
+ name: Schema.string().required().description("Display name shown in the chat group."),
38
+ avatar: Schema.string().description("Short emoji or ASCII avatar."),
39
+ provider: Schema.string().required().description("Provider route passed to ctx.llm, e.g. deepseek-official or openai."),
40
+ model: Schema.string().required().description("Model id on that provider route."),
41
+ systemPrompt: Schema.string().default(DEFAULT_ADVISOR_PROMPT),
42
+ temperature: Schema.number().min(0).max(2),
43
+ maxTokens: Schema.number().min(1),
44
+ baseURL: Schema.string().description("Optional direct-http fallback base URL."),
45
+ apiKey: Schema.string().role("secret").description("Direct API key used for direct-http fallback. Stored in local config; prefer apiKeyEnv if you do not want the key in settings.yaml."),
46
+ apiKeysByProvider: Schema.dict(Schema.string()).role("secret").default({}).description("Server-side per-provider key history; never returned to the browser. Marked secret so the official settings redaction (describe) never sends it over any wire."),
47
+ clearApiKey: Schema.boolean().description("Transient client intent: clear the current provider direct key. Stripped before persistence."),
48
+ apiKeyEnv: Schema.string().description("Optional env var name for direct-http fallback."),
49
+ protocol: ProviderProtocol,
50
+ authMode: ProviderAuthMode.description("Anthropic auth header style: x-api-key or bearer."),
51
+ thinking: Schema.union([Schema.const("enabled"), Schema.const("disabled")]).description("Per-advisor thinking mode; unset = provider default. disabled forces no thinking block (DSH built-in channel maps it to reasoningEffort off)."),
52
+ reasoningEffort: Schema.union([
53
+ Schema.const("off"),
54
+ Schema.const("low"),
55
+ Schema.const("high"),
56
+ Schema.const("max")
57
+ ]).description("Per-advisor thinking strength; unset = provider/model default. Standard DSH effort ids; off disables thinking per request."),
58
+ tools: Schema.union([
59
+ Schema.const("readonly"),
60
+ Schema.const("all"),
61
+ Schema.const("off")
62
+ ]).description("Per-advisor tool calling scope override; unset = follow the global discussion.advisorTools default.")
63
+ });
64
+ const DriverModel = Schema.object({
65
+ provider: Schema.string().required().description("Provider route of the fallback driver model."),
66
+ model: Schema.string().required().description("Model id of the fallback driver model.")
67
+ });
68
+ const DiscussionConfig = Schema.object({
69
+ maxRounds: Schema.natural().min(1).max(5).default(2).description("Total rounds of the auto-deepen consultation (each round = driver deep-question + all advisors in relay order)."),
70
+ maxAdvisorsPerCall: Schema.natural().min(1).max(10).default(3).description("Maximum number of advisors consulted in one ask_advisors call."),
71
+ parallel: Schema.boolean().default(true).description("Deprecated: rounds are a sequential A→B→C relay; kept for stored-config compatibility."),
72
+ autoDeepen: Schema.boolean().default(true).description("Run the auto-deepen pipeline: after each round the driver model asks a deeper follow-up, and a final conclusion closes the consultation."),
73
+ driverModel: Schema.union([DriverModel, Schema.const(void 0)]).description("Fallback driver model (provider/model) used when the session header cannot be read."),
74
+ stopOnConsensus: Schema.boolean().default(false).description("Deprecated: dormant under the auto-deepen pipeline; kept for stored-config compatibility."),
75
+ advisorTimeoutMs: Schema.number().min(1e3).max(6e5).default(6e5).description("Per-advisor call timeout in milliseconds (default 600000 = 10 min). Long-reasoning models can exhaust shorter budgets on the thinking chain and lose the answer body; a timeout keeps the partial thinking chain and marks the message as truncated."),
76
+ driverTimeoutMs: Schema.number().min(1e3).max(12e5).default(6e5).description("Driver generation timeout in milliseconds (deep-question / conclusion; default 600000 = 10 min). Long discussions with many tool results need a real budget."),
77
+ advisorTools: Schema.union([
78
+ Schema.const("readonly"),
79
+ Schema.const("all"),
80
+ Schema.const("off")
81
+ ]).default("readonly").description("Global default advisor tool calling scope for advisors that do not set their own `tools` field: readonly (default; read-only session tools), all (every session-visible tool incl. writable ones), off (disabled). Only the direct-http OpenAI/Anthropic channel supports tool calling.")
82
+ });
83
+ const TriggerConfig = Schema.object({
84
+ requireClassifier: Schema.boolean().default(true).description("Run the pre-classifier before starting a consultation."),
85
+ allowWebFallback: Schema.boolean().default(true).description("When classifier says web search is better, return that hint to the main model."),
86
+ confidenceThreshold: Schema.number().min(0).max(1).default(.6).description("Below this confidence score, ask_advisors should escalate even without domain keywords.")
87
+ });
88
+ const UiConfig = Schema.object({
89
+ theme: Schema.union([
90
+ Schema.const("retro-green"),
91
+ Schema.const("retro-amber"),
92
+ Schema.const("retro-blue")
93
+ ]).default("retro-green"),
94
+ showTimestamps: Schema.boolean().default(true),
95
+ autoExpand: Schema.boolean().default(true)
96
+ });
97
+ const QuotaConfig = Schema.object({
98
+ enabled: Schema.boolean().default(true).description("Enable the daily new-consultation cap (cost safety valve)."),
99
+ maxPerDay: Schema.natural().min(1).max(1e5).default(50).description("Max new consultations per UTC day (1–100000); ignored when enabled is false.")
100
+ });
101
+ const Config = Schema.object({
102
+ enabled: Schema.boolean().default(true),
103
+ discussion: DiscussionConfig,
104
+ trigger: TriggerConfig,
105
+ ui: UiConfig,
106
+ quota: QuotaConfig,
107
+ advisors: Schema.array(AdvisorConfig).default([])
108
+ });
109
+ //#endregion
110
+ //#region src/providers/advisor-prompt.ts
111
+ /**
112
+ * Standard suffix appended to every advisor system prompt.
113
+ *
114
+ * It asks the advisor to state its own confidence/uncertainty so the main
115
+ * model can weigh the answer instead of treating all advisor output equally.
116
+ */
117
+ const ADVISOR_OUTPUT_POLICY = `
118
+
119
+ 回答要求:
120
+ - 先给结论,再简要说明依据;
121
+ - 在回答末尾用一行标注:置信度(0-10)或不确定性;
122
+ - 如果没有把握,请明确写“不确定”,不要强行编造;
123
+ - **全程使用中文输出**:包括调用工具前的行动说明、正文与最终结论(代码、路径、专业术语可保留原文);不要把“我接下来要做什么”的说明写成英文;
124
+ - 思考(思维链)请保持精简:只列关键推理要点,切勿让思考链占满输出预算——必须保证正文完整(历史教训:长思考+长上下文会耗尽输出上限,导致正文丢失/中途截断);
125
+ - **最终答复必须输出一段完整的中文正文回答**:不要只输出工具调用描述/标记,不要输出空白或空消息——即使本轮没有新结论,也要用正文说明你的判断(这是硬性输出约束)。
126
+ `;
127
+ /**
128
+ * Tool-calling guidance appended to an advisor's system prompt ONLY when the
129
+ * advisor actually has tools configured and the session-visible set resolved
130
+ * (callAdvisor adds it on the direct-http tool loop path).
131
+ */
132
+ const ADVISOR_TOOL_GUIDANCE = `
133
+
134
+ 工具使用指引:
135
+ - 你可以调用当前会话提供的工具(检索/读取/搜索等)来辅助回答;
136
+ - 对于你不了解、不熟悉的内容,尤其是项目背景、代码细节、仓库状态等不在你知识范围内的情况,请优先使用联网工具(如 web_search、web_fetch)搜索核实,再基于实际信息给出意见;
137
+ - 仅在确有必要时调用工具:先了解现状,再下结论;不要编造你未核实的事实;
138
+ - **调用工具前必须先用一句中文说明你准备做什么**(行动说明),例如“我先读取 package.json 确认版本”;这段行动说明会显示在卡片上,不要用英文写。
139
+ `;
140
+ /**
141
+ * Sequential-relay role hint appended to one advisor's system prompt.
142
+ *
143
+ * The first advisor answers the (main-model) question directly; every later
144
+ * advisor joins the same turn AFTER its predecessors and is told to give its
145
+ * OWN view against the full conversation (agree / complement / rebut), so the
146
+ * turn reads as main-question → A → B(sees A) → C(sees A+B).
147
+ */
148
+ function advisorJoinPrompt(joinIndex, advisorCount) {
149
+ if (joinIndex <= 1) return `
150
+ 本次你作为第 1 位接入的顾问:请直接、完整地回答主模型的问题;你在本轮中会最先发言,后续其他顾问会基于你的回答继续补充。
151
+ `;
152
+ return `
153
+ 本次你作为第 ${joinIndex} / ${advisorCount} 位接入的顾问。你可以在上下文中看到:
154
+ - 项目背景与主模型的问题;
155
+ - 前面已接入顾问(第 1 至第 ${joinIndex - 1} 位)对本问题的完整回答。
156
+
157
+ 请给出你**自己的独立见解**:可以认同、补充、质疑或反驳前面顾问的观点,但请明确点出你的回答与前序观点的差异和理由;不要简单复述前序内容。
158
+ `;
159
+ }
160
+ //#endregion
161
+ //#region src/providers/timeout.ts
162
+ /**
163
+ * Combine an optional caller signal with a hard timeout.
164
+ * Node 22+ provides AbortSignal.any and AbortSignal.timeout.
165
+ */
166
+ function withTimeout(ms, signal) {
167
+ const timeout = AbortSignal.timeout(ms);
168
+ if (!signal) return timeout;
169
+ return AbortSignal.any([signal, timeout]);
170
+ }
171
+ /**
172
+ * Same combination, but keeps the timeout signal reachable so the caller can
173
+ * classify an aborted stream: timeout fired -> graceful truncation (keep the
174
+ * partial thinking, mark `truncated`); caller signal fired -> cancellation.
175
+ */
176
+ function timeoutSignalPair(ms, signal) {
177
+ const timeout = AbortSignal.timeout(ms);
178
+ return {
179
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
180
+ isTimeout: () => timeout.aborted
181
+ };
182
+ }
183
+ //#endregion
184
+ //#region src/providers/ctx-llm.ts
185
+ function formatTranscript(entries) {
186
+ return entries.map((entry) => `[${entry.role === "main" ? "主模型" : entry.name}]\n${entry.content}`).join("\n\n");
187
+ }
188
+ /**
189
+ * Call an advisor through DSH's built-in ctx.llm seam.
190
+ *
191
+ * This is the primary path: it reuses whatever provider routes the user has
192
+ * already configured in DSH (deepseek-official, llm-pi-ai providers, etc.),
193
+ * including credentials resolved by the harness.
194
+ *
195
+ * Timeout semantics (root cause of "thinking cut mid-sentence, no body"): a
196
+ * long-reasoning model can spend the whole advisor budget on its thinking
197
+ * chain. When `timeoutMs` fires, the stream simply stops (it does not throw),
198
+ * which used to produce a silent empty body. We now detect the timeout and
199
+ * report `truncated` so the caller can surface it to the user instead of
200
+ * treating the reply as complete. A caller-initiated abort still propagates.
201
+ */
202
+ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs) {
203
+ const userText = formatTranscript(transcript);
204
+ const messages = [createUserMessage({
205
+ content: [{
206
+ type: "text",
207
+ text: userText
208
+ }],
209
+ source: {
210
+ kind: "plugin",
211
+ plugin: "dsh-advisor-group"
212
+ }
213
+ })];
214
+ const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
215
+ const reasoningEffort = advisor.thinking === "disabled" ? "off" : advisor.reasoningEffort;
216
+ const options = {
217
+ provider: advisor.provider,
218
+ model: advisor.model,
219
+ system: `${advisor.systemPrompt}${ADVISOR_OUTPUT_POLICY}`,
220
+ messages,
221
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort },
222
+ temperature: advisor.temperature,
223
+ maxTokens: advisor.maxTokens ?? 16384,
224
+ signal: pair.signal
225
+ };
226
+ let text = "";
227
+ let thinking = "";
228
+ try {
229
+ for await (const chunk of ctx.llm.stream(options)) if (chunk.type === "text-delta") {
230
+ text += chunk.text;
231
+ onDelta?.({ text: chunk.text });
232
+ } else if (chunk.type === "reasoning-delta") {
233
+ thinking += chunk.text;
234
+ onDelta?.({ thinking: chunk.text });
235
+ }
236
+ } catch (error) {
237
+ if (pair.isTimeout()) return {
238
+ content: text,
239
+ thinking,
240
+ truncated: {
241
+ reason: "timeout",
242
+ atMs: Date.now()
243
+ }
244
+ };
245
+ throw error;
246
+ }
247
+ if (pair.isTimeout()) return {
248
+ content: text,
249
+ thinking,
250
+ truncated: {
251
+ reason: "timeout",
252
+ atMs: Date.now()
253
+ }
254
+ };
255
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
256
+ return {
257
+ content: text,
258
+ thinking
259
+ };
260
+ }
261
+ //#endregion
262
+ //#region src/providers/presets.ts
263
+ const PROVIDER_PRESETS = {
264
+ deepseek: {
265
+ id: "deepseek",
266
+ label: "DeepSeek",
267
+ protocol: "openai",
268
+ baseURL: "https://api.deepseek.com",
269
+ apiKeyEnv: "DEEPSEEK_API_KEY",
270
+ defaultModels: [
271
+ "deepseek-v4-flash",
272
+ "deepseek-v4-pro",
273
+ "deepseek-v4-flash-vision-exp"
274
+ ],
275
+ notes: "Also available as deepseek-official via dsh-llm-deepseek, and Anthropic-compatible at https://api.deepseek.com/anthropic."
276
+ },
277
+ "deepseek-anthropic": {
278
+ id: "deepseek-anthropic",
279
+ label: "DeepSeek(Anthropic 兼容)",
280
+ protocol: "anthropic",
281
+ baseURL: "https://api.deepseek.com/anthropic",
282
+ apiKeyEnv: "DEEPSEEK_API_KEY",
283
+ defaultModels: [
284
+ "deepseek-v4-flash",
285
+ "deepseek-v4-pro",
286
+ "deepseek-v4-flash-vision-exp"
287
+ ],
288
+ authMode: "x-api-key",
289
+ modelsBaseURL: "https://api.deepseek.com",
290
+ modelsAuthMode: "bearer",
291
+ notes: "DeepSeek Anthropic-compatible endpoint."
292
+ },
293
+ moonshot: {
294
+ id: "moonshot",
295
+ label: "月之暗面 Kimi",
296
+ protocol: "openai",
297
+ baseURL: "https://api.moonshot.cn/v1",
298
+ apiKeyEnv: "MOONSHOT_API_KEY",
299
+ defaultModels: [
300
+ "kimi-k3",
301
+ "kimi-k2.7-code",
302
+ "kimi-k2.7-code-highspeed",
303
+ "kimi-k2.6"
304
+ ],
305
+ notes: "OpenAI-compatible; Anthropic-compatible endpoint at https://api.moonshot.cn/anthropic"
306
+ },
307
+ "moonshot-anthropic": {
308
+ id: "moonshot-anthropic",
309
+ label: "月之暗面 Kimi(Anthropic 兼容)",
310
+ protocol: "anthropic",
311
+ baseURL: "https://api.moonshot.cn/anthropic",
312
+ apiKeyEnv: "MOONSHOT_API_KEY",
313
+ defaultModels: [
314
+ "kimi-k3",
315
+ "kimi-k2.7-code",
316
+ "kimi-k2.7-code-highspeed",
317
+ "kimi-k2.6"
318
+ ],
319
+ authMode: "bearer",
320
+ modelsBaseURL: "https://api.moonshot.cn/v1",
321
+ modelsAuthMode: "bearer",
322
+ notes: "Kimi Anthropic-compatible endpoint; 实际端点 /anthropic/v1/messages(官方 Claude Code 文档)。"
323
+ },
324
+ "kimi-code": {
325
+ id: "kimi-code",
326
+ label: "Kimi Code(OpenAI 兼容)",
327
+ protocol: "openai",
328
+ baseURL: "https://api.kimi.com/coding/v1",
329
+ apiKeyEnv: "KIMI_CODE_API_KEY",
330
+ defaultModels: [
331
+ "kimi-k3",
332
+ "kimi-k2.7-code",
333
+ "kimi-k2.7-code-highspeed",
334
+ "kimi-for-coding"
335
+ ],
336
+ modelsBaseURL: "https://api.kimi.com/coding/v1",
337
+ modelsAuthMode: "bearer",
338
+ notes: "Kimi Code 会员 API;可用模型以会员页为准,常用 kimi-k3 / kimi-k2.7-code / kimi-k2.7-code-highspeed,旧 kimi-for-coding 仍兼容。"
339
+ },
340
+ "kimi-code-anthropic": {
341
+ id: "kimi-code-anthropic",
342
+ label: "Kimi Code(Anthropic 兼容)",
343
+ protocol: "anthropic",
344
+ baseURL: "https://api.kimi.com/coding",
345
+ apiKeyEnv: "KIMI_CODE_API_KEY",
346
+ defaultModels: [
347
+ "kimi-k3",
348
+ "kimi-k2.7-code",
349
+ "kimi-k2.7-code-highspeed",
350
+ "kimi-for-coding"
351
+ ],
352
+ authMode: "bearer",
353
+ modelsBaseURL: "https://api.kimi.com/coding/v1",
354
+ modelsAuthMode: "bearer",
355
+ notes: "Kimi Code 会员 Anthropic 兼容端点;实际端点 /coding/v1/messages。"
356
+ },
357
+ bailian: {
358
+ id: "bailian",
359
+ label: "阿里云百炼",
360
+ protocol: "openai",
361
+ baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
362
+ apiKeyEnv: "DASHSCOPE_API_KEY",
363
+ defaultModels: [
364
+ "qwen3.8-max",
365
+ "qwen3.8-flash",
366
+ "qwen3.7-plus"
367
+ ],
368
+ notes: "DashScope OpenAI-compatible endpoint(北京公共域名)。"
369
+ },
370
+ "bailian-openai-singapore": {
371
+ id: "bailian-openai-singapore",
372
+ label: "阿里云百炼(OpenAI 兼容 · 新加坡公共)",
373
+ protocol: "openai",
374
+ baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
375
+ apiKeyEnv: "DASHSCOPE_API_KEY",
376
+ defaultModels: [
377
+ "qwen3.8-max",
378
+ "qwen3.8-flash",
379
+ "qwen3.7-plus"
380
+ ],
381
+ notes: "DashScope 新加坡公共域名 OpenAI 兼容端点。"
382
+ },
383
+ "bailian-openai-us": {
384
+ id: "bailian-openai-us",
385
+ label: "阿里云百炼(OpenAI 兼容 · 美国公共)",
386
+ protocol: "openai",
387
+ baseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
388
+ apiKeyEnv: "DASHSCOPE_API_KEY",
389
+ defaultModels: [
390
+ "qwen3.8-max",
391
+ "qwen3.8-flash",
392
+ "qwen3.7-plus"
393
+ ],
394
+ notes: "DashScope 美国(弗吉尼亚)公共域名 OpenAI 兼容端点。"
395
+ },
396
+ "bailian-anthropic": {
397
+ id: "bailian-anthropic",
398
+ label: "阿里云百炼(Anthropic 兼容 · 按量付费 · 业务空间)",
399
+ protocol: "anthropic",
400
+ baseURL: "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/apps/anthropic",
401
+ apiKeyEnv: "DASHSCOPE_API_KEY",
402
+ defaultModels: [
403
+ "qwen3.8-max",
404
+ "qwen3.8-flash",
405
+ "qwen3.7-plus",
406
+ "deepseek-v4-pro",
407
+ "deepseek-v4-flash",
408
+ "kimi-k3",
409
+ "glm-5.3"
410
+ ],
411
+ authMode: "bearer",
412
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
413
+ modelsAuthMode: "bearer",
414
+ notes: "需要把 {WorkspaceId} 替换为你的百炼业务空间 ID;API Key 为 sk- 开头(按量付费)。"
415
+ },
416
+ "bailian-anthropic-public": {
417
+ id: "bailian-anthropic-public",
418
+ label: "阿里云百炼(Anthropic 兼容 · 北京公共)",
419
+ protocol: "anthropic",
420
+ baseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
421
+ apiKeyEnv: "DASHSCOPE_API_KEY",
422
+ defaultModels: [
423
+ "qwen3.8-max",
424
+ "qwen3.8-flash",
425
+ "qwen3.7-plus",
426
+ "deepseek-v4-pro",
427
+ "deepseek-v4-flash",
428
+ "kimi-k3",
429
+ "glm-5.3"
430
+ ],
431
+ authMode: "bearer",
432
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
433
+ modelsAuthMode: "bearer",
434
+ notes: "DashScope 北京公共域名 Anthropic 兼容端点。"
435
+ },
436
+ "bailian-anthropic-singapore-public": {
437
+ id: "bailian-anthropic-singapore-public",
438
+ label: "阿里云百炼(Anthropic 兼容 · 新加坡公共)",
439
+ protocol: "anthropic",
440
+ baseURL: "https://dashscope-intl.aliyuncs.com/apps/anthropic",
441
+ apiKeyEnv: "DASHSCOPE_API_KEY",
442
+ defaultModels: [
443
+ "qwen3.8-max",
444
+ "qwen3.8-flash",
445
+ "qwen3.7-plus",
446
+ "deepseek-v4-pro",
447
+ "deepseek-v4-flash",
448
+ "kimi-k3",
449
+ "glm-5.3"
450
+ ],
451
+ authMode: "bearer",
452
+ modelsBaseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
453
+ modelsAuthMode: "bearer",
454
+ notes: "DashScope 新加坡公共域名 Anthropic 兼容端点。"
455
+ },
456
+ "bailian-anthropic-us-public": {
457
+ id: "bailian-anthropic-us-public",
458
+ label: "阿里云百炼(Anthropic 兼容 · 美国公共)",
459
+ protocol: "anthropic",
460
+ baseURL: "https://dashscope-us.aliyuncs.com/apps/anthropic",
461
+ apiKeyEnv: "DASHSCOPE_API_KEY",
462
+ defaultModels: [
463
+ "qwen3.8-max",
464
+ "qwen3.8-flash",
465
+ "qwen3.7-plus",
466
+ "deepseek-v4-pro",
467
+ "deepseek-v4-flash",
468
+ "kimi-k3",
469
+ "glm-5.3"
470
+ ],
471
+ authMode: "bearer",
472
+ modelsBaseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
473
+ modelsAuthMode: "bearer",
474
+ notes: "DashScope 美国(弗吉尼亚)公共域名 Anthropic 兼容端点。"
475
+ },
476
+ "bailian-token-plan-anthropic": {
477
+ id: "bailian-token-plan-anthropic",
478
+ label: "阿里云百炼(Anthropic 兼容 · Token Plan)",
479
+ protocol: "anthropic",
480
+ baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
481
+ apiKeyEnv: "DASHSCOPE_TOKEN_PLAN_API_KEY",
482
+ defaultModels: [
483
+ "qwen3.8-max",
484
+ "qwen3.8-flash",
485
+ "qwen3.7-plus",
486
+ "deepseek-v4-pro",
487
+ "deepseek-v4-flash",
488
+ "kimi-k3",
489
+ "glm-5.3"
490
+ ],
491
+ authMode: "bearer",
492
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
493
+ modelsAuthMode: "bearer",
494
+ notes: "API Key 为 sk-sp- 开头,必须与 Token Plan Base URL 配套使用。"
495
+ },
496
+ "bailian-coding-plan-anthropic": {
497
+ id: "bailian-coding-plan-anthropic",
498
+ label: "阿里云百炼(Anthropic 兼容 · Coding Plan)",
499
+ protocol: "anthropic",
500
+ baseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
501
+ apiKeyEnv: "DASHSCOPE_CODING_PLAN_API_KEY",
502
+ defaultModels: [
503
+ "qwen3.8-max",
504
+ "qwen3.8-flash",
505
+ "qwen3.7-plus",
506
+ "deepseek-v4-pro",
507
+ "deepseek-v4-flash",
508
+ "kimi-k3",
509
+ "glm-5.3"
510
+ ],
511
+ authMode: "bearer",
512
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
513
+ modelsAuthMode: "bearer",
514
+ notes: "API Key 为 sk-ws- 开头,必须与 Coding Plan Base URL 配套使用。"
515
+ },
516
+ zhipu: {
517
+ id: "zhipu",
518
+ label: "智谱 AI",
519
+ protocol: "openai",
520
+ baseURL: "https://open.bigmodel.cn/api/paas/v4",
521
+ apiKeyEnv: "ZHIPU_API_KEY",
522
+ defaultModels: [
523
+ "glm-5.3",
524
+ "glm-5.3-flash",
525
+ "glm-5.2",
526
+ "glm-5.1"
527
+ ],
528
+ modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
529
+ modelsAuthMode: "bearer",
530
+ notes: "智谱 AI OpenAI 兼容端点;chat completions 为 /v4/chat/completions,模型列表为 /v4/models。"
531
+ },
532
+ "zhipu-anthropic": {
533
+ id: "zhipu-anthropic",
534
+ label: "智谱 AI(Anthropic 兼容)",
535
+ protocol: "anthropic",
536
+ baseURL: "https://open.bigmodel.cn/api/anthropic",
537
+ apiKeyEnv: "ZHIPU_API_KEY",
538
+ defaultModels: [
539
+ "glm-5.3",
540
+ "glm-5.3-flash",
541
+ "glm-5.2",
542
+ "glm-5.1"
543
+ ],
544
+ authMode: "bearer",
545
+ modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
546
+ modelsAuthMode: "bearer",
547
+ notes: "智谱 AI Claude API 兼容端点;ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic。"
548
+ },
549
+ openai: {
550
+ id: "openai",
551
+ label: "OpenAI",
552
+ protocol: "openai",
553
+ baseURL: "https://api.openai.com/v1",
554
+ apiKeyEnv: "OPENAI_API_KEY",
555
+ defaultModels: [
556
+ "gpt-6-astra",
557
+ "gpt-5.6-sol",
558
+ "gpt-5.6-terra",
559
+ "gpt-5.6-luna",
560
+ "gpt-5.5",
561
+ "gpt-5.4"
562
+ ],
563
+ notes: "Official OpenAI API."
564
+ },
565
+ anthropic: {
566
+ id: "anthropic",
567
+ label: "Claude",
568
+ protocol: "anthropic",
569
+ baseURL: "https://api.anthropic.com",
570
+ apiKeyEnv: "ANTHROPIC_API_KEY",
571
+ defaultModels: [
572
+ "claude-opus-5",
573
+ "claude-sonnet-5",
574
+ "claude-opus-4-8",
575
+ "claude-opus-4-7",
576
+ "claude-sonnet-4-6"
577
+ ],
578
+ authMode: "x-api-key",
579
+ notes: "Anthropic Messages API; docs in D:/X/X/14-claude."
580
+ },
581
+ gemini: {
582
+ id: "gemini",
583
+ label: "Gemini",
584
+ protocol: "gemini",
585
+ baseURL: "https://generativelanguage.googleapis.com",
586
+ apiKeyEnv: "GEMINI_API_KEY",
587
+ defaultModels: [
588
+ "gemini-3.8-flash",
589
+ "gemini-3.7-flash",
590
+ "gemini-3.1-pro-preview",
591
+ "gemini-3-flash-preview"
592
+ ],
593
+ notes: "Google Gemini API; docs in D:/X/X/13-gemini."
594
+ },
595
+ siliconflow: {
596
+ id: "siliconflow",
597
+ label: "硅基流动",
598
+ protocol: "openai",
599
+ baseURL: "https://api.siliconflow.cn/v1",
600
+ apiKeyEnv: "SILICONFLOW_API_KEY",
601
+ defaultModels: [
602
+ "deepseek-ai/DeepSeek-V3.2",
603
+ "Qwen/Qwen3-235B-A22B",
604
+ "zai-org/GLM-5.1"
605
+ ],
606
+ notes: "OpenAI-compatible; also supports Anthropic-compatible at /v1/messages."
607
+ },
608
+ "siliconflow-anthropic": {
609
+ id: "siliconflow-anthropic",
610
+ label: "硅基流动(Anthropic 兼容)",
611
+ protocol: "anthropic",
612
+ baseURL: "https://api.siliconflow.cn/v1",
613
+ apiKeyEnv: "SILICONFLOW_API_KEY",
614
+ defaultModels: [
615
+ "deepseek-ai/DeepSeek-V3.2",
616
+ "Qwen/Qwen3-32B",
617
+ "zai-org/GLM-5.1"
618
+ ],
619
+ authMode: "bearer",
620
+ modelsBaseURL: "https://api.siliconflow.cn/v1",
621
+ modelsAuthMode: "bearer",
622
+ notes: "SiliconFlow Anthropic-compatible endpoint: ANTHROPIC_BASE_URL=https://api.siliconflow.cn/ 或 /v1。"
623
+ },
624
+ aihubmix: {
625
+ id: "aihubmix",
626
+ label: "AIHubMix",
627
+ protocol: "openai",
628
+ baseURL: "https://aihubmix.com/v1",
629
+ apiKeyEnv: "AIHUBMIX_API_KEY",
630
+ defaultModels: [
631
+ "gpt-5.2",
632
+ "claude-sonnet-4-5",
633
+ "claude-opus-4-5",
634
+ "gemini-3-flash-preview"
635
+ ],
636
+ notes: "Aggregator; also supports Anthropic-compatible at https://aihubmix.com."
637
+ },
638
+ "aihubmix-anthropic": {
639
+ id: "aihubmix-anthropic",
640
+ label: "AIHubMix(Anthropic 兼容)",
641
+ protocol: "anthropic",
642
+ baseURL: "https://aihubmix.com",
643
+ apiKeyEnv: "AIHUBMIX_API_KEY",
644
+ defaultModels: [
645
+ "claude-sonnet-4-5",
646
+ "claude-opus-4-6",
647
+ "claude-opus-4-5"
648
+ ],
649
+ authMode: "x-api-key",
650
+ modelsBaseURL: "https://aihubmix.com/v1",
651
+ modelsAuthMode: "bearer",
652
+ notes: "AIHubMix Anthropic-compatible endpoint."
653
+ },
654
+ openrouter: {
655
+ id: "openrouter",
656
+ label: "OpenRouter",
657
+ protocol: "openai",
658
+ baseURL: "https://openrouter.ai/api/v1",
659
+ apiKeyEnv: "OPENROUTER_API_KEY",
660
+ defaultModels: [
661
+ "openai/gpt-5.6",
662
+ "openai/gpt-5.5",
663
+ "anthropic/claude-sonnet-4.6",
664
+ "deepseek/deepseek-chat"
665
+ ],
666
+ notes: "OpenAI-compatible; docs in D:/X/X/06-OpenRouter."
667
+ },
668
+ "openrouter-anthropic": {
669
+ id: "openrouter-anthropic",
670
+ label: "OpenRouter(Anthropic 兼容)",
671
+ protocol: "anthropic",
672
+ baseURL: "https://openrouter.ai/api",
673
+ apiKeyEnv: "OPENROUTER_API_KEY",
674
+ defaultModels: [
675
+ "anthropic/claude-sonnet-4.6",
676
+ "anthropic/claude-opus-4-6",
677
+ "openai/gpt-5.6",
678
+ "deepseek/deepseek-chat"
679
+ ],
680
+ authMode: "bearer",
681
+ modelsBaseURL: "https://openrouter.ai/api/v1",
682
+ modelsAuthMode: "bearer",
683
+ notes: "OpenRouter Anthropic Skin;使用 ANTHROPIC_BASE_URL=https://openrouter.ai/api + ANTHROPIC_AUTH_TOKEN=OpenRouter Key(Bearer)。"
684
+ }
685
+ };
686
+ function getProviderPreset(id) {
687
+ return PROVIDER_PRESETS[id];
688
+ }
689
+ //#endregion
690
+ //#region src/dsml.ts
691
+ const OUTER_OPEN = /<[||]{0,2}(?:DSML[||]{0,2})?(?:tool_calls|function_calls)\s*>/g;
692
+ const OUTER_CLOSE_RE = /<\/[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}(?:tool_calls|function_calls)\s*>/;
693
+ const INVOKE_RE = /<[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*invoke\s+name="([^"]+)">([\s\S]*?)<\/[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*invoke\s*>/g;
694
+ const PARAM_RE = /<[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*parameter\s+name="([^"]+)"(?:\s+string="true")?>([\s\S]*?)<\/[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*parameter\s*>/g;
695
+ const PARAM_OPEN_RE = /<[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*parameter\s+name="[^"]+"/g;
696
+ function insideFence(content, index) {
697
+ const fences = content.slice(0, index).match(/```/g);
698
+ return Boolean(fences && fences.length % 2 === 1);
699
+ }
700
+ /** Parse every invoke inside one outer block; null when any gate fails. */
701
+ function parseOuterBlock(block, allowedNames) {
702
+ const calls = [];
703
+ INVOKE_RE.lastIndex = 0;
704
+ let invoke;
705
+ let invokeCount = 0;
706
+ while ((invoke = INVOKE_RE.exec(block)) !== null) {
707
+ invokeCount += 1;
708
+ const name = invoke[1];
709
+ if (!allowedNames.has(name)) return null;
710
+ const body = invoke[2];
711
+ const params = {};
712
+ PARAM_RE.lastIndex = 0;
713
+ let param;
714
+ let paramCount = 0;
715
+ while ((param = PARAM_RE.exec(body)) !== null) {
716
+ paramCount += 1;
717
+ if (PARAM_OPEN_RE.test(param[2])) return null;
718
+ params[param[1]] = param[2];
719
+ PARAM_OPEN_RE.lastIndex = 0;
720
+ }
721
+ if ((body.match(PARAM_OPEN_RE) ?? []).length !== paramCount) return null;
722
+ PARAM_OPEN_RE.lastIndex = 0;
723
+ calls.push({
724
+ id: `text-${calls.length}`,
725
+ name,
726
+ argumentsJson: paramCount > 0 ? JSON.stringify(params) : "{}"
727
+ });
728
+ }
729
+ if (invokeCount === 0) return null;
730
+ return calls;
731
+ }
732
+ /** Extract text tool calls; blocks outside code fences, whitelisted names. */
733
+ function parseTextToolCalls(content, allowedNames) {
734
+ if (!content || allowedNames.size === 0) return {
735
+ toolCalls: [],
736
+ cleaned: content
737
+ };
738
+ const ranges = [];
739
+ const allCalls = [];
740
+ OUTER_OPEN.lastIndex = 0;
741
+ let match;
742
+ while ((match = OUTER_OPEN.exec(content)) !== null) {
743
+ const start = match.index;
744
+ if (insideFence(content, start)) continue;
745
+ const rest = content.slice(start);
746
+ const close = rest.match(OUTER_CLOSE_RE);
747
+ const calls = parseOuterBlock(close && close.index !== void 0 ? rest.slice(0, close.index) : rest, allowedNames);
748
+ if (calls === null) continue;
749
+ const end = close && close.index !== void 0 ? start + close.index + close[0].length : content.length;
750
+ allCalls.push(...calls);
751
+ ranges.push([start, end]);
752
+ }
753
+ if (allCalls.length === 0) return {
754
+ toolCalls: [],
755
+ cleaned: content
756
+ };
757
+ let cleaned = content;
758
+ for (let i = ranges.length - 1; i >= 0; i -= 1) {
759
+ const [from, to] = ranges[i];
760
+ cleaned = cleaned.slice(0, from) + cleaned.slice(to);
761
+ }
762
+ return {
763
+ toolCalls: allCalls,
764
+ cleaned: cleaned.trim()
765
+ };
766
+ }
767
+ //#endregion
768
+ //#region src/providers/direct-http.ts
769
+ /** True when the user asked to force thinking off, through either spelling. */
770
+ function thinkingOff(advisor) {
771
+ return advisor.thinking === "disabled" || advisor.reasoningEffort === "off";
772
+ }
773
+ /**
774
+ * OpenAI-compatible reasoning params, provider-aware (2026-09-05 知识 X 最新):
775
+ * - DeepSeek: `thinking {type}` switch + `reasoning_effort` low/high/max.
776
+ * - Kimi/Kimi Code: kimi-k3 = top-level `reasoning_effort` low/high/max (no
777
+ * `thinking`); kimi-k2.7-code = always thinking, no params; kimi-k2.6 =
778
+ * `thinking {type}` switch only.
779
+ * - OpenAI official: `reasoning_effort` minimal|low|medium|high (no max, no off).
780
+ * - 阿里云百炼: `enable_thinking` bool (Qwen chat completions).
781
+ * - 智谱: `thinking {type}` switch + `reasoning_effort` (GLM-5.3 force-enabled,
782
+ * disabled→enabled+low; GLM-5.2/5.1/5 can disable).
783
+ * - 硅基流动: `enable_thinking` + `thinking_budget`.
784
+ * - Others (AIHubMix/OpenRouter/custom): best-effort `reasoning_effort`.
785
+ */
786
+ function openAiThinkingParams(advisor) {
787
+ const provider = advisor.provider;
788
+ const model = advisor.model;
789
+ const effort = advisor.reasoningEffort;
790
+ if (provider === "deepseek") {
791
+ if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
792
+ return {
793
+ ...advisor.thinking === "enabled" ? { thinking: { type: "enabled" } } : {},
794
+ ...effort && effort !== "off" ? { reasoning_effort: effort } : {}
795
+ };
796
+ }
797
+ if (provider === "moonshot" || provider === "kimi-code") {
798
+ if (/^(k3|kimi-k3)(-|$)/i.test(model)) {
799
+ if (effort && effort !== "off") return { reasoning_effort: effort };
800
+ return {};
801
+ }
802
+ if (/kimi-k2\.7-code|kimi-for-coding/i.test(model)) return {};
803
+ if (advisor.thinking === "disabled") return { thinking: { type: "disabled" } };
804
+ if (advisor.thinking === "enabled") return { thinking: { type: "enabled" } };
805
+ return {};
806
+ }
807
+ if (provider === "openai") {
808
+ if (effort === "low") return { reasoning_effort: "low" };
809
+ if (effort === "high" || effort === "max") return { reasoning_effort: "high" };
810
+ return {};
811
+ }
812
+ if (provider === "bailian" || provider === "bailian-openai-singapore" || provider === "bailian-openai-us") {
813
+ if (thinkingOff(advisor)) return { enable_thinking: false };
814
+ if (advisor.thinking === "enabled") return { enable_thinking: true };
815
+ return {};
816
+ }
817
+ if (provider === "zhipu") {
818
+ if (/glm-5\.3/i.test(model)) return {
819
+ thinking: { type: "enabled" },
820
+ reasoning_effort: thinkingOff(advisor) ? "low" : effort && effort !== "off" ? effort : "max"
821
+ };
822
+ if (/glm-5/i.test(model)) {
823
+ if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
824
+ return {
825
+ ...advisor.thinking === "enabled" ? { thinking: { type: "enabled" } } : {},
826
+ ...effort && effort !== "off" ? { reasoning_effort: effort } : {}
827
+ };
828
+ }
829
+ if (advisor.thinking === "disabled") return { thinking: { type: "disabled" } };
830
+ if (advisor.thinking === "enabled") return { thinking: { type: "enabled" } };
831
+ return {};
832
+ }
833
+ if (provider === "siliconflow") {
834
+ if (thinkingOff(advisor)) return { enable_thinking: false };
835
+ const budget = effort === "low" ? 1024 : effort === "high" ? 8192 : effort === "max" ? 16384 : 0;
836
+ return {
837
+ ...advisor.thinking === "enabled" || budget > 0 ? { enable_thinking: true } : {},
838
+ ...budget > 0 ? { thinking_budget: budget } : {}
839
+ };
840
+ }
841
+ if (effort && effort !== "off") return { reasoning_effort: effort };
842
+ return {};
843
+ }
844
+ /** Anthropic Messages reasoning params, provider-aware.
845
+ * Official Anthropic: adaptive (`thinking:{type:'adaptive'}` + `output_config.effort`)
846
+ * for Opus 4.6/4.7/Sonnet 4.6/Mythos; manual `enabled`+`budget_tokens` for older
847
+ * models. DeepSeek's Anthropic-compatible endpoint uses `reasoning.effort` +
848
+ * `output_config.effort` (its `budget_tokens` is ignored). Other Anthropic skins
849
+ * follow the official Anthropic wire shape. */
850
+ function anthropicThinkingParams(advisor) {
851
+ if (advisor.provider === "deepseek-anthropic") {
852
+ if (thinkingOff(advisor)) return { reasoning: { effort: "none" } };
853
+ if (advisor.reasoningEffort && advisor.reasoningEffort !== "off") return {
854
+ reasoning: { effort: advisor.reasoningEffort },
855
+ output_config: { effort: advisor.reasoningEffort }
856
+ };
857
+ return {};
858
+ }
859
+ if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
860
+ if (advisor.reasoningEffort && advisor.reasoningEffort !== "off") {
861
+ if (/(?:opus-4-[6-8]|opus-5|sonnet-4-6|sonnet-5|fable|mythos)/i.test(advisor.model)) return {
862
+ thinking: { type: "adaptive" },
863
+ output_config: { effort: advisor.reasoningEffort }
864
+ };
865
+ return { thinking: {
866
+ type: "enabled",
867
+ budget_tokens: advisor.reasoningEffort === "low" ? 2048 : advisor.reasoningEffort === "high" ? 8192 : 16384
868
+ } };
869
+ }
870
+ return {};
871
+ }
872
+ /** Gemini `generateContent` thinking config. Disabling thinking uses
873
+ * `thinkingBudget: 0` (NOT `includeThoughts:false`, which only hides returned
874
+ * thoughts). Effort uses `thinkingBudget`, the backward-compatible knob that
875
+ * works across Gemini 2.5 and Gemini 3 per Google's migration note. */
876
+ function geminiThinkingParams(advisor) {
877
+ if (thinkingOff(advisor)) return { thinkingConfig: { thinkingBudget: 0 } };
878
+ const budget = advisor.reasoningEffort === "low" ? 1024 : advisor.reasoningEffort === "high" ? 8192 : advisor.reasoningEffort === "max" ? 16384 : 0;
879
+ if (budget > 0) return { thinkingConfig: { thinkingBudget: budget } };
880
+ return {};
881
+ }
882
+ /** Validate the per-advisor thinking fields at the direct-http boundary so a
883
+ * malformed stored config fails loudly instead of silently ignoring. */
884
+ function assertThinkingParams(advisor) {
885
+ if (advisor.thinking !== void 0 && advisor.thinking !== "enabled" && advisor.thinking !== "disabled") throw new Error(`Unsupported thinking mode: ${String(advisor.thinking)}`);
886
+ if (advisor.reasoningEffort !== void 0 && ![
887
+ "off",
888
+ "low",
889
+ "high",
890
+ "max"
891
+ ].includes(advisor.reasoningEffort)) throw new Error(`Unsupported reasoningEffort: ${String(advisor.reasoningEffort)}`);
892
+ }
893
+ /** Stream an advisor response through direct HTTP (OpenAI/Anthropic SSE). */
894
+ async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs, tools, parseToolNames) {
895
+ const preset = advisor.provider ? getProviderPreset(advisor.provider) : void 0;
896
+ const baseURL = (advisor.baseURL ?? preset?.baseURL ?? "").replace(/\/+$/, "");
897
+ const apiKey = advisor.apiKey ?? process.env[advisor.apiKeyEnv ?? preset?.apiKeyEnv ?? ""] ?? "";
898
+ const protocol = advisor.protocol ?? preset?.protocol ?? "openai";
899
+ const authMode = advisor.authMode ?? preset?.authMode ?? "x-api-key";
900
+ assertThinkingParams(advisor);
901
+ if (!baseURL || !apiKey) throw new Error(`Direct HTTP fallback is not configured for advisor "${advisor.name}" (${advisor.provider}). Add baseURL/apiKey or configure the provider in DSH.`);
902
+ const transcriptText = transcript.map((entry) => `[${entry.role === "main" ? "主模型" : entry.name}]\n${entry.content}`).join("\n\n");
903
+ let result;
904
+ switch (protocol) {
905
+ case "openai":
906
+ result = await streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools);
907
+ break;
908
+ case "anthropic":
909
+ result = await streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools);
910
+ break;
911
+ case "gemini": {
912
+ const content = await callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs);
913
+ onDelta({ text: content });
914
+ result = {
915
+ content,
916
+ thinking: ""
917
+ };
918
+ break;
919
+ }
920
+ default: throw new Error(`Unsupported protocol: ${String(protocol)}`);
921
+ }
922
+ const parsed = parseTextToolCalls(result.content, new Set(parseToolNames ?? (tools ?? []).map((tool) => tool.name)));
923
+ if (parsed.toolCalls.length > 0) {
924
+ console.warn(`[dsh-advisor-group] 检测到文本工具调用(DSML/非结构流),已还原并交给 runAdvisorToolLoop 执行:${parsed.toolCalls.map((tool) => tool.name).join("、")}`);
925
+ return {
926
+ ...result,
927
+ content: parsed.cleaned,
928
+ toolCalls: [...result.toolCalls ?? [], ...parsed.toolCalls]
929
+ };
930
+ }
931
+ return result;
932
+ }
933
+ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools) {
934
+ const endpoint = baseURL.endsWith("/chat/completions") ? baseURL : `${baseURL.replace(/\/+$/, "")}/chat/completions`;
935
+ const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
936
+ const body = {
937
+ model: advisor.model,
938
+ messages: [{
939
+ role: "system",
940
+ content: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY
941
+ }, {
942
+ role: "user",
943
+ content: transcriptText
944
+ }],
945
+ temperature: advisor.temperature ?? .3,
946
+ max_tokens: advisor.maxTokens ?? 16384,
947
+ ...openAiThinkingParams(advisor),
948
+ stream: true
949
+ };
950
+ if (tools && tools.length > 0) body.tools = tools.map((tool) => ({
951
+ type: "function",
952
+ function: {
953
+ name: tool.name,
954
+ ...tool.description === void 0 ? {} : { description: tool.description },
955
+ parameters: tool.parameters ?? { type: "object" }
956
+ }
957
+ }));
958
+ const response = await fetch(endpoint, {
959
+ method: "POST",
960
+ headers: {
961
+ "content-type": "application/json",
962
+ authorization: `Bearer ${apiKey}`
963
+ },
964
+ body: JSON.stringify(body),
965
+ signal: pair.signal
966
+ });
967
+ if (!response.ok) throw new Error(`OpenAI-compatible request failed: ${response.status} ${await response.text()}`);
968
+ if (!response.body) throw new Error("OpenAI-compatible response has no body");
969
+ const reader = response.body.getReader();
970
+ const decoder = new TextDecoder();
971
+ let buffer = "";
972
+ let content = "";
973
+ let thinking = "";
974
+ const toolCalls = /* @__PURE__ */ new Map();
975
+ while (true) {
976
+ let done;
977
+ let value;
978
+ try {
979
+ const read = await reader.read();
980
+ done = read.done;
981
+ value = read.value;
982
+ } catch (error) {
983
+ const errName = error?.name ?? "UnknownError";
984
+ const errMessage = error instanceof Error ? error.message : String(error);
985
+ console.warn(`[dsh-advisor-group] direct-http stream read error: name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
986
+ if (pair.isTimeout()) return {
987
+ content,
988
+ thinking,
989
+ toolCalls: collectToolCalls(toolCalls),
990
+ truncated: {
991
+ reason: "timeout",
992
+ atMs: Date.now()
993
+ }
994
+ };
995
+ if (signal?.aborted || error?.name === "AbortError") throw error;
996
+ return {
997
+ content,
998
+ thinking,
999
+ toolCalls: collectToolCalls(toolCalls),
1000
+ truncated: {
1001
+ reason: "network",
1002
+ atMs: Date.now()
1003
+ }
1004
+ };
1005
+ }
1006
+ if (done) break;
1007
+ buffer += decoder.decode(value, { stream: true });
1008
+ let sep;
1009
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
1010
+ const raw = buffer.slice(0, sep);
1011
+ buffer = buffer.slice(sep + 2);
1012
+ for (const line of raw.split("\n")) {
1013
+ if (!line.startsWith("data:")) continue;
1014
+ const data = line.slice(5).trim();
1015
+ if (!data || data === "[DONE]") continue;
1016
+ try {
1017
+ const delta = JSON.parse(data).choices?.[0]?.delta ?? {};
1018
+ const text = typeof delta.content === "string" ? delta.content : "";
1019
+ const think = typeof delta.reasoning_content === "string" ? delta.reasoning_content : typeof delta.reasoning === "string" ? delta.reasoning : "";
1020
+ if (delta.tool_calls) for (const tc of delta.tool_calls) {
1021
+ const idx = tc.index ?? 0;
1022
+ const current = toolCalls.get(idx) ?? {
1023
+ id: "",
1024
+ name: "",
1025
+ args: ""
1026
+ };
1027
+ if (tc.id) current.id = tc.id;
1028
+ if (tc.function?.name) current.name = tc.function.name;
1029
+ if (tc.function?.arguments) current.args += tc.function.arguments;
1030
+ toolCalls.set(idx, current);
1031
+ }
1032
+ if (text) {
1033
+ content += text;
1034
+ onDelta({ text });
1035
+ }
1036
+ if (think) {
1037
+ thinking += think;
1038
+ onDelta({ thinking: think });
1039
+ }
1040
+ } catch {}
1041
+ }
1042
+ }
1043
+ }
1044
+ return {
1045
+ content,
1046
+ thinking,
1047
+ toolCalls: collectToolCalls(toolCalls)
1048
+ };
1049
+ }
1050
+ /** Map<index, {id,name,args}> → model-facing tool call list. */
1051
+ function collectToolCalls(byIndex) {
1052
+ const calls = [];
1053
+ for (const [, current] of byIndex) {
1054
+ if (!current.name) continue;
1055
+ calls.push({
1056
+ id: current.id || `tool-${calls.length}`,
1057
+ name: current.name,
1058
+ argumentsJson: current.args
1059
+ });
1060
+ }
1061
+ return calls.length > 0 ? calls : void 0;
1062
+ }
1063
+ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools) {
1064
+ const endpoint = baseURL.endsWith("/v1/messages") ? baseURL : baseURL.endsWith("/v1") ? `${baseURL}/messages` : `${baseURL.replace(/\/+$/, "")}/v1/messages`;
1065
+ const headers = {
1066
+ "content-type": "application/json",
1067
+ "anthropic-version": "2023-06-01"
1068
+ };
1069
+ if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
1070
+ else headers["x-api-key"] = apiKey;
1071
+ const body = {
1072
+ model: advisor.model,
1073
+ system: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY,
1074
+ messages: [{
1075
+ role: "user",
1076
+ content: transcriptText
1077
+ }],
1078
+ temperature: advisor.temperature ?? .3,
1079
+ max_tokens: advisor.maxTokens ?? 16384,
1080
+ ...anthropicThinkingParams(advisor),
1081
+ stream: true
1082
+ };
1083
+ if (tools && tools.length > 0) body.tools = tools.map((tool) => ({
1084
+ name: tool.name,
1085
+ ...tool.description === void 0 ? {} : { description: tool.description },
1086
+ input_schema: tool.parameters ?? { type: "object" }
1087
+ }));
1088
+ const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
1089
+ const response = await fetch(endpoint, {
1090
+ method: "POST",
1091
+ headers,
1092
+ body: JSON.stringify(body),
1093
+ signal: pair.signal
1094
+ });
1095
+ if (!response.ok) throw new Error(`Anthropic request failed: ${response.status} ${await response.text()}`);
1096
+ if (!response.body) throw new Error("Anthropic response has no body");
1097
+ const reader = response.body.getReader();
1098
+ const decoder = new TextDecoder();
1099
+ let buffer = "";
1100
+ let content = "";
1101
+ let thinking = "";
1102
+ let currentTool = null;
1103
+ let activeTool = false;
1104
+ const toolCalls = [];
1105
+ while (true) {
1106
+ let done;
1107
+ let value;
1108
+ try {
1109
+ const read = await reader.read();
1110
+ done = read.done;
1111
+ value = read.value;
1112
+ } catch (error) {
1113
+ const errName = error?.name ?? "UnknownError";
1114
+ const errMessage = error instanceof Error ? error.message : String(error);
1115
+ console.warn(`[dsh-advisor-group] anthropic stream read error: name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
1116
+ if (pair.isTimeout()) return {
1117
+ content,
1118
+ thinking,
1119
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
1120
+ truncated: {
1121
+ reason: "timeout",
1122
+ atMs: Date.now()
1123
+ }
1124
+ };
1125
+ if (signal?.aborted || error?.name === "AbortError") throw error;
1126
+ return {
1127
+ content,
1128
+ thinking,
1129
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
1130
+ truncated: {
1131
+ reason: "network",
1132
+ atMs: Date.now()
1133
+ }
1134
+ };
1135
+ }
1136
+ if (done) break;
1137
+ buffer += decoder.decode(value, { stream: true });
1138
+ let sep;
1139
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
1140
+ const raw = buffer.slice(0, sep);
1141
+ buffer = buffer.slice(sep + 2);
1142
+ for (const line of raw.split("\n")) {
1143
+ if (!line.startsWith("data:")) continue;
1144
+ const data = line.slice(5).trim();
1145
+ if (!data) continue;
1146
+ try {
1147
+ const json = JSON.parse(data);
1148
+ if (json.type === "content_block_start" && json.content_block?.type === "tool_use") {
1149
+ currentTool = currentTool === null ? {
1150
+ id: json.content_block.id ?? "",
1151
+ name: json.content_block.name ?? "",
1152
+ args: ""
1153
+ } : currentTool;
1154
+ activeTool = true;
1155
+ } else if (json.type === "content_block_delta" && json.delta) {
1156
+ if (json.delta.type === "text_delta" && typeof json.delta.text === "string") {
1157
+ content += json.delta.text;
1158
+ onDelta({ text: json.delta.text });
1159
+ } else if (json.delta.type === "thinking_delta" && typeof json.delta.thinking === "string") {
1160
+ thinking += json.delta.thinking;
1161
+ onDelta({ thinking: json.delta.thinking });
1162
+ } else if (json.delta.type === "input_json_delta" && typeof json.delta.partial_json === "string") {
1163
+ if (currentTool) currentTool.args += json.delta.partial_json;
1164
+ }
1165
+ } else if (json.type === "content_block_stop") {
1166
+ if (activeTool && currentTool) {
1167
+ toolCalls.push({
1168
+ id: currentTool.id,
1169
+ name: currentTool.name,
1170
+ argumentsJson: currentTool.args
1171
+ });
1172
+ currentTool = null;
1173
+ activeTool = false;
1174
+ }
1175
+ }
1176
+ } catch {}
1177
+ }
1178
+ }
1179
+ }
1180
+ return {
1181
+ content,
1182
+ thinking,
1183
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0
1184
+ };
1185
+ }
1186
+ async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs) {
1187
+ const model = encodeURIComponent(advisor.model);
1188
+ const base = baseURL.replace(/\/+$/, "");
1189
+ const endpoint = base.endsWith("/v1beta") ? `${base}/models/${model}:generateContent?key=${apiKey}` : `${base}/v1beta/models/${model}:generateContent?key=${apiKey}`;
1190
+ const response = await fetch(endpoint, {
1191
+ method: "POST",
1192
+ headers: { "content-type": "application/json" },
1193
+ body: JSON.stringify({
1194
+ contents: [{
1195
+ role: "user",
1196
+ parts: [{ text: transcriptText }]
1197
+ }],
1198
+ systemInstruction: { parts: [{ text: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY }] },
1199
+ generationConfig: {
1200
+ temperature: advisor.temperature ?? .3,
1201
+ maxOutputTokens: advisor.maxTokens ?? 16384,
1202
+ ...geminiThinkingParams(advisor)
1203
+ }
1204
+ }),
1205
+ signal: withTimeout(timeoutMs ?? 12e4, signal)
1206
+ });
1207
+ if (!response.ok) throw new Error(`Gemini request failed: ${response.status} ${await response.text()}`);
1208
+ return (await response.json()).candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("") ?? "";
1209
+ }
1210
+ //#endregion
1211
+ //#region src/driver.ts
1212
+ /**
1213
+ * The "driver model": a small internal client that reuses the CURRENT agent's
1214
+ * configured provider/model (read from the session's latest `request/header`
1215
+ * LlmCallConfig) to (a) deepen the consultation between rounds and (b) write
1216
+ * the final conclusion. If no header exists and no `discussion.driverModel`
1217
+ * fallback is configured, a static fallback text is used so the pipeline never
1218
+ * stalls. Any generation error degrades to the fallback text, never throws.
1219
+ */
1220
+ const FALLBACK_DEEPEN_QUESTION = "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1221
+ const FALLBACK_CONCLUSION = "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1222
+ const DRIVER_SYSTEM_PROMPT = `
1223
+ 你是顾问群的主持人(驱动模型)。你的职责:
1224
+ 1. 阅读项目背景、主模型问题与全部顾问回答;
1225
+ 2. 提出一个更深入、聚焦的追问,推动讨论走向更细的根因、分歧或行动方案;
1226
+ 3. 只输出追问本身(一句话,不超过 80 字),不要回答、不要打招呼、不要解释。
1227
+ `;
1228
+ const CONCLUSION_SYSTEM_PROMPT = `
1229
+ 你是顾问群的主持人(驱动模型)。请综合全部讨论,输出最终结论:
1230
+ - 用 3 段以内:共识、关键分歧(简要列出)、最可靠的行动建议;
1231
+ - 不要复述每个顾问的完整回答,只提炼差异与要点。
1232
+ `;
1233
+ /** Resolve the current agent's model config from the session header, or the config fallback. */
1234
+ function resolveDriverSource(sessionLog, driverModel) {
1235
+ if (sessionLog) try {
1236
+ const header = sessionLog.requestHeader();
1237
+ if (header?.config?.provider && header?.config?.model) return {
1238
+ provider: header.config.provider,
1239
+ model: header.config.model
1240
+ };
1241
+ } catch {}
1242
+ if (driverModel?.provider && driverModel?.model) return {
1243
+ provider: driverModel.provider,
1244
+ model: driverModel.model
1245
+ };
1246
+ }
1247
+ function transcriptOf(session) {
1248
+ return session.messages.filter((message) => message.role !== "system").map((message) => {
1249
+ return `[${message.role === "main" ? "主模型" : message.advisorName ?? message.advisorId ?? "顾问"}]\n${message.content}`;
1250
+ }).join("\n\n").slice(-24e3);
1251
+ }
1252
+ async function generateWith(ctx, source, system, prompt, signal, timeoutMs = 6e5) {
1253
+ try {
1254
+ const options = {
1255
+ provider: source.provider,
1256
+ model: source.model,
1257
+ system,
1258
+ messages: [createUserMessage({
1259
+ content: [{
1260
+ type: "text",
1261
+ text: prompt
1262
+ }],
1263
+ source: {
1264
+ kind: "plugin",
1265
+ plugin: "dsh-advisor-group"
1266
+ }
1267
+ })],
1268
+ temperature: .7,
1269
+ maxTokens: 2048,
1270
+ signal: withTimeout(timeoutMs, signal)
1271
+ };
1272
+ let text = "";
1273
+ let chunks = 0;
1274
+ let textDeltas = 0;
1275
+ let reasoningDeltas = 0;
1276
+ for await (const chunk of ctx.llm.stream(options)) {
1277
+ chunks += 1;
1278
+ if (chunk.type === "text-delta") {
1279
+ textDeltas += 1;
1280
+ text += chunk.text;
1281
+ } else reasoningDeltas += 1;
1282
+ }
1283
+ const trimmed = text.trim();
1284
+ if (!trimmed) console.warn(`[dsh-advisor-group] 驱动模型空响应(${source.provider}/${source.model}):chunks=${chunks} textDeltas=${textDeltas} reasoningDeltas=${reasoningDeltas};建议配置 discussion.driverModel 或提高 maxTokens`);
1285
+ return trimmed || void 0;
1286
+ } catch (error) {
1287
+ if (signal?.aborted) throw error;
1288
+ console.warn(`[dsh-advisor-group] 驱动模型生成失败(${source.provider}/${source.model}):`, error instanceof Error ? error.message : String(error));
1289
+ return;
1290
+ }
1291
+ }
1292
+ /** One deepening question for the next round (never throws; returns fallback text). */
1293
+ async function generateDeepenQuestion(ctx, session, source, signal, timeoutMs) {
1294
+ if (!source) return FALLBACK_DEEPEN_QUESTION;
1295
+ return await generateWith(ctx, source, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1296
+ }
1297
+ /** Final synthesis after all rounds (never throws; returns fallback text). */
1298
+ async function generateConclusion(ctx, session, source, signal, timeoutMs) {
1299
+ if (!source) return FALLBACK_CONCLUSION;
1300
+ return await generateWith(ctx, source, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1301
+ }
1302
+ //#endregion
1303
+ //#region src/session-log.ts
1304
+ /**
1305
+ * Derive the latest turn/step from the durable session log.
1306
+ *
1307
+ * ToolRunContext does not expose turn/step directly. The agent loop logs
1308
+ * `turn/start` and `step/start` before tool execution, so scanning backwards
1309
+ * gives the closest coordinates; fallback to 0 when none exist.
1310
+ */
1311
+ function latestTurnStep(log) {
1312
+ const events = log.snapshotEvents();
1313
+ for (let i = events.length - 1; i >= 0; i--) {
1314
+ const event = events[i];
1315
+ if (event.type === "step/start") return {
1316
+ turn: event.data.turn,
1317
+ step: event.data.step
1318
+ };
1319
+ if (event.type === "turn/start") return {
1320
+ turn: event.data.turn,
1321
+ step: 0
1322
+ };
1323
+ }
1324
+ return {
1325
+ turn: 0,
1326
+ step: 0
1327
+ };
1328
+ }
1329
+ function appendAdvisorStart(log, session) {
1330
+ const { turn, step } = latestTurnStep(log);
1331
+ const advisors = session.advisors.map(({ id, name, avatar }) => avatar === void 0 ? {
1332
+ id,
1333
+ name
1334
+ } : {
1335
+ id,
1336
+ name,
1337
+ avatar
1338
+ });
1339
+ log.append("advisor-group/start", {
1340
+ sessionId: session.id,
1341
+ turn,
1342
+ step,
1343
+ question: session.question,
1344
+ ...session.context === void 0 ? {} : { context: session.context },
1345
+ advisors
1346
+ });
1347
+ }
1348
+ function appendAdvisorMessage(log, sessionId, message) {
1349
+ const { turn, step } = latestTurnStep(log);
1350
+ const data = {
1351
+ sessionId,
1352
+ turn,
1353
+ step,
1354
+ role: message.role,
1355
+ content: message.content,
1356
+ ...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
1357
+ ...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
1358
+ ...message.round === void 0 ? {} : { round: message.round },
1359
+ ...message.thinking === void 0 ? {} : { thinking: message.thinking },
1360
+ ...message.thinkingSegments === void 0 ? {} : { thinkingSegments: message.thinkingSegments },
1361
+ ...message.actionDescriptions === void 0 ? {} : { actionDescriptions: message.actionDescriptions },
1362
+ ...message.toolSteps === void 0 ? {} : { toolSteps: message.toolSteps },
1363
+ ...message.truncated === void 0 ? {} : { truncated: message.truncated }
1364
+ };
1365
+ log.append("advisor-group/message", data);
1366
+ }
1367
+ function appendAdvisorDelta(log, sessionId, delta) {
1368
+ const { turn, step } = latestTurnStep(log);
1369
+ const data = {
1370
+ sessionId,
1371
+ turn,
1372
+ step,
1373
+ advisorId: delta.advisorId,
1374
+ ...delta.advisorName === void 0 ? {} : { advisorName: delta.advisorName },
1375
+ ...delta.round === void 0 ? {} : { round: delta.round },
1376
+ ...delta.contentDelta === void 0 ? {} : { contentDelta: delta.contentDelta },
1377
+ ...delta.thinkingDelta === void 0 ? {} : { thinkingDelta: delta.thinkingDelta },
1378
+ ...delta.toolStep === void 0 ? {} : { toolStep: delta.toolStep },
1379
+ ...delta.done === void 0 ? {} : { done: delta.done }
1380
+ };
1381
+ log.append("advisor-group/delta", data);
1382
+ }
1383
+ function appendAdvisorResume(log, sessionId) {
1384
+ const { turn, step } = latestTurnStep(log);
1385
+ log.append("advisor-group/resume", {
1386
+ sessionId,
1387
+ turn,
1388
+ step
1389
+ });
1390
+ }
1391
+ function appendAdvisorEnd(log, session, summary) {
1392
+ const { turn, step } = latestTurnStep(log);
1393
+ const data = {
1394
+ sessionId: session.id,
1395
+ turn,
1396
+ step,
1397
+ summary: {
1398
+ question: summary.question,
1399
+ advisors: summary.advisors.map(({ advisorId, advisorName, corePoints }) => ({
1400
+ advisorId,
1401
+ advisorName,
1402
+ corePoints
1403
+ })),
1404
+ ...summary.riskNotes.length > 0 ? { riskNotes: summary.riskNotes } : {},
1405
+ ...summary.stopped === void 0 ? {} : { stopped: summary.stopped },
1406
+ ...summary.conclusion === void 0 ? {} : { conclusion: summary.conclusion }
1407
+ }
1408
+ };
1409
+ log.append("advisor-group/end", data);
1410
+ }
1411
+ //#endregion
1412
+ //#region src/stream-channel.ts
1413
+ const BOOT_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1414
+ const subscribers = /* @__PURE__ */ new Map();
1415
+ const buffers = /* @__PURE__ */ new Map();
1416
+ const lastIds = /* @__PURE__ */ new Map();
1417
+ const REPLAY_LIMIT = 500;
1418
+ const BUFFER_TTL_MS = 3e5;
1419
+ function pruneBuffer(sessionId, buffer) {
1420
+ const cutoff = Date.now() - BUFFER_TTL_MS;
1421
+ const pruned = buffer.filter((frame) => frame.ts >= cutoff);
1422
+ if (pruned.length === 0) {
1423
+ buffers.delete(sessionId);
1424
+ return [];
1425
+ }
1426
+ buffers.set(sessionId, pruned);
1427
+ return pruned;
1428
+ }
1429
+ function writeFrame(res, frame) {
1430
+ try {
1431
+ res.write(`id: ${BOOT_ID}:${frame.id}\n${frame.payload}`);
1432
+ } catch {}
1433
+ }
1434
+ function writeResync(res, reason) {
1435
+ try {
1436
+ res.write(`event: resync\ndata: ${JSON.stringify({ reason })}\n\n`);
1437
+ } catch {}
1438
+ }
1439
+ function subscribe(sessionId, res, lastEventId = 0, bootId) {
1440
+ const rawBuffer = buffers.get(sessionId);
1441
+ const buffer = rawBuffer ? pruneBuffer(sessionId, rawBuffer) : void 0;
1442
+ const firstBufferedId = buffer?.[0]?.id;
1443
+ if (bootId && bootId !== BOOT_ID) writeResync(res, "restart");
1444
+ else if (lastEventId > 0 && firstBufferedId !== void 0 && lastEventId < firstBufferedId - 1) writeResync(res, "gap");
1445
+ else if (buffer) {
1446
+ for (const frame of buffer) if (frame.id > lastEventId) writeFrame(res, frame);
1447
+ }
1448
+ let set = subscribers.get(sessionId);
1449
+ if (!set) {
1450
+ set = /* @__PURE__ */ new Set();
1451
+ subscribers.set(sessionId, set);
1452
+ }
1453
+ set.add(res);
1454
+ const cleanup = () => {
1455
+ set?.delete(res);
1456
+ if (set?.size === 0) subscribers.delete(sessionId);
1457
+ };
1458
+ res.on("close", cleanup);
1459
+ return cleanup;
1460
+ }
1461
+ function publish(sessionId, delta) {
1462
+ const id = (lastIds.get(sessionId) ?? 0) + 1;
1463
+ lastIds.set(sessionId, id);
1464
+ const frame = {
1465
+ id,
1466
+ payload: `data: ${JSON.stringify({
1467
+ ...delta,
1468
+ eventId: id,
1469
+ bootId: BOOT_ID
1470
+ })}\n\n`,
1471
+ ts: Date.now()
1472
+ };
1473
+ const rawBuffer = buffers.get(sessionId);
1474
+ const buffer = rawBuffer ? pruneBuffer(sessionId, rawBuffer) : [];
1475
+ buffer.push(frame);
1476
+ if (buffer.length > REPLAY_LIMIT) buffer.shift();
1477
+ buffers.set(sessionId, buffer);
1478
+ const set = subscribers.get(sessionId);
1479
+ if (!set || set.size === 0) return;
1480
+ for (const res of set) writeFrame(res, frame);
1481
+ }
1482
+ //#endregion
1483
+ //#region src/advisor-tools.ts
1484
+ /** Raw tool-call / DSML markup that some providers echo as plain text instead
1485
+ * of a structured tool call. Observed shapes (deepseek-v4-pro, anthropic
1486
+ * channel):
1487
+ * - plain `<tool_calls>…</tool_calls>` (line-start block)
1488
+ * - piped `<||tool_calls>\n<||DSML|…` without a closing tag (U+FF5C)
1489
+ * - MID-LINE occurrence: `证据。<tool_calls><invoke name="bash">…` — the
1490
+ * marker is glued to prose, so line-start anchoring alone is NOT enough. */
1491
+ const RAW_TOOL_CALL_BLOCK = /<(?:[||]{1,2})?(?:tool_calls|DSML)[\s\S]*?<\/(?:[||]{1,2})?(?:tool_calls|DSML)\s*>/g;
1492
+ /** Unclosed tail: cut from the first marker that is actually a STRUCTURED
1493
+ * block (followed by `<invoke`/`<parameter`/`<||DSML` within the next ~60
1494
+ * chars). An isolated prose/code mention like `` `<tool_calls>` `` is never
1495
+ * followed by those children, so it stays untouched. */
1496
+ const RAW_TOOL_CALL_TAIL = /<(?:[||]{1,2})?(?:tool_calls|DSML)[\s\S]{0,60}?(?:<invoke|<parameter|<[||]{1,2}DSML)[\s\S]*$/g;
1497
+ /**
1498
+ * Strip raw `<tool_calls>` / piped `<||tool_calls>` / `<||DSML|` markup from
1499
+ * an advisor's final content (line-start OR mid-line, closed or unclosed).
1500
+ * Returns the cleaned text; when the content was only leaked tool markup, a
1501
+ * short notice replaces it so the bubble is never empty/raw (the thinking
1502
+ * panel and tool rows still carry the real substance).
1503
+ */
1504
+ function sanitizeAdvisorContent(content) {
1505
+ if (!content.trim()) return content;
1506
+ return content.replace(RAW_TOOL_CALL_BLOCK, "").replace(RAW_TOOL_CALL_TAIL, "").trim() || "(顾问未生成正文:输出仅为工具调用描述,请参考其思维链与工具调用记录。)";
1507
+ }
1508
+ /** Conservative read-only whitelist for the default advisor tool scope. */
1509
+ const READONLY_TOOL_NAMES = /* @__PURE__ */ new Set([
1510
+ "read",
1511
+ "grep",
1512
+ "glob",
1513
+ "web_search",
1514
+ "web_fetch",
1515
+ "scan_discover",
1516
+ "list_imported_sessions"
1517
+ ]);
1518
+ const MAX_TOOL_ROUNDS = 4;
1519
+ const TOOL_EXEC_TIMEOUT_MS = 3e4;
1520
+ const TOOL_RESULT_MAX_CHARS = 8e3;
1521
+ /**
1522
+ * Resolve the advisor-visible tool schemas. `agent` (when provided) makes the
1523
+ * registry resolve the scoped view — exactly the tools that session's Agent
1524
+ * loop serves, honouring presets and restrict() filters.
1525
+ */
1526
+ function resolveAdvisorToolSchemas(ctx, agent, mode) {
1527
+ if (mode === "off") return [];
1528
+ const schemas = ctx.tools?.schemas?.(agent) ?? [];
1529
+ let list = schemas;
1530
+ if (mode === "readonly") list = schemas.filter((schema) => typeof schema === "object" && schema !== null && READONLY_TOOL_NAMES.has(schema.name));
1531
+ return list.map((schema) => {
1532
+ const { name, description, parameters } = schema;
1533
+ return {
1534
+ name,
1535
+ ...typeof description === "string" ? { description } : {},
1536
+ ...parameters === void 0 ? {} : { parameters }
1537
+ };
1538
+ });
1539
+ }
1540
+ /** Human-readable one-line summary of a tool result (for the thinking panel). */
1541
+ function summarizeToolResult(result) {
1542
+ let text = "";
1543
+ if (result.isError) text = `(错误)${result.error?.message ?? "unknown"}`;
1544
+ else {
1545
+ text = (result.content ?? []).map((block) => typeof block === "object" && block !== null && "text" in block && typeof block.text === "string" ? block.text : "").join("").trim();
1546
+ if (!text && result.value !== void 0) try {
1547
+ text = JSON.stringify(result.value);
1548
+ } catch {
1549
+ text = String(result.value);
1550
+ }
1551
+ }
1552
+ return text.length > TOOL_RESULT_MAX_CHARS ? `${text.slice(0, TOOL_RESULT_MAX_CHARS)}…(已截断)` : text;
1553
+ }
1554
+ /**
1555
+ * Execute one tool via the official pipeline (scoped dispatch when `agent` is
1556
+ * provided). Returns the model-facing text; never throws.
1557
+ */
1558
+ async function executeAdvisorTool(ctx, agent, call, signal) {
1559
+ const tools = ctx.tools;
1560
+ if (!tools?.execute) return "(顾问工具执行不可用:当前环境未提供工具执行服务)";
1561
+ let args = {};
1562
+ try {
1563
+ args = call.argumentsJson ? JSON.parse(call.argumentsJson) : {};
1564
+ } catch {
1565
+ return "(工具参数不是合法 JSON)";
1566
+ }
1567
+ try {
1568
+ return summarizeToolResult(await tools.execute({
1569
+ callId: call.id,
1570
+ name: call.name,
1571
+ arguments: args,
1572
+ ...agent === void 0 ? {} : { agent },
1573
+ signal: withTimeout(TOOL_EXEC_TIMEOUT_MS, signal)
1574
+ }));
1575
+ } catch (error) {
1576
+ return `(工具执行失败:${error instanceof Error ? error.message : String(error)})`;
1577
+ }
1578
+ }
1579
+ /**
1580
+ * Drive the tool-calling loop: stream with `tools` → execute every
1581
+ * model-requested call (each step surfaced via `onToolStep` as its own row) →
1582
+ * feed a compact "already executed" block back → repeat until the model
1583
+ * answers (or MAX_TOOL_ROUNDS, then one final no-tools round to force a text
1584
+ * answer).
1585
+ */
1586
+ async function runAdvisorToolLoop(tools, streamOnce, executeTool, onToolStep) {
1587
+ let extraContext = "";
1588
+ let truncated;
1589
+ const executedMemo = /* @__PURE__ */ new Map();
1590
+ const executeDeduped = async (call) => {
1591
+ const key = `${call.name}:${call.argumentsJson}`;
1592
+ const cached = executedMemo.get(key);
1593
+ if (cached !== void 0) {
1594
+ console.warn(`[dsh-advisor-group] 同参工具调用去重(复用已执行结果):${call.name}`);
1595
+ return {
1596
+ outcome: cached,
1597
+ reused: true
1598
+ };
1599
+ }
1600
+ const outcome = await executeTool(call);
1601
+ executedMemo.set(key, outcome);
1602
+ return {
1603
+ outcome,
1604
+ reused: false
1605
+ };
1606
+ };
1607
+ /** Result fed back to the model: capped so extraContext never balloons
1608
+ * (display rows keep their own 240-char preview). */
1609
+ const capOutcome = (outcome) => outcome.length > 800 ? `${outcome.slice(0, 800)}…` : outcome;
1610
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
1611
+ const result = await streamOnce(tools, extraContext);
1612
+ if (result.toolCalls.length === 0) return {
1613
+ content: result.content,
1614
+ truncated: result.truncated
1615
+ };
1616
+ truncated = result.truncated ?? truncated;
1617
+ const executed = [];
1618
+ for (const call of result.toolCalls) {
1619
+ const argsPreview = call.argumentsJson.length > 120 ? `${call.argumentsJson.slice(0, 120)}…` : call.argumentsJson;
1620
+ onToolStep({
1621
+ kind: "call",
1622
+ name: call.name,
1623
+ text: argsPreview || "无参数"
1624
+ });
1625
+ if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
1626
+ const { outcome, reused } = await executeDeduped(call);
1627
+ if (outcome.trim()) {
1628
+ const preview = outcome.replace(/\n+/g, " ").slice(0, 240).trim();
1629
+ onToolStep({
1630
+ kind: "result",
1631
+ name: call.name,
1632
+ text: `${reused ? "(复用已执行结果)" : ""}${preview}`
1633
+ });
1634
+ }
1635
+ executed.push(`- ${call.name}${call.argumentsJson ? ` 参数:${call.argumentsJson.slice(0, 200)}` : ""}\n结果:${capOutcome(outcome)}`);
1636
+ }
1637
+ extraContext = `${extraContext}\n\n【顾问已执行工具】\n${executed.join("\n\n")}`;
1638
+ }
1639
+ const final = await streamOnce([], extraContext);
1640
+ if (final.toolCalls.length > 0) {
1641
+ const executed = [];
1642
+ for (const call of final.toolCalls) {
1643
+ const argsPreview = call.argumentsJson.length > 120 ? `${call.argumentsJson.slice(0, 120)}…` : call.argumentsJson;
1644
+ onToolStep({
1645
+ kind: "call",
1646
+ name: call.name,
1647
+ text: argsPreview || "无参数"
1648
+ });
1649
+ if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
1650
+ const { outcome, reused } = await executeDeduped(call);
1651
+ if (outcome.trim()) {
1652
+ const preview = outcome.replace(/\n+/g, " ").slice(0, 240).trim();
1653
+ onToolStep({
1654
+ kind: "result",
1655
+ name: call.name,
1656
+ text: `${reused ? "(复用已执行结果)" : ""}${preview}`
1657
+ });
1658
+ }
1659
+ executed.push(`- ${call.name}${call.argumentsJson ? ` 参数:${call.argumentsJson.slice(0, 200)}` : ""}\n结果:${capOutcome(outcome)}`);
1660
+ }
1661
+ const final2 = await streamOnce([], `${extraContext}\n\n【顾问已执行工具】\n${executed.join("\n\n")}`);
1662
+ return {
1663
+ content: final2.content,
1664
+ truncated: final2.truncated ?? final.truncated ?? truncated
1665
+ };
1666
+ }
1667
+ return {
1668
+ content: final.content,
1669
+ truncated: final.truncated ?? truncated
1670
+ };
1671
+ }
1672
+ //#endregion
1673
+ //#region src/service.ts
1674
+ function isAbortError(error, signal) {
1675
+ if (signal?.aborted) return true;
1676
+ return error instanceof Error && error.name === "AbortError";
1677
+ }
1678
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1679
+ /** Real consult ids are server-minted uuids; anything else is rejected. */
1680
+ function isSafeConsultId(id) {
1681
+ return UUID_RE.test(id);
1682
+ }
1683
+ /** Agent DSH session ids look like `session-<uuid>` or a plain uuid. */
1684
+ function isSafeDshSessionId(id) {
1685
+ return UUID_RE.test(id) || UUID_RE.test(id.replace(/^session-/i, ""));
1686
+ }
1687
+ function extractUserMessageText(event) {
1688
+ if (event.type !== "user/message") return null;
1689
+ const data = event.data;
1690
+ if (!data?.content) return null;
1691
+ const text = data.content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join(" ").trim();
1692
+ return text.length > 0 ? text : null;
1693
+ }
1694
+ var AdvisorGroupService = class {
1695
+ ctx;
1696
+ sessions = /* @__PURE__ */ new Map();
1697
+ repeatTracker = /* @__PURE__ */ new Map();
1698
+ persistEnabledCallback;
1699
+ dailyConsultationDate = "";
1700
+ dailyConsultationCount = 0;
1701
+ /** Durable daily-guard counter fixture under the DSH home storage area. */
1702
+ dailyGuardPath;
1703
+ dailyWriteChain = Promise.resolve();
1704
+ /** Per-session stop controllers for the auto-deepen pipeline. */
1705
+ activeAborts = /* @__PURE__ */ new Map();
1706
+ /** Durable consultation snapshots (usable after a dsh restart). */
1707
+ sessionsDir;
1708
+ sessionWriteChain = Promise.resolve();
1709
+ enabled;
1710
+ config;
1711
+ constructor(ctx, config) {
1712
+ this.ctx = ctx;
1713
+ this.config = config;
1714
+ this.enabled = config.enabled;
1715
+ const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
1716
+ this.dailyGuardPath = join(dshHome, "storages", "advisor-group", "daily-guard.json");
1717
+ this.sessionsDir = join(dshHome, "storages", "advisor-group", "sessions");
1718
+ this.loadDailyGuard();
1719
+ this.loadStoredSessions();
1720
+ ctx.on("session/event", (session, event) => {
1721
+ const text = extractUserMessageText(event);
1722
+ if (!text) return;
1723
+ const tracked = this.repeatTracker.get(session.id);
1724
+ if (!tracked || tracked.text !== text) this.repeatTracker.set(session.id, {
1725
+ text,
1726
+ count: 1
1727
+ });
1728
+ else tracked.count += 1;
1729
+ });
1730
+ }
1731
+ loadDailyGuard() {
1732
+ try {
1733
+ const parsed = JSON.parse(readFileSync(this.dailyGuardPath, "utf8"));
1734
+ if (typeof parsed.date === "string" && typeof parsed.count === "number" && Number.isSafeInteger(parsed.count) && parsed.count >= 0) {
1735
+ this.dailyConsultationDate = parsed.date;
1736
+ this.dailyConsultationCount = parsed.count;
1737
+ }
1738
+ } catch {}
1739
+ }
1740
+ /** Serialized, atomic (tmp + rename) daily-guard persistence; never blocks a consultation. */
1741
+ persistDailyGuard() {
1742
+ const payload = JSON.stringify({
1743
+ date: this.dailyConsultationDate,
1744
+ count: this.dailyConsultationCount
1745
+ });
1746
+ this.dailyWriteChain = this.dailyWriteChain.then(async () => {
1747
+ await promises.mkdir(dirname(this.dailyGuardPath), { recursive: true });
1748
+ const tmp = `${this.dailyGuardPath}.tmp`;
1749
+ await promises.writeFile(tmp, payload, "utf8");
1750
+ await promises.rename(tmp, this.dailyGuardPath);
1751
+ }).catch((error) => {
1752
+ console.warn("[dsh-advisor-group] 每日护栏持久化失败:", error instanceof Error ? error.message : String(error));
1753
+ });
1754
+ }
1755
+ /**
1756
+ * Restore interrupted consultations from durable snapshots so the card's
1757
+ * 「▶ 继续聊天」 button still works after a dsh restart. Completed sessions
1758
+ * are not restored (they cannot resume) and their snapshot is cleaned up.
1759
+ */
1760
+ loadStoredSessions() {
1761
+ let files;
1762
+ try {
1763
+ files = readdirSync(this.sessionsDir).filter((name) => name.endsWith(".json"));
1764
+ } catch {
1765
+ return;
1766
+ }
1767
+ for (const file of files) try {
1768
+ const raw = JSON.parse(readFileSync(join(this.sessionsDir, file), "utf8"));
1769
+ if (raw.version !== 1 || typeof raw.id !== "string" || typeof raw.question !== "string") continue;
1770
+ if (!isSafeConsultId(raw.id)) continue;
1771
+ if (raw.dshSessionId !== void 0 && !isSafeDshSessionId(raw.dshSessionId)) continue;
1772
+ if (raw.status === "completed") {
1773
+ unlinkSync(join(this.sessionsDir, file));
1774
+ continue;
1775
+ }
1776
+ const advisors = this.resolveAdvisors(raw.advisorIds);
1777
+ if (advisors.length === 0) continue;
1778
+ const session = {
1779
+ id: raw.id,
1780
+ status: "cancelled",
1781
+ question: raw.question,
1782
+ context: raw.context,
1783
+ advisors,
1784
+ maxRounds: raw.maxRounds,
1785
+ messages: raw.messages.map((message) => ({
1786
+ role: message.role,
1787
+ advisorId: message.advisorId,
1788
+ advisorName: message.advisorName,
1789
+ content: message.content,
1790
+ thinking: message.thinking,
1791
+ thinkingSegments: message.thinkingSegments,
1792
+ actionDescriptions: message.actionDescriptions,
1793
+ round: message.round,
1794
+ truncated: message.truncated,
1795
+ toolSteps: message.toolSteps,
1796
+ ts: message.ts
1797
+ })),
1798
+ cwd: raw.cwd,
1799
+ dshSessionId: raw.dshSessionId,
1800
+ driverSource: raw.driverSource,
1801
+ stopReason: raw.stopReason,
1802
+ createdAt: raw.createdAt,
1803
+ updatedAt: raw.updatedAt
1804
+ };
1805
+ this.sessions.set(session.id, session);
1806
+ } catch {}
1807
+ }
1808
+ /** Serialization-safe snapshot: advisor identity only, NEVER credentials. */
1809
+ snapshotPayload(session) {
1810
+ return {
1811
+ version: 1,
1812
+ id: session.id,
1813
+ status: session.status,
1814
+ question: session.question,
1815
+ ...session.context === void 0 ? {} : { context: session.context },
1816
+ ...session.cwd === void 0 ? {} : { cwd: session.cwd },
1817
+ ...session.dshSessionId === void 0 ? {} : { dshSessionId: session.dshSessionId },
1818
+ ...session.driverSource === void 0 ? {} : { driverSource: session.driverSource },
1819
+ ...session.stopReason === void 0 ? {} : { stopReason: session.stopReason },
1820
+ advisorIds: session.advisors.map((advisor) => advisor.id),
1821
+ maxRounds: session.maxRounds,
1822
+ createdAt: session.createdAt,
1823
+ updatedAt: session.updatedAt,
1824
+ messages: session.messages.map((message) => ({
1825
+ role: message.role,
1826
+ ...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
1827
+ ...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
1828
+ content: message.content,
1829
+ ...message.thinking === void 0 ? {} : { thinking: message.thinking },
1830
+ ...message.thinkingSegments === void 0 ? {} : { thinkingSegments: message.thinkingSegments },
1831
+ ...message.actionDescriptions === void 0 ? {} : { actionDescriptions: message.actionDescriptions },
1832
+ ...message.round === void 0 ? {} : { round: message.round },
1833
+ ...message.truncated === void 0 ? {} : { truncated: message.truncated },
1834
+ ...message.toolSteps === void 0 ? {} : { toolSteps: message.toolSteps },
1835
+ ts: message.ts
1836
+ }))
1837
+ };
1838
+ }
1839
+ /** Serialized, atomic (tmp + rename) consultation snapshot persistence. */
1840
+ persistSession(session) {
1841
+ if (!isSafeConsultId(session.id)) {
1842
+ console.warn("[dsh-advisor-group] 拒绝持久化非法会话 id:", session.id);
1843
+ return;
1844
+ }
1845
+ const payload = JSON.stringify(this.snapshotPayload(session));
1846
+ const target = join(this.sessionsDir, `${session.id}.json`);
1847
+ this.sessionWriteChain = this.sessionWriteChain.then(async () => {
1848
+ await promises.mkdir(this.sessionsDir, { recursive: true });
1849
+ const tmp = `${target}.tmp`;
1850
+ await promises.writeFile(tmp, payload, "utf8");
1851
+ await promises.rename(tmp, target);
1852
+ }).catch((error) => {
1853
+ console.warn("[dsh-advisor-group] 咨询快照持久化失败:", error instanceof Error ? error.message : String(error));
1854
+ });
1855
+ }
1856
+ /** Wait for queued durable writes before a host or test tears down storage. */
1857
+ async flushPersistence() {
1858
+ await Promise.all([this.dailyWriteChain, this.sessionWriteChain]);
1859
+ }
1860
+ /** Structural view of the DSH in-memory session store (may be absent). */
1861
+ get sessionStore() {
1862
+ let store;
1863
+ try {
1864
+ store = this.ctx.sessions;
1865
+ } catch {
1866
+ return;
1867
+ }
1868
+ if (!store || typeof store !== "object") return void 0;
1869
+ const typed = store;
1870
+ if (typeof typed.get !== "function" || typeof typed.prepare !== "function" || typeof typed.enter !== "function" || typeof typed.flush !== "function") return;
1871
+ return store;
1872
+ }
1873
+ /** Best-effort durability barrier for the advisor-group log events. */
1874
+ async flushLog(sessionLog) {
1875
+ if (!sessionLog) return;
1876
+ const store = this.sessionStore;
1877
+ if (!store) return;
1878
+ try {
1879
+ await store.flush(sessionLog);
1880
+ } catch {}
1881
+ }
1882
+ /** Build a contiguous seed log from the snapshot (cross-restart resume). */
1883
+ buildSeedEvents(session) {
1884
+ const events = [];
1885
+ let seq = 0;
1886
+ events.push({
1887
+ type: "advisor-group/start",
1888
+ seq: seq++,
1889
+ time: session.createdAt,
1890
+ data: {
1891
+ sessionId: session.id,
1892
+ turn: 0,
1893
+ step: 0,
1894
+ question: session.question,
1895
+ ...session.context === void 0 ? {} : { context: session.context },
1896
+ advisors: session.advisors.map(({ id, name, avatar }) => avatar === void 0 ? {
1897
+ id,
1898
+ name
1899
+ } : {
1900
+ id,
1901
+ name,
1902
+ avatar
1903
+ })
1904
+ }
1905
+ });
1906
+ for (const message of session.messages) {
1907
+ if (message.role === "system") continue;
1908
+ events.push({
1909
+ type: "advisor-group/message",
1910
+ seq: seq++,
1911
+ time: message.ts || Date.now(),
1912
+ data: {
1913
+ sessionId: session.id,
1914
+ turn: 0,
1915
+ step: 0,
1916
+ role: message.role,
1917
+ content: message.content,
1918
+ ...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
1919
+ ...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
1920
+ ...message.thinking === void 0 ? {} : { thinking: message.thinking },
1921
+ ...message.round === void 0 ? {} : { round: message.round },
1922
+ ...message.truncated === void 0 ? {} : { truncated: message.truncated }
1923
+ }
1924
+ });
1925
+ }
1926
+ return events;
1927
+ }
1928
+ /**
1929
+ * Obtain the DSH Session handle for durable log events. Prefers the live
1930
+ * store entry (same process). After a restart the store is empty, so the
1931
+ * session is rebuilt with `prepare` + `enter` (append hooks publish events
1932
+ * and the persistence layer picks them up; the session is NOT announced, so
1933
+ * no agent lifecycle reactivation is triggered). Falls back to a detached
1934
+ * `Session.create` (live SSE + snapshot only, no durable log) when neither
1935
+ * works.
1936
+ */
1937
+ resolveSessionHandle(session) {
1938
+ const store = this.sessionStore;
1939
+ const resumeId = session.dshSessionId !== void 0 && isSafeDshSessionId(session.dshSessionId) ? session.dshSessionId : isSafeConsultId(session.id) ? session.id : "";
1940
+ if (!resumeId) return void 0;
1941
+ const live = store?.get(resumeId);
1942
+ if (live) return live;
1943
+ if (!store) return this.buildDetachedSession(session, resumeId);
1944
+ try {
1945
+ const meta = { cwd: session.cwd !== void 0 && isAbsolute(session.cwd) ? session.cwd : process.cwd() };
1946
+ const prepared = store.prepare(resumeId, {
1947
+ seed: this.buildSeedEvents(session),
1948
+ meta
1949
+ });
1950
+ store.enter(prepared);
1951
+ return prepared;
1952
+ } catch (error) {
1953
+ console.warn("[dsh-advisor-group] 重建会话句柄失败(降级为 detached):", error instanceof Error ? error.message : String(error));
1954
+ const again = store.get(resumeId);
1955
+ if (again) return again;
1956
+ return this.buildDetachedSession(session, resumeId);
1957
+ }
1958
+ }
1959
+ buildDetachedSession(session, sessionId = session.id) {
1960
+ try {
1961
+ return Session.create(sessionId, this.buildSeedEvents(session), void 0, 0);
1962
+ } catch {
1963
+ return;
1964
+ }
1965
+ }
1966
+ getRepeatPressure(sessionId) {
1967
+ if (!sessionId) return void 0;
1968
+ const tracked = this.repeatTracker.get(sessionId);
1969
+ if (!tracked) return void 0;
1970
+ return {
1971
+ count: tracked.count,
1972
+ text: tracked.text
1973
+ };
1974
+ }
1975
+ getConfig() {
1976
+ return this.config;
1977
+ }
1978
+ setConfig(config) {
1979
+ this.config = config;
1980
+ this.enabled = config.enabled;
1981
+ }
1982
+ isEnabled() {
1983
+ return this.enabled;
1984
+ }
1985
+ setEnabled(enabled) {
1986
+ this.enabled = enabled;
1987
+ this.config = {
1988
+ ...this.config,
1989
+ enabled
1990
+ };
1991
+ }
1992
+ setPersistEnabled(callback) {
1993
+ this.persistEnabledCallback = callback;
1994
+ }
1995
+ async toggleEnabled(enabled) {
1996
+ this.setEnabled(enabled);
1997
+ if (this.persistEnabledCallback) try {
1998
+ await this.persistEnabledCallback(enabled);
1999
+ } catch {}
2000
+ return this.enabled;
2001
+ }
2002
+ /**
2003
+ * Current daily-guard facts for display (does NOT increment — read-only).
2004
+ * Rolls the in-memory counter to a new UTC day view without persisting.
2005
+ * `enabled` reflects the configurable cap; `remaining` is -1 when the cap
2006
+ * is disabled (the counter still accumulates for visibility).
2007
+ */
2008
+ getDailyGuard() {
2009
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2010
+ const used = this.dailyConsultationDate === today ? this.dailyConsultationCount : 0;
2011
+ const enabled = this.config.quota.enabled;
2012
+ const limit = this.config.quota.maxPerDay;
2013
+ return {
2014
+ used,
2015
+ enabled,
2016
+ limit,
2017
+ remaining: enabled ? Math.max(0, limit - used) : -1
2018
+ };
2019
+ }
2020
+ /**
2021
+ * Minimal cost guard: atomically check and increment the per-day new
2022
+ * consultation counter. Node is single-threaded, so doing both inside one
2023
+ * synchronous block closes the TOCTOU window a two-step check would leave.
2024
+ * The cap is configurable (`quota.enabled` / `quota.maxPerDay`); when the
2025
+ * cap is disabled the counter still increments (used for display only).
2026
+ * The counter is persisted (UTC day key) so a harness restart does not
2027
+ * reset the quota; persistence is fire-and-forget after the sync block.
2028
+ */
2029
+ tryStartConsultation() {
2030
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2031
+ if (this.dailyConsultationDate !== today) {
2032
+ this.dailyConsultationDate = today;
2033
+ this.dailyConsultationCount = 0;
2034
+ }
2035
+ if (this.config.quota.enabled && this.dailyConsultationCount >= this.config.quota.maxPerDay) return {
2036
+ ok: false,
2037
+ reason: "今日顾问咨询次数已达上限,请明天再试,或在设置中调整每日咨询上限。"
2038
+ };
2039
+ this.dailyConsultationCount += 1;
2040
+ this.persistDailyGuard();
2041
+ return { ok: true };
2042
+ }
2043
+ getSession(id) {
2044
+ return this.sessions.get(id);
2045
+ }
2046
+ createSession(question, context, advisorIds = [], cwd, dshSessionId) {
2047
+ const maxAdvisors = this.config.discussion.maxAdvisorsPerCall;
2048
+ const advisors = this.resolveAdvisors(advisorIds).slice(0, maxAdvisors);
2049
+ const now = Date.now();
2050
+ const session = {
2051
+ id: randomUUID(),
2052
+ status: "active",
2053
+ question,
2054
+ context,
2055
+ advisors,
2056
+ maxRounds: this.config.discussion.maxRounds,
2057
+ messages: [{
2058
+ role: "system",
2059
+ content: `顾问群已建立,共 ${advisors.length} 位顾问。`,
2060
+ ts: now
2061
+ }, {
2062
+ role: "main",
2063
+ content: context ? `${context}\n\n${question}` : question,
2064
+ ts: now
2065
+ }],
2066
+ cwd,
2067
+ dshSessionId,
2068
+ createdAt: now,
2069
+ updatedAt: now
2070
+ };
2071
+ if (this.sessions.size >= 100) {
2072
+ const oldest = this.sessions.keys().next().value;
2073
+ if (oldest !== void 0) this.sessions.delete(oldest);
2074
+ }
2075
+ this.sessions.set(session.id, session);
2076
+ this.persistSession(session);
2077
+ this.ctx.emit("advisor-group/session-start", session);
2078
+ return session;
2079
+ }
2080
+ appendFollowUp(sessionId, followUp, sessionLog) {
2081
+ const session = this.sessions.get(sessionId);
2082
+ if (!session) return void 0;
2083
+ const message = {
2084
+ role: "main",
2085
+ content: followUp,
2086
+ ts: Date.now()
2087
+ };
2088
+ session.messages.push(message);
2089
+ session.updatedAt = Date.now();
2090
+ if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
2091
+ this.persistSession(session);
2092
+ this.ctx.emit("advisor-group/message", {
2093
+ sessionId: session.id,
2094
+ message
2095
+ });
2096
+ return session;
2097
+ }
2098
+ /**
2099
+ * The next round that still needs work, derived from the durable messages:
2100
+ * when the last round is incomplete (an advisor answered, a later one was
2101
+ * cut by a stop), that round resumes with ONLY the missing advisors; when
2102
+ * it is complete, the next round is fresh. `undefined` means finished.
2103
+ */
2104
+ nextPendingRound(session) {
2105
+ const advisorMessages = session.messages.filter((message) => message.role === "advisor");
2106
+ const maxRound = advisorMessages.reduce((max, message) => Math.max(max, message.round ?? 1), 0);
2107
+ const advisorCount = Math.max(1, session.advisors.length);
2108
+ if (maxRound === 0) return {
2109
+ round: 1,
2110
+ answeredIds: []
2111
+ };
2112
+ if (maxRound > session.maxRounds) return void 0;
2113
+ const answeredInMax = advisorMessages.filter((message) => (message.round ?? 1) === maxRound).map((message) => message.advisorId ?? "").filter((id) => id !== "");
2114
+ const unique = [...new Set(answeredInMax)];
2115
+ if (unique.length < advisorCount) return {
2116
+ round: maxRound,
2117
+ answeredIds: unique
2118
+ };
2119
+ if (maxRound + 1 > session.maxRounds) return void 0;
2120
+ return {
2121
+ round: maxRound + 1,
2122
+ answeredIds: []
2123
+ };
2124
+ }
2125
+ /** Number of main-model messages: the opening question + all deep-questions. */
2126
+ mainMessageCount(session) {
2127
+ return session.messages.filter((message) => message.role === "main").length;
2128
+ }
2129
+ /** Run exactly one round: every advisor that has NOT answered this round. */
2130
+ async runRoundFrom(session, round, answeredIds, signal, sessionLog, agent) {
2131
+ for (let index = 0; index < session.advisors.length; index++) {
2132
+ const advisor = session.advisors[index];
2133
+ if (answeredIds.includes(advisor.id)) continue;
2134
+ const transcript = this.buildTranscript(session);
2135
+ await this.callAdvisor(session, advisor, transcript, round, index + 1, signal, sessionLog, agent);
2136
+ }
2137
+ session.status = "active";
2138
+ session.updatedAt = Date.now();
2139
+ this.persistSession(session);
2140
+ }
2141
+ async runOneRound(session, signal, sessionLog, agent) {
2142
+ const pending = this.nextPendingRound(session);
2143
+ if (!pending) return;
2144
+ await this.runRoundFrom(session, pending.round, pending.answeredIds, signal, sessionLog, agent);
2145
+ }
2146
+ /**
2147
+ * Auto-deepen pipeline (2026-09-05): one ask_advisors call runs the whole
2148
+ * consultation — up to `maxRounds` rounds, each round being
2149
+ * [driver deep-question (after the first)] → advisor A → advisor B (sees A)
2150
+ * → advisor C (sees A+B) → …
2151
+ * and closes with a driver-generated conclusion. The pipeline is synchronous
2152
+ * (SSE keeps the card streaming); `exec.signal` aborts the whole run and the
2153
+ * user may stop it explicitly via `stopConsultation(sessionId)` (POST
2154
+ * /advisor-group/stop) — a stop degrades to a graceful partial summary.
2155
+ *
2156
+ * Resumption: after a stop (or a dsh restart that restored the snapshot)
2157
+ * `resumeConsultation()` re-enters the SAME pipeline; `nextPendingRound()`
2158
+ * detects the interruption point — a partially-answered round is completed
2159
+ * with only the missing advisors (the deep-question is NOT regenerated for
2160
+ * that round), then remaining rounds run as usual.
2161
+ */
2162
+ async runAutoPipeline(session, signal, sessionLog, agent) {
2163
+ const stop = new AbortController();
2164
+ this.activeAborts.set(session.id, stop);
2165
+ const combined = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
2166
+ if (!session.driverSource) {
2167
+ const source = resolveDriverSource(sessionLog, this.config.discussion.driverModel);
2168
+ if (source) {
2169
+ session.driverSource = source;
2170
+ this.persistSession(session);
2171
+ }
2172
+ }
2173
+ try {
2174
+ while (true) {
2175
+ const pending = this.nextPendingRound(session);
2176
+ if (!pending) break;
2177
+ const freshRound = pending.answeredIds.length === 0;
2178
+ const questionAlreadyAsked = this.mainMessageCount(session) >= pending.round;
2179
+ if (pending.round > 1 && freshRound && this.config.discussion.autoDeepen && !questionAlreadyAsked) {
2180
+ const question = await generateDeepenQuestion(this.ctx, session, session.driverSource ?? resolveDriverSource(sessionLog, this.config.discussion.driverModel), combined, this.config.discussion.driverTimeoutMs);
2181
+ this.appendMainMessage(session, question, sessionLog);
2182
+ }
2183
+ await this.runRoundFrom(session, pending.round, pending.answeredIds, combined, sessionLog, agent);
2184
+ }
2185
+ session.status = "completed";
2186
+ session.stopReason = void 0;
2187
+ const summary = await this.generateFinalSummary(session, combined, sessionLog, false);
2188
+ this.persistSession(session);
2189
+ await this.flushLog(sessionLog);
2190
+ return summary;
2191
+ } catch (error) {
2192
+ if (isAbortError(error, combined) || stop.signal.aborted) {
2193
+ if (stop.signal.aborted) session.stopReason = "user-stop";
2194
+ else if (signal?.aborted) session.stopReason = "exec-cancel";
2195
+ else session.stopReason = "abort-error";
2196
+ session.status = "cancelled";
2197
+ this.persistSession(session);
2198
+ const summary = await this.generateFinalSummary(session, void 0, sessionLog, true);
2199
+ await this.flushLog(sessionLog);
2200
+ return summary;
2201
+ }
2202
+ throw error;
2203
+ } finally {
2204
+ this.activeAborts.delete(session.id);
2205
+ }
2206
+ }
2207
+ /**
2208
+ * User-initiated stop: aborts the in-flight consultation pipeline. Returns
2209
+ * whether a running consultation was found and aborted.
2210
+ */
2211
+ stopConsultation(sessionId) {
2212
+ const stop = this.activeAborts.get(sessionId);
2213
+ if (!stop) return false;
2214
+ stop.abort();
2215
+ return true;
2216
+ }
2217
+ /**
2218
+ * Resume a stopped/interrupted consultation from its interruption point.
2219
+ * Works for a user stop (live session) and after a dsh restart (snapshot
2220
+ * restored; the DSH session handle is rebuilt via `prepare` + `enter`).
2221
+ * Returns immediately; the pipeline runs in the background and streams over
2222
+ * the existing SSE channel, then closes with a fresh `advisor-group/end`.
2223
+ */
2224
+ resumeConsultation(sessionId) {
2225
+ const session = this.sessions.get(sessionId);
2226
+ if (!session) return {
2227
+ ok: false,
2228
+ reason: "咨询会话不存在或已过期,请重新发起咨询。"
2229
+ };
2230
+ if (this.activeAborts.has(sessionId)) return {
2231
+ ok: false,
2232
+ reason: "该咨询正在运行中,无需继续。"
2233
+ };
2234
+ if (session.status === "completed" || this.nextPendingRound(session) === void 0) return {
2235
+ ok: false,
2236
+ reason: "该咨询已完成,无需继续。"
2237
+ };
2238
+ if (session.advisors.length === 0) return {
2239
+ ok: false,
2240
+ reason: "恢复失败:当前配置无法匹配该咨询的原顾问(顾问配置可能已被修改)。"
2241
+ };
2242
+ if (!this.acquireResumeLock(session.id)) return {
2243
+ ok: false,
2244
+ reason: "该咨询正在另一实例中恢复,请稍后再试(若确认无其他实例运行,10 分钟后会自动接管该锁)。"
2245
+ };
2246
+ const dshSession = this.resolveSessionHandle(session);
2247
+ const agentRegistry = this.ctx.agents;
2248
+ const resumeAgent = session.dshSessionId !== void 0 && agentRegistry?.get ? agentRegistry.get(session.dshSessionId) : void 0;
2249
+ session.status = "active";
2250
+ session.stopReason = void 0;
2251
+ session.updatedAt = Date.now();
2252
+ this.persistSession(session);
2253
+ if (dshSession) appendAdvisorResume(dshSession, session.id);
2254
+ this.ctx.emit("advisor-group/resume", { sessionId: session.id });
2255
+ this.runAutoPipeline(session, void 0, dshSession, resumeAgent).catch(() => {}).finally(() => this.releaseResumeLock(session.id));
2256
+ return { ok: true };
2257
+ }
2258
+ /** Atomic cross-instance lock (mkdir-based, with stale takeover). */
2259
+ acquireResumeLock(sessionId) {
2260
+ const lockDir = join(this.sessionsDir, `${sessionId}.lock`);
2261
+ try {
2262
+ mkdirSync(this.sessionsDir, { recursive: true });
2263
+ mkdirSync(lockDir);
2264
+ try {
2265
+ writeFileSync(join(lockDir, "owner"), String(process.pid), "utf8");
2266
+ } catch {}
2267
+ return true;
2268
+ } catch {
2269
+ try {
2270
+ const stat = statSync(lockDir);
2271
+ if (Date.now() - stat.mtimeMs > 6e5) {
2272
+ rmSync(lockDir, {
2273
+ recursive: true,
2274
+ force: true
2275
+ });
2276
+ mkdirSync(lockDir);
2277
+ return true;
2278
+ }
2279
+ } catch {}
2280
+ return false;
2281
+ }
2282
+ }
2283
+ releaseResumeLock(sessionId) {
2284
+ try {
2285
+ rmSync(join(this.sessionsDir, `${sessionId}.lock`), {
2286
+ recursive: true,
2287
+ force: true
2288
+ });
2289
+ } catch {}
2290
+ }
2291
+ async generateFinalSummary(session, signal, sessionLog, stopped) {
2292
+ session.updatedAt = Date.now();
2293
+ const summary = this.buildSummary(session, stopped);
2294
+ const conclusion = stopped ? "" : await generateConclusion(this.ctx, session, session.driverSource ?? resolveDriverSource(sessionLog, this.config.discussion.driverModel), signal, this.config.discussion.driverTimeoutMs);
2295
+ const finalSummary = stopped ? {
2296
+ ...summary,
2297
+ stopped: true
2298
+ } : {
2299
+ ...summary,
2300
+ conclusion
2301
+ };
2302
+ if (sessionLog) appendAdvisorEnd(sessionLog, session, finalSummary);
2303
+ this.ctx.emit("advisor-group/session-end", {
2304
+ sessionId: session.id,
2305
+ summary: finalSummary
2306
+ });
2307
+ return finalSummary;
2308
+ }
2309
+ appendMainMessage(session, content, sessionLog) {
2310
+ const message = {
2311
+ role: "main",
2312
+ content,
2313
+ ts: Date.now()
2314
+ };
2315
+ session.messages.push(message);
2316
+ if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
2317
+ this.persistSession(session);
2318
+ this.ctx.emit("advisor-group/message", {
2319
+ sessionId: session.id,
2320
+ message
2321
+ });
2322
+ }
2323
+ /**
2324
+ * Run exactly ONE discussion round: the main model's question -> each
2325
+ * advisor answers once. Cross-advisor discussion is driven by the main model
2326
+ * calling ask_advisors again with sessionId + followUp, never by the
2327
+ * advisor models talking to themselves back-to-back.
2328
+ */
2329
+ async runOneRoundAndSummarize(session, signal, sessionLog, agent) {
2330
+ await this.runOneRound(session, signal, sessionLog, agent);
2331
+ session.status = "completed";
2332
+ session.updatedAt = Date.now();
2333
+ const summary = this.buildSummary(session, false);
2334
+ if (sessionLog) appendAdvisorEnd(sessionLog, session, summary);
2335
+ this.persistSession(session);
2336
+ await this.flushLog(sessionLog);
2337
+ this.ctx.emit("advisor-group/session-end", {
2338
+ sessionId: session.id,
2339
+ summary
2340
+ });
2341
+ return summary;
2342
+ }
2343
+ getRoundCount(session) {
2344
+ const advisorCount = Math.max(1, session.advisors.length);
2345
+ return Math.floor(session.messages.filter((message) => message.role === "advisor").length / advisorCount);
2346
+ }
2347
+ hasReachedMaxRounds(session) {
2348
+ const maxRounds = Math.max(1, session.maxRounds);
2349
+ return this.getRoundCount(session) >= maxRounds;
2350
+ }
2351
+ resolveAdvisors(advisorIds) {
2352
+ if (advisorIds.length === 0) return this.config.advisors;
2353
+ return this.config.advisors.filter((advisor) => advisorIds.includes(advisor.id));
2354
+ }
2355
+ buildTranscript(session) {
2356
+ return session.messages.filter((message) => message.role !== "system").slice(-12).map((message) => {
2357
+ if (message.role === "main") return {
2358
+ role: "main",
2359
+ name: "主模型",
2360
+ content: message.content.slice(0, 3e3)
2361
+ };
2362
+ if (message.role === "advisor" && message.advisorName) return {
2363
+ role: "advisor",
2364
+ name: message.advisorName,
2365
+ content: message.content.slice(0, 3e3)
2366
+ };
2367
+ return null;
2368
+ }).filter((entry) => entry !== null);
2369
+ }
2370
+ async callAdvisor(session, advisor, transcript, round, joinIndex, signal, sessionLog, agent) {
2371
+ const relational = { ...advisor };
2372
+ relational.systemPrompt = `${advisor.systemPrompt}\n${advisorJoinPrompt(joinIndex, session.advisors.length)}`;
2373
+ const message = {
2374
+ role: "advisor",
2375
+ advisorId: advisor.id,
2376
+ advisorName: advisor.name,
2377
+ content: "",
2378
+ round,
2379
+ ts: Date.now()
2380
+ };
2381
+ try {
2382
+ let content;
2383
+ let isDshLlmProvider = false;
2384
+ try {
2385
+ isDshLlmProvider = this.ctx.llm.listProviders().some((item) => item.id === advisor.provider);
2386
+ } catch {}
2387
+ let pendingText = "";
2388
+ let pendingThinking = "";
2389
+ let timer = null;
2390
+ const flush = () => {
2391
+ if (timer) {
2392
+ clearTimeout(timer);
2393
+ timer = null;
2394
+ }
2395
+ if (!pendingText && !pendingThinking) return;
2396
+ if (sessionLog) appendAdvisorDelta(sessionLog, session.id, {
2397
+ sessionId: session.id,
2398
+ advisorId: advisor.id,
2399
+ advisorName: advisor.name,
2400
+ round,
2401
+ contentDelta: pendingText || void 0,
2402
+ thinkingDelta: pendingThinking || void 0,
2403
+ done: false
2404
+ });
2405
+ pendingText = "";
2406
+ pendingThinking = "";
2407
+ };
2408
+ const emitDelta = (delta, phase = "answer") => {
2409
+ if (delta.text) {
2410
+ message.content += delta.text;
2411
+ pendingText += delta.text;
2412
+ }
2413
+ if (delta.thinking) {
2414
+ message.thinking = (message.thinking ?? "") + delta.thinking;
2415
+ thinkingBuffer += delta.thinking;
2416
+ pendingThinking += delta.thinking;
2417
+ }
2418
+ publish(session.id, {
2419
+ advisorId: advisor.id,
2420
+ advisorName: advisor.name,
2421
+ round,
2422
+ phase,
2423
+ contentDelta: delta.text,
2424
+ thinkingDelta: delta.thinking,
2425
+ done: false
2426
+ });
2427
+ if (!timer) timer = setTimeout(flush, 200);
2428
+ };
2429
+ const advisorTimeoutMs = this.config.discussion.advisorTimeoutMs;
2430
+ let truncated;
2431
+ const thinkingSegments = [];
2432
+ let thinkingBuffer = "";
2433
+ const actionDescriptions = [];
2434
+ if (!isDshLlmProvider || advisor.baseURL || advisor.apiKey || advisor.apiKeyEnv) {
2435
+ const toolMode = advisor.tools ?? this.config.discussion.advisorTools ?? "readonly";
2436
+ const toolSchemas = resolveAdvisorToolSchemas(this.ctx, agent, toolMode);
2437
+ if (toolSchemas.length > 0) {
2438
+ relational.systemPrompt = `${relational.systemPrompt}${ADVISOR_TOOL_GUIDANCE}`;
2439
+ const messageToolSteps = [];
2440
+ let narrationBuffer = "";
2441
+ const emitToolStep = (step) => {
2442
+ const full = {
2443
+ ...step,
2444
+ atMs: Date.now()
2445
+ };
2446
+ const parts = [];
2447
+ if (narrationBuffer.trim()) {
2448
+ parts.push(narrationBuffer.trim());
2449
+ narrationBuffer = "";
2450
+ }
2451
+ if (thinkingBuffer.trim()) parts.push(thinkingBuffer);
2452
+ if (parts.length > 0) {
2453
+ thinkingSegments.push(parts.join("\n\n"));
2454
+ thinkingBuffer = "";
2455
+ }
2456
+ messageToolSteps.push(full);
2457
+ if (sessionLog) appendAdvisorDelta(sessionLog, session.id, {
2458
+ sessionId: session.id,
2459
+ advisorId: advisor.id,
2460
+ advisorName: advisor.name,
2461
+ round,
2462
+ toolStep: full
2463
+ });
2464
+ publish(session.id, {
2465
+ advisorId: advisor.id,
2466
+ advisorName: advisor.name,
2467
+ round,
2468
+ toolStep: full
2469
+ });
2470
+ };
2471
+ const streamOnce = (tools, extra) => {
2472
+ const withExtra = extra.trim() ? [...transcript, {
2473
+ role: "main",
2474
+ name: "已执行工具",
2475
+ content: extra.trim()
2476
+ }] : transcript;
2477
+ const parseNames = toolSchemas.map((tool) => tool.name);
2478
+ const emitRoundDelta = (delta) => emitDelta(delta, tools.length > 0 ? "tool" : "answer");
2479
+ return streamDirectHttp(relational, withExtra, emitRoundDelta, signal, advisorTimeoutMs, tools, parseNames).then((result) => {
2480
+ narrationBuffer = result.content.trim();
2481
+ if (tools.length > 0 && narrationBuffer) actionDescriptions.push(narrationBuffer);
2482
+ return {
2483
+ content: result.content,
2484
+ thinking: result.thinking,
2485
+ toolCalls: result.toolCalls ?? [],
2486
+ truncated: result.truncated
2487
+ };
2488
+ });
2489
+ };
2490
+ const loop = await runAdvisorToolLoop(toolSchemas, streamOnce, (call) => executeAdvisorTool(this.ctx, agent, call, signal), (step) => emitToolStep(step));
2491
+ flush();
2492
+ content = loop.content;
2493
+ truncated = loop.truncated;
2494
+ message.toolSteps = messageToolSteps;
2495
+ } else {
2496
+ const streamResult = await streamDirectHttp(relational, transcript, emitDelta, signal, advisorTimeoutMs);
2497
+ flush();
2498
+ content = streamResult.content;
2499
+ message.thinking = streamResult.thinking;
2500
+ truncated = streamResult.truncated;
2501
+ }
2502
+ } else {
2503
+ const result = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs);
2504
+ flush();
2505
+ content = result.content;
2506
+ truncated = result.truncated;
2507
+ }
2508
+ message.content = sanitizeAdvisorContent(content);
2509
+ if (truncated) message.truncated = truncated;
2510
+ if (thinkingBuffer.trim()) thinkingSegments.push(thinkingBuffer);
2511
+ if (thinkingSegments.length > 0) message.thinkingSegments = thinkingSegments;
2512
+ if (actionDescriptions.length > 0) message.actionDescriptions = actionDescriptions;
2513
+ } catch (error) {
2514
+ if (isAbortError(error, signal)) throw error;
2515
+ message.content = `(顾问调用失败:${error instanceof Error ? error.message : String(error)})`;
2516
+ }
2517
+ publish(session.id, {
2518
+ advisorId: advisor.id,
2519
+ advisorName: advisor.name,
2520
+ round,
2521
+ done: true
2522
+ });
2523
+ session.messages.push(message);
2524
+ if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
2525
+ session.updatedAt = Date.now();
2526
+ this.persistSession(session);
2527
+ this.ctx.emit("advisor-group/message", {
2528
+ sessionId: session.id,
2529
+ message
2530
+ });
2531
+ }
2532
+ buildSummary(session, cancelled = false) {
2533
+ const advisorMessages = session.messages.filter((message) => message.role === "advisor");
2534
+ const byAdvisor = /* @__PURE__ */ new Map();
2535
+ for (const message of advisorMessages) {
2536
+ const list = byAdvisor.get(message.advisorId ?? "") ?? [];
2537
+ list.push(message);
2538
+ byAdvisor.set(message.advisorId ?? "", list);
2539
+ }
2540
+ const advisors = [...byAdvisor.entries()].map(([id, messages]) => {
2541
+ const advisor = session.advisors.find((item) => item.id === id);
2542
+ const meaningful = messages.filter((message) => message.content && message.content.trim() && !message.content.startsWith("(顾问调用失败") && !message.content.startsWith("(顾问未生成正文"));
2543
+ const last = meaningful[meaningful.length - 1] ?? messages[messages.length - 1];
2544
+ return {
2545
+ advisorId: id,
2546
+ advisorName: advisor?.name ?? id,
2547
+ corePoints: last ? this.extractCorePoints(last.content) : []
2548
+ };
2549
+ });
2550
+ const riskNotes = advisorMessages.some((message) => /风险|注意|不确定|risk|uncertain|confidence/i.test(message.content)) ? ["部分顾问提到了风险、不确定性或置信度较低,请主模型谨慎采用。"] : [];
2551
+ if (advisorMessages.some((message) => message.truncated !== void 0)) riskNotes.push("有顾问输出在流式过程中被截断(超时或网络中断),其正文可能不完整,且该顾问本轮可能只提供了部分意见。");
2552
+ if (cancelled) riskNotes.push("本次顾问群讨论已被用户或主模型取消,结论可能不完整。");
2553
+ return {
2554
+ sessionId: session.id,
2555
+ question: session.question,
2556
+ advisors,
2557
+ consensus: [],
2558
+ disagreements: [],
2559
+ riskNotes
2560
+ };
2561
+ }
2562
+ extractCorePoints(content) {
2563
+ return content.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 5);
2564
+ }
2565
+ };
2566
+ //#endregion
2567
+ //#region src/model-validation.ts
2568
+ /**
2569
+ * Advisors whose model is empty or whitespace-only.
2570
+ *
2571
+ * The ask_advisors tool refuses to start a consultation while any advisor is
2572
+ * missing a model (fast fail with a precise reason instead of a mid-round
2573
+ * failure); the settings card marks the field red in the same condition.
2574
+ */
2575
+ function advisorsMissingModel(config) {
2576
+ return (Array.isArray(config) ? config : config.advisors).filter((advisor) => !advisor.model || !advisor.model.trim());
2577
+ }
2578
+ /** Pure projection used by tests and storage: truncates and normalizes a sample. */
2579
+ function formatShadowSample(sample) {
2580
+ return {
2581
+ ...sample,
2582
+ ts: Number.isSafeInteger(sample.ts) ? sample.ts : Date.now(),
2583
+ question: sample.question.length > 200 ? `${sample.question.slice(0, 200)}…` : sample.question
2584
+ };
2585
+ }
2586
+ function shadowLogPath() {
2587
+ const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
2588
+ return join(dshHome, "storages", "advisor-group", "classifier-shadow.jsonl");
2589
+ }
2590
+ let writeChain = Promise.resolve();
2591
+ /** Append one shadow sample (serialized; failures degrade to a silent no-op). */
2592
+ function appendShadowSample(sample) {
2593
+ const line = `${JSON.stringify(formatShadowSample(sample))}\n`;
2594
+ writeChain = writeChain.then(async () => {
2595
+ const path = shadowLogPath();
2596
+ await promises.mkdir(dirname(path), { recursive: true });
2597
+ await promises.appendFile(path, line, "utf8");
2598
+ }).catch((error) => {
2599
+ console.warn("[dsh-advisor-group] 分类器影子样本写入失败:", error instanceof Error ? error.message : String(error));
2600
+ });
2601
+ }
2602
+ /** Read the most recent `limit` samples plus lightweight statistics. */
2603
+ async function readShadowSamples(limit = 200) {
2604
+ let raw = "";
2605
+ try {
2606
+ raw = await promises.readFile(shadowLogPath(), "utf8");
2607
+ } catch {
2608
+ return {
2609
+ total: 0,
2610
+ escalated: 0,
2611
+ launched: 0,
2612
+ samples: []
2613
+ };
2614
+ }
2615
+ const all = [];
2616
+ for (const line of raw.split("\n")) {
2617
+ if (!line.trim()) continue;
2618
+ try {
2619
+ const parsed = JSON.parse(line);
2620
+ if (typeof parsed.ts !== "number" || typeof parsed.question !== "string") continue;
2621
+ all.push(formatShadowSample(parsed));
2622
+ } catch {}
2623
+ }
2624
+ const tail = all.slice(-Math.max(1, Math.min(limit, 500)));
2625
+ const withConfidence = all.filter((s) => typeof s.confidence === "number");
2626
+ return {
2627
+ total: all.length,
2628
+ escalated: all.filter((s) => s.shouldEscalate).length,
2629
+ launched: all.filter((s) => s.launched).length,
2630
+ ...withConfidence.length > 0 ? { avgConfidence: withConfidence.reduce((sum, s) => sum + (s.confidence ?? 0), 0) / withConfidence.length } : {},
2631
+ samples: tail
2632
+ };
2633
+ }
2634
+ //#endregion
2635
+ //#region src/classifier.ts
2636
+ const HIGH_RISK_KEYWORDS = [
2637
+ "医疗",
2638
+ "药物",
2639
+ "剂量",
2640
+ "手术",
2641
+ "诊断",
2642
+ "法律",
2643
+ "合同",
2644
+ "诉讼",
2645
+ "合规",
2646
+ "税务",
2647
+ "投资",
2648
+ "理财",
2649
+ "保险",
2650
+ "安全漏洞",
2651
+ "漏洞利用",
2652
+ "核保",
2653
+ "判例",
2654
+ "法条",
2655
+ "责任",
2656
+ "赔偿",
2657
+ "监管",
2658
+ "刑事",
2659
+ "民事",
2660
+ "仲裁",
2661
+ "劳动法",
2662
+ "知识产权"
2663
+ ];
2664
+ const WEB_SEARCH_KEYWORDS = [
2665
+ "最新",
2666
+ "新闻",
2667
+ "天气",
2668
+ "汇率",
2669
+ "股价",
2670
+ "价格",
2671
+ "API文档",
2672
+ "版本",
2673
+ "更新",
2674
+ "公告",
2675
+ "活动",
2676
+ "时间",
2677
+ "日期",
2678
+ "在哪",
2679
+ "怎么下载",
2680
+ "官网",
2681
+ "仓库地址",
2682
+ "当前",
2683
+ "实时",
2684
+ "今日",
2685
+ "本周",
2686
+ "排行",
2687
+ "销量",
2688
+ "行情"
2689
+ ];
2690
+ const WORLD_KNOWLEDGE_KEYWORDS = [
2691
+ "历史",
2692
+ "哲学",
2693
+ "理论",
2694
+ "学派",
2695
+ "文化",
2696
+ "术语",
2697
+ "概念",
2698
+ "原理",
2699
+ "行业惯例",
2700
+ "经验",
2701
+ "经典",
2702
+ "法理",
2703
+ "判例",
2704
+ "方法论",
2705
+ "框架",
2706
+ "最佳实践",
2707
+ "架构评审",
2708
+ "技术选型",
2709
+ "代码审查",
2710
+ "疑难",
2711
+ "根因",
2712
+ "学术",
2713
+ "论文",
2714
+ "研究",
2715
+ "文献",
2716
+ "政策",
2717
+ "制度",
2718
+ "机制",
2719
+ "商业模式",
2720
+ "战略",
2721
+ "组织",
2722
+ "管理",
2723
+ "数学",
2724
+ "证明",
2725
+ "推导",
2726
+ "统计",
2727
+ "回归",
2728
+ "概率",
2729
+ "算法",
2730
+ "复杂度",
2731
+ "优化",
2732
+ "物理",
2733
+ "化学",
2734
+ "生物",
2735
+ "工程",
2736
+ "材料",
2737
+ "量子"
2738
+ ];
2739
+ const DOMAIN_KEYWORDS = [
2740
+ {
2741
+ domain: "legal",
2742
+ keywords: [
2743
+ "法律",
2744
+ "合同",
2745
+ "诉讼",
2746
+ "合规",
2747
+ "税务",
2748
+ "法条",
2749
+ "判例",
2750
+ "责任",
2751
+ "仲裁",
2752
+ "劳动法",
2753
+ "知识产权"
2754
+ ]
2755
+ },
2756
+ {
2757
+ domain: "code",
2758
+ keywords: [
2759
+ "代码",
2760
+ "架构",
2761
+ "技术选型",
2762
+ "bug",
2763
+ "调试",
2764
+ "性能",
2765
+ "安全漏洞",
2766
+ "API",
2767
+ "系统设计",
2768
+ "数据库",
2769
+ "前端",
2770
+ "后端",
2771
+ "微服务"
2772
+ ]
2773
+ },
2774
+ {
2775
+ domain: "finance",
2776
+ keywords: [
2777
+ "投资",
2778
+ "理财",
2779
+ "金融",
2780
+ "税务",
2781
+ "保险",
2782
+ "估值",
2783
+ "财报",
2784
+ "现金流",
2785
+ "风险",
2786
+ "股票",
2787
+ "基金",
2788
+ "债券"
2789
+ ]
2790
+ },
2791
+ {
2792
+ domain: "medical",
2793
+ keywords: [
2794
+ "医疗",
2795
+ "药物",
2796
+ "剂量",
2797
+ "症状",
2798
+ "手术",
2799
+ "疾病",
2800
+ "诊断",
2801
+ "治疗",
2802
+ "康复"
2803
+ ]
2804
+ },
2805
+ {
2806
+ domain: "domain",
2807
+ keywords: [
2808
+ "行业惯例",
2809
+ "经验",
2810
+ "历史",
2811
+ "文化",
2812
+ "术语",
2813
+ "概念",
2814
+ "原理",
2815
+ "战略",
2816
+ "商业模式",
2817
+ "组织",
2818
+ "管理"
2819
+ ]
2820
+ },
2821
+ {
2822
+ domain: "technical",
2823
+ keywords: [
2824
+ "数学",
2825
+ "证明",
2826
+ "推导",
2827
+ "统计",
2828
+ "回归",
2829
+ "概率",
2830
+ "算法",
2831
+ "复杂度",
2832
+ "优化",
2833
+ "物理",
2834
+ "化学",
2835
+ "生物",
2836
+ "工程",
2837
+ "材料",
2838
+ "量子"
2839
+ ]
2840
+ },
2841
+ {
2842
+ domain: "academic",
2843
+ keywords: [
2844
+ "学术",
2845
+ "论文",
2846
+ "研究",
2847
+ "文献",
2848
+ "理论",
2849
+ "方法论"
2850
+ ]
2851
+ }
2852
+ ];
2853
+ function classifyRequest(question, context, advisors = [], confidence, confidenceThreshold = .6) {
2854
+ const text = `${question}\n${context ?? ""}`;
2855
+ const lowerText = text.toLowerCase();
2856
+ if (text.includes("@顾问群") || text.includes("@顧問群")) return {
2857
+ shouldEscalate: true,
2858
+ reason: "用户点名顾问群,强制启动顾问咨询。",
2859
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
2860
+ suggestWebSearch: false
2861
+ };
2862
+ if (HIGH_RISK_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase()))) return {
2863
+ shouldEscalate: true,
2864
+ reason: "命中高风险关键词,需要专业顾问参与并输出风险提示。",
2865
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
2866
+ suggestWebSearch: false
2867
+ };
2868
+ if (WORLD_KNOWLEDGE_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase()))) return {
2869
+ shouldEscalate: true,
2870
+ reason: "命中世界知识/专业经验关键词,适合由顾问模型提供多视角解答。",
2871
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
2872
+ suggestWebSearch: false
2873
+ };
2874
+ if (typeof confidence === "number" && confidence < confidenceThreshold) return {
2875
+ shouldEscalate: true,
2876
+ reason: `主模型置信度 ${confidence} 低于阈值 ${confidenceThreshold},建议升级到顾问群。`,
2877
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
2878
+ suggestWebSearch: false
2879
+ };
2880
+ if (WEB_SEARCH_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase()))) return {
2881
+ shouldEscalate: false,
2882
+ reason: "该问题更依赖实时/事实信息,建议先联网搜索而不是启动顾问群。",
2883
+ suggestedAdvisors: [],
2884
+ suggestWebSearch: true
2885
+ };
2886
+ return {
2887
+ shouldEscalate: false,
2888
+ reason: "未识别出需要顾问参与的强信号,可由主模型自行处理。",
2889
+ suggestedAdvisors: [],
2890
+ suggestWebSearch: false
2891
+ };
2892
+ }
2893
+ function matchAdvisors(text, advisors) {
2894
+ if (advisors.length === 0) return [];
2895
+ const matches = /* @__PURE__ */ new Set();
2896
+ const lowerText = text.toLowerCase();
2897
+ for (const advisor of advisors) {
2898
+ const haystack = `${advisor.id} ${advisor.name} ${advisor.systemPrompt}`.toLowerCase();
2899
+ for (const { domain, keywords } of DOMAIN_KEYWORDS) if (haystack.includes(domain)) {
2900
+ if (keywords.some((keyword) => lowerText.includes(keyword.toLowerCase()))) matches.add(advisor.id);
2901
+ }
2902
+ if (lowerText.includes(advisor.name.toLowerCase()) || lowerText.includes(advisor.id.toLowerCase())) matches.add(advisor.id);
2903
+ }
2904
+ return [...matches];
2905
+ }
2906
+ //#endregion
2907
+ //#region src/tools.ts
2908
+ function registerAdvisorTools(service) {
2909
+ return [defineTool({
2910
+ name: "ask_advisors",
2911
+ description: "Consult configured expert advisor models when the question is professional, long-tail world knowledge, high-risk, or the main model is uncertain. ALWAYS provide context with (a) a brief overview of the current project and (b) concrete details of the problem, including what has been tried and what went wrong. Also call immediately when the user @顾问群 or repeats the same question three times without resolution. Do NOT use for simple facts that web search can answer. Starting a new consultation runs the auto-deepen pipeline: up to maxRounds rounds of [driver deep-question → advisor A → advisor B (sees A) → advisor C (sees A+B) → …], closed by a driver-generated conclusion.",
2912
+ parameters: {
2913
+ question: {
2914
+ type: "string",
2915
+ description: "The question to ask the advisors. Required when starting a new consultation."
2916
+ },
2917
+ context: {
2918
+ type: "string",
2919
+ description: "Required background: (a) current project overview (domain/goal/stack/constraints) and (b) problem details (steps tried, expected vs actual, errors). Write \"该项背景未知\" for anything unknown; never leave it empty."
2920
+ },
2921
+ sessionId: {
2922
+ type: "string",
2923
+ description: "Existing consultation session id to continue."
2924
+ },
2925
+ followUp: {
2926
+ type: "string",
2927
+ description: "Follow-up question to send to the advisors in an existing session."
2928
+ },
2929
+ advisorIds: {
2930
+ type: "array",
2931
+ items: { type: "string" },
2932
+ description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
2933
+ },
2934
+ confidence: {
2935
+ type: "number",
2936
+ description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
2937
+ }
2938
+ },
2939
+ output: {
2940
+ schema: {
2941
+ type: "object",
2942
+ additionalProperties: false,
2943
+ properties: {
2944
+ sessionId: {
2945
+ type: "string",
2946
+ required: true
2947
+ },
2948
+ advice: {
2949
+ type: "string",
2950
+ required: true
2951
+ },
2952
+ skipped: { type: "boolean" },
2953
+ reason: { type: "string" }
2954
+ }
2955
+ },
2956
+ render: (_args, value) => [{
2957
+ type: "text",
2958
+ text: value.advice
2959
+ }]
2960
+ },
2961
+ async execute(args, exec) {
2962
+ const cfg = service.getConfig();
2963
+ if (args.confidence !== void 0 && (args.confidence < 0 || args.confidence > 1)) return {
2964
+ sessionId: "",
2965
+ advice: "confidence 参数必须在 0-1 之间。",
2966
+ skipped: true,
2967
+ reason: "invalid-confidence"
2968
+ };
2969
+ if (!service.isEnabled()) return {
2970
+ sessionId: "",
2971
+ advice: "顾问群插件已禁用。请先调用 toggle_advisor_group 启用。",
2972
+ skipped: true,
2973
+ reason: "disabled"
2974
+ };
2975
+ if (cfg.advisors.length === 0) return {
2976
+ sessionId: "",
2977
+ advice: "顾问群尚未配置任何顾问。请先到 设置 → 插件 → 顾问群 添加顾问。",
2978
+ skipped: true,
2979
+ reason: "no-advisors"
2980
+ };
2981
+ const withoutModel = advisorsMissingModel(cfg);
2982
+ if (withoutModel.length > 0) return {
2983
+ sessionId: "",
2984
+ advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
2985
+ skipped: true,
2986
+ reason: "advisor-model-missing"
2987
+ };
2988
+ const sessionLog = exec.agent?.session;
2989
+ const questionText = typeof args.question === "string" ? args.question : "";
2990
+ const contextText = typeof args.context === "string" ? args.context : "";
2991
+ const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
2992
+ const forcedByRepeat = (service.getRepeatPressure(sessionLog?.id)?.count ?? 0) >= 3;
2993
+ if (args.sessionId) {
2994
+ if (!args.followUp) return {
2995
+ sessionId: args.sessionId,
2996
+ advice: "继续追问需要提供 followUp 参数。",
2997
+ skipped: true,
2998
+ reason: "missing-follow-up"
2999
+ };
3000
+ const session = service.getSession(args.sessionId);
3001
+ if (!session) return {
3002
+ sessionId: args.sessionId,
3003
+ advice: "顾问群会话不存在或已过期,请重新发起 ask_advisors。",
3004
+ skipped: true,
3005
+ reason: "session-not-found"
3006
+ };
3007
+ if (service.hasReachedMaxRounds(session)) return {
3008
+ sessionId: session.id,
3009
+ advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
3010
+ skipped: true,
3011
+ reason: "max-rounds-reached"
3012
+ };
3013
+ service.appendFollowUp(session.id, args.followUp, sessionLog);
3014
+ await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
3015
+ const canContinue = !service.hasReachedMaxRounds(session);
3016
+ return {
3017
+ sessionId: session.id,
3018
+ advice: formatConversation(session) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
3019
+ skipped: false
3020
+ };
3021
+ }
3022
+ if (!args.question) return {
3023
+ sessionId: "",
3024
+ advice: "发起新讨论需要提供 question 参数。",
3025
+ skipped: true,
3026
+ reason: "missing-question"
3027
+ };
3028
+ if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
3029
+ const classification = classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
3030
+ appendShadowSample({
3031
+ ts: Date.now(),
3032
+ question: args.question,
3033
+ ...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
3034
+ shouldEscalate: classification.shouldEscalate,
3035
+ reason: classification.reason,
3036
+ suggestWebSearch: classification.suggestWebSearch,
3037
+ launched: classification.shouldEscalate
3038
+ });
3039
+ if (!classification.shouldEscalate) {
3040
+ if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
3041
+ sessionId: "",
3042
+ advice: `前置分类器建议不启动顾问群:${classification.reason}`,
3043
+ skipped: true,
3044
+ reason: "web-search-recommended"
3045
+ };
3046
+ return {
3047
+ sessionId: "",
3048
+ advice: `前置分类器建议不启动顾问群:${classification.reason}`,
3049
+ skipped: true,
3050
+ reason: "classifier-rejected"
3051
+ };
3052
+ }
3053
+ }
3054
+ if (args.advisorIds && args.advisorIds.length > 0) {
3055
+ const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
3056
+ if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) return {
3057
+ sessionId: "",
3058
+ advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
3059
+ skipped: true,
3060
+ reason: "no-matching-advisors"
3061
+ };
3062
+ }
3063
+ const costGuard = service.tryStartConsultation();
3064
+ if (!costGuard.ok) return {
3065
+ sessionId: "",
3066
+ advice: costGuard.reason,
3067
+ skipped: true,
3068
+ reason: "daily-limit-reached"
3069
+ };
3070
+ const session = service.createSession(args.question, args.context, args.advisorIds, sessionLog ? sessionLog.header.cwd : void 0, sessionLog ? sessionLog.id : void 0);
3071
+ if (sessionLog) appendAdvisorStart(sessionLog, session);
3072
+ const summary = await service.runAutoPipeline(session, exec.signal, sessionLog, exec.agent);
3073
+ return {
3074
+ sessionId: session.id,
3075
+ advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
3076
+ skipped: false
3077
+ };
3078
+ }
3079
+ }), defineTool({
3080
+ name: "toggle_advisor_group",
3081
+ description: "Enable or disable the advisor group plugin at runtime. Omit enabled to toggle the current state.",
3082
+ parameters: { enabled: {
3083
+ type: "boolean",
3084
+ description: "Optional desired state."
3085
+ } },
3086
+ output: {
3087
+ schema: {
3088
+ type: "object",
3089
+ additionalProperties: false,
3090
+ properties: { enabled: {
3091
+ type: "boolean",
3092
+ required: true
3093
+ } }
3094
+ },
3095
+ render: (_args, value) => [{
3096
+ type: "text",
3097
+ text: `顾问群插件已${value.enabled ? "启用" : "禁用"}。`
3098
+ }]
3099
+ },
3100
+ async execute(args) {
3101
+ const next = typeof args.enabled === "boolean" ? args.enabled : !service.isEnabled();
3102
+ return { enabled: await service.toggleEnabled(next) };
3103
+ }
3104
+ })];
3105
+ }
3106
+ function formatConversation(session) {
3107
+ const lines = [`顾问群对话(session: ${session.id})`, ""];
3108
+ for (const message of session.messages) {
3109
+ if (message.role === "system") continue;
3110
+ if (message.role === "main") lines.push(`主模型:${message.content}`);
3111
+ else if (message.role === "advisor") {
3112
+ const truncatedNote = message.truncated === void 0 ? "" : `(顾问输出在流式过程中被截断:${message.truncated.reason === "timeout" ? "超时" : "网络中断"},正文可能不完整)\n`;
3113
+ const toolNote = message.toolSteps && message.toolSteps.length > 0 ? `${message.toolSteps.map((step) => step.kind === "call" ? ` ⛭ 调用 ${step.name} · ${step.text}` : ` ↳ ${step.text}`).join("\n")}\n` : "";
3114
+ const body = message.content && message.content.trim() ? truncatedNote + toolNote + message.content : truncatedNote + toolNote + (message.thinking && message.thinking.trim() ? `(思维链)\n${message.thinking}` : "(顾问未返回正文)");
3115
+ lines.push(`【${message.advisorName ?? message.advisorId ?? "顾问"}】:${body}`);
3116
+ }
3117
+ lines.push("");
3118
+ }
3119
+ const stopNote = session.status === "cancelled" && session.stopReason ? `(注意:本次咨询被中断(${STOP_REASON_TEXT[session.stopReason] ?? session.stopReason});可在对话卡片点击「▶ 继续聊天」从断点续跑。)` : "";
3120
+ lines.push(stopNote);
3121
+ lines.push("(以上是顾问模型与主模型的对话记录,主模型可直接基于其中内容继续思考或追问。)");
3122
+ return lines.join("\n");
3123
+ }
3124
+ const STOP_REASON_TEXT = {
3125
+ "user-stop": "用户主动停止",
3126
+ "exec-cancel": "宿主取消",
3127
+ "abort-error": "流异常中断",
3128
+ timeout: "顾问响应超时",
3129
+ network: "网络中断"
3130
+ };
3131
+ //#endregion
3132
+ //#region src/settings-api.ts
3133
+ function sendJson(res, status, body) {
3134
+ res.writeHead(status, { "content-type": "application/json" });
3135
+ res.end(JSON.stringify(body));
3136
+ }
3137
+ /** Mask a stored API key so it never leaves the host in plain text. */
3138
+ function maskApiKey(apiKey) {
3139
+ if (!apiKey) return apiKey;
3140
+ if (apiKey.length <= 4) return "*".repeat(apiKey.length);
3141
+ return `${"*".repeat(apiKey.length - 4)}${apiKey.slice(-4)}`;
3142
+ }
3143
+ /**
3144
+ * Whether an inbound apiKey is a mask of the previously stored key.
3145
+ *
3146
+ * Accepts both the current length-preserving mask and the legacy 8-character
3147
+ * `****<last4>` mask so an unrefreshed settings page does not overwrite the
3148
+ * real key with a mask string after upgrading.
3149
+ */
3150
+ function isMaskedApiKey(incoming, previous) {
3151
+ if (!incoming || !previous) return false;
3152
+ if (incoming === maskApiKey(previous)) return true;
3153
+ if (previous.length <= 4) return incoming === "*".repeat(previous.length);
3154
+ return incoming === `****${previous.slice(-4)}`;
3155
+ }
3156
+ /**
3157
+ * Return a copy of the config safe to send to the browser.
3158
+ *
3159
+ * 0.1.2-rc.1 SecretField convergence: no key material (not even a mask) leaves
3160
+ * the host. `apiKey` is returned empty and per-provider presence facts
3161
+ * (`configured` + `last4`) ride in `apiKeyMetaByProvider`, so the card can
3162
+ * render the official "已配置/未配置" badge and publish new/cleared keys
3163
+ * without ever round-tripping a mask.
3164
+ */
3165
+ function sanitizeConfig(config) {
3166
+ return {
3167
+ ...config,
3168
+ advisors: config.advisors.map((advisor) => {
3169
+ const { apiKeysByProvider: _apiKeysByProvider, clearApiKey: _clearApiKey, apiKeyMetaByProvider: _meta, ...rest } = advisor;
3170
+ const meta = {};
3171
+ for (const [provider, key] of Object.entries(advisor.apiKeysByProvider ?? {})) if (key) meta[provider] = {
3172
+ configured: true,
3173
+ last4: key.slice(-4)
3174
+ };
3175
+ if (advisor.apiKey && advisor.provider) meta[advisor.provider] = {
3176
+ configured: true,
3177
+ last4: advisor.apiKey.slice(-4)
3178
+ };
3179
+ return {
3180
+ ...rest,
3181
+ apiKey: "",
3182
+ ...Object.keys(meta).length > 0 ? { apiKeyMetaByProvider: meta } : {}
3183
+ };
3184
+ })
3185
+ };
3186
+ }
3187
+ /** Credential-affecting fields: changing any of these invalidates a stored key. */
3188
+ function hasSameCredentialScope(a, b) {
3189
+ return a.provider === b.provider && normalizeBase(a.baseURL ?? "") === normalizeBase(b.baseURL ?? "") && (a.apiKeyEnv ?? "") === (b.apiKeyEnv ?? "") && (a.protocol ?? "openai") === (b.protocol ?? "openai") && (a.authMode ?? "x-api-key") === (b.authMode ?? "x-api-key");
3190
+ }
3191
+ /**
3192
+ * Reconcile inbound advisor config with the currently stored config.
3193
+ *
3194
+ * SecretField semantics (adopted from the official settings-card model):
3195
+ * - `clearApiKey: true` → drop the active key AND this provider's archived
3196
+ * copy (the explicit "清除" action).
3197
+ * - An empty/undefined apiKey on the same credential scope → KEEP the stored
3198
+ * key (an empty field never clears; the old mask-echo path is retained as a
3199
+ * defensive fallback only).
3200
+ * - A new plain-text key wins and is remembered for the current provider.
3201
+ * - Credential scope changed with no new key → restore from per-provider
3202
+ * history when one exists, otherwise the active key is undefined.
3203
+ * The transient `clearApiKey` flag is stripped before persistence.
3204
+ */
3205
+ function reconcileApiKeys(incoming, previous) {
3206
+ const previousById = new Map(previous.advisors.map((advisor) => [advisor.id, advisor]));
3207
+ return {
3208
+ ...incoming,
3209
+ advisors: incoming.advisors.map((advisor) => {
3210
+ const { clearApiKey, ...advisorRest } = advisor;
3211
+ const prev = previousById.get(advisor.id);
3212
+ if (!prev) return {
3213
+ ...advisorRest,
3214
+ apiKeysByProvider: advisorRest.apiKeysByProvider ?? {}
3215
+ };
3216
+ const history = { ...prev.apiKeysByProvider ?? {} };
3217
+ if (prev.apiKey && prev.provider) history[prev.provider] = prev.apiKey;
3218
+ const maskedSame = isMaskedApiKey(advisorRest.apiKey ?? void 0, prev.apiKey ?? void 0);
3219
+ const sameScope = hasSameCredentialScope(advisorRest, prev);
3220
+ let nextApiKey;
3221
+ if (clearApiKey === true) {
3222
+ nextApiKey = void 0;
3223
+ if (advisorRest.provider) delete history[advisorRest.provider];
3224
+ } else if (sameScope && maskedSame) nextApiKey = prev.apiKey;
3225
+ else if (advisorRest.apiKey && !isMaskedApiKey(advisorRest.apiKey ?? void 0, prev.apiKey ?? void 0)) {
3226
+ nextApiKey = advisorRest.apiKey;
3227
+ if (advisorRest.provider) history[advisorRest.provider] = advisorRest.apiKey;
3228
+ } else if (!sameScope) nextApiKey = advisorRest.provider ? history[advisorRest.provider] : void 0;
3229
+ else nextApiKey = prev.apiKey;
3230
+ const { apiKeyMetaByProvider: _inputMeta, ...clean } = advisorRest;
3231
+ return {
3232
+ ...clean,
3233
+ apiKey: nextApiKey,
3234
+ apiKeysByProvider: history
3235
+ };
3236
+ })
3237
+ };
3238
+ }
3239
+ function readBody(req, limit = 1048576) {
3240
+ return new Promise((resolve, reject) => {
3241
+ let data = "";
3242
+ req.on("data", (chunk) => {
3243
+ data += String(chunk);
3244
+ if (data.length > limit) {
3245
+ req.destroy();
3246
+ reject(/* @__PURE__ */ new Error("body too large"));
3247
+ }
3248
+ });
3249
+ req.on("end", () => resolve(data));
3250
+ req.on("error", reject);
3251
+ });
3252
+ }
3253
+ const LOOKUP_KEYS = [
3254
+ "provider",
3255
+ "baseURL",
3256
+ "apiKey",
3257
+ "apiKeyEnv",
3258
+ "protocol",
3259
+ "authMode",
3260
+ "advisorId"
3261
+ ];
3262
+ async function parseLookupParams(req) {
3263
+ const url = new URL(req.url ?? "", "http://localhost");
3264
+ const params = {};
3265
+ for (const key of LOOKUP_KEYS) params[key] = url.searchParams.get(key) ?? "";
3266
+ if (req.method === "POST") {
3267
+ const raw = await readBody(req, 1048576);
3268
+ if (raw) try {
3269
+ const body = JSON.parse(raw);
3270
+ for (const key of LOOKUP_KEYS) if (typeof body[key] === "string") params[key] = body[key];
3271
+ } catch {}
3272
+ }
3273
+ return params;
3274
+ }
3275
+ const ALLOWED_API_KEY_ENVS = new Set(Object.values(PROVIDER_PRESETS).map((preset) => preset.apiKeyEnv));
3276
+ function validateSecurity(input) {
3277
+ const ids = /* @__PURE__ */ new Set();
3278
+ for (const advisor of input.advisors) {
3279
+ if (ids.has(advisor.id)) return `顾问 id 重复:${advisor.id}`;
3280
+ ids.add(advisor.id);
3281
+ if (advisor.baseURL) {
3282
+ const url = advisor.baseURL.trim();
3283
+ if (!(url.startsWith("https://") || url.startsWith("http://127.0.0.1") || url.startsWith("http://localhost"))) return `顾问 ${advisor.id} 的 API 地址仅允许 https:// 或本机 http://127.0.0.1 / http://localhost`;
3284
+ }
3285
+ if (advisor.apiKeyEnv) {
3286
+ const env = advisor.apiKeyEnv.trim();
3287
+ if (!ALLOWED_API_KEY_ENVS.has(env)) return `顾问 ${advisor.id} 的 API Key 不被允许:${env}。请使用预设供应商对应的 API Key。`;
3288
+ }
3289
+ }
3290
+ return null;
3291
+ }
3292
+ function normalizeBase(baseURL) {
3293
+ return (baseURL ?? "").replace(/\/+$/, "");
3294
+ }
3295
+ /** Loopback hostnames explicitly permitted for local diagnostic HTTP. */
3296
+ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
3297
+ "127.0.0.1",
3298
+ "localhost",
3299
+ "::1"
3300
+ ]);
3301
+ /** IPv4 literals in non-public ranges (private, loopback, link-local, CGNAT meta). */
3302
+ const NON_PUBLIC_IPV4 = /^(?:10\.|127\.|169\.254\.|172\.(?:1[6-9]|2\d|3[0-1])\.|192\.168\.|0\.)/i;
3303
+ /** Well-known cloud metadata endpoints that must never be probed. */
3304
+ const METADATA_HOSTNAME = /(?:^|\.)(?:metadata\.google\.internal|metadata\.gcp\.internal|metadata\.azure\.com)$/i;
3305
+ function isIpLiteral(host) {
3306
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
3307
+ }
3308
+ /**
3309
+ * SSRF guard for the diagnostics endpoints (models list / connection test).
3310
+ *
3311
+ * Diagnostics accept a client-supplied baseURL at request time, so the same
3312
+ * https/loopback rule as `validateSecurity` is enforced HERE as well, plus
3313
+ * stricter host rules: no IP literals (private/loopback/link-local/metadata)
3314
+ * under https, and no cloud metadata hostnames. The fetch layer additionally
3315
+ * refuses redirects (`redirect: 'error'`). DNS-rebinding protection (a domain
3316
+ * resolving to an internal address) is out of scope for a local single-user
3317
+ * host and is documented as a known limit.
3318
+ *
3319
+ * @returns the rejection reason, or null when the base URL is acceptable.
3320
+ */
3321
+ function assertSafeDiagnosticBase(raw) {
3322
+ const trimmed = (raw ?? "").trim();
3323
+ if (!trimmed) return "缺少 baseURL";
3324
+ let url;
3325
+ try {
3326
+ url = new URL(trimmed);
3327
+ } catch {
3328
+ return "baseURL 不是合法 URL";
3329
+ }
3330
+ const host = url.hostname.toLowerCase();
3331
+ if (url.protocol === "https:") {
3332
+ if (isIpLiteral(host) || NON_PUBLIC_IPV4.test(host)) return "baseURL 不允许使用 IP 字面量";
3333
+ if (METADATA_HOSTNAME.test(host)) return "baseURL 不允许访问云元数据地址";
3334
+ return null;
3335
+ }
3336
+ if (url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(host)) return null;
3337
+ return "baseURL 仅允许 https:// 或本机 http://127.0.0.1 / http://localhost";
3338
+ }
3339
+ /**
3340
+ * Resolve the real API key for diagnostics (models list / connection test).
3341
+ *
3342
+ * The browser form can only ever hold a masked or empty key, so a direct
3343
+ * pass-through would send `****xxxx` (or nothing) to the provider and fail
3344
+ * with HTTP 401 — exactly what happens after switching a provider away and
3345
+ * back. The host keeps the real key (`advisor.apiKey` for the active scope +
3346
+ * `apiKeysByProvider` history), so when the inbound key is empty or a mask of
3347
+ * the stored key, substitute the stored one. A fresh plaintext key typed into
3348
+ * the form always wins.
3349
+ */
3350
+ function resolveDiagnosticApiKey(service, advisorId, provider, baseURL, apiKeyEnv, protocol, incomingApiKey) {
3351
+ if (!advisorId) return incomingApiKey;
3352
+ const stored = service.getConfig().advisors.find((advisor) => advisor.id === advisorId);
3353
+ if (!stored) return incomingApiKey;
3354
+ const storedKey = stored.provider === provider && normalizeBase(stored.baseURL ?? "") === normalizeBase(baseURL) && (stored.apiKeyEnv ?? "") === apiKeyEnv && (stored.protocol ?? "openai") === (protocol || "openai") ? stored.apiKey : stored.apiKeysByProvider?.[provider];
3355
+ if (!storedKey) return incomingApiKey;
3356
+ if (!incomingApiKey || isMaskedApiKey(incomingApiKey, storedKey)) return storedKey;
3357
+ return incomingApiKey;
3358
+ }
3359
+ async function queryOpenAiModels(baseURL, apiKey) {
3360
+ const base = normalizeBase(baseURL);
3361
+ const endpoint = base.endsWith("/models") ? base : `${base}/models`;
3362
+ const response = await fetch(endpoint, {
3363
+ headers: { authorization: `Bearer ${apiKey}` },
3364
+ signal: AbortSignal.timeout(15e3),
3365
+ redirect: "error"
3366
+ });
3367
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
3368
+ return ((await response.json()).data ?? []).map((item) => item.id ?? "").filter(Boolean);
3369
+ }
3370
+ async function queryAnthropicModels(baseURL, apiKey, authMode = "x-api-key") {
3371
+ const base = normalizeBase(baseURL);
3372
+ const endpoint = base.endsWith("/v1/models") ? base : base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`;
3373
+ const headers = { "anthropic-version": "2023-06-01" };
3374
+ if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
3375
+ else headers["x-api-key"] = apiKey;
3376
+ const response = await fetch(endpoint, {
3377
+ headers,
3378
+ signal: AbortSignal.timeout(15e3),
3379
+ redirect: "error"
3380
+ });
3381
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
3382
+ return ((await response.json()).data ?? []).map((item) => item.id ?? "").filter(Boolean);
3383
+ }
3384
+ async function queryGeminiModels(baseURL, apiKey) {
3385
+ const base = normalizeBase(baseURL);
3386
+ const endpoint = base.endsWith("/v1beta") ? `${base}/models?key=${apiKey}` : `${base}/v1beta/models?key=${apiKey}`;
3387
+ const response = await fetch(endpoint, {
3388
+ signal: AbortSignal.timeout(15e3),
3389
+ redirect: "error"
3390
+ });
3391
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
3392
+ return ((await response.json()).models ?? []).map((item) => (item.name ?? "").replace(/^models\//, "")).filter(Boolean);
3393
+ }
3394
+ async function handleModelsRequest(ctx, service, req, res) {
3395
+ const params = await parseLookupParams(req);
3396
+ const provider = params.provider ?? "";
3397
+ const baseURL = params.baseURL ?? "";
3398
+ const apiKey = params.apiKey ?? "";
3399
+ const apiKeyEnv = params.apiKeyEnv ?? "";
3400
+ const protocol = params.protocol ?? "openai";
3401
+ const advisorId = params.advisorId ?? "";
3402
+ if (!provider) {
3403
+ sendJson(res, 400, {
3404
+ ok: false,
3405
+ error: "缺少 provider 参数"
3406
+ });
3407
+ return;
3408
+ }
3409
+ try {
3410
+ if ((await ctx.llm.listProviders()).some((item) => item.id === provider)) {
3411
+ sendJson(res, 200, {
3412
+ ok: true,
3413
+ models: (await ctx.llm.listModels(provider)).map((item) => item.id)
3414
+ });
3415
+ return;
3416
+ }
3417
+ } catch {}
3418
+ const preset = Object.values(PROVIDER_PRESETS).find((item) => item.id === provider);
3419
+ const effectiveBase = normalizeBase(baseURL || preset?.baseURL);
3420
+ const effectiveEnv = apiKeyEnv || preset?.apiKeyEnv || "";
3421
+ const effectiveProtocol = protocol || preset?.protocol || "openai";
3422
+ const effectiveAuthMode = preset?.authMode ?? "x-api-key";
3423
+ const fetchBase = normalizeBase(preset?.modelsBaseURL || effectiveBase);
3424
+ const fetchProtocol = preset?.modelsBaseURL ? "openai" : effectiveProtocol;
3425
+ const fetchAuthMode = preset?.modelsAuthMode ?? (preset?.modelsBaseURL ? "bearer" : effectiveAuthMode);
3426
+ if (!effectiveBase || !apiKey && !effectiveEnv) {
3427
+ sendJson(res, 400, {
3428
+ ok: false,
3429
+ error: "该预设缺少 baseURL 或 API Key"
3430
+ });
3431
+ return;
3432
+ }
3433
+ if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
3434
+ sendJson(res, 400, {
3435
+ ok: false,
3436
+ error: "API Key 不能使用该环境变量名"
3437
+ });
3438
+ return;
3439
+ }
3440
+ const baseError = assertSafeDiagnosticBase(effectiveBase);
3441
+ if (baseError) {
3442
+ sendJson(res, 400, {
3443
+ ok: false,
3444
+ error: baseError
3445
+ });
3446
+ return;
3447
+ }
3448
+ const resolvedApiKey = resolveDiagnosticApiKey(service, advisorId, provider, baseURL, apiKeyEnv, protocol, apiKey) || (effectiveEnv ? process.env[effectiveEnv] ?? "" : "");
3449
+ if (!resolvedApiKey) {
3450
+ sendJson(res, 400, {
3451
+ ok: false,
3452
+ error: "API Key 未设置"
3453
+ });
3454
+ return;
3455
+ }
3456
+ try {
3457
+ let models = [];
3458
+ if (fetchProtocol === "anthropic") models = await queryAnthropicModels(fetchBase, resolvedApiKey, fetchAuthMode);
3459
+ else if (fetchProtocol === "gemini") models = await queryGeminiModels(fetchBase, resolvedApiKey);
3460
+ else models = await queryOpenAiModels(fetchBase, resolvedApiKey);
3461
+ sendJson(res, 200, {
3462
+ ok: true,
3463
+ models
3464
+ });
3465
+ } catch (error) {
3466
+ sendJson(res, 400, {
3467
+ ok: false,
3468
+ error: error instanceof Error ? error.message : String(error)
3469
+ });
3470
+ }
3471
+ }
3472
+ async function handleTestConnectionRequest(ctx, service, req, res) {
3473
+ const params = await parseLookupParams(req);
3474
+ const provider = params.provider ?? "";
3475
+ const baseURL = params.baseURL ?? "";
3476
+ const apiKey = params.apiKey ?? "";
3477
+ const apiKeyEnv = params.apiKeyEnv ?? "";
3478
+ const protocol = params.protocol ?? "openai";
3479
+ const advisorId = params.advisorId ?? "";
3480
+ if (!provider) {
3481
+ sendJson(res, 400, {
3482
+ ok: false,
3483
+ error: "缺少 provider 参数"
3484
+ });
3485
+ return;
3486
+ }
3487
+ try {
3488
+ if ((await ctx.llm.listProviders()).some((item) => item.id === provider)) {
3489
+ sendJson(res, 200, {
3490
+ ok: true,
3491
+ message: "连接成功",
3492
+ modelCount: (await ctx.llm.listModels(provider)).length
3493
+ });
3494
+ return;
3495
+ }
3496
+ } catch {}
3497
+ const preset = Object.values(PROVIDER_PRESETS).find((item) => item.id === provider);
3498
+ const effectiveBase = normalizeBase(baseURL || preset?.baseURL);
3499
+ const effectiveEnv = apiKeyEnv || preset?.apiKeyEnv || "";
3500
+ const effectiveProtocol = protocol || preset?.protocol || "openai";
3501
+ const effectiveAuthMode = preset?.authMode ?? "x-api-key";
3502
+ const fetchBase = normalizeBase(preset?.modelsBaseURL || effectiveBase);
3503
+ const fetchProtocol = preset?.modelsBaseURL ? "openai" : effectiveProtocol;
3504
+ const fetchAuthMode = preset?.modelsAuthMode ?? (preset?.modelsBaseURL ? "bearer" : effectiveAuthMode);
3505
+ if (!effectiveBase || !apiKey && !effectiveEnv) {
3506
+ sendJson(res, 400, {
3507
+ ok: false,
3508
+ error: "该预设缺少 baseURL 或 API Key"
3509
+ });
3510
+ return;
3511
+ }
3512
+ if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
3513
+ sendJson(res, 400, {
3514
+ ok: false,
3515
+ error: "API Key 不能使用该环境变量名"
3516
+ });
3517
+ return;
3518
+ }
3519
+ const baseError = assertSafeDiagnosticBase(effectiveBase);
3520
+ if (baseError) {
3521
+ sendJson(res, 400, {
3522
+ ok: false,
3523
+ error: baseError
3524
+ });
3525
+ return;
3526
+ }
3527
+ const resolvedApiKey = resolveDiagnosticApiKey(service, advisorId, provider, baseURL, apiKeyEnv, protocol, apiKey) || (effectiveEnv ? process.env[effectiveEnv] ?? "" : "");
3528
+ if (!resolvedApiKey) {
3529
+ sendJson(res, 400, {
3530
+ ok: false,
3531
+ error: "API Key 未设置"
3532
+ });
3533
+ return;
3534
+ }
3535
+ try {
3536
+ let models = [];
3537
+ if (fetchProtocol === "anthropic") models = await queryAnthropicModels(fetchBase, resolvedApiKey, fetchAuthMode);
3538
+ else if (fetchProtocol === "gemini") models = await queryGeminiModels(fetchBase, resolvedApiKey);
3539
+ else models = await queryOpenAiModels(fetchBase, resolvedApiKey);
3540
+ sendJson(res, 200, {
3541
+ ok: true,
3542
+ message: "连接成功",
3543
+ modelCount: models.length
3544
+ });
3545
+ } catch (error) {
3546
+ sendJson(res, 400, {
3547
+ ok: false,
3548
+ error: error instanceof Error ? error.message : String(error)
3549
+ });
3550
+ }
3551
+ }
3552
+ function currentRevision(scopedCtx, namespace) {
3553
+ return scopedCtx.settings.describe().find((descriptor) => descriptor.ns === namespace)?.revision ?? 0;
3554
+ }
3555
+ function handleStreamRequest(req, res) {
3556
+ const url = new URL(req.url ?? "", "http://localhost");
3557
+ const sessionId = url.searchParams.get("sessionId") ?? "";
3558
+ if (!sessionId) {
3559
+ res.writeHead(400, { "content-type": "application/json" });
3560
+ res.end(JSON.stringify({
3561
+ ok: false,
3562
+ error: "缺少 sessionId"
3563
+ }));
3564
+ return Promise.resolve();
3565
+ }
3566
+ const lastEventIdRaw = url.searchParams.get("lastEventId") ?? req.headers["last-event-id"] ?? "0";
3567
+ const lastEventId = Number.parseInt(String(lastEventIdRaw), 10) || 0;
3568
+ const bootId = url.searchParams.get("bootId") ?? void 0;
3569
+ res.writeHead(200, {
3570
+ "content-type": "text/event-stream",
3571
+ "cache-control": "no-cache",
3572
+ connection: "keep-alive",
3573
+ "access-control-allow-origin": "*",
3574
+ "x-accel-buffering": "no"
3575
+ });
3576
+ res.write(": connected\n\n");
3577
+ return new Promise((resolve) => {
3578
+ res.on("close", resolve);
3579
+ subscribe(sessionId, res, lastEventId, bootId);
3580
+ });
3581
+ }
3582
+ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3583
+ const namespace = "advisor-group";
3584
+ return async (req, res) => {
3585
+ const rawUrl = req.url ?? "";
3586
+ const url = new URL(rawUrl, "http://localhost");
3587
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
3588
+ const token = String(req.headers["x-advisor-group-token"] ?? url.searchParams.get("token") ?? "");
3589
+ if (authToken && token !== authToken) {
3590
+ sendJson(res, 401, {
3591
+ ok: false,
3592
+ error: "unauthorized"
3593
+ });
3594
+ return;
3595
+ }
3596
+ if (req.method === "GET" && (pathname === "/advisor-group/stream" || pathname === "/advisor-group/stream/")) {
3597
+ await handleStreamRequest(req, res);
3598
+ return;
3599
+ }
3600
+ if ((req.method === "GET" || req.method === "POST") && (pathname === "/advisor-group/test-connection" || pathname === "/advisor-group/test-connection/")) {
3601
+ await handleTestConnectionRequest(ctx, service, req, res);
3602
+ return;
3603
+ }
3604
+ if ((req.method === "GET" || req.method === "POST") && (pathname === "/advisor-group/models" || pathname === "/advisor-group/models/")) {
3605
+ await handleModelsRequest(ctx, service, req, res);
3606
+ return;
3607
+ }
3608
+ if (req.method === "GET" && (pathname === "/advisor-group/providers" || pathname === "/advisor-group/providers/")) {
3609
+ await sendProviders(ctx, res);
3610
+ return;
3611
+ }
3612
+ if (req.method === "POST" && (pathname === "/advisor-group/stop" || pathname === "/advisor-group/stop/")) {
3613
+ try {
3614
+ const raw = await readBody(req);
3615
+ const parsed = JSON.parse(raw);
3616
+ if (typeof parsed.sessionId !== "string" || !parsed.sessionId) {
3617
+ sendJson(res, 400, {
3618
+ ok: false,
3619
+ error: "缺少 sessionId"
3620
+ });
3621
+ return;
3622
+ }
3623
+ sendJson(res, 200, {
3624
+ ok: true,
3625
+ stopped: service.stopConsultation(parsed.sessionId)
3626
+ });
3627
+ } catch (error) {
3628
+ sendJson(res, 400, {
3629
+ ok: false,
3630
+ error: error instanceof Error ? error.message : String(error)
3631
+ });
3632
+ }
3633
+ return;
3634
+ }
3635
+ if (req.method === "POST" && (pathname === "/advisor-group/resume" || pathname === "/advisor-group/resume/")) {
3636
+ try {
3637
+ const raw = await readBody(req);
3638
+ const parsed = JSON.parse(raw);
3639
+ if (typeof parsed.sessionId !== "string" || !parsed.sessionId) {
3640
+ sendJson(res, 400, {
3641
+ ok: false,
3642
+ error: "缺少 sessionId"
3643
+ });
3644
+ return;
3645
+ }
3646
+ const result = service.resumeConsultation(parsed.sessionId);
3647
+ sendJson(res, result.ok ? 200 : 400, {
3648
+ ok: result.ok,
3649
+ ...result.reason ? { error: result.reason } : {}
3650
+ });
3651
+ } catch (error) {
3652
+ sendJson(res, 400, {
3653
+ ok: false,
3654
+ error: error instanceof Error ? error.message : String(error)
3655
+ });
3656
+ }
3657
+ return;
3658
+ }
3659
+ if (req.method === "GET" && (pathname === "/advisor-group/shadow" || pathname === "/advisor-group/shadow/")) {
3660
+ sendJson(res, 200, {
3661
+ ok: true,
3662
+ ...await readShadowSamples(200)
3663
+ });
3664
+ return;
3665
+ }
3666
+ if (req.method === "GET" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
3667
+ sendJson(res, 200, {
3668
+ ok: true,
3669
+ config: sanitizeConfig(service.getConfig()),
3670
+ revision: currentRevision(scopedCtx, namespace),
3671
+ dailyGuard: service.getDailyGuard()
3672
+ });
3673
+ return;
3674
+ }
3675
+ if (req.method === "POST" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
3676
+ try {
3677
+ const raw = await readBody(req);
3678
+ const parsed = JSON.parse(raw);
3679
+ const configInput = parsed.config ?? parsed;
3680
+ const expectedRevision = typeof parsed.expectedRevision === "number" ? parsed.expectedRevision : currentRevision(scopedCtx, namespace);
3681
+ const reconciled = reconcileApiKeys(Config(configInput), service.getConfig());
3682
+ const securityError = validateSecurity(reconciled);
3683
+ if (securityError) {
3684
+ sendJson(res, 400, {
3685
+ ok: false,
3686
+ error: securityError
3687
+ });
3688
+ return;
3689
+ }
3690
+ await scopedCtx.settings.replace(namespace, reconciled, expectedRevision);
3691
+ service.setConfig(reconciled);
3692
+ sendJson(res, 200, {
3693
+ ok: true,
3694
+ config: sanitizeConfig(service.getConfig()),
3695
+ revision: currentRevision(scopedCtx, namespace)
3696
+ });
3697
+ } catch (error) {
3698
+ if (error instanceof SettingsConflictError) {
3699
+ sendJson(res, 409, {
3700
+ ok: false,
3701
+ error: "配置已被其他窗口修改,请刷新后重试。"
3702
+ });
3703
+ return;
3704
+ }
3705
+ sendJson(res, 400, {
3706
+ ok: false,
3707
+ error: error instanceof Error ? error.message : String(error)
3708
+ });
3709
+ }
3710
+ return;
3711
+ }
3712
+ sendJson(res, 404, {
3713
+ ok: false,
3714
+ error: "not found"
3715
+ });
3716
+ };
3717
+ }
3718
+ async function sendProviders(ctx, res) {
3719
+ const providers = [];
3720
+ try {
3721
+ const llmProviders = await ctx.llm.listProviders();
3722
+ const llmResults = await Promise.all(llmProviders.map(async (provider) => {
3723
+ let models = [];
3724
+ try {
3725
+ models = (await ctx.llm.listModels(provider.id)).map((model) => model.id);
3726
+ } catch {}
3727
+ return {
3728
+ id: provider.id,
3729
+ name: provider.name,
3730
+ kind: "llm",
3731
+ models
3732
+ };
3733
+ }));
3734
+ for (const item of llmResults) if (!providers.some((entry) => entry.id === item.id)) providers.push(item);
3735
+ } catch {}
3736
+ for (const preset of Object.values(PROVIDER_PRESETS)) if (!providers.some((item) => item.id === preset.id)) providers.push({
3737
+ id: preset.id,
3738
+ name: preset.label,
3739
+ kind: "preset",
3740
+ models: preset.defaultModels,
3741
+ baseURL: preset.baseURL,
3742
+ apiKeyEnv: preset.apiKeyEnv,
3743
+ protocol: preset.protocol
3744
+ });
3745
+ sendJson(res, 200, {
3746
+ ok: true,
3747
+ providers
3748
+ });
3749
+ }
3750
+ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
3751
+ const inject = ctx.inject;
3752
+ if (!inject) return;
3753
+ inject(["settings", "webServer"], (scoped) => {
3754
+ const scopedCtx = scoped;
3755
+ const namespace = "advisor-group";
3756
+ const scope = scopedCtx.settings.register(namespace, Config, { base: config });
3757
+ service.setConfig(scope.get());
3758
+ service.setPersistEnabled(async (enabled) => {
3759
+ await scopedCtx.settings.update(namespace, { enabled });
3760
+ });
3761
+ const watchDisposer = scope.watch((next) => {
3762
+ service.setConfig(next);
3763
+ });
3764
+ const authToken = randomUUID();
3765
+ const indexTapDisposer = scopedCtx.webServer.tapIndex ? scopedCtx.webServer.tapIndex((html) => html.replace("<head>", `<head><script>globalThis.__ADVISOR_GROUP_TOKEN__ = ${JSON.stringify(authToken)}<\/script>`)) : null;
3766
+ const routeDisposer = scopedCtx.webServer.register({
3767
+ kind: "prefix",
3768
+ path: "/advisor-group",
3769
+ handler: handleConfigRequest(ctx, scopedCtx, service, authToken)
3770
+ });
3771
+ ctx.effect(() => () => {
3772
+ watchDisposer();
3773
+ routeDisposer();
3774
+ indexTapDisposer?.();
3775
+ }, "dsh-advisor-group: settings watcher + config routes");
3776
+ });
3777
+ }
3778
+ //#endregion
3779
+ //#region src/prompt.ts
3780
+ /**
3781
+ * Build the system-prompt section that teaches the main model when to call
3782
+ * ask_advisors. Keep it concise: it is injected into every model step.
3783
+ */
3784
+ function buildAdvisorPrompt(config, repeatPressure) {
3785
+ if (!config.enabled || config.advisors.length === 0) return "";
3786
+ const advisorNames = config.advisors.slice(0, 3).map((advisor) => advisor.name).join("、");
3787
+ const lines = [
3788
+ "## 顾问群使用边界",
3789
+ "你可以调用 ask_advisors 向专家顾问请教。请遵守以下边界:",
3790
+ "",
3791
+ "1. 优先调用 ask_advisors 的情况:",
3792
+ " - 专业/长尾世界知识:法律、医疗、金融、税务、行业经验、复杂理论等;",
3793
+ " - 高风险判断:医疗建议、法律承诺、投资决策、安全漏洞评估等;",
3794
+ " - 你对自己的答案没有把握(confidence 低于阈值);",
3795
+ " - 需要多个视角或资深经验才能给出可靠结论;",
3796
+ " - 用户消息中明确 @顾问群、点名“顾问群/顾问模型”,或要求专家会诊:必须立即调用,不要跳过。",
3797
+ "2. 不要调用 ask_advisors 的情况:",
3798
+ " - 可以直接联网查证的事实:新闻、价格、天气、API 文档、版本号等;",
3799
+ " - 简单常识、日常闲聊、纯创意;",
3800
+ " - 用户明确要求快速回答、不需要专家讨论。",
3801
+ "3. 如果前置分类器建议先联网搜索,请先执行 web search;只有搜索无法解决或属于专业/高风险时再调用 ask_advisors。",
3802
+ "4. 当你对自己没有把握时:先自评一个 0-1 的 confidence,并在调用 ask_advisors 时传入 confidence 字段,例如 confidence: 0.4。",
3803
+ "5. 调用 ask_advisors 时,context 参数必须包含两部分背景信息:",
3804
+ " a) 当前项目的大致情况(领域/目标/技术栈/关键约束,一到三句话概括);",
3805
+ " b) 遇到问题的具体细节(做了什么、期望什么、实际发生了什么、已尝试哪些步骤、相关报错或现象)。",
3806
+ " 如果某项信息确实未知,明确写“该项背景未知”,不要留空 context,也不要让顾问在缺少背景的情况下猜测。",
3807
+ "6. 拿到顾问结论后:自行判断、综合并执行,不要原样复制整段顾问回复。",
3808
+ "7. 发起新咨询:插件会自动进行多轮深挖(默认 autoDeepen:每轮结束由驱动模型基于全部讨论生成更深入的追问,顾问按顺序 A→B→C 接力回答,直到 maxRounds 轮),并自动产出综合结论——你无需再手动 followUp;只有想追加一轮固定主题时才传 sessionId+followUp。"
3809
+ ];
3810
+ if (repeatPressure && repeatPressure.count >= 3) lines.push("", `【强制升级】当前会话中同一问题已被用户重复 ${repeatPressure.count} 次仍未解决。你必须立即调用 ask_advisors,把用户最新一次的问题原样作为 question 传入,并在 context 中补充项目概况与已尝试步骤;不得再让用户重复或自行空转。`);
3811
+ else if (repeatPressure && repeatPressure.count > 1) lines.push("", `【提醒】当前会话中同一问题已出现 ${repeatPressure.count} 次。若本轮仍无法解决,必须调用 ask_advisors 升级到顾问群。`);
3812
+ lines.push(`当前可用的顾问:${advisorNames}(完整列表见设置页)。`);
3813
+ return lines.join("\n");
3814
+ }
3815
+ //#endregion
3816
+ //#region src/session-events-host.ts
3817
+ /**
3818
+ * Host-only registration for the advisor-group session event vocabulary.
3819
+ *
3820
+ * The official 0.1.2-rc.1 compatibility mechanism is the per-event
3821
+ * `SessionEvent.ignorable: true` envelope marker: `dsh-session-persistence`
3822
+ * refuses unknown event types unless `KNOWN_SESSION_EVENT_TYPES.has(type)`
3823
+ * or the stored event is marked ignorable. However `Session.append()` exposes
3824
+ * options only for surface events, so a plugin writer currently cannot set
3825
+ * `ignorable` on its own log-only events through the public API.
3826
+ *
3827
+ * This module mutates the runtime `KNOWN_SESSION_EVENT_TYPES` Set so
3828
+ * advisor-group events survive restart/resume. This is a pragmatic workaround
3829
+ * for the current rc API; if a future DSH version lets plugin appends carry
3830
+ * `ignorable` (or adds an official plugin-event registration surface), replace
3831
+ * this module instead of mutating shared state.
3832
+ */
3833
+ const ADVISOR_GROUP_EVENT_TYPES = [
3834
+ "advisor-group/start",
3835
+ "advisor-group/message",
3836
+ "advisor-group/delta",
3837
+ "advisor-group/end",
3838
+ "advisor-group/resume"
3839
+ ];
3840
+ function ensureAdvisorGroupSessionEventTypes() {
3841
+ const known = KNOWN_SESSION_EVENT_TYPES;
3842
+ if (!known || typeof known !== "object" || typeof known.add !== "function" || Object.isFrozen(known)) throw new Error("dsh-advisor-group: KNOWN_SESSION_EVENT_TYPES is not mutable. The plugin cannot safely register durable advisor-group events. Please update the plugin when DSH provides an official plugin-event registration API.");
3843
+ const set = known;
3844
+ for (const type of ADVISOR_GROUP_EVENT_TYPES) set.add(type);
3845
+ }
3846
+ ensureAdvisorGroupSessionEventTypes();
3847
+ //#endregion
3848
+ //#region src/index.ts
3849
+ const name = "dsh-advisor-group";
3850
+ const inject = [
3851
+ "tools",
3852
+ "llm",
3853
+ "systemPrompt",
3854
+ "sessions"
3855
+ ];
3856
+ function apply(ctx, config) {
3857
+ const service = new AdvisorGroupService(ctx, config);
3858
+ registerAdvisorSettingsAndRoutes(ctx, config, service);
3859
+ for (const tool of registerAdvisorTools(service)) ctx.tools.register(tool);
3860
+ const systemPrompt = ctx.systemPrompt;
3861
+ if (systemPrompt) ctx.effect(() => systemPrompt.section({
3862
+ name: "advisor-group-boundary",
3863
+ order: 150,
3864
+ text: (assembly) => {
3865
+ const sessionId = assembly?.agent?.session?.id;
3866
+ return buildAdvisorPrompt(service.getConfig(), service.getRepeatPressure(sessionId));
3867
+ }
3868
+ }), "dsh-advisor-group: system prompt section");
3869
+ }
3870
+ //#endregion
3871
+ export { Config, apply, inject, name };