dsh-advisor-group 0.1.0 → 0.1.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 CHANGED
@@ -1,12 +1,12 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
2
  import { randomUUID } from "node:crypto";
3
- import { promises, readFileSync } from "node:fs";
3
+ import { mkdirSync, promises, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, isAbsolute, join } from "node:path";
6
+ import { KNOWN_SESSION_EVENT_TYPES, Session } from "@deepseek-ai/dsh-session";
6
7
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
8
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
9
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
9
- import { KNOWN_SESSION_EVENT_TYPES } from "@deepseek-ai/dsh-session";
10
10
  //#region src/defaults.ts
11
11
  const DEFAULT_ADVISOR_PROMPT = `
12
12
  你是顾问群中的资深专家。你的任务不是替用户执行,而是提供高质量的专业意见供主模型参考。
@@ -47,7 +47,19 @@ const AdvisorConfig = Schema.object({
47
47
  clearApiKey: Schema.boolean().description("Transient client intent: clear the current provider direct key. Stripped before persistence."),
48
48
  apiKeyEnv: Schema.string().description("Optional env var name for direct-http fallback."),
49
49
  protocol: ProviderProtocol,
50
- authMode: ProviderAuthMode.description("Anthropic auth header style: x-api-key or bearer.")
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.")
51
63
  });
52
64
  const DriverModel = Schema.object({
53
65
  provider: Schema.string().required().description("Provider route of the fallback driver model."),
@@ -60,12 +72,34 @@ const DiscussionConfig = Schema.object({
60
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."),
61
73
  driverModel: Schema.union([DriverModel, Schema.const(void 0)]).description("Fallback driver model (provider/model) used when the session header cannot be read."),
62
74
  stopOnConsensus: Schema.boolean().default(false).description("Deprecated: dormant under the auto-deepen pipeline; kept for stored-config compatibility."),
63
- advisorTimeoutMs: Schema.number().min(1e3).max(6e5).default(12e4).description("Per-advisor call timeout in milliseconds (default 120000).")
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.")
64
82
  });
65
83
  const TriggerConfig = Schema.object({
66
84
  requireClassifier: Schema.boolean().default(true).description("Run the pre-classifier before starting a consultation."),
67
85
  allowWebFallback: Schema.boolean().default(true).description("When classifier says web search is better, return that hint to the main model."),
68
- confidenceThreshold: Schema.number().min(0).max(1).default(.6).description("Below this confidence score, ask_advisors should escalate even without domain keywords.")
86
+ confidenceThreshold: Schema.number().min(0).max(1).default(.6).description("Below this confidence score, ask_advisors should escalate even without domain keywords."),
87
+ jev: Schema.object({
88
+ enabled: Schema.boolean().default(false).description("Use Jev for semantic pre-classification when configured."),
89
+ provider: Schema.union([Schema.const("typesafe"), Schema.const("openrouter")]).default("typesafe"),
90
+ model: Schema.string().default("jev-latest"),
91
+ baseURL: Schema.string().description("Optional custom Jev API endpoint."),
92
+ apiKey: Schema.string().role("secret").description("Jev API key; prefer apiKeyEnv when possible."),
93
+ apiKeyEnv: Schema.string().description("Optional environment variable containing the Jev API key."),
94
+ timeoutMs: Schema.number().min(1e3).max(6e4).default(1e4),
95
+ confidenceThreshold: Schema.number().min(0).max(1).default(.6),
96
+ useEnglishState: Schema.boolean().default(false).description("Judge with the caller-provided English gist (questionEn) as the Jev state instead of the Chinese question; display and session records stay Chinese."),
97
+ highRiskThreshold: Schema.number().min(0).max(1).description("Threshold for the high_risk question; unset means follow confidenceThreshold."),
98
+ apiKeyMeta: Schema.object({
99
+ configured: Schema.boolean(),
100
+ last4: Schema.string()
101
+ }).description("Server-only API key presence metadata.")
102
+ }).default({})
69
103
  });
70
104
  const UiConfig = Schema.object({
71
105
  theme: Schema.union([
@@ -76,13 +110,24 @@ const UiConfig = Schema.object({
76
110
  showTimestamps: Schema.boolean().default(true),
77
111
  autoExpand: Schema.boolean().default(true)
78
112
  });
113
+ const QuotaConfig = Schema.object({
114
+ enabled: Schema.boolean().default(true).description("Enable the daily new-consultation cap (cost safety valve)."),
115
+ maxPerDay: Schema.natural().min(1).max(1e5).default(50).description("Max new consultations per UTC day (1–100000); ignored when enabled is false.")
116
+ });
79
117
  const Config = Schema.object({
80
- enabled: Schema.boolean().default(true),
81
- discussion: DiscussionConfig,
82
- trigger: TriggerConfig,
83
- ui: UiConfig,
84
- advisors: Schema.array(AdvisorConfig).default([])
118
+ enabled: Schema.boolean().default(true).volatile(),
119
+ discussion: DiscussionConfig.volatile(),
120
+ trigger: TriggerConfig.volatile(),
121
+ ui: UiConfig.volatile(),
122
+ quota: QuotaConfig.volatile(),
123
+ advisors: Schema.array(AdvisorConfig).default([]).volatile()
85
124
  });
125
+ function unwrapVolatileConfig(value) {
126
+ if (value && typeof value === "object" && "get" in value && typeof value.get === "function") return unwrapVolatileConfig(value.get());
127
+ if (Array.isArray(value)) return value.map((item) => unwrapVolatileConfig(item));
128
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrapVolatileConfig(item)]));
129
+ return value;
130
+ }
86
131
  //#endregion
87
132
  //#region src/providers/advisor-prompt.ts
88
133
  /**
@@ -96,7 +141,23 @@ const ADVISOR_OUTPUT_POLICY = `
96
141
  回答要求:
97
142
  - 先给结论,再简要说明依据;
98
143
  - 在回答末尾用一行标注:置信度(0-10)或不确定性;
99
- - 如果没有把握,请明确写“不确定”,不要强行编造。
144
+ - 如果没有把握,请明确写“不确定”,不要强行编造;
145
+ - **全程使用中文输出**:包括调用工具前的行动说明、正文与最终结论(代码、路径、专业术语可保留原文);不要把“我接下来要做什么”的说明写成英文;
146
+ - 思考(思维链)请保持精简:只列关键推理要点,切勿让思考链占满输出预算——必须保证正文完整(历史教训:长思考+长上下文会耗尽输出上限,导致正文丢失/中途截断);
147
+ - **最终答复必须输出一段完整的中文正文回答**:不要只输出工具调用描述/标记,不要输出空白或空消息——即使本轮没有新结论,也要用正文说明你的判断(这是硬性输出约束)。
148
+ `;
149
+ /**
150
+ * Tool-calling guidance appended to an advisor's system prompt ONLY when the
151
+ * advisor actually has tools configured and the session-visible set resolved
152
+ * (callAdvisor adds it on the direct-http tool loop path).
153
+ */
154
+ const ADVISOR_TOOL_GUIDANCE = `
155
+
156
+ 工具使用指引:
157
+ - 你可以调用当前会话提供的工具(检索/读取/搜索等)来辅助回答;
158
+ - 对于你不了解、不熟悉的内容,尤其是项目背景、代码细节、仓库状态等不在你知识范围内的情况,请优先使用联网工具(如 web_search、web_fetch)搜索核实,再基于实际信息给出意见;
159
+ - 仅在确有必要时调用工具:先了解现状,再下结论;不要编造你未核实的事实;
160
+ - **调用工具前必须先用一句中文说明你准备做什么**(行动说明),例如“我先读取 package.json 确认版本”;这段行动说明会显示在卡片上,不要用英文写。
100
161
  `;
101
162
  /**
102
163
  * Sequential-relay role hint appended to one advisor's system prompt.
@@ -129,6 +190,18 @@ function withTimeout(ms, signal) {
129
190
  if (!signal) return timeout;
130
191
  return AbortSignal.any([signal, timeout]);
131
192
  }
193
+ /**
194
+ * Same combination, but keeps the timeout signal reachable so the caller can
195
+ * classify an aborted stream: timeout fired -> graceful truncation (keep the
196
+ * partial thinking, mark `truncated`); caller signal fired -> cancellation.
197
+ */
198
+ function timeoutSignalPair(ms, signal) {
199
+ const timeout = AbortSignal.timeout(ms);
200
+ return {
201
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
202
+ isTimeout: () => timeout.aborted
203
+ };
204
+ }
132
205
  //#endregion
133
206
  //#region src/providers/ctx-llm.ts
134
207
  function formatTranscript(entries) {
@@ -140,6 +213,13 @@ function formatTranscript(entries) {
140
213
  * This is the primary path: it reuses whatever provider routes the user has
141
214
  * already configured in DSH (deepseek-official, llm-pi-ai providers, etc.),
142
215
  * including credentials resolved by the harness.
216
+ *
217
+ * Timeout semantics (root cause of "thinking cut mid-sentence, no body"): a
218
+ * long-reasoning model can spend the whole advisor budget on its thinking
219
+ * chain. When `timeoutMs` fires, the stream simply stops (it does not throw),
220
+ * which used to produce a silent empty body. We now detect the timeout and
221
+ * report `truncated` so the caller can surface it to the user instead of
222
+ * treating the reply as complete. A caller-initiated abort still propagates.
143
223
  */
144
224
  async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs) {
145
225
  const userText = formatTranscript(transcript);
@@ -148,26 +228,54 @@ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutM
148
228
  type: "text",
149
229
  text: userText
150
230
  }],
151
- source: {
152
- kind: "plugin",
153
- plugin: "dsh-advisor-group"
154
- }
231
+ source: { kind: "user" }
155
232
  })];
233
+ const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
234
+ const reasoningEffort = advisor.thinking === "disabled" ? "off" : advisor.reasoningEffort;
156
235
  const options = {
157
236
  provider: advisor.provider,
158
237
  model: advisor.model,
159
238
  system: `${advisor.systemPrompt}${ADVISOR_OUTPUT_POLICY}`,
160
239
  messages,
240
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort },
161
241
  temperature: advisor.temperature,
162
- maxTokens: advisor.maxTokens,
163
- signal: withTimeout(timeoutMs ?? 12e4, signal)
242
+ maxTokens: advisor.maxTokens ?? 16384,
243
+ signal: pair.signal
164
244
  };
165
245
  let text = "";
166
- for await (const chunk of ctx.llm.stream(options)) if (chunk.type === "text-delta") {
167
- text += chunk.text;
168
- onDelta?.({ text: chunk.text });
169
- } else if (chunk.type === "reasoning-delta") onDelta?.({ thinking: chunk.text });
170
- return text;
246
+ let thinking = "";
247
+ try {
248
+ for await (const chunk of ctx.llm.stream(options)) if (chunk.type === "text-delta") {
249
+ text += chunk.text;
250
+ onDelta?.({ text: chunk.text });
251
+ } else if (chunk.type === "reasoning-delta") {
252
+ thinking += chunk.text;
253
+ onDelta?.({ thinking: chunk.text });
254
+ }
255
+ } catch (error) {
256
+ if (pair.isTimeout()) return {
257
+ content: text,
258
+ thinking,
259
+ truncated: {
260
+ reason: "timeout",
261
+ atMs: Date.now()
262
+ }
263
+ };
264
+ throw error;
265
+ }
266
+ if (pair.isTimeout()) return {
267
+ content: text,
268
+ thinking,
269
+ truncated: {
270
+ reason: "timeout",
271
+ atMs: Date.now()
272
+ }
273
+ };
274
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
275
+ return {
276
+ content: text,
277
+ thinking
278
+ };
171
279
  }
172
280
  //#endregion
173
281
  //#region src/providers/presets.ts
@@ -178,7 +286,11 @@ const PROVIDER_PRESETS = {
178
286
  protocol: "openai",
179
287
  baseURL: "https://api.deepseek.com",
180
288
  apiKeyEnv: "DEEPSEEK_API_KEY",
181
- defaultModels: ["deepseek-v4-flash", "deepseek-v4-pro"],
289
+ defaultModels: [
290
+ "deepseek-v4-flash",
291
+ "deepseek-v4-pro",
292
+ "deepseek-v4-flash-vision-exp"
293
+ ],
182
294
  notes: "Also available as deepseek-official via dsh-llm-deepseek, and Anthropic-compatible at https://api.deepseek.com/anthropic."
183
295
  },
184
296
  "deepseek-anthropic": {
@@ -187,7 +299,11 @@ const PROVIDER_PRESETS = {
187
299
  protocol: "anthropic",
188
300
  baseURL: "https://api.deepseek.com/anthropic",
189
301
  apiKeyEnv: "DEEPSEEK_API_KEY",
190
- defaultModels: ["deepseek-v4-flash", "deepseek-v4-pro"],
302
+ defaultModels: [
303
+ "deepseek-v4-flash",
304
+ "deepseek-v4-pro",
305
+ "deepseek-v4-flash-vision-exp"
306
+ ],
191
307
  authMode: "x-api-key",
192
308
  modelsBaseURL: "https://api.deepseek.com",
193
309
  modelsAuthMode: "bearer",
@@ -199,7 +315,12 @@ const PROVIDER_PRESETS = {
199
315
  protocol: "openai",
200
316
  baseURL: "https://api.moonshot.cn/v1",
201
317
  apiKeyEnv: "MOONSHOT_API_KEY",
202
- defaultModels: ["kimi-k2.6", "kimi-k2.5"],
318
+ defaultModels: [
319
+ "kimi-k3",
320
+ "kimi-k2.7-code",
321
+ "kimi-k2.7-code-highspeed",
322
+ "kimi-k2.6"
323
+ ],
203
324
  notes: "OpenAI-compatible; Anthropic-compatible endpoint at https://api.moonshot.cn/anthropic"
204
325
  },
205
326
  "moonshot-anthropic": {
@@ -208,7 +329,12 @@ const PROVIDER_PRESETS = {
208
329
  protocol: "anthropic",
209
330
  baseURL: "https://api.moonshot.cn/anthropic",
210
331
  apiKeyEnv: "MOONSHOT_API_KEY",
211
- defaultModels: ["kimi-k2.6", "kimi-k2.5"],
332
+ defaultModels: [
333
+ "kimi-k3",
334
+ "kimi-k2.7-code",
335
+ "kimi-k2.7-code-highspeed",
336
+ "kimi-k2.6"
337
+ ],
212
338
  authMode: "bearer",
213
339
  modelsBaseURL: "https://api.moonshot.cn/v1",
214
340
  modelsAuthMode: "bearer",
@@ -221,14 +347,14 @@ const PROVIDER_PRESETS = {
221
347
  baseURL: "https://api.kimi.com/coding/v1",
222
348
  apiKeyEnv: "KIMI_CODE_API_KEY",
223
349
  defaultModels: [
224
- "k3",
225
- "k3-256k",
226
- "kimi-for-coding",
227
- "kimi-for-coding-highspeed"
350
+ "kimi-k3",
351
+ "kimi-k2.7-code",
352
+ "kimi-k2.7-code-highspeed",
353
+ "kimi-for-coding"
228
354
  ],
229
355
  modelsBaseURL: "https://api.kimi.com/coding/v1",
230
356
  modelsAuthMode: "bearer",
231
- notes: "Kimi Code 会员 API;模型 ID 为 k3 / k3-256k / kimi-for-coding / kimi-for-coding-highspeed。"
357
+ notes: "Kimi Code 会员 API;可用模型以会员页为准,常用 kimi-k3 / kimi-k2.7-code / kimi-k2.7-code-highspeed,旧 kimi-for-coding 仍兼容。"
232
358
  },
233
359
  "kimi-code-anthropic": {
234
360
  id: "kimi-code-anthropic",
@@ -237,10 +363,10 @@ const PROVIDER_PRESETS = {
237
363
  baseURL: "https://api.kimi.com/coding",
238
364
  apiKeyEnv: "KIMI_CODE_API_KEY",
239
365
  defaultModels: [
240
- "k3",
241
- "k3-256k",
242
- "kimi-for-coding",
243
- "kimi-for-coding-highspeed"
366
+ "kimi-k3",
367
+ "kimi-k2.7-code",
368
+ "kimi-k2.7-code-highspeed",
369
+ "kimi-for-coding"
244
370
  ],
245
371
  authMode: "bearer",
246
372
  modelsBaseURL: "https://api.kimi.com/coding/v1",
@@ -254,9 +380,9 @@ const PROVIDER_PRESETS = {
254
380
  baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
255
381
  apiKeyEnv: "DASHSCOPE_API_KEY",
256
382
  defaultModels: [
257
- "qwen3.7-plus",
258
- "qwen3.7-max",
259
- "qwen-max"
383
+ "qwen3.8-max",
384
+ "qwen3.8-flash",
385
+ "qwen3.7-plus"
260
386
  ],
261
387
  notes: "DashScope OpenAI-compatible endpoint(北京公共域名)。"
262
388
  },
@@ -267,9 +393,9 @@ const PROVIDER_PRESETS = {
267
393
  baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
268
394
  apiKeyEnv: "DASHSCOPE_API_KEY",
269
395
  defaultModels: [
270
- "qwen3.7-plus",
271
- "qwen3.7-max",
272
- "qwen-max"
396
+ "qwen3.8-max",
397
+ "qwen3.8-flash",
398
+ "qwen3.7-plus"
273
399
  ],
274
400
  notes: "DashScope 新加坡公共域名 OpenAI 兼容端点。"
275
401
  },
@@ -280,9 +406,9 @@ const PROVIDER_PRESETS = {
280
406
  baseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
281
407
  apiKeyEnv: "DASHSCOPE_API_KEY",
282
408
  defaultModels: [
283
- "qwen3.7-plus",
284
- "qwen3.7-max",
285
- "qwen-max"
409
+ "qwen3.8-max",
410
+ "qwen3.8-flash",
411
+ "qwen3.7-plus"
286
412
  ],
287
413
  notes: "DashScope 美国(弗吉尼亚)公共域名 OpenAI 兼容端点。"
288
414
  },
@@ -293,13 +419,13 @@ const PROVIDER_PRESETS = {
293
419
  baseURL: "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/apps/anthropic",
294
420
  apiKeyEnv: "DASHSCOPE_API_KEY",
295
421
  defaultModels: [
296
- "qwen3.7-max",
422
+ "qwen3.8-max",
423
+ "qwen3.8-flash",
297
424
  "qwen3.7-plus",
298
- "qwen3.6-plus",
299
425
  "deepseek-v4-pro",
300
426
  "deepseek-v4-flash",
301
- "kimi-k2.6",
302
- "glm-5.1"
427
+ "kimi-k3",
428
+ "glm-5.3"
303
429
  ],
304
430
  authMode: "bearer",
305
431
  modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
@@ -313,13 +439,13 @@ const PROVIDER_PRESETS = {
313
439
  baseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
314
440
  apiKeyEnv: "DASHSCOPE_API_KEY",
315
441
  defaultModels: [
316
- "qwen3.7-max",
442
+ "qwen3.8-max",
443
+ "qwen3.8-flash",
317
444
  "qwen3.7-plus",
318
- "qwen3.6-plus",
319
445
  "deepseek-v4-pro",
320
446
  "deepseek-v4-flash",
321
- "kimi-k2.6",
322
- "glm-5.1"
447
+ "kimi-k3",
448
+ "glm-5.3"
323
449
  ],
324
450
  authMode: "bearer",
325
451
  modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
@@ -333,13 +459,13 @@ const PROVIDER_PRESETS = {
333
459
  baseURL: "https://dashscope-intl.aliyuncs.com/apps/anthropic",
334
460
  apiKeyEnv: "DASHSCOPE_API_KEY",
335
461
  defaultModels: [
336
- "qwen3.7-max",
462
+ "qwen3.8-max",
463
+ "qwen3.8-flash",
337
464
  "qwen3.7-plus",
338
- "qwen3.6-plus",
339
465
  "deepseek-v4-pro",
340
466
  "deepseek-v4-flash",
341
- "kimi-k2.6",
342
- "glm-5.1"
467
+ "kimi-k3",
468
+ "glm-5.3"
343
469
  ],
344
470
  authMode: "bearer",
345
471
  modelsBaseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
@@ -353,13 +479,13 @@ const PROVIDER_PRESETS = {
353
479
  baseURL: "https://dashscope-us.aliyuncs.com/apps/anthropic",
354
480
  apiKeyEnv: "DASHSCOPE_API_KEY",
355
481
  defaultModels: [
356
- "qwen3.7-max",
482
+ "qwen3.8-max",
483
+ "qwen3.8-flash",
357
484
  "qwen3.7-plus",
358
- "qwen3.6-plus",
359
485
  "deepseek-v4-pro",
360
486
  "deepseek-v4-flash",
361
- "kimi-k2.6",
362
- "glm-5.1"
487
+ "kimi-k3",
488
+ "glm-5.3"
363
489
  ],
364
490
  authMode: "bearer",
365
491
  modelsBaseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
@@ -373,13 +499,13 @@ const PROVIDER_PRESETS = {
373
499
  baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
374
500
  apiKeyEnv: "DASHSCOPE_TOKEN_PLAN_API_KEY",
375
501
  defaultModels: [
376
- "qwen3.7-max",
502
+ "qwen3.8-max",
503
+ "qwen3.8-flash",
377
504
  "qwen3.7-plus",
378
- "qwen3.6-plus",
379
505
  "deepseek-v4-pro",
380
506
  "deepseek-v4-flash",
381
- "kimi-k2.6",
382
- "glm-5.1"
507
+ "kimi-k3",
508
+ "glm-5.3"
383
509
  ],
384
510
  authMode: "bearer",
385
511
  modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
@@ -393,13 +519,13 @@ const PROVIDER_PRESETS = {
393
519
  baseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
394
520
  apiKeyEnv: "DASHSCOPE_CODING_PLAN_API_KEY",
395
521
  defaultModels: [
396
- "qwen3.7-max",
522
+ "qwen3.8-max",
523
+ "qwen3.8-flash",
397
524
  "qwen3.7-plus",
398
- "qwen3.6-plus",
399
525
  "deepseek-v4-pro",
400
526
  "deepseek-v4-flash",
401
- "kimi-k2.6",
402
- "glm-5.1"
527
+ "kimi-k3",
528
+ "glm-5.3"
403
529
  ],
404
530
  authMode: "bearer",
405
531
  modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
@@ -414,12 +540,9 @@ const PROVIDER_PRESETS = {
414
540
  apiKeyEnv: "ZHIPU_API_KEY",
415
541
  defaultModels: [
416
542
  "glm-5.3",
543
+ "glm-5.3-flash",
417
544
  "glm-5.2",
418
- "glm-5",
419
- "glm-4.6",
420
- "glm-4.6v",
421
- "glm-4.6v-flashx",
422
- "glm-4.5v"
545
+ "glm-5.1"
423
546
  ],
424
547
  modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
425
548
  modelsAuthMode: "bearer",
@@ -433,12 +556,9 @@ const PROVIDER_PRESETS = {
433
556
  apiKeyEnv: "ZHIPU_API_KEY",
434
557
  defaultModels: [
435
558
  "glm-5.3",
559
+ "glm-5.3-flash",
436
560
  "glm-5.2",
437
- "glm-5",
438
- "glm-4.6",
439
- "glm-4.6v",
440
- "glm-4.6v-flashx",
441
- "glm-4.5v"
561
+ "glm-5.1"
442
562
  ],
443
563
  authMode: "bearer",
444
564
  modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
@@ -451,7 +571,14 @@ const PROVIDER_PRESETS = {
451
571
  protocol: "openai",
452
572
  baseURL: "https://api.openai.com/v1",
453
573
  apiKeyEnv: "OPENAI_API_KEY",
454
- defaultModels: ["gpt-5", "gpt-5-mini"],
574
+ defaultModels: [
575
+ "gpt-6-astra",
576
+ "gpt-5.6-sol",
577
+ "gpt-5.6-terra",
578
+ "gpt-5.6-luna",
579
+ "gpt-5.5",
580
+ "gpt-5.4"
581
+ ],
455
582
  notes: "Official OpenAI API."
456
583
  },
457
584
  anthropic: {
@@ -460,7 +587,13 @@ const PROVIDER_PRESETS = {
460
587
  protocol: "anthropic",
461
588
  baseURL: "https://api.anthropic.com",
462
589
  apiKeyEnv: "ANTHROPIC_API_KEY",
463
- defaultModels: ["claude-sonnet-4-5", "claude-opus-4-1"],
590
+ defaultModels: [
591
+ "claude-opus-5",
592
+ "claude-sonnet-5",
593
+ "claude-opus-4-8",
594
+ "claude-opus-4-7",
595
+ "claude-sonnet-4-6"
596
+ ],
464
597
  authMode: "x-api-key",
465
598
  notes: "Anthropic Messages API; docs in D:/X/X/14-claude."
466
599
  },
@@ -470,7 +603,12 @@ const PROVIDER_PRESETS = {
470
603
  protocol: "gemini",
471
604
  baseURL: "https://generativelanguage.googleapis.com",
472
605
  apiKeyEnv: "GEMINI_API_KEY",
473
- defaultModels: ["gemini-3-flash-preview", "gemini-3-pro-preview"],
606
+ defaultModels: [
607
+ "gemini-3.8-flash",
608
+ "gemini-3.7-flash",
609
+ "gemini-3.1-pro-preview",
610
+ "gemini-3-flash-preview"
611
+ ],
474
612
  notes: "Google Gemini API; docs in D:/X/X/13-gemini."
475
613
  },
476
614
  siliconflow: {
@@ -479,7 +617,11 @@ const PROVIDER_PRESETS = {
479
617
  protocol: "openai",
480
618
  baseURL: "https://api.siliconflow.cn/v1",
481
619
  apiKeyEnv: "SILICONFLOW_API_KEY",
482
- defaultModels: ["deepseek-ai/DeepSeek-V3", "Qwen/Qwen3-235B-A22B"],
620
+ defaultModels: [
621
+ "deepseek-ai/DeepSeek-V3.2",
622
+ "Qwen/Qwen3-235B-A22B",
623
+ "zai-org/GLM-5.1"
624
+ ],
483
625
  notes: "OpenAI-compatible; also supports Anthropic-compatible at /v1/messages."
484
626
  },
485
627
  "siliconflow-anthropic": {
@@ -490,8 +632,8 @@ const PROVIDER_PRESETS = {
490
632
  apiKeyEnv: "SILICONFLOW_API_KEY",
491
633
  defaultModels: [
492
634
  "deepseek-ai/DeepSeek-V3.2",
493
- "moonshotai/Kimi-K2-Instruct",
494
- "Qwen/Qwen3-32B"
635
+ "Qwen/Qwen3-32B",
636
+ "zai-org/GLM-5.1"
495
637
  ],
496
638
  authMode: "bearer",
497
639
  modelsBaseURL: "https://api.siliconflow.cn/v1",
@@ -505,8 +647,9 @@ const PROVIDER_PRESETS = {
505
647
  baseURL: "https://aihubmix.com/v1",
506
648
  apiKeyEnv: "AIHUBMIX_API_KEY",
507
649
  defaultModels: [
508
- "gpt-5",
650
+ "gpt-5.2",
509
651
  "claude-sonnet-4-5",
652
+ "claude-opus-4-5",
510
653
  "gemini-3-flash-preview"
511
654
  ],
512
655
  notes: "Aggregator; also supports Anthropic-compatible at https://aihubmix.com."
@@ -519,8 +662,8 @@ const PROVIDER_PRESETS = {
519
662
  apiKeyEnv: "AIHUBMIX_API_KEY",
520
663
  defaultModels: [
521
664
  "claude-sonnet-4-5",
522
- "claude-opus-4-1",
523
- "gpt-5"
665
+ "claude-opus-4-6",
666
+ "claude-opus-4-5"
524
667
  ],
525
668
  authMode: "x-api-key",
526
669
  modelsBaseURL: "https://aihubmix.com/v1",
@@ -534,8 +677,9 @@ const PROVIDER_PRESETS = {
534
677
  baseURL: "https://openrouter.ai/api/v1",
535
678
  apiKeyEnv: "OPENROUTER_API_KEY",
536
679
  defaultModels: [
537
- "openai/gpt-5",
538
- "anthropic/claude-sonnet-4.5",
680
+ "openai/gpt-5.6",
681
+ "openai/gpt-5.5",
682
+ "anthropic/claude-sonnet-4.6",
539
683
  "deepseek/deepseek-chat"
540
684
  ],
541
685
  notes: "OpenAI-compatible; docs in D:/X/X/06-OpenRouter."
@@ -547,9 +691,9 @@ const PROVIDER_PRESETS = {
547
691
  baseURL: "https://openrouter.ai/api",
548
692
  apiKeyEnv: "OPENROUTER_API_KEY",
549
693
  defaultModels: [
550
- "anthropic/claude-sonnet-4.5",
551
- "anthropic/claude-opus-4-1",
552
- "openai/gpt-5",
694
+ "anthropic/claude-sonnet-4.6",
695
+ "anthropic/claude-opus-4-6",
696
+ "openai/gpt-5.6",
553
697
  "deepseek/deepseek-chat"
554
698
  ],
555
699
  authMode: "bearer",
@@ -562,52 +706,282 @@ function getProviderPreset(id) {
562
706
  return PROVIDER_PRESETS[id];
563
707
  }
564
708
  //#endregion
709
+ //#region src/dsml.ts
710
+ const OUTER_OPEN = /<[||]{0,2}(?:DSML[||]{0,2})?(?:tool_calls|function_calls)\s*>/g;
711
+ const OUTER_CLOSE_RE = /<\/[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}(?:tool_calls|function_calls)\s*>/;
712
+ 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;
713
+ 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;
714
+ const PARAM_OPEN_RE = /<[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*parameter\s+name="[^"]+"/g;
715
+ function insideFence(content, index) {
716
+ const fences = content.slice(0, index).match(/```/g);
717
+ return Boolean(fences && fences.length % 2 === 1);
718
+ }
719
+ /** Parse every invoke inside one outer block; null when any gate fails. */
720
+ function parseOuterBlock(block, allowedNames) {
721
+ const calls = [];
722
+ INVOKE_RE.lastIndex = 0;
723
+ let invoke;
724
+ let invokeCount = 0;
725
+ while ((invoke = INVOKE_RE.exec(block)) !== null) {
726
+ invokeCount += 1;
727
+ const name = invoke[1];
728
+ if (!allowedNames.has(name)) return null;
729
+ const body = invoke[2];
730
+ const params = {};
731
+ PARAM_RE.lastIndex = 0;
732
+ let param;
733
+ let paramCount = 0;
734
+ while ((param = PARAM_RE.exec(body)) !== null) {
735
+ paramCount += 1;
736
+ if (PARAM_OPEN_RE.test(param[2])) return null;
737
+ params[param[1]] = param[2];
738
+ PARAM_OPEN_RE.lastIndex = 0;
739
+ }
740
+ if ((body.match(PARAM_OPEN_RE) ?? []).length !== paramCount) return null;
741
+ PARAM_OPEN_RE.lastIndex = 0;
742
+ calls.push({
743
+ id: `text-${calls.length}`,
744
+ name,
745
+ argumentsJson: paramCount > 0 ? JSON.stringify(params) : "{}"
746
+ });
747
+ }
748
+ if (invokeCount === 0) return null;
749
+ return calls;
750
+ }
751
+ /** Extract text tool calls; blocks outside code fences, whitelisted names. */
752
+ function parseTextToolCalls(content, allowedNames) {
753
+ if (!content || allowedNames.size === 0) return {
754
+ toolCalls: [],
755
+ cleaned: content
756
+ };
757
+ const ranges = [];
758
+ const allCalls = [];
759
+ OUTER_OPEN.lastIndex = 0;
760
+ let match;
761
+ while ((match = OUTER_OPEN.exec(content)) !== null) {
762
+ const start = match.index;
763
+ if (insideFence(content, start)) continue;
764
+ const rest = content.slice(start);
765
+ const close = rest.match(OUTER_CLOSE_RE);
766
+ const calls = parseOuterBlock(close && close.index !== void 0 ? rest.slice(0, close.index) : rest, allowedNames);
767
+ if (calls === null) continue;
768
+ const end = close && close.index !== void 0 ? start + close.index + close[0].length : content.length;
769
+ allCalls.push(...calls);
770
+ ranges.push([start, end]);
771
+ }
772
+ if (allCalls.length === 0) return {
773
+ toolCalls: [],
774
+ cleaned: content
775
+ };
776
+ let cleaned = content;
777
+ for (let i = ranges.length - 1; i >= 0; i -= 1) {
778
+ const [from, to] = ranges[i];
779
+ cleaned = cleaned.slice(0, from) + cleaned.slice(to);
780
+ }
781
+ return {
782
+ toolCalls: allCalls,
783
+ cleaned: cleaned.trim()
784
+ };
785
+ }
786
+ //#endregion
565
787
  //#region src/providers/direct-http.ts
788
+ /** True when the user asked to force thinking off, through either spelling. */
789
+ function thinkingOff(advisor) {
790
+ return advisor.thinking === "disabled" || advisor.reasoningEffort === "off";
791
+ }
792
+ /**
793
+ * OpenAI-compatible reasoning params, provider-aware (2026-09-05 知识 X 最新):
794
+ * - DeepSeek: `thinking {type}` switch + `reasoning_effort` low/high/max.
795
+ * - Kimi/Kimi Code: kimi-k3 = top-level `reasoning_effort` low/high/max (no
796
+ * `thinking`); kimi-k2.7-code = always thinking, no params; kimi-k2.6 =
797
+ * `thinking {type}` switch only.
798
+ * - OpenAI official: `reasoning_effort` minimal|low|medium|high (no max, no off).
799
+ * - 阿里云百炼: `enable_thinking` bool (Qwen chat completions).
800
+ * - 智谱: `thinking {type}` switch + `reasoning_effort` (GLM-5.3 force-enabled,
801
+ * disabled→enabled+low; GLM-5.2/5.1/5 can disable).
802
+ * - 硅基流动: `enable_thinking` + `thinking_budget`.
803
+ * - Others (AIHubMix/OpenRouter/custom): best-effort `reasoning_effort`.
804
+ */
805
+ function openAiThinkingParams(advisor) {
806
+ const provider = advisor.provider;
807
+ const model = advisor.model;
808
+ const effort = advisor.reasoningEffort;
809
+ if (provider === "deepseek") {
810
+ if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
811
+ return {
812
+ ...advisor.thinking === "enabled" ? { thinking: { type: "enabled" } } : {},
813
+ ...effort && effort !== "off" ? { reasoning_effort: effort } : {}
814
+ };
815
+ }
816
+ if (provider === "moonshot" || provider === "kimi-code") {
817
+ if (/^(k3|kimi-k3)(-|$)/i.test(model)) {
818
+ if (effort && effort !== "off") return { reasoning_effort: effort };
819
+ return {};
820
+ }
821
+ if (/kimi-k2\.7-code|kimi-for-coding/i.test(model)) return {};
822
+ if (advisor.thinking === "disabled") return { thinking: { type: "disabled" } };
823
+ if (advisor.thinking === "enabled") return { thinking: { type: "enabled" } };
824
+ return {};
825
+ }
826
+ if (provider === "openai") {
827
+ if (effort === "low") return { reasoning_effort: "low" };
828
+ if (effort === "high" || effort === "max") return { reasoning_effort: "high" };
829
+ return {};
830
+ }
831
+ if (provider === "bailian" || provider === "bailian-openai-singapore" || provider === "bailian-openai-us") {
832
+ if (thinkingOff(advisor)) return { enable_thinking: false };
833
+ if (advisor.thinking === "enabled") return { enable_thinking: true };
834
+ return {};
835
+ }
836
+ if (provider === "zhipu") {
837
+ if (/glm-5\.3/i.test(model)) return {
838
+ thinking: { type: "enabled" },
839
+ reasoning_effort: thinkingOff(advisor) ? "low" : effort && effort !== "off" ? effort : "max"
840
+ };
841
+ if (/glm-5/i.test(model)) {
842
+ if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
843
+ return {
844
+ ...advisor.thinking === "enabled" ? { thinking: { type: "enabled" } } : {},
845
+ ...effort && effort !== "off" ? { reasoning_effort: effort } : {}
846
+ };
847
+ }
848
+ if (advisor.thinking === "disabled") return { thinking: { type: "disabled" } };
849
+ if (advisor.thinking === "enabled") return { thinking: { type: "enabled" } };
850
+ return {};
851
+ }
852
+ if (provider === "siliconflow") {
853
+ if (thinkingOff(advisor)) return { enable_thinking: false };
854
+ const budget = effort === "low" ? 1024 : effort === "high" ? 8192 : effort === "max" ? 16384 : 0;
855
+ return {
856
+ ...advisor.thinking === "enabled" || budget > 0 ? { enable_thinking: true } : {},
857
+ ...budget > 0 ? { thinking_budget: budget } : {}
858
+ };
859
+ }
860
+ if (effort && effort !== "off") return { reasoning_effort: effort };
861
+ return {};
862
+ }
863
+ /** Anthropic Messages reasoning params, provider-aware.
864
+ * Official Anthropic: adaptive (`thinking:{type:'adaptive'}` + `output_config.effort`)
865
+ * for Opus 4.6/4.7/Sonnet 4.6/Mythos; manual `enabled`+`budget_tokens` for older
866
+ * models. DeepSeek's Anthropic-compatible endpoint uses `reasoning.effort` +
867
+ * `output_config.effort` (its `budget_tokens` is ignored). Other Anthropic skins
868
+ * follow the official Anthropic wire shape. */
869
+ function anthropicThinkingParams(advisor) {
870
+ if (advisor.provider === "deepseek-anthropic") {
871
+ if (thinkingOff(advisor)) return { reasoning: { effort: "none" } };
872
+ if (advisor.reasoningEffort && advisor.reasoningEffort !== "off") return {
873
+ reasoning: { effort: advisor.reasoningEffort },
874
+ output_config: { effort: advisor.reasoningEffort }
875
+ };
876
+ return {};
877
+ }
878
+ if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
879
+ if (advisor.reasoningEffort && advisor.reasoningEffort !== "off") {
880
+ if (/(?:opus-4-[6-8]|opus-5|sonnet-4-6|sonnet-5|fable|mythos)/i.test(advisor.model)) return {
881
+ thinking: { type: "adaptive" },
882
+ output_config: { effort: advisor.reasoningEffort }
883
+ };
884
+ return { thinking: {
885
+ type: "enabled",
886
+ budget_tokens: advisor.reasoningEffort === "low" ? 2048 : advisor.reasoningEffort === "high" ? 8192 : 16384
887
+ } };
888
+ }
889
+ return {};
890
+ }
891
+ /** Gemini `generateContent` thinking config. Disabling thinking uses
892
+ * `thinkingBudget: 0` (NOT `includeThoughts:false`, which only hides returned
893
+ * thoughts). Effort uses `thinkingBudget`, the backward-compatible knob that
894
+ * works across Gemini 2.5 and Gemini 3 per Google's migration note. */
895
+ function geminiThinkingParams(advisor) {
896
+ if (thinkingOff(advisor)) return { thinkingConfig: { thinkingBudget: 0 } };
897
+ const budget = advisor.reasoningEffort === "low" ? 1024 : advisor.reasoningEffort === "high" ? 8192 : advisor.reasoningEffort === "max" ? 16384 : 0;
898
+ if (budget > 0) return { thinkingConfig: { thinkingBudget: budget } };
899
+ return {};
900
+ }
901
+ /** Validate the per-advisor thinking fields at the direct-http boundary so a
902
+ * malformed stored config fails loudly instead of silently ignoring. */
903
+ function assertThinkingParams(advisor) {
904
+ if (advisor.thinking !== void 0 && advisor.thinking !== "enabled" && advisor.thinking !== "disabled") throw new Error(`Unsupported thinking mode: ${String(advisor.thinking)}`);
905
+ if (advisor.reasoningEffort !== void 0 && ![
906
+ "off",
907
+ "low",
908
+ "high",
909
+ "max"
910
+ ].includes(advisor.reasoningEffort)) throw new Error(`Unsupported reasoningEffort: ${String(advisor.reasoningEffort)}`);
911
+ }
566
912
  /** Stream an advisor response through direct HTTP (OpenAI/Anthropic SSE). */
567
- async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs) {
913
+ async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs, tools, parseToolNames) {
568
914
  const preset = advisor.provider ? getProviderPreset(advisor.provider) : void 0;
569
915
  const baseURL = (advisor.baseURL ?? preset?.baseURL ?? "").replace(/\/+$/, "");
570
916
  const apiKey = advisor.apiKey ?? process.env[advisor.apiKeyEnv ?? preset?.apiKeyEnv ?? ""] ?? "";
571
917
  const protocol = advisor.protocol ?? preset?.protocol ?? "openai";
572
918
  const authMode = advisor.authMode ?? preset?.authMode ?? "x-api-key";
919
+ assertThinkingParams(advisor);
573
920
  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.`);
574
921
  const transcriptText = transcript.map((entry) => `[${entry.role === "main" ? "主模型" : entry.name}]\n${entry.content}`).join("\n\n");
922
+ let result;
575
923
  switch (protocol) {
576
- case "openai": return streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs);
577
- case "anthropic": return streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs);
924
+ case "openai":
925
+ result = await streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools);
926
+ break;
927
+ case "anthropic":
928
+ result = await streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools);
929
+ break;
578
930
  case "gemini": {
579
931
  const content = await callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs);
580
932
  onDelta({ text: content });
581
- return {
933
+ result = {
582
934
  content,
583
935
  thinking: ""
584
936
  };
937
+ break;
585
938
  }
586
939
  default: throw new Error(`Unsupported protocol: ${String(protocol)}`);
587
940
  }
941
+ const parsed = parseTextToolCalls(result.content, new Set(parseToolNames ?? (tools ?? []).map((tool) => tool.name)));
942
+ if (parsed.toolCalls.length > 0) {
943
+ console.warn(`[dsh-advisor-group] 检测到文本工具调用(DSML/非结构流),已还原并交给 runAdvisorToolLoop 执行:${parsed.toolCalls.map((tool) => tool.name).join("、")}`);
944
+ return {
945
+ ...result,
946
+ content: parsed.cleaned,
947
+ toolCalls: [...result.toolCalls ?? [], ...parsed.toolCalls]
948
+ };
949
+ }
950
+ return result;
588
951
  }
589
- async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs) {
952
+ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools) {
590
953
  const endpoint = baseURL.endsWith("/chat/completions") ? baseURL : `${baseURL.replace(/\/+$/, "")}/chat/completions`;
954
+ const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
955
+ const body = {
956
+ model: advisor.model,
957
+ messages: [{
958
+ role: "system",
959
+ content: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY
960
+ }, {
961
+ role: "user",
962
+ content: transcriptText
963
+ }],
964
+ temperature: advisor.temperature ?? .3,
965
+ max_tokens: advisor.maxTokens ?? 16384,
966
+ ...openAiThinkingParams(advisor),
967
+ stream: true
968
+ };
969
+ if (tools && tools.length > 0) body.tools = tools.map((tool) => ({
970
+ type: "function",
971
+ function: {
972
+ name: tool.name,
973
+ ...tool.description === void 0 ? {} : { description: tool.description },
974
+ parameters: tool.parameters ?? { type: "object" }
975
+ }
976
+ }));
591
977
  const response = await fetch(endpoint, {
592
978
  method: "POST",
593
979
  headers: {
594
980
  "content-type": "application/json",
595
981
  authorization: `Bearer ${apiKey}`
596
982
  },
597
- body: JSON.stringify({
598
- model: advisor.model,
599
- messages: [{
600
- role: "system",
601
- content: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY
602
- }, {
603
- role: "user",
604
- content: transcriptText
605
- }],
606
- temperature: advisor.temperature ?? .3,
607
- max_tokens: advisor.maxTokens ?? 16384,
608
- stream: true
609
- }),
610
- signal: withTimeout(timeoutMs ?? 12e4, signal)
983
+ body: JSON.stringify(body),
984
+ signal: pair.signal
611
985
  });
612
986
  if (!response.ok) throw new Error(`OpenAI-compatible request failed: ${response.status} ${await response.text()}`);
613
987
  if (!response.body) throw new Error("OpenAI-compatible response has no body");
@@ -616,6 +990,7 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
616
990
  let buffer = "";
617
991
  let content = "";
618
992
  let thinking = "";
993
+ const toolCalls = /* @__PURE__ */ new Map();
619
994
  while (true) {
620
995
  let done;
621
996
  let value;
@@ -624,8 +999,28 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
624
999
  done = read.done;
625
1000
  value = read.value;
626
1001
  } catch (error) {
1002
+ const errName = error?.name ?? "UnknownError";
1003
+ const errMessage = error instanceof Error ? error.message : String(error);
1004
+ console.warn(`[dsh-advisor-group] direct-http stream read error: advisor=${advisor.name} base=${baseURL} name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
1005
+ if (pair.isTimeout()) return {
1006
+ content,
1007
+ thinking,
1008
+ toolCalls: collectToolCalls(toolCalls),
1009
+ truncated: {
1010
+ reason: "timeout",
1011
+ atMs: Date.now()
1012
+ }
1013
+ };
627
1014
  if (signal?.aborted || error?.name === "AbortError") throw error;
628
- break;
1015
+ return {
1016
+ content,
1017
+ thinking,
1018
+ toolCalls: collectToolCalls(toolCalls),
1019
+ truncated: {
1020
+ reason: "network",
1021
+ atMs: Date.now()
1022
+ }
1023
+ };
629
1024
  }
630
1025
  if (done) break;
631
1026
  buffer += decoder.decode(value, { stream: true });
@@ -641,6 +1036,18 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
641
1036
  const delta = JSON.parse(data).choices?.[0]?.delta ?? {};
642
1037
  const text = typeof delta.content === "string" ? delta.content : "";
643
1038
  const think = typeof delta.reasoning_content === "string" ? delta.reasoning_content : typeof delta.reasoning === "string" ? delta.reasoning : "";
1039
+ if (delta.tool_calls) for (const tc of delta.tool_calls) {
1040
+ const idx = tc.index ?? 0;
1041
+ const current = toolCalls.get(idx) ?? {
1042
+ id: "",
1043
+ name: "",
1044
+ args: ""
1045
+ };
1046
+ if (tc.id) current.id = tc.id;
1047
+ if (tc.function?.name) current.name = tc.function.name;
1048
+ if (tc.function?.arguments) current.args += tc.function.arguments;
1049
+ toolCalls.set(idx, current);
1050
+ }
644
1051
  if (text) {
645
1052
  content += text;
646
1053
  onDelta({ text });
@@ -655,10 +1062,24 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
655
1062
  }
656
1063
  return {
657
1064
  content,
658
- thinking
1065
+ thinking,
1066
+ toolCalls: collectToolCalls(toolCalls)
659
1067
  };
660
1068
  }
661
- async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs) {
1069
+ /** Map<index, {id,name,args}> → model-facing tool call list. */
1070
+ function collectToolCalls(byIndex) {
1071
+ const calls = [];
1072
+ for (const [, current] of byIndex) {
1073
+ if (!current.name) continue;
1074
+ calls.push({
1075
+ id: current.id || `tool-${calls.length}`,
1076
+ name: current.name,
1077
+ argumentsJson: current.args
1078
+ });
1079
+ }
1080
+ return calls.length > 0 ? calls : void 0;
1081
+ }
1082
+ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools) {
662
1083
  const endpoint = baseURL.endsWith("/v1/messages") ? baseURL : baseURL.endsWith("/v1") ? `${baseURL}/messages` : `${baseURL.replace(/\/+$/, "")}/v1/messages`;
663
1084
  const headers = {
664
1085
  "content-type": "application/json",
@@ -666,21 +1087,29 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
666
1087
  };
667
1088
  if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
668
1089
  else headers["x-api-key"] = apiKey;
1090
+ const body = {
1091
+ model: advisor.model,
1092
+ system: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY,
1093
+ messages: [{
1094
+ role: "user",
1095
+ content: transcriptText
1096
+ }],
1097
+ temperature: advisor.temperature ?? .3,
1098
+ max_tokens: advisor.maxTokens ?? 16384,
1099
+ ...anthropicThinkingParams(advisor),
1100
+ stream: true
1101
+ };
1102
+ if (tools && tools.length > 0) body.tools = tools.map((tool) => ({
1103
+ name: tool.name,
1104
+ ...tool.description === void 0 ? {} : { description: tool.description },
1105
+ input_schema: tool.parameters ?? { type: "object" }
1106
+ }));
1107
+ const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
669
1108
  const response = await fetch(endpoint, {
670
1109
  method: "POST",
671
1110
  headers,
672
- body: JSON.stringify({
673
- model: advisor.model,
674
- system: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY,
675
- messages: [{
676
- role: "user",
677
- content: transcriptText
678
- }],
679
- temperature: advisor.temperature ?? .3,
680
- max_tokens: advisor.maxTokens ?? 16384,
681
- stream: true
682
- }),
683
- signal: withTimeout(timeoutMs ?? 12e4, signal)
1111
+ body: JSON.stringify(body),
1112
+ signal: pair.signal
684
1113
  });
685
1114
  if (!response.ok) throw new Error(`Anthropic request failed: ${response.status} ${await response.text()}`);
686
1115
  if (!response.body) throw new Error("Anthropic response has no body");
@@ -689,6 +1118,9 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
689
1118
  let buffer = "";
690
1119
  let content = "";
691
1120
  let thinking = "";
1121
+ let currentTool = null;
1122
+ let activeTool = false;
1123
+ const toolCalls = [];
692
1124
  while (true) {
693
1125
  let done;
694
1126
  let value;
@@ -697,8 +1129,28 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
697
1129
  done = read.done;
698
1130
  value = read.value;
699
1131
  } catch (error) {
1132
+ const errName = error?.name ?? "UnknownError";
1133
+ const errMessage = error instanceof Error ? error.message : String(error);
1134
+ console.warn(`[dsh-advisor-group] anthropic stream read error: advisor=${advisor.name} base=${baseURL} name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
1135
+ if (pair.isTimeout()) return {
1136
+ content,
1137
+ thinking,
1138
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
1139
+ truncated: {
1140
+ reason: "timeout",
1141
+ atMs: Date.now()
1142
+ }
1143
+ };
700
1144
  if (signal?.aborted || error?.name === "AbortError") throw error;
701
- break;
1145
+ return {
1146
+ content,
1147
+ thinking,
1148
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
1149
+ truncated: {
1150
+ reason: "network",
1151
+ atMs: Date.now()
1152
+ }
1153
+ };
702
1154
  }
703
1155
  if (done) break;
704
1156
  buffer += decoder.decode(value, { stream: true });
@@ -712,13 +1164,32 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
712
1164
  if (!data) continue;
713
1165
  try {
714
1166
  const json = JSON.parse(data);
715
- if (json.type === "content_block_delta" && json.delta) {
1167
+ if (json.type === "content_block_start" && json.content_block?.type === "tool_use") {
1168
+ currentTool = currentTool === null ? {
1169
+ id: json.content_block.id ?? "",
1170
+ name: json.content_block.name ?? "",
1171
+ args: ""
1172
+ } : currentTool;
1173
+ activeTool = true;
1174
+ } else if (json.type === "content_block_delta" && json.delta) {
716
1175
  if (json.delta.type === "text_delta" && typeof json.delta.text === "string") {
717
1176
  content += json.delta.text;
718
1177
  onDelta({ text: json.delta.text });
719
1178
  } else if (json.delta.type === "thinking_delta" && typeof json.delta.thinking === "string") {
720
1179
  thinking += json.delta.thinking;
721
1180
  onDelta({ thinking: json.delta.thinking });
1181
+ } else if (json.delta.type === "input_json_delta" && typeof json.delta.partial_json === "string") {
1182
+ if (currentTool) currentTool.args += json.delta.partial_json;
1183
+ }
1184
+ } else if (json.type === "content_block_stop") {
1185
+ if (activeTool && currentTool) {
1186
+ toolCalls.push({
1187
+ id: currentTool.id,
1188
+ name: currentTool.name,
1189
+ argumentsJson: currentTool.args
1190
+ });
1191
+ currentTool = null;
1192
+ activeTool = false;
722
1193
  }
723
1194
  }
724
1195
  } catch {}
@@ -727,7 +1198,8 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
727
1198
  }
728
1199
  return {
729
1200
  content,
730
- thinking
1201
+ thinking,
1202
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0
731
1203
  };
732
1204
  }
733
1205
  async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs) {
@@ -745,7 +1217,8 @@ async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, time
745
1217
  systemInstruction: { parts: [{ text: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY }] },
746
1218
  generationConfig: {
747
1219
  temperature: advisor.temperature ?? .3,
748
- maxOutputTokens: advisor.maxTokens ?? 16384
1220
+ maxOutputTokens: advisor.maxTokens ?? 16384,
1221
+ ...geminiThinkingParams(advisor)
749
1222
  }
750
1223
  }),
751
1224
  signal: withTimeout(timeoutMs ?? 12e4, signal)
@@ -764,7 +1237,7 @@ async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, time
764
1237
  * stalls. Any generation error degrades to the fallback text, never throws.
765
1238
  */
766
1239
  const FALLBACK_DEEPEN_QUESTION = "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
767
- const FALLBACK_CONCLUSION = "请综合以上全部讨论,给出结论:各位顾问的共识、分歧,以及最可靠的行动建议。";
1240
+ const FALLBACK_CONCLUSION = "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
768
1241
  const DRIVER_SYSTEM_PROMPT = `
769
1242
  你是顾问群的主持人(驱动模型)。你的职责:
770
1243
  1. 阅读项目背景、主模型问题与全部顾问回答;
@@ -795,7 +1268,7 @@ function transcriptOf(session) {
795
1268
  return `[${message.role === "main" ? "主模型" : message.advisorName ?? message.advisorId ?? "顾问"}]\n${message.content}`;
796
1269
  }).join("\n\n").slice(-24e3);
797
1270
  }
798
- async function generateWith(ctx, source, system, prompt, signal) {
1271
+ async function generateWith(ctx, source, sessionId, system, prompt, signal, timeoutMs = 6e5) {
799
1272
  try {
800
1273
  const options = {
801
1274
  provider: source.provider,
@@ -806,31 +1279,56 @@ async function generateWith(ctx, source, system, prompt, signal) {
806
1279
  type: "text",
807
1280
  text: prompt
808
1281
  }],
809
- source: {
810
- kind: "plugin",
811
- plugin: "dsh-advisor-group"
812
- }
1282
+ source: { kind: "user" }
813
1283
  })],
814
1284
  temperature: .7,
815
- maxTokens: 600,
816
- signal: withTimeout(6e4, signal)
1285
+ maxTokens: 2048,
1286
+ signal: withTimeout(timeoutMs, signal)
817
1287
  };
818
1288
  let text = "";
819
- for await (const chunk of ctx.llm.stream(options)) if (chunk.type === "text-delta") text += chunk.text;
820
- return text.trim() || void 0;
821
- } catch {
1289
+ let chunks = 0;
1290
+ let textDeltas = 0;
1291
+ let reasoningDeltas = 0;
1292
+ for await (const chunk of ctx.llm.stream(options)) {
1293
+ chunks += 1;
1294
+ if (chunk.type === "text-delta") {
1295
+ textDeltas += 1;
1296
+ text += chunk.text;
1297
+ } else reasoningDeltas += 1;
1298
+ }
1299
+ const trimmed = text.trim();
1300
+ if (!trimmed) console.warn(`[dsh-advisor-group] 驱动模型空响应(${source.provider}/${source.model}):`, sessionId, `chunks=${chunks} textDeltas=${textDeltas} reasoningDeltas=${reasoningDeltas};建议配置 discussion.driverModel 或提高 maxTokens`);
1301
+ return trimmed || void 0;
1302
+ } catch (error) {
1303
+ if (signal?.aborted) throw error;
1304
+ console.warn(`[dsh-advisor-group] 驱动模型生成失败(${source.provider}/${source.model}):`, sessionId, error instanceof Error ? error.message : String(error));
822
1305
  return;
823
1306
  }
824
1307
  }
825
1308
  /** One deepening question for the next round (never throws; returns fallback text). */
826
- async function generateDeepenQuestion(ctx, session, source, signal) {
1309
+ async function generateDeepenQuestion(ctx, session, source, signal, timeoutMs) {
827
1310
  if (!source) return FALLBACK_DEEPEN_QUESTION;
828
- return await generateWith(ctx, source, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1311
+ return await generateWith(ctx, source, session.id, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
829
1312
  }
830
1313
  /** Final synthesis after all rounds (never throws; returns fallback text). */
831
- async function generateConclusion(ctx, session, source, signal) {
1314
+ async function generateConclusion(ctx, session, source, signal, timeoutMs) {
832
1315
  if (!source) return FALLBACK_CONCLUSION;
833
- return await generateWith(ctx, source, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal) || "请综合以上全部讨论,给出结论:各位顾问的共识、分歧,以及最可靠的行动建议。";
1316
+ return await generateWith(ctx, source, session.id, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1317
+ }
1318
+ //#endregion
1319
+ //#region src/risk-notes.ts
1320
+ const TRUNCATION_NOTE = "有顾问输出在流式过程中被截断(超时或网络中断),其正文可能不完整,且该顾问本轮可能只提供了部分意见。";
1321
+ const CANCEL_NOTE = "本次顾问群讨论已被用户或主模型取消,结论可能不完整。";
1322
+ /**
1323
+ * 汇总风险提示(两条规则:截断 / 取消)。
1324
+ * 判定只看消息上的截断标志与 cancelled 标志,**不读正文**——没有基于文本的猜测,
1325
+ * 因此不会误报。content 字段保留在入参类型里只为兼容既有调用方(本函数不再读它)。
1326
+ */
1327
+ function buildRiskNotes(messages, cancelled) {
1328
+ const notes = [];
1329
+ if (messages.some((message) => message.truncated !== void 0)) notes.push(TRUNCATION_NOTE);
1330
+ if (cancelled) notes.push(CANCEL_NOTE);
1331
+ return notes;
834
1332
  }
835
1333
  //#endregion
836
1334
  //#region src/session-log.ts
@@ -889,7 +1387,11 @@ function appendAdvisorMessage(log, sessionId, message) {
889
1387
  ...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
890
1388
  ...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
891
1389
  ...message.round === void 0 ? {} : { round: message.round },
892
- ...message.thinking === void 0 ? {} : { thinking: message.thinking }
1390
+ ...message.thinking === void 0 ? {} : { thinking: message.thinking },
1391
+ ...message.thinkingSegments === void 0 ? {} : { thinkingSegments: message.thinkingSegments },
1392
+ ...message.actionDescriptions === void 0 ? {} : { actionDescriptions: message.actionDescriptions },
1393
+ ...message.toolSteps === void 0 ? {} : { toolSteps: message.toolSteps },
1394
+ ...message.truncated === void 0 ? {} : { truncated: message.truncated }
893
1395
  };
894
1396
  log.append("advisor-group/message", data);
895
1397
  }
@@ -904,10 +1406,19 @@ function appendAdvisorDelta(log, sessionId, delta) {
904
1406
  ...delta.round === void 0 ? {} : { round: delta.round },
905
1407
  ...delta.contentDelta === void 0 ? {} : { contentDelta: delta.contentDelta },
906
1408
  ...delta.thinkingDelta === void 0 ? {} : { thinkingDelta: delta.thinkingDelta },
1409
+ ...delta.toolStep === void 0 ? {} : { toolStep: delta.toolStep },
907
1410
  ...delta.done === void 0 ? {} : { done: delta.done }
908
1411
  };
909
1412
  log.append("advisor-group/delta", data);
910
1413
  }
1414
+ function appendAdvisorResume(log, sessionId) {
1415
+ const { turn, step } = latestTurnStep(log);
1416
+ log.append("advisor-group/resume", {
1417
+ sessionId,
1418
+ turn,
1419
+ step
1420
+ });
1421
+ }
911
1422
  function appendAdvisorEnd(log, session, summary) {
912
1423
  const { turn, step } = latestTurnStep(log);
913
1424
  const data = {
@@ -921,7 +1432,9 @@ function appendAdvisorEnd(log, session, summary) {
921
1432
  advisorName,
922
1433
  corePoints
923
1434
  })),
924
- ...summary.riskNotes.length > 0 ? { riskNotes: summary.riskNotes } : {}
1435
+ ...summary.riskNotes.length > 0 ? { riskNotes: summary.riskNotes } : {},
1436
+ ...summary.stopped === void 0 ? {} : { stopped: summary.stopped },
1437
+ ...summary.conclusion === void 0 ? {} : { conclusion: summary.conclusion }
925
1438
  }
926
1439
  };
927
1440
  log.append("advisor-group/end", data);
@@ -998,12 +1511,212 @@ function publish(sessionId, delta) {
998
1511
  for (const res of set) writeFrame(res, frame);
999
1512
  }
1000
1513
  //#endregion
1514
+ //#region src/advisor-tools.ts
1515
+ /** Raw tool-call / DSML markup that some providers echo as plain text instead
1516
+ * of a structured tool call. Observed shapes (deepseek-v4-pro, anthropic
1517
+ * channel):
1518
+ * - plain `<tool_calls>…</tool_calls>` (line-start block)
1519
+ * - piped `<||tool_calls>\n<||DSML|…` without a closing tag (U+FF5C)
1520
+ * - MID-LINE occurrence: `证据。<tool_calls><invoke name="bash">…` — the
1521
+ * marker is glued to prose, so line-start anchoring alone is NOT enough. */
1522
+ const RAW_TOOL_CALL_BLOCK = /<(?:[||]{1,2})?(?:tool_calls|DSML)[\s\S]*?<\/(?:[||]{1,2})?(?:tool_calls|DSML)\s*>/g;
1523
+ /** Unclosed tail: cut from the first marker that is actually a STRUCTURED
1524
+ * block (followed by `<invoke`/`<parameter`/`<||DSML` within the next ~60
1525
+ * chars). An isolated prose/code mention like `` `<tool_calls>` `` is never
1526
+ * followed by those children, so it stays untouched. */
1527
+ const RAW_TOOL_CALL_TAIL = /<(?:[||]{1,2})?(?:tool_calls|DSML)[\s\S]{0,60}?(?:<invoke|<parameter|<[||]{1,2}DSML)[\s\S]*$/g;
1528
+ /**
1529
+ * Strip raw `<tool_calls>` / piped `<||tool_calls>` / `<||DSML|` markup from
1530
+ * an advisor's final content (line-start OR mid-line, closed or unclosed).
1531
+ * Returns the cleaned text; when the content was only leaked tool markup, a
1532
+ * short notice replaces it so the bubble is never empty/raw (the thinking
1533
+ * panel and tool rows still carry the real substance).
1534
+ */
1535
+ function sanitizeAdvisorContent(content) {
1536
+ if (!content.trim()) return content;
1537
+ return content.replace(RAW_TOOL_CALL_BLOCK, "").replace(RAW_TOOL_CALL_TAIL, "").trim() || "(顾问未生成正文:输出仅为工具调用描述,请参考其思维链与工具调用记录。)";
1538
+ }
1539
+ /** Conservative read-only whitelist for the default advisor tool scope. */
1540
+ const READONLY_TOOL_NAMES = /* @__PURE__ */ new Set([
1541
+ "read",
1542
+ "grep",
1543
+ "glob",
1544
+ "web_search",
1545
+ "web_fetch",
1546
+ "scan_discover",
1547
+ "list_imported_sessions"
1548
+ ]);
1549
+ const MAX_TOOL_ROUNDS = 4;
1550
+ const TOOL_EXEC_TIMEOUT_MS = 3e4;
1551
+ const TOOL_RESULT_MAX_CHARS = 8e3;
1552
+ /**
1553
+ * Resolve the advisor-visible tool schemas. `agent` (when provided) makes the
1554
+ * registry resolve the scoped view — exactly the tools that session's Agent
1555
+ * loop serves, honouring presets and restrict() filters.
1556
+ */
1557
+ function resolveAdvisorToolSchemas(ctx, agent, mode) {
1558
+ if (mode === "off") return [];
1559
+ const schemas = ctx.tools?.schemas?.(agent) ?? [];
1560
+ let list = schemas;
1561
+ if (mode === "readonly") list = schemas.filter((schema) => typeof schema === "object" && schema !== null && READONLY_TOOL_NAMES.has(schema.name));
1562
+ return list.map((schema) => {
1563
+ const { name, description, parameters } = schema;
1564
+ return {
1565
+ name,
1566
+ ...typeof description === "string" ? { description } : {},
1567
+ ...parameters === void 0 ? {} : { parameters }
1568
+ };
1569
+ });
1570
+ }
1571
+ /** Human-readable one-line summary of a tool result (for the thinking panel). */
1572
+ function summarizeToolResult(result) {
1573
+ let text = "";
1574
+ if (result.isError) text = `(错误)${result.error?.message ?? "unknown"}`;
1575
+ else {
1576
+ text = (result.content ?? []).map((block) => typeof block === "object" && block !== null && "text" in block && typeof block.text === "string" ? block.text : "").join("").trim();
1577
+ if (!text && result.value !== void 0) try {
1578
+ text = JSON.stringify(result.value);
1579
+ } catch {
1580
+ text = String(result.value);
1581
+ }
1582
+ }
1583
+ return text.length > TOOL_RESULT_MAX_CHARS ? `${text.slice(0, TOOL_RESULT_MAX_CHARS)}…(已截断)` : text;
1584
+ }
1585
+ /**
1586
+ * Execute one tool via the official pipeline (scoped dispatch when `agent` is
1587
+ * provided). Returns the model-facing text; never throws.
1588
+ */
1589
+ async function executeAdvisorTool(ctx, agent, call, signal) {
1590
+ const tools = ctx.tools;
1591
+ if (!tools?.execute) return "(顾问工具执行不可用:当前环境未提供工具执行服务)";
1592
+ let args = {};
1593
+ try {
1594
+ args = call.argumentsJson ? JSON.parse(call.argumentsJson) : {};
1595
+ } catch {
1596
+ return "(工具参数不是合法 JSON)";
1597
+ }
1598
+ try {
1599
+ return summarizeToolResult(await tools.execute({
1600
+ callId: call.id,
1601
+ name: call.name,
1602
+ arguments: args,
1603
+ ...agent === void 0 ? {} : { agent },
1604
+ signal: withTimeout(TOOL_EXEC_TIMEOUT_MS, signal)
1605
+ }));
1606
+ } catch (error) {
1607
+ return `(工具执行失败:${error instanceof Error ? error.message : String(error)})`;
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Drive the tool-calling loop: stream with `tools` → execute every
1612
+ * model-requested call (each step surfaced via `onToolStep` as its own row) →
1613
+ * feed a compact "already executed" block back → repeat until the model
1614
+ * answers (or MAX_TOOL_ROUNDS, then one final no-tools round to force a text
1615
+ * answer).
1616
+ */
1617
+ async function runAdvisorToolLoop(tools, streamOnce, executeTool, onToolStep) {
1618
+ let extraContext = "";
1619
+ let truncated;
1620
+ const executedMemo = /* @__PURE__ */ new Map();
1621
+ const executeDeduped = async (call) => {
1622
+ const key = `${call.name}:${call.argumentsJson}`;
1623
+ const cached = executedMemo.get(key);
1624
+ if (cached !== void 0) {
1625
+ console.warn(`[dsh-advisor-group] 同参工具调用去重(复用已执行结果):${call.name}`);
1626
+ return {
1627
+ outcome: cached,
1628
+ reused: true
1629
+ };
1630
+ }
1631
+ const outcome = await executeTool(call);
1632
+ executedMemo.set(key, outcome);
1633
+ return {
1634
+ outcome,
1635
+ reused: false
1636
+ };
1637
+ };
1638
+ /** Result fed back to the model: capped so extraContext never balloons
1639
+ * (display rows keep their own 240-char preview). */
1640
+ const capOutcome = (outcome) => outcome.length > 800 ? `${outcome.slice(0, 800)}…` : outcome;
1641
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
1642
+ const result = await streamOnce(tools, extraContext);
1643
+ if (result.toolCalls.length === 0) return {
1644
+ content: result.content,
1645
+ truncated: result.truncated
1646
+ };
1647
+ truncated = result.truncated ?? truncated;
1648
+ const executed = [];
1649
+ for (const call of result.toolCalls) {
1650
+ const argsPreview = call.argumentsJson.length > 120 ? `${call.argumentsJson.slice(0, 120)}…` : call.argumentsJson;
1651
+ onToolStep({
1652
+ kind: "call",
1653
+ name: call.name,
1654
+ text: argsPreview || "无参数"
1655
+ });
1656
+ if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
1657
+ const { outcome, reused } = await executeDeduped(call);
1658
+ if (outcome.trim()) {
1659
+ const flat = outcome.replace(/\n+/g, " ").trim();
1660
+ const preview = flat.length > 240 ? `${flat.slice(0, 240)}…` : flat;
1661
+ onToolStep({
1662
+ kind: "result",
1663
+ name: call.name,
1664
+ text: `${reused ? "(复用已执行结果)" : ""}${preview}`
1665
+ });
1666
+ }
1667
+ executed.push(`- ${call.name}${call.argumentsJson ? ` 参数:${call.argumentsJson.slice(0, 200)}` : ""}\n结果:${capOutcome(outcome)}`);
1668
+ }
1669
+ extraContext = `${extraContext}\n\n【顾问已执行工具】\n${executed.join("\n\n")}`;
1670
+ }
1671
+ const final = await streamOnce([], extraContext);
1672
+ if (final.toolCalls.length > 0) {
1673
+ const executed = [];
1674
+ for (const call of final.toolCalls) {
1675
+ const argsPreview = call.argumentsJson.length > 120 ? `${call.argumentsJson.slice(0, 120)}…` : call.argumentsJson;
1676
+ onToolStep({
1677
+ kind: "call",
1678
+ name: call.name,
1679
+ text: argsPreview || "无参数"
1680
+ });
1681
+ if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
1682
+ const { outcome, reused } = await executeDeduped(call);
1683
+ if (outcome.trim()) {
1684
+ const flat = outcome.replace(/\n+/g, " ").trim();
1685
+ const preview = flat.length > 240 ? `${flat.slice(0, 240)}…` : flat;
1686
+ onToolStep({
1687
+ kind: "result",
1688
+ name: call.name,
1689
+ text: `${reused ? "(复用已执行结果)" : ""}${preview}`
1690
+ });
1691
+ }
1692
+ executed.push(`- ${call.name}${call.argumentsJson ? ` 参数:${call.argumentsJson.slice(0, 200)}` : ""}\n结果:${capOutcome(outcome)}`);
1693
+ }
1694
+ const final2 = await streamOnce([], `${extraContext}\n\n【顾问已执行工具】\n${executed.join("\n\n")}`);
1695
+ return {
1696
+ content: final2.content,
1697
+ truncated: final2.truncated ?? final.truncated ?? truncated
1698
+ };
1699
+ }
1700
+ return {
1701
+ content: final.content,
1702
+ truncated: final.truncated ?? truncated
1703
+ };
1704
+ }
1705
+ //#endregion
1001
1706
  //#region src/service.ts
1002
1707
  function isAbortError(error, signal) {
1003
1708
  if (signal?.aborted) return true;
1004
1709
  return error instanceof Error && error.name === "AbortError";
1005
1710
  }
1006
- const MAX_DAILY_CONSULTATIONS = 50;
1711
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1712
+ /** Real consult ids are server-minted uuids; anything else is rejected. */
1713
+ function isSafeConsultId(id) {
1714
+ return UUID_RE.test(id);
1715
+ }
1716
+ /** Agent DSH session ids look like `session-<uuid>` or a plain uuid. */
1717
+ function isSafeDshSessionId(id) {
1718
+ return UUID_RE.test(id) || UUID_RE.test(id.replace(/^session-/i, ""));
1719
+ }
1007
1720
  function extractUserMessageText(event) {
1008
1721
  if (event.type !== "user/message") return null;
1009
1722
  const data = event.data;
@@ -1021,6 +1734,11 @@ var AdvisorGroupService = class {
1021
1734
  /** Durable daily-guard counter fixture under the DSH home storage area. */
1022
1735
  dailyGuardPath;
1023
1736
  dailyWriteChain = Promise.resolve();
1737
+ /** Per-session stop controllers for the auto-deepen pipeline. */
1738
+ activeAborts = /* @__PURE__ */ new Map();
1739
+ /** Durable consultation snapshots (usable after a dsh restart). */
1740
+ sessionsDir;
1741
+ sessionWriteChain = Promise.resolve();
1024
1742
  enabled;
1025
1743
  config;
1026
1744
  constructor(ctx, config) {
@@ -1029,7 +1747,9 @@ var AdvisorGroupService = class {
1029
1747
  this.enabled = config.enabled;
1030
1748
  const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
1031
1749
  this.dailyGuardPath = join(dshHome, "storages", "advisor-group", "daily-guard.json");
1750
+ this.sessionsDir = join(dshHome, "storages", "advisor-group", "sessions");
1032
1751
  this.loadDailyGuard();
1752
+ this.loadStoredSessions();
1033
1753
  ctx.on("session/event", (session, event) => {
1034
1754
  const text = extractUserMessageText(event);
1035
1755
  if (!text) return;
@@ -1065,6 +1785,228 @@ var AdvisorGroupService = class {
1065
1785
  console.warn("[dsh-advisor-group] 每日护栏持久化失败:", error instanceof Error ? error.message : String(error));
1066
1786
  });
1067
1787
  }
1788
+ /**
1789
+ * Restore interrupted consultations from durable snapshots so the card's
1790
+ * 「▶ 继续聊天」 button still works after a dsh restart. Completed sessions
1791
+ * are not restored (they cannot resume) and their snapshot is cleaned up.
1792
+ */
1793
+ loadStoredSessions() {
1794
+ let files;
1795
+ try {
1796
+ files = readdirSync(this.sessionsDir).filter((name) => name.endsWith(".json"));
1797
+ } catch {
1798
+ return;
1799
+ }
1800
+ for (const file of files) try {
1801
+ const raw = JSON.parse(readFileSync(join(this.sessionsDir, file), "utf8"));
1802
+ if (raw.version !== 1 || typeof raw.id !== "string" || typeof raw.question !== "string") continue;
1803
+ if (!isSafeConsultId(raw.id)) continue;
1804
+ if (raw.dshSessionId !== void 0 && !isSafeDshSessionId(raw.dshSessionId)) continue;
1805
+ if (raw.status === "completed") {
1806
+ unlinkSync(join(this.sessionsDir, file));
1807
+ continue;
1808
+ }
1809
+ const advisors = this.resolveAdvisors(raw.advisorIds);
1810
+ if (advisors.length === 0) continue;
1811
+ const session = {
1812
+ id: raw.id,
1813
+ status: "cancelled",
1814
+ question: raw.question,
1815
+ context: raw.context,
1816
+ advisors,
1817
+ maxRounds: raw.maxRounds,
1818
+ messages: raw.messages.map((message) => ({
1819
+ role: message.role,
1820
+ advisorId: message.advisorId,
1821
+ advisorName: message.advisorName,
1822
+ content: message.content,
1823
+ thinking: message.thinking,
1824
+ thinkingSegments: message.thinkingSegments,
1825
+ actionDescriptions: message.actionDescriptions,
1826
+ round: message.round,
1827
+ truncated: message.truncated,
1828
+ toolSteps: message.toolSteps,
1829
+ ts: message.ts
1830
+ })),
1831
+ cwd: raw.cwd,
1832
+ dshSessionId: raw.dshSessionId,
1833
+ driverSource: raw.driverSource,
1834
+ stopReason: raw.stopReason ?? "abort-error",
1835
+ createdAt: raw.createdAt,
1836
+ updatedAt: raw.updatedAt
1837
+ };
1838
+ this.sessions.set(session.id, session);
1839
+ } catch (error) {
1840
+ console.warn("[dsh-advisor-group] 会话快照读取失败,已跳过:", file, error instanceof Error ? error.message : String(error));
1841
+ }
1842
+ }
1843
+ /** Serialization-safe snapshot: advisor identity only, NEVER credentials. */
1844
+ snapshotPayload(session) {
1845
+ return {
1846
+ version: 1,
1847
+ id: session.id,
1848
+ status: session.status,
1849
+ question: session.question,
1850
+ ...session.context === void 0 ? {} : { context: session.context },
1851
+ ...session.cwd === void 0 ? {} : { cwd: session.cwd },
1852
+ ...session.dshSessionId === void 0 ? {} : { dshSessionId: session.dshSessionId },
1853
+ ...session.driverSource === void 0 ? {} : { driverSource: session.driverSource },
1854
+ ...session.stopReason === void 0 ? {} : { stopReason: session.stopReason },
1855
+ advisorIds: session.advisors.map((advisor) => advisor.id),
1856
+ maxRounds: session.maxRounds,
1857
+ createdAt: session.createdAt,
1858
+ updatedAt: session.updatedAt,
1859
+ messages: session.messages.map((message) => ({
1860
+ role: message.role,
1861
+ ...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
1862
+ ...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
1863
+ content: message.content,
1864
+ ...message.thinking === void 0 ? {} : { thinking: message.thinking },
1865
+ ...message.thinkingSegments === void 0 ? {} : { thinkingSegments: message.thinkingSegments },
1866
+ ...message.actionDescriptions === void 0 ? {} : { actionDescriptions: message.actionDescriptions },
1867
+ ...message.round === void 0 ? {} : { round: message.round },
1868
+ ...message.truncated === void 0 ? {} : { truncated: message.truncated },
1869
+ ...message.toolSteps === void 0 ? {} : { toolSteps: message.toolSteps },
1870
+ ts: message.ts
1871
+ }))
1872
+ };
1873
+ }
1874
+ /** Serialized, atomic (tmp + rename) consultation snapshot persistence. */
1875
+ persistSession(session) {
1876
+ if (!isSafeConsultId(session.id)) {
1877
+ console.warn("[dsh-advisor-group] 拒绝持久化非法会话 id:", session.id);
1878
+ return;
1879
+ }
1880
+ const payload = JSON.stringify(this.snapshotPayload(session));
1881
+ const target = join(this.sessionsDir, `${session.id}.json`);
1882
+ this.sessionWriteChain = this.sessionWriteChain.then(async () => {
1883
+ await promises.mkdir(this.sessionsDir, { recursive: true });
1884
+ const tmp = `${target}.tmp`;
1885
+ await promises.writeFile(tmp, payload, "utf8");
1886
+ await promises.rename(tmp, target);
1887
+ }).catch((error) => {
1888
+ console.warn("[dsh-advisor-group] 咨询快照持久化失败:", session.id, error instanceof Error ? error.message : String(error));
1889
+ });
1890
+ }
1891
+ /**
1892
+ * Card-facing advisor failure text: credentials redacted, collapsed to one
1893
+ * line, length-capped. The full raw error is logged server-side instead.
1894
+ */
1895
+ summarizeAdvisorError(raw) {
1896
+ const singleLine = raw.replace(/\b(api[_-]?key["']?\s*[:=]\s*["']?)[^\s"',}&]+/gi, "$1[已隐藏]").replace(/([?&]key=)[^\s&"']+/gi, "$1[已隐藏]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [已隐藏]").replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "[已隐藏]").replace(/\s+/g, " ").trim();
1897
+ if (singleLine.length <= 300) return singleLine;
1898
+ return `${singleLine.slice(0, 300)}…(完整错误已记录到服务端日志)`;
1899
+ }
1900
+ /** Wait for queued durable writes before a host or test tears down storage. */
1901
+ async flushPersistence() {
1902
+ await Promise.all([this.dailyWriteChain, this.sessionWriteChain]);
1903
+ }
1904
+ /** Structural view of the DSH in-memory session store (may be absent). */
1905
+ get sessionStore() {
1906
+ let store;
1907
+ try {
1908
+ store = this.ctx.sessions;
1909
+ } catch {
1910
+ return;
1911
+ }
1912
+ if (!store || typeof store !== "object") return void 0;
1913
+ const typed = store;
1914
+ if (typeof typed.get !== "function" || typeof typed.prepare !== "function" || typeof typed.enter !== "function" || typeof typed.flush !== "function") return;
1915
+ return store;
1916
+ }
1917
+ /** Best-effort durability barrier for the advisor-group log events. */
1918
+ async flushLog(sessionLog) {
1919
+ if (!sessionLog) return;
1920
+ const store = this.sessionStore;
1921
+ if (!store) return;
1922
+ try {
1923
+ await store.flush(sessionLog);
1924
+ } catch {}
1925
+ }
1926
+ /** Build a contiguous seed log from the snapshot (cross-restart resume). */
1927
+ buildSeedEvents(session) {
1928
+ const events = [];
1929
+ let seq = 0;
1930
+ events.push({
1931
+ type: "advisor-group/start",
1932
+ seq: seq++,
1933
+ time: session.createdAt,
1934
+ data: {
1935
+ sessionId: session.id,
1936
+ turn: 0,
1937
+ step: 0,
1938
+ question: session.question,
1939
+ ...session.context === void 0 ? {} : { context: session.context },
1940
+ advisors: session.advisors.map(({ id, name, avatar }) => avatar === void 0 ? {
1941
+ id,
1942
+ name
1943
+ } : {
1944
+ id,
1945
+ name,
1946
+ avatar
1947
+ })
1948
+ }
1949
+ });
1950
+ for (const message of session.messages) {
1951
+ if (message.role === "system") continue;
1952
+ events.push({
1953
+ type: "advisor-group/message",
1954
+ seq: seq++,
1955
+ time: message.ts || Date.now(),
1956
+ data: {
1957
+ sessionId: session.id,
1958
+ turn: 0,
1959
+ step: 0,
1960
+ role: message.role,
1961
+ content: message.content,
1962
+ ...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
1963
+ ...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
1964
+ ...message.thinking === void 0 ? {} : { thinking: message.thinking },
1965
+ ...message.round === void 0 ? {} : { round: message.round },
1966
+ ...message.truncated === void 0 ? {} : { truncated: message.truncated }
1967
+ }
1968
+ });
1969
+ }
1970
+ return events;
1971
+ }
1972
+ /**
1973
+ * Obtain the DSH Session handle for durable log events. Prefers the live
1974
+ * store entry (same process). After a restart the store is empty, so the
1975
+ * session is rebuilt with `prepare` + `enter` (append hooks publish events
1976
+ * and the persistence layer picks them up; the session is NOT announced, so
1977
+ * no agent lifecycle reactivation is triggered). Falls back to a detached
1978
+ * `Session.create` (live SSE + snapshot only, no durable log) when neither
1979
+ * works.
1980
+ */
1981
+ resolveSessionHandle(session) {
1982
+ const store = this.sessionStore;
1983
+ const resumeId = session.dshSessionId !== void 0 && isSafeDshSessionId(session.dshSessionId) ? session.dshSessionId : isSafeConsultId(session.id) ? session.id : "";
1984
+ if (!resumeId) return void 0;
1985
+ const live = store?.get(resumeId);
1986
+ if (live) return live;
1987
+ if (!store) return this.buildDetachedSession(session, resumeId);
1988
+ try {
1989
+ const meta = { cwd: session.cwd !== void 0 && isAbsolute(session.cwd) ? session.cwd : process.cwd() };
1990
+ const prepared = store.prepare(resumeId, {
1991
+ seed: this.buildSeedEvents(session),
1992
+ meta
1993
+ });
1994
+ store.enter(prepared);
1995
+ return prepared;
1996
+ } catch (error) {
1997
+ console.warn("[dsh-advisor-group] 重建会话句柄失败(降级为 detached):", session.id, error instanceof Error ? error.message : String(error));
1998
+ const again = store.get(resumeId);
1999
+ if (again) return again;
2000
+ return this.buildDetachedSession(session, resumeId);
2001
+ }
2002
+ }
2003
+ buildDetachedSession(session, sessionId = session.id) {
2004
+ try {
2005
+ return Session.create(sessionId, this.buildSeedEvents(session), void 0, 0);
2006
+ } catch {
2007
+ return;
2008
+ }
2009
+ }
1068
2010
  getRepeatPressure(sessionId) {
1069
2011
  if (!sessionId) return void 0;
1070
2012
  const tracked = this.repeatTracker.get(sessionId);
@@ -1098,26 +2040,35 @@ var AdvisorGroupService = class {
1098
2040
  this.setEnabled(enabled);
1099
2041
  if (this.persistEnabledCallback) try {
1100
2042
  await this.persistEnabledCallback(enabled);
1101
- } catch {}
2043
+ } catch (error) {
2044
+ console.warn("[dsh-advisor-group] 启用开关保存失败(内存态未回滚):", error instanceof Error ? error.message : String(error));
2045
+ }
1102
2046
  return this.enabled;
1103
2047
  }
1104
2048
  /**
1105
2049
  * Current daily-guard facts for display (does NOT increment — read-only).
1106
2050
  * Rolls the in-memory counter to a new UTC day view without persisting.
2051
+ * `enabled` reflects the configurable cap; `remaining` is -1 when the cap
2052
+ * is disabled (the counter still accumulates for visibility).
1107
2053
  */
1108
2054
  getDailyGuard() {
1109
2055
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1110
2056
  const used = this.dailyConsultationDate === today ? this.dailyConsultationCount : 0;
2057
+ const enabled = this.config.quota.enabled;
2058
+ const limit = this.config.quota.maxPerDay;
1111
2059
  return {
1112
2060
  used,
1113
- limit: MAX_DAILY_CONSULTATIONS,
1114
- remaining: Math.max(0, MAX_DAILY_CONSULTATIONS - used)
2061
+ enabled,
2062
+ limit,
2063
+ remaining: enabled ? Math.max(0, limit - used) : -1
1115
2064
  };
1116
2065
  }
1117
2066
  /**
1118
2067
  * Minimal cost guard: atomically check and increment the per-day new
1119
2068
  * consultation counter. Node is single-threaded, so doing both inside one
1120
2069
  * synchronous block closes the TOCTOU window a two-step check would leave.
2070
+ * The cap is configurable (`quota.enabled` / `quota.maxPerDay`); when the
2071
+ * cap is disabled the counter still increments (used for display only).
1121
2072
  * The counter is persisted (UTC day key) so a harness restart does not
1122
2073
  * reset the quota; persistence is fire-and-forget after the sync block.
1123
2074
  */
@@ -1127,9 +2078,9 @@ var AdvisorGroupService = class {
1127
2078
  this.dailyConsultationDate = today;
1128
2079
  this.dailyConsultationCount = 0;
1129
2080
  }
1130
- if (this.dailyConsultationCount >= MAX_DAILY_CONSULTATIONS) return {
2081
+ if (this.config.quota.enabled && this.dailyConsultationCount >= this.config.quota.maxPerDay) return {
1131
2082
  ok: false,
1132
- reason: "今日顾问咨询次数已达上限,请明天再试或调整配置。"
2083
+ reason: "今日顾问咨询次数已达上限,请明天再试,或在设置中调整每日咨询上限。"
1133
2084
  };
1134
2085
  this.dailyConsultationCount += 1;
1135
2086
  this.persistDailyGuard();
@@ -1138,9 +2089,10 @@ var AdvisorGroupService = class {
1138
2089
  getSession(id) {
1139
2090
  return this.sessions.get(id);
1140
2091
  }
1141
- createSession(question, context, advisorIds = []) {
2092
+ createSession(question, context, advisorIds = [], cwd, dshSessionId) {
1142
2093
  const maxAdvisors = this.config.discussion.maxAdvisorsPerCall;
1143
2094
  const advisors = this.resolveAdvisors(advisorIds).slice(0, maxAdvisors);
2095
+ const now = Date.now();
1144
2096
  const session = {
1145
2097
  id: randomUUID(),
1146
2098
  status: "active",
@@ -1151,20 +2103,23 @@ var AdvisorGroupService = class {
1151
2103
  messages: [{
1152
2104
  role: "system",
1153
2105
  content: `顾问群已建立,共 ${advisors.length} 位顾问。`,
1154
- ts: Date.now()
2106
+ ts: now
1155
2107
  }, {
1156
2108
  role: "main",
1157
2109
  content: context ? `${context}\n\n${question}` : question,
1158
- ts: Date.now()
2110
+ ts: now
1159
2111
  }],
1160
- createdAt: Date.now(),
1161
- updatedAt: Date.now()
2112
+ cwd,
2113
+ dshSessionId,
2114
+ createdAt: now,
2115
+ updatedAt: now
1162
2116
  };
1163
2117
  if (this.sessions.size >= 100) {
1164
2118
  const oldest = this.sessions.keys().next().value;
1165
2119
  if (oldest !== void 0) this.sessions.delete(oldest);
1166
2120
  }
1167
2121
  this.sessions.set(session.id, session);
2122
+ this.persistSession(session);
1168
2123
  this.ctx.emit("advisor-group/session-start", session);
1169
2124
  return session;
1170
2125
  }
@@ -1179,22 +2134,60 @@ var AdvisorGroupService = class {
1179
2134
  session.messages.push(message);
1180
2135
  session.updatedAt = Date.now();
1181
2136
  if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
2137
+ this.persistSession(session);
1182
2138
  this.ctx.emit("advisor-group/message", {
1183
2139
  sessionId: session.id,
1184
2140
  message
1185
2141
  });
1186
2142
  return session;
1187
2143
  }
1188
- async runOneRound(session, signal, sessionLog) {
2144
+ /**
2145
+ * The next round that still needs work, derived from the durable messages:
2146
+ * when the last round is incomplete (an advisor answered, a later one was
2147
+ * cut by a stop), that round resumes with ONLY the missing advisors; when
2148
+ * it is complete, the next round is fresh. `undefined` means finished.
2149
+ */
2150
+ nextPendingRound(session) {
2151
+ const advisorMessages = session.messages.filter((message) => message.role === "advisor");
2152
+ const maxRound = advisorMessages.reduce((max, message) => Math.max(max, message.round ?? 1), 0);
1189
2153
  const advisorCount = Math.max(1, session.advisors.length);
1190
- const round = Math.floor(session.messages.filter((message) => message.role === "advisor").length / advisorCount) + 1;
2154
+ if (maxRound === 0) return {
2155
+ round: 1,
2156
+ answeredIds: []
2157
+ };
2158
+ if (maxRound > session.maxRounds) return void 0;
2159
+ const answeredInMax = advisorMessages.filter((message) => (message.round ?? 1) === maxRound).map((message) => message.advisorId ?? "").filter((id) => id !== "");
2160
+ const unique = [...new Set(answeredInMax)];
2161
+ if (unique.length < advisorCount) return {
2162
+ round: maxRound,
2163
+ answeredIds: unique
2164
+ };
2165
+ if (maxRound + 1 > session.maxRounds) return void 0;
2166
+ return {
2167
+ round: maxRound + 1,
2168
+ answeredIds: []
2169
+ };
2170
+ }
2171
+ /** Number of main-model messages: the opening question + all deep-questions. */
2172
+ mainMessageCount(session) {
2173
+ return session.messages.filter((message) => message.role === "main").length;
2174
+ }
2175
+ /** Run exactly one round: every advisor that has NOT answered this round. */
2176
+ async runRoundFrom(session, round, answeredIds, signal, sessionLog, agent) {
1191
2177
  for (let index = 0; index < session.advisors.length; index++) {
1192
2178
  const advisor = session.advisors[index];
2179
+ if (answeredIds.includes(advisor.id)) continue;
1193
2180
  const transcript = this.buildTranscript(session);
1194
- await this.callAdvisor(session, advisor, transcript, round, index + 1, signal, sessionLog);
2181
+ await this.callAdvisor(session, advisor, transcript, round, index + 1, signal, sessionLog, agent);
1195
2182
  }
1196
2183
  session.status = "active";
1197
2184
  session.updatedAt = Date.now();
2185
+ this.persistSession(session);
2186
+ }
2187
+ async runOneRound(session, signal, sessionLog, agent) {
2188
+ const pending = this.nextPendingRound(session);
2189
+ if (!pending) return;
2190
+ await this.runRoundFrom(session, pending.round, pending.answeredIds, signal, sessionLog, agent);
1198
2191
  }
1199
2192
  /**
1200
2193
  * Auto-deepen pipeline (2026-09-05): one ask_advisors call runs the whole
@@ -1202,21 +2195,159 @@ var AdvisorGroupService = class {
1202
2195
  * [driver deep-question (after the first)] → advisor A → advisor B (sees A)
1203
2196
  * → advisor C (sees A+B) → …
1204
2197
  * and closes with a driver-generated conclusion. The pipeline is synchronous
1205
- * (SSE keeps the card streaming); `exec.signal` aborts the whole run.
2198
+ * (SSE keeps the card streaming); `exec.signal` aborts the whole run and the
2199
+ * user may stop it explicitly via `stopConsultation(sessionId)` (POST
2200
+ * /advisor-group/stop) — a stop degrades to a graceful partial summary.
2201
+ *
2202
+ * Resumption: after a stop (or a dsh restart that restored the snapshot)
2203
+ * `resumeConsultation()` re-enters the SAME pipeline; `nextPendingRound()`
2204
+ * detects the interruption point — a partially-answered round is completed
2205
+ * with only the missing advisors (the deep-question is NOT regenerated for
2206
+ * that round), then remaining rounds run as usual.
1206
2207
  */
1207
- async runAutoPipeline(session, signal, sessionLog) {
1208
- while (!this.hasReachedMaxRounds(session)) {
1209
- if (this.getRoundCount(session) + 1 > 1 && this.config.discussion.autoDeepen) {
1210
- const question = await generateDeepenQuestion(this.ctx, session, resolveDriverSource(sessionLog, this.config.discussion.driverModel), signal);
1211
- this.appendMainMessage(session, question, sessionLog);
2208
+ async runAutoPipeline(session, signal, sessionLog, agent) {
2209
+ const stop = new AbortController();
2210
+ this.activeAborts.set(session.id, stop);
2211
+ const combined = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
2212
+ if (!session.driverSource) {
2213
+ const source = resolveDriverSource(sessionLog, this.config.discussion.driverModel);
2214
+ if (source) {
2215
+ session.driverSource = source;
2216
+ this.persistSession(session);
1212
2217
  }
1213
- await this.runOneRound(session, signal, sessionLog);
1214
2218
  }
1215
- session.status = "completed";
2219
+ try {
2220
+ while (true) {
2221
+ const pending = this.nextPendingRound(session);
2222
+ if (!pending) break;
2223
+ const freshRound = pending.answeredIds.length === 0;
2224
+ const questionAlreadyAsked = this.mainMessageCount(session) >= pending.round;
2225
+ if (pending.round > 1 && freshRound && this.config.discussion.autoDeepen && !questionAlreadyAsked) {
2226
+ const question = await generateDeepenQuestion(this.ctx, session, session.driverSource ?? resolveDriverSource(sessionLog, this.config.discussion.driverModel), combined, this.config.discussion.driverTimeoutMs);
2227
+ this.appendMainMessage(session, question, sessionLog);
2228
+ }
2229
+ await this.runRoundFrom(session, pending.round, pending.answeredIds, combined, sessionLog, agent);
2230
+ }
2231
+ session.status = "completed";
2232
+ session.stopReason = void 0;
2233
+ const summary = await this.generateFinalSummary(session, combined, sessionLog, false);
2234
+ this.persistSession(session);
2235
+ await this.flushLog(sessionLog);
2236
+ return summary;
2237
+ } catch (error) {
2238
+ if (isAbortError(error, combined) || stop.signal.aborted) {
2239
+ if (stop.signal.aborted) session.stopReason = "user-stop";
2240
+ else if (signal?.aborted) session.stopReason = "exec-cancel";
2241
+ else session.stopReason = "abort-error";
2242
+ session.status = "cancelled";
2243
+ this.persistSession(session);
2244
+ const summary = await this.generateFinalSummary(session, void 0, sessionLog, true);
2245
+ await this.flushLog(sessionLog);
2246
+ return summary;
2247
+ }
2248
+ throw error;
2249
+ } finally {
2250
+ this.activeAborts.delete(session.id);
2251
+ }
2252
+ }
2253
+ /**
2254
+ * User-initiated stop: aborts the in-flight consultation pipeline. Returns
2255
+ * whether a running consultation was found and aborted.
2256
+ */
2257
+ stopConsultation(sessionId) {
2258
+ const stop = this.activeAborts.get(sessionId);
2259
+ if (!stop) return false;
2260
+ stop.abort();
2261
+ return true;
2262
+ }
2263
+ /** True while a pipeline run (auto-deepen or a follow-up round) is in flight. */
2264
+ isSessionRunning(sessionId) {
2265
+ return this.activeAborts.has(sessionId);
2266
+ }
2267
+ /**
2268
+ * Resume a stopped/interrupted consultation from its interruption point.
2269
+ * Works for a user stop (live session) and after a dsh restart (snapshot
2270
+ * restored; the DSH session handle is rebuilt via `prepare` + `enter`).
2271
+ * Returns immediately; the pipeline runs in the background and streams over
2272
+ * the existing SSE channel, then closes with a fresh `advisor-group/end`.
2273
+ */
2274
+ resumeConsultation(sessionId) {
2275
+ const session = this.sessions.get(sessionId);
2276
+ if (!session) return {
2277
+ ok: false,
2278
+ reason: "咨询会话不存在或已过期,请重新发起咨询。"
2279
+ };
2280
+ if (this.activeAborts.has(sessionId)) return {
2281
+ ok: false,
2282
+ reason: "该咨询正在运行中,无需继续。"
2283
+ };
2284
+ if (session.status === "completed" || this.nextPendingRound(session) === void 0) return {
2285
+ ok: false,
2286
+ reason: "该咨询已完成,无需继续。"
2287
+ };
2288
+ if (session.advisors.length === 0) return {
2289
+ ok: false,
2290
+ reason: "恢复失败:当前配置无法匹配该咨询的原顾问(顾问配置可能已被修改)。"
2291
+ };
2292
+ if (!this.acquireResumeLock(session.id)) return {
2293
+ ok: false,
2294
+ reason: "该咨询正在另一实例中恢复,请稍后再试(若确认无其他实例运行,10 分钟后会自动接管该锁)。"
2295
+ };
2296
+ const dshSession = this.resolveSessionHandle(session);
2297
+ const agentRegistry = this.ctx.agents;
2298
+ const resumeAgent = session.dshSessionId !== void 0 && agentRegistry?.get ? agentRegistry.get(session.dshSessionId) : void 0;
2299
+ session.status = "active";
2300
+ session.stopReason = void 0;
1216
2301
  session.updatedAt = Date.now();
1217
- const summary = this.buildSummary(session, false);
1218
- const conclusion = await generateConclusion(this.ctx, session, resolveDriverSource(sessionLog, this.config.discussion.driverModel), signal);
1219
- const finalSummary = {
2302
+ this.persistSession(session);
2303
+ if (dshSession) appendAdvisorResume(dshSession, session.id);
2304
+ this.ctx.emit("advisor-group/resume", { sessionId: session.id });
2305
+ this.runAutoPipeline(session, void 0, dshSession, resumeAgent).catch((error) => {
2306
+ console.error("[dsh-advisor-group] 续跑流水线失败:", session.id, error instanceof Error ? error.message : String(error));
2307
+ }).finally(() => this.releaseResumeLock(session.id));
2308
+ return { ok: true };
2309
+ }
2310
+ /** Atomic cross-instance lock (mkdir-based, with stale takeover). */
2311
+ acquireResumeLock(sessionId) {
2312
+ const lockDir = join(this.sessionsDir, `${sessionId}.lock`);
2313
+ try {
2314
+ mkdirSync(this.sessionsDir, { recursive: true });
2315
+ mkdirSync(lockDir);
2316
+ try {
2317
+ writeFileSync(join(lockDir, "owner"), String(process.pid), "utf8");
2318
+ } catch {}
2319
+ return true;
2320
+ } catch {
2321
+ try {
2322
+ const stat = statSync(lockDir);
2323
+ if (Date.now() - stat.mtimeMs > 6e5) {
2324
+ rmSync(lockDir, {
2325
+ recursive: true,
2326
+ force: true
2327
+ });
2328
+ mkdirSync(lockDir);
2329
+ return true;
2330
+ }
2331
+ } catch {}
2332
+ return false;
2333
+ }
2334
+ }
2335
+ releaseResumeLock(sessionId) {
2336
+ try {
2337
+ rmSync(join(this.sessionsDir, `${sessionId}.lock`), {
2338
+ recursive: true,
2339
+ force: true
2340
+ });
2341
+ } catch {}
2342
+ }
2343
+ async generateFinalSummary(session, signal, sessionLog, stopped) {
2344
+ session.updatedAt = Date.now();
2345
+ const summary = this.buildSummary(session, stopped);
2346
+ const conclusion = stopped ? "" : await generateConclusion(this.ctx, session, session.driverSource ?? resolveDriverSource(sessionLog, this.config.discussion.driverModel), signal, this.config.discussion.driverTimeoutMs);
2347
+ const finalSummary = stopped ? {
2348
+ ...summary,
2349
+ stopped: true
2350
+ } : {
1220
2351
  ...summary,
1221
2352
  conclusion
1222
2353
  };
@@ -1235,6 +2366,7 @@ var AdvisorGroupService = class {
1235
2366
  };
1236
2367
  session.messages.push(message);
1237
2368
  if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
2369
+ this.persistSession(session);
1238
2370
  this.ctx.emit("advisor-group/message", {
1239
2371
  sessionId: session.id,
1240
2372
  message
@@ -1246,17 +2378,38 @@ var AdvisorGroupService = class {
1246
2378
  * calling ask_advisors again with sessionId + followUp, never by the
1247
2379
  * advisor models talking to themselves back-to-back.
1248
2380
  */
1249
- async runOneRoundAndSummarize(session, signal, sessionLog) {
1250
- await this.runOneRound(session, signal, sessionLog);
1251
- session.status = "completed";
1252
- session.updatedAt = Date.now();
1253
- const summary = this.buildSummary(session, false);
1254
- if (sessionLog) appendAdvisorEnd(sessionLog, session, summary);
1255
- this.ctx.emit("advisor-group/session-end", {
1256
- sessionId: session.id,
1257
- summary
1258
- });
1259
- return summary;
2381
+ async runOneRoundAndSummarize(session, signal, sessionLog, agent) {
2382
+ const stop = new AbortController();
2383
+ this.activeAborts.set(session.id, stop);
2384
+ const combined = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
2385
+ try {
2386
+ await this.runOneRound(session, combined, sessionLog, agent);
2387
+ session.status = "completed";
2388
+ session.updatedAt = Date.now();
2389
+ const summary = this.buildSummary(session, false);
2390
+ if (sessionLog) appendAdvisorEnd(sessionLog, session, summary);
2391
+ this.persistSession(session);
2392
+ await this.flushLog(sessionLog);
2393
+ this.ctx.emit("advisor-group/session-end", {
2394
+ sessionId: session.id,
2395
+ summary
2396
+ });
2397
+ return summary;
2398
+ } catch (error) {
2399
+ if (isAbortError(error, combined) || stop.signal.aborted) {
2400
+ if (stop.signal.aborted) session.stopReason = "user-stop";
2401
+ else if (signal?.aborted) session.stopReason = "exec-cancel";
2402
+ else session.stopReason = "abort-error";
2403
+ session.status = "cancelled";
2404
+ this.persistSession(session);
2405
+ const summary = await this.generateFinalSummary(session, void 0, sessionLog, true);
2406
+ await this.flushLog(sessionLog);
2407
+ return summary;
2408
+ }
2409
+ throw error;
2410
+ } finally {
2411
+ this.activeAborts.delete(session.id);
2412
+ }
1260
2413
  }
1261
2414
  getRoundCount(session) {
1262
2415
  const advisorCount = Math.max(1, session.advisors.length);
@@ -1271,21 +2424,21 @@ var AdvisorGroupService = class {
1271
2424
  return this.config.advisors.filter((advisor) => advisorIds.includes(advisor.id));
1272
2425
  }
1273
2426
  buildTranscript(session) {
1274
- return session.messages.filter((message) => message.role !== "system").slice(-20).map((message) => {
2427
+ return session.messages.filter((message) => message.role !== "system").slice(-12).map((message) => {
1275
2428
  if (message.role === "main") return {
1276
2429
  role: "main",
1277
2430
  name: "主模型",
1278
- content: message.content.slice(0, 4e3)
2431
+ content: message.content.slice(0, 3e3)
1279
2432
  };
1280
2433
  if (message.role === "advisor" && message.advisorName) return {
1281
2434
  role: "advisor",
1282
2435
  name: message.advisorName,
1283
- content: message.content.slice(0, 4e3)
2436
+ content: message.content.slice(0, 3e3)
1284
2437
  };
1285
2438
  return null;
1286
2439
  }).filter((entry) => entry !== null);
1287
2440
  }
1288
- async callAdvisor(session, advisor, transcript, round, joinIndex, signal, sessionLog) {
2441
+ async callAdvisor(session, advisor, transcript, round, joinIndex, signal, sessionLog, agent) {
1289
2442
  const relational = { ...advisor };
1290
2443
  relational.systemPrompt = `${advisor.systemPrompt}\n${advisorJoinPrompt(joinIndex, session.advisors.length)}`;
1291
2444
  const message = {
@@ -1323,18 +2476,21 @@ var AdvisorGroupService = class {
1323
2476
  pendingText = "";
1324
2477
  pendingThinking = "";
1325
2478
  };
1326
- const emitDelta = (delta) => {
2479
+ const emitDelta = (delta, phase = "answer") => {
1327
2480
  if (delta.text) {
1328
2481
  message.content += delta.text;
1329
2482
  pendingText += delta.text;
1330
2483
  }
1331
2484
  if (delta.thinking) {
1332
2485
  message.thinking = (message.thinking ?? "") + delta.thinking;
2486
+ thinkingBuffer += delta.thinking;
1333
2487
  pendingThinking += delta.thinking;
1334
2488
  }
1335
2489
  publish(session.id, {
1336
2490
  advisorId: advisor.id,
1337
2491
  advisorName: advisor.name,
2492
+ round,
2493
+ phase,
1338
2494
  contentDelta: delta.text,
1339
2495
  thinkingDelta: delta.thinking,
1340
2496
  done: false
@@ -1342,28 +2498,105 @@ var AdvisorGroupService = class {
1342
2498
  if (!timer) timer = setTimeout(flush, 200);
1343
2499
  };
1344
2500
  const advisorTimeoutMs = this.config.discussion.advisorTimeoutMs;
2501
+ let truncated;
2502
+ const thinkingSegments = [];
2503
+ let thinkingBuffer = "";
2504
+ const actionDescriptions = [];
1345
2505
  if (!isDshLlmProvider || advisor.baseURL || advisor.apiKey || advisor.apiKeyEnv) {
1346
- const streamResult = await streamDirectHttp(relational, transcript, emitDelta, signal, advisorTimeoutMs);
1347
- flush();
1348
- content = streamResult.content;
1349
- message.thinking = streamResult.thinking;
2506
+ const toolMode = advisor.tools ?? this.config.discussion.advisorTools ?? "readonly";
2507
+ const toolSchemas = resolveAdvisorToolSchemas(this.ctx, agent, toolMode);
2508
+ if (toolSchemas.length > 0) {
2509
+ relational.systemPrompt = `${relational.systemPrompt}${ADVISOR_TOOL_GUIDANCE}`;
2510
+ const messageToolSteps = [];
2511
+ let narrationBuffer = "";
2512
+ const emitToolStep = (step) => {
2513
+ const full = {
2514
+ ...step,
2515
+ atMs: Date.now()
2516
+ };
2517
+ const parts = [];
2518
+ if (narrationBuffer.trim()) {
2519
+ parts.push(narrationBuffer.trim());
2520
+ narrationBuffer = "";
2521
+ }
2522
+ if (thinkingBuffer.trim()) parts.push(thinkingBuffer);
2523
+ if (parts.length > 0) {
2524
+ thinkingSegments.push(parts.join("\n\n"));
2525
+ thinkingBuffer = "";
2526
+ }
2527
+ messageToolSteps.push(full);
2528
+ if (sessionLog) appendAdvisorDelta(sessionLog, session.id, {
2529
+ sessionId: session.id,
2530
+ advisorId: advisor.id,
2531
+ advisorName: advisor.name,
2532
+ round,
2533
+ toolStep: full
2534
+ });
2535
+ publish(session.id, {
2536
+ advisorId: advisor.id,
2537
+ advisorName: advisor.name,
2538
+ round,
2539
+ toolStep: full
2540
+ });
2541
+ };
2542
+ const streamOnce = (tools, extra) => {
2543
+ const withExtra = extra.trim() ? [...transcript, {
2544
+ role: "main",
2545
+ name: "已执行工具",
2546
+ content: extra.trim()
2547
+ }] : transcript;
2548
+ const parseNames = toolSchemas.map((tool) => tool.name);
2549
+ const emitRoundDelta = (delta) => emitDelta(delta, tools.length > 0 ? "tool" : "answer");
2550
+ return streamDirectHttp(relational, withExtra, emitRoundDelta, signal, advisorTimeoutMs, tools, parseNames).then((result) => {
2551
+ narrationBuffer = result.content.trim();
2552
+ if (tools.length > 0 && narrationBuffer) actionDescriptions.push(narrationBuffer);
2553
+ return {
2554
+ content: result.content,
2555
+ thinking: result.thinking,
2556
+ toolCalls: result.toolCalls ?? [],
2557
+ truncated: result.truncated
2558
+ };
2559
+ });
2560
+ };
2561
+ const loop = await runAdvisorToolLoop(toolSchemas, streamOnce, (call) => executeAdvisorTool(this.ctx, agent, call, signal), (step) => emitToolStep(step));
2562
+ flush();
2563
+ content = loop.content;
2564
+ truncated = loop.truncated;
2565
+ message.toolSteps = messageToolSteps;
2566
+ } else {
2567
+ const streamResult = await streamDirectHttp(relational, transcript, emitDelta, signal, advisorTimeoutMs);
2568
+ flush();
2569
+ content = streamResult.content;
2570
+ message.thinking = streamResult.thinking;
2571
+ truncated = streamResult.truncated;
2572
+ }
1350
2573
  } else {
1351
- content = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs);
2574
+ const result = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs);
1352
2575
  flush();
2576
+ content = result.content;
2577
+ truncated = result.truncated;
1353
2578
  }
1354
- message.content = content;
2579
+ message.content = sanitizeAdvisorContent(content);
2580
+ if (truncated) message.truncated = truncated;
2581
+ if (thinkingBuffer.trim()) thinkingSegments.push(thinkingBuffer);
2582
+ if (thinkingSegments.length > 0) message.thinkingSegments = thinkingSegments;
2583
+ if (actionDescriptions.length > 0) message.actionDescriptions = actionDescriptions;
1355
2584
  } catch (error) {
1356
2585
  if (isAbortError(error, signal)) throw error;
1357
- message.content = `(顾问调用失败:${error instanceof Error ? error.message : String(error)})`;
2586
+ const rawMessage = error instanceof Error ? error.message : String(error);
2587
+ console.warn("[dsh-advisor-group] 顾问调用失败:", session.id, advisor.name, rawMessage);
2588
+ message.content = `(顾问调用失败:${this.summarizeAdvisorError(rawMessage)})`;
1358
2589
  }
1359
2590
  publish(session.id, {
1360
2591
  advisorId: advisor.id,
1361
2592
  advisorName: advisor.name,
2593
+ round,
1362
2594
  done: true
1363
2595
  });
1364
2596
  session.messages.push(message);
1365
2597
  if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
1366
2598
  session.updatedAt = Date.now();
2599
+ this.persistSession(session);
1367
2600
  this.ctx.emit("advisor-group/message", {
1368
2601
  sessionId: session.id,
1369
2602
  message
@@ -1379,7 +2612,7 @@ var AdvisorGroupService = class {
1379
2612
  }
1380
2613
  const advisors = [...byAdvisor.entries()].map(([id, messages]) => {
1381
2614
  const advisor = session.advisors.find((item) => item.id === id);
1382
- const meaningful = messages.filter((message) => message.content && message.content.trim() && !message.content.startsWith("(顾问调用失败"));
2615
+ const meaningful = messages.filter((message) => message.content && message.content.trim() && !message.content.startsWith("(顾问调用失败") && !message.content.startsWith("(顾问未生成正文"));
1383
2616
  const last = meaningful[meaningful.length - 1] ?? messages[messages.length - 1];
1384
2617
  return {
1385
2618
  advisorId: id,
@@ -1387,8 +2620,7 @@ var AdvisorGroupService = class {
1387
2620
  corePoints: last ? this.extractCorePoints(last.content) : []
1388
2621
  };
1389
2622
  });
1390
- const riskNotes = advisorMessages.some((message) => /风险|注意|不确定|risk|uncertain|confidence/i.test(message.content)) ? ["部分顾问提到了风险、不确定性或置信度较低,请主模型谨慎采用。"] : [];
1391
- if (cancelled) riskNotes.push("本次顾问群讨论已被用户或主模型取消,结论可能不完整。");
2623
+ const riskNotes = buildRiskNotes(advisorMessages, cancelled);
1392
2624
  return {
1393
2625
  sessionId: session.id,
1394
2626
  question: session.question,
@@ -1743,6 +2975,167 @@ function matchAdvisors(text, advisors) {
1743
2975
  return [...matches];
1744
2976
  }
1745
2977
  //#endregion
2978
+ //#region src/providers/jev.ts
2979
+ const DEFAULT_ENDPOINTS = {
2980
+ typesafe: "https://api.typesafe.ai/v1/systemone",
2981
+ openrouter: "https://openrouter.ai/api/alpha/decisions"
2982
+ };
2983
+ function numberValue(value) {
2984
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2985
+ }
2986
+ function answerIsTrue(answer, threshold) {
2987
+ const noul = numberValue(answer?.noul);
2988
+ if (noul !== void 0) return noul >= threshold;
2989
+ const choice = typeof answer?.choice === "string" ? answer.choice.toLowerCase() : "";
2990
+ if (choice === "yes" || choice === "true") return true;
2991
+ if (choice === "no" || choice === "false") return false;
2992
+ return false;
2993
+ }
2994
+ /**
2995
+ * An answer is usable only when it carries a finite noul or an explicit
2996
+ * yes/no choice. Anything else (missing key, string noul, "maybe") would be
2997
+ * read as `false` by answerIsTrue — which for needs_advisor/high_risk means a
2998
+ * silent rejection with no fallback to the local classifier.
2999
+ */
3000
+ function answerUsable(answer) {
3001
+ if (numberValue(answer?.noul) !== void 0) return true;
3002
+ const choice = typeof answer?.choice === "string" ? answer.choice.toLowerCase() : "";
3003
+ return choice === "yes" || choice === "true" || choice === "no" || choice === "false";
3004
+ }
3005
+ function parseResponse(body, config, advisors) {
3006
+ if (!body || typeof body !== "object") throw new Error("Jev 响应不是对象");
3007
+ const answers = body.answers;
3008
+ if (!answers || typeof answers !== "object") throw new Error("Jev 响应缺少 answers");
3009
+ const needsAdvisor = answers.needs_advisor;
3010
+ const webSearch = answers.web_search;
3011
+ const risk = answers.high_risk;
3012
+ const domainAnswer = answers.domain;
3013
+ if (!answerUsable(needsAdvisor)) throw new Error("Jev 响应缺少 needs_advisor 的有效判定");
3014
+ if (!answerUsable(risk)) throw new Error("Jev 响应缺少 high_risk 的有效判定");
3015
+ const domain = typeof domainAnswer?.choice === "string" ? domainAnswer.choice : "general";
3016
+ const needsAdvisorNoul = numberValue(needsAdvisor?.noul);
3017
+ const confidence = numberValue(needsAdvisor?.confidence) ?? needsAdvisorNoul;
3018
+ const shouldEscalate = answerIsTrue(needsAdvisor, config.confidenceThreshold);
3019
+ const suggestWebSearch = answerIsTrue(webSearch, config.confidenceThreshold);
3020
+ const highRisk = answerIsTrue(risk, config.highRiskThreshold ?? config.confidenceThreshold);
3021
+ const reportedModel = body.model;
3022
+ const webSearchNoul = numberValue(webSearch?.noul);
3023
+ const riskNoul = numberValue(risk?.noul);
3024
+ const rawAnswers = {
3025
+ ...needsAdvisorNoul === void 0 ? {} : { needsAdvisor: needsAdvisorNoul },
3026
+ ...webSearchNoul === void 0 ? {} : { webSearch: webSearchNoul },
3027
+ ...riskNoul === void 0 ? {} : { highRisk: riskNoul },
3028
+ domain
3029
+ };
3030
+ return {
3031
+ shouldEscalate: shouldEscalate || highRisk,
3032
+ suggestWebSearch: !shouldEscalate && !highRisk && suggestWebSearch,
3033
+ suggestedAdvisors: matchAdvisors(`${domain}\n${domainAnswer?.choice ?? ""}`, advisors),
3034
+ reason: highRisk ? "Jev 判断该问题包含高风险因素,建议启动顾问群。" : shouldEscalate ? `Jev 判断该问题需要顾问群(${confidence === void 0 ? "无置信度" : `置信度 ${confidence}`})。` : suggestWebSearch ? "Jev 判断该问题更适合联网搜索。" : "Jev 判断该问题不需要启动顾问群。",
3035
+ ...confidence === void 0 ? {} : { confidence },
3036
+ rawAnswers,
3037
+ ...typeof reportedModel === "string" ? { model: reportedModel } : {},
3038
+ provider: config.provider
3039
+ };
3040
+ }
3041
+ async function classifyWithJev(question, context, advisors, config, signal, questionEn) {
3042
+ const endpoint = config.baseURL?.trim() || DEFAULT_ENDPOINTS[config.provider];
3043
+ const storedKey = typeof config.apiKey === "string" ? config.apiKey : config.apiKey && typeof config.apiKey === "object" && "value" in config.apiKey ? String(config.apiKey.value ?? "") : void 0;
3044
+ const apiKey = (config.apiKeyEnv ? process.env[config.apiKeyEnv] : void 0) || storedKey;
3045
+ if (!apiKey) throw new Error("Jev 未配置 API key");
3046
+ const controller = new AbortController();
3047
+ const timeout = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`Jev 超时(${config.timeoutMs}ms)`)), config.timeoutMs);
3048
+ const abort = () => controller.abort();
3049
+ signal?.addEventListener("abort", abort, { once: true });
3050
+ try {
3051
+ const response = await fetch(endpoint, {
3052
+ method: "POST",
3053
+ headers: {
3054
+ authorization: `Bearer ${apiKey}`,
3055
+ "content-type": "application/json"
3056
+ },
3057
+ body: JSON.stringify({
3058
+ state: config.useEnglishState && questionEn?.trim() ? questionEn.trim() : `${question}\n\n背景:${context || "无"}`,
3059
+ model: config.model || "jev-latest",
3060
+ questions: {
3061
+ needs_advisor: {
3062
+ type: "noul",
3063
+ instructions: "Does answering this question require expert-level knowledge?",
3064
+ criteria: {
3065
+ true: {
3066
+ what: "The subject matter is professional, specialist, or long-tail knowledge a competent generalist would not reliably get right, or the field itself has no single settled answer without specialist judgment.",
3067
+ examples: [
3068
+ "professional-domain judgment such as accounting treatment, engineering trade-offs, or contract drafting",
3069
+ "rare or long-tail situations with little public consensus",
3070
+ "topics where reasonable experts disagree and picking correctly matters"
3071
+ ]
3072
+ },
3073
+ false: {
3074
+ what: "A competent generalist can answer from common knowledge, widely documented facts, or ordinary reasoning without specialist judgment.",
3075
+ examples: ["everyday facts, definitions, or casual opinions", "well-documented mainstream topics with an agreed answer"]
3076
+ }
3077
+ }
3078
+ },
3079
+ web_search: {
3080
+ type: "noul",
3081
+ instructions: "Does answering this question primarily require current, real-time, or externally verifiable information?",
3082
+ criteria: {
3083
+ true: {
3084
+ what: "The correct answer depends on facts that change over time or live outside the provided state, so it must be looked up rather than reasoned out.",
3085
+ examples: [
3086
+ "today’s weather, prices, exchange rates, or quotes",
3087
+ "recent releases, news, or version-specific changelogs",
3088
+ "local or niche facts with no authoritative source in memory"
3089
+ ]
3090
+ },
3091
+ false: {
3092
+ what: "The answer follows from stable knowledge or reasoning already available in the state.",
3093
+ examples: ["concepts, definitions, and established best practices", "questions fully answerable from the provided background"]
3094
+ }
3095
+ }
3096
+ },
3097
+ high_risk: {
3098
+ type: "noul",
3099
+ instructions: "Does this question involve medical, legal, financial, safety, compliance, or other high-risk advice?",
3100
+ criteria: {
3101
+ true: {
3102
+ what: "A wrong or careless answer could materially harm the asker or a third party in health, money, safety, or legal standing.",
3103
+ examples: [
3104
+ "medical symptoms, medication, diagnosis, or treatment decisions",
3105
+ "legal rights, contracts, litigation, or regulatory compliance",
3106
+ "investment, insurance, tax, or other money decisions with real downside",
3107
+ "physical safety, hazardous actions, or regulated activities"
3108
+ ]
3109
+ },
3110
+ false: {
3111
+ what: "Being wrong carries no material harm to health, money, safety, or legal standing.",
3112
+ examples: ["general knowledge, opinions, or entertainment questions", "technical details whose worst case is wasted time"]
3113
+ }
3114
+ }
3115
+ },
3116
+ domain: {
3117
+ type: "choice",
3118
+ instructions: "Which domain best describes this question?",
3119
+ criteria: {
3120
+ legal: "law, contracts, litigation, compliance, or taxation",
3121
+ medical: "medicine, symptoms, medication, diagnosis, or treatment",
3122
+ finance: "investment, insurance, accounting, or financial risk",
3123
+ code: "software, programming, architecture, debugging, or APIs",
3124
+ general: "none of the listed domains"
3125
+ }
3126
+ }
3127
+ }
3128
+ }),
3129
+ signal: controller.signal
3130
+ });
3131
+ if (!response.ok) throw new Error(`Jev HTTP ${response.status}`);
3132
+ return parseResponse(await response.json(), config, advisors);
3133
+ } finally {
3134
+ clearTimeout(timeout);
3135
+ signal?.removeEventListener("abort", abort);
3136
+ }
3137
+ }
3138
+ //#endregion
1746
3139
  //#region src/tools.ts
1747
3140
  function registerAdvisorTools(service) {
1748
3141
  return [defineTool({
@@ -1773,6 +3166,10 @@ function registerAdvisorTools(service) {
1773
3166
  confidence: {
1774
3167
  type: "number",
1775
3168
  description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
3169
+ },
3170
+ questionEn: {
3171
+ type: "string",
3172
+ description: "Optional self-contained English gist of the question plus key background. Used as the Jev pre-classification input only when the「Jev 英文判定」setting is enabled; the displayed question, session records, and duplicate detection always keep using question/context in Chinese. Omit it otherwise."
1776
3173
  }
1777
3174
  },
1778
3175
  output: {
@@ -1828,7 +3225,18 @@ function registerAdvisorTools(service) {
1828
3225
  const questionText = typeof args.question === "string" ? args.question : "";
1829
3226
  const contextText = typeof args.context === "string" ? args.context : "";
1830
3227
  const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
1831
- const forcedByRepeat = (service.getRepeatPressure(sessionLog?.id)?.count ?? 0) >= 3;
3228
+ const repeatCount = service.getRepeatPressure(sessionLog?.id)?.count ?? 0;
3229
+ const forcedByRepeat = repeatCount >= 3;
3230
+ let shadow;
3231
+ let shadowWritten = false;
3232
+ const recordShadow = (launched) => {
3233
+ if (!shadow || shadowWritten) return;
3234
+ shadowWritten = true;
3235
+ appendShadowSample({
3236
+ ...shadow,
3237
+ launched
3238
+ });
3239
+ };
1832
3240
  if (args.sessionId) {
1833
3241
  if (!args.followUp) return {
1834
3242
  sessionId: args.sessionId,
@@ -1843,6 +3251,12 @@ function registerAdvisorTools(service) {
1843
3251
  skipped: true,
1844
3252
  reason: "session-not-found"
1845
3253
  };
3254
+ if (service.isSessionRunning(session.id)) return {
3255
+ sessionId: session.id,
3256
+ advice: "该顾问群会话正在运行中,请等本轮结束后再追问。",
3257
+ skipped: true,
3258
+ reason: "session-running"
3259
+ };
1846
3260
  if (service.hasReachedMaxRounds(session)) return {
1847
3261
  sessionId: session.id,
1848
3262
  advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
@@ -1850,11 +3264,11 @@ function registerAdvisorTools(service) {
1850
3264
  reason: "max-rounds-reached"
1851
3265
  };
1852
3266
  service.appendFollowUp(session.id, args.followUp, sessionLog);
1853
- await service.runOneRoundAndSummarize(session, exec.signal, sessionLog);
3267
+ const summary = await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
1854
3268
  const canContinue = !service.hasReachedMaxRounds(session);
1855
3269
  return {
1856
3270
  sessionId: session.id,
1857
- advice: formatConversation(session) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
3271
+ advice: formatConversation(session) + formatRiskNotes(summary) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
1858
3272
  skipped: false
1859
3273
  };
1860
3274
  }
@@ -1865,17 +3279,41 @@ function registerAdvisorTools(service) {
1865
3279
  reason: "missing-question"
1866
3280
  };
1867
3281
  if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
1868
- const classification = classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
1869
- appendShadowSample({
3282
+ let classification;
3283
+ let jevResult;
3284
+ let jevError;
3285
+ let jevLatencyMs;
3286
+ const jev = cfg.trigger.jev;
3287
+ if (jev?.enabled) {
3288
+ const jevStartedAt = Date.now();
3289
+ try {
3290
+ jevResult = await classifyWithJev(args.question, args.context, cfg.advisors, jev, exec.signal, typeof args.questionEn === "string" ? args.questionEn : void 0);
3291
+ classification = jevResult;
3292
+ } catch (error) {
3293
+ if (exec.signal.aborted) throw error;
3294
+ jevError = error instanceof Error ? error.message : String(error);
3295
+ console.warn("[dsh-advisor-group] Jev 分类失败,回退到本地分类器:", sessionLog?.id ?? "no-session", jevError);
3296
+ } finally {
3297
+ jevLatencyMs = Date.now() - jevStartedAt;
3298
+ }
3299
+ }
3300
+ classification ??= classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
3301
+ shadow = {
1870
3302
  ts: Date.now(),
1871
3303
  question: args.question,
1872
3304
  ...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
1873
3305
  shouldEscalate: classification.shouldEscalate,
1874
3306
  reason: classification.reason,
1875
3307
  suggestWebSearch: classification.suggestWebSearch,
1876
- launched: classification.shouldEscalate
1877
- });
3308
+ provider: jevResult ? "jev" : "local",
3309
+ ...jevError === void 0 ? {} : { jevError },
3310
+ ...jevLatencyMs === void 0 ? {} : { jevLatencyMs },
3311
+ ...repeatCount > 0 ? { repeatCount } : {},
3312
+ ...jevResult?.rawAnswers ? { scores: jevResult.rawAnswers } : {},
3313
+ ...jevResult?.model ? { model: jevResult.model } : {}
3314
+ };
1878
3315
  if (!classification.shouldEscalate) {
3316
+ recordShadow(false);
1879
3317
  if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
1880
3318
  sessionId: "",
1881
3319
  advice: `前置分类器建议不启动顾问群:${classification.reason}`,
@@ -1889,29 +3327,45 @@ function registerAdvisorTools(service) {
1889
3327
  reason: "classifier-rejected"
1890
3328
  };
1891
3329
  }
1892
- }
3330
+ } else if (cfg.trigger.requireClassifier) shadow = {
3331
+ ts: Date.now(),
3332
+ question: args.question,
3333
+ shouldEscalate: true,
3334
+ reason: mentionedAdvisorGroup ? "用户点名 @顾问群,跳过前置分类器。" : `同一问题已重复 ${repeatCount} 次,强制升级跳过前置分类器。`,
3335
+ suggestWebSearch: false,
3336
+ provider: "bypass",
3337
+ bypass: mentionedAdvisorGroup ? "mention" : "repeat",
3338
+ repeatCount
3339
+ };
1893
3340
  if (args.advisorIds && args.advisorIds.length > 0) {
1894
3341
  const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
1895
- if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) return {
3342
+ if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) {
3343
+ recordShadow(false);
3344
+ return {
3345
+ sessionId: "",
3346
+ advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
3347
+ skipped: true,
3348
+ reason: "no-matching-advisors"
3349
+ };
3350
+ }
3351
+ }
3352
+ const costGuard = service.tryStartConsultation();
3353
+ if (!costGuard.ok) {
3354
+ recordShadow(false);
3355
+ return {
1896
3356
  sessionId: "",
1897
- advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
3357
+ advice: costGuard.reason,
1898
3358
  skipped: true,
1899
- reason: "no-matching-advisors"
3359
+ reason: "daily-limit-reached"
1900
3360
  };
1901
3361
  }
1902
- const costGuard = service.tryStartConsultation();
1903
- if (!costGuard.ok) return {
1904
- sessionId: "",
1905
- advice: costGuard.reason,
1906
- skipped: true,
1907
- reason: "daily-limit-reached"
1908
- };
1909
- const session = service.createSession(args.question, args.context, args.advisorIds);
3362
+ const session = service.createSession(args.question, args.context, args.advisorIds, sessionLog ? sessionLog.header.cwd : void 0, sessionLog ? sessionLog.id : void 0);
1910
3363
  if (sessionLog) appendAdvisorStart(sessionLog, session);
1911
- const summary = await service.runAutoPipeline(session, exec.signal, sessionLog);
3364
+ recordShadow(true);
3365
+ const summary = await service.runAutoPipeline(session, exec.signal, sessionLog, exec.agent);
1912
3366
  return {
1913
3367
  sessionId: session.id,
1914
- advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
3368
+ advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + formatRiskNotes(summary) + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
1915
3369
  skipped: false
1916
3370
  };
1917
3371
  }
@@ -1942,26 +3396,164 @@ function registerAdvisorTools(service) {
1942
3396
  }
1943
3397
  })];
1944
3398
  }
3399
+ function formatRiskNotes(summary) {
3400
+ return summary.riskNotes.length > 0 ? `\n\n【风险提示】\n${summary.riskNotes.join("\n")}` : "";
3401
+ }
1945
3402
  function formatConversation(session) {
1946
3403
  const lines = [`顾问群对话(session: ${session.id})`, ""];
1947
3404
  for (const message of session.messages) {
1948
3405
  if (message.role === "system") continue;
1949
3406
  if (message.role === "main") lines.push(`主模型:${message.content}`);
1950
3407
  else if (message.role === "advisor") {
1951
- const body = message.content && message.content.trim() ? message.content : message.thinking && message.thinking.trim() ? `(思维链)\n${message.thinking}` : "(顾问未返回正文)";
3408
+ const truncatedNote = message.truncated === void 0 ? "" : `(顾问输出在流式过程中被截断:${message.truncated.reason === "timeout" ? "超时" : "网络中断"},正文可能不完整)\n`;
3409
+ const toolNote = message.toolSteps && message.toolSteps.length > 0 ? `${message.toolSteps.map((step) => step.kind === "call" ? ` ⛭ 调用 ${step.name} · ${step.text}` : ` ↳ ${step.text}`).join("\n")}\n` : "";
3410
+ const body = message.content && message.content.trim() ? truncatedNote + toolNote + message.content : truncatedNote + toolNote + (message.thinking && message.thinking.trim() ? `(思维链)\n${message.thinking}` : "(顾问未返回正文)");
1952
3411
  lines.push(`【${message.advisorName ?? message.advisorId ?? "顾问"}】:${body}`);
1953
3412
  }
1954
3413
  lines.push("");
1955
3414
  }
3415
+ const stopNote = session.status === "cancelled" && session.stopReason ? `(注意:本次咨询被中断(${STOP_REASON_TEXT[session.stopReason] ?? session.stopReason});可在对话卡片点击「▶ 继续聊天」从断点续跑。)` : "";
3416
+ lines.push(stopNote);
1956
3417
  lines.push("(以上是顾问模型与主模型的对话记录,主模型可直接基于其中内容继续思考或追问。)");
1957
3418
  return lines.join("\n");
1958
3419
  }
3420
+ const STOP_REASON_TEXT = {
3421
+ "user-stop": "用户主动停止",
3422
+ "exec-cancel": "宿主取消",
3423
+ "abort-error": "流异常中断",
3424
+ timeout: "顾问响应超时",
3425
+ network: "网络中断"
3426
+ };
3427
+ //#endregion
3428
+ //#region src/prompt-guard.ts
3429
+ const REPLACEMENT = "�";
3430
+ const BOM = "";
3431
+ /**
3432
+ * Unambiguity check: same line count, and every character either equals the
3433
+ * template or is a replacement char (the byte-loss corruption signature).
3434
+ */
3435
+ function matchesTemplateWithReplacement(storedLines, templateLines) {
3436
+ for (let i = 0; i < storedLines.length; i++) {
3437
+ const stored = storedLines[i];
3438
+ const template = templateLines[i];
3439
+ if (stored === template) continue;
3440
+ if (stored.length !== template.length) return false;
3441
+ for (let j = 0; j < stored.length; j++) if (stored[j] !== template[j] && stored[j] !== REPLACEMENT) return false;
3442
+ }
3443
+ return true;
3444
+ }
3445
+ /**
3446
+ * Advisor system-prompt corruption guard (P1, 2026-09-27).
3447
+ * Fast path is free: prompts without replacement chars pass through
3448
+ * untouched (a customized prompt is legitimate and must never be overwritten).
3449
+ * A leading BOM alone is stripped (action 'stripped', no template involved).
3450
+ * Only on a replacement char do we compare against the built-in template:
3451
+ * unambiguous (line count + per-character match) -> heal to the template;
3452
+ * anything else keeps the stored value and reports it for manual handling.
3453
+ */
3454
+ function healAdvisorSystemPrompt(stored, template = DEFAULT_ADVISOR_PROMPT) {
3455
+ let value = stored;
3456
+ let hadBom = false;
3457
+ if (value.startsWith(BOM)) {
3458
+ value = value.slice(1);
3459
+ hadBom = true;
3460
+ }
3461
+ const replacements = (value.match(/\uFFFD/g) ?? []).length;
3462
+ if (replacements === 0) {
3463
+ if (hadBom) return {
3464
+ action: "stripped",
3465
+ value,
3466
+ reason: "开头含 BOM,已剥除"
3467
+ };
3468
+ return {
3469
+ action: "ok",
3470
+ value: stored
3471
+ };
3472
+ }
3473
+ const storedLines = value.split("\n");
3474
+ const templateLines = template.split("\n");
3475
+ if (storedLines.length !== templateLines.length) return {
3476
+ action: "needs-manual",
3477
+ value: stored,
3478
+ reason: `含 ${replacements} 个替换符,行数 ${storedLines.length} 与模板 ${templateLines.length} 不一致,无法无歧义回填`
3479
+ };
3480
+ if (!matchesTemplateWithReplacement(storedLines, templateLines)) return {
3481
+ action: "needs-manual",
3482
+ value: stored,
3483
+ reason: `含 ${replacements} 个替换符,但与模板逐位比对存在不一致(可能含自定义内容),未回填`
3484
+ };
3485
+ return {
3486
+ action: "healed",
3487
+ value: template,
3488
+ reason: `含 ${replacements} 个替换符${hadBom ? ",另开头含 BOM" : ""},行数与逐位比对均与模板一致,已回填内置模板`
3489
+ };
3490
+ }
3491
+ /**
3492
+ * Load-time guard over every advisor prompt. Returns the original config
3493
+ * object untouched when nothing was healed, so callers can rely on identity
3494
+ * checks to detect "no change".
3495
+ */
3496
+ function guardAdvisorPrompts(config) {
3497
+ const findings = [];
3498
+ let healedAny = false;
3499
+ const advisors = config.advisors.map((advisor) => {
3500
+ const result = healAdvisorSystemPrompt(advisor.systemPrompt);
3501
+ if (result.action === "ok") return advisor;
3502
+ findings.push({
3503
+ advisorId: advisor.id,
3504
+ action: result.action,
3505
+ reason: result.reason ?? ""
3506
+ });
3507
+ if (result.action === "healed" || result.action === "stripped") {
3508
+ healedAny = true;
3509
+ return {
3510
+ ...advisor,
3511
+ systemPrompt: result.value
3512
+ };
3513
+ }
3514
+ return advisor;
3515
+ });
3516
+ return {
3517
+ config: healedAny ? {
3518
+ ...config,
3519
+ advisors
3520
+ } : config,
3521
+ findings
3522
+ };
3523
+ }
3524
+ /**
3525
+ * Shared logger for both call sites: one line per finding, stating the action
3526
+ * actually taken (healed vs left untouched) plus the concrete reason.
3527
+ * Every line carries an ISO-8601 timestamp so the host console redirect
3528
+ * (which does not timestamp lines itself) stays forensically usable.
3529
+ */
3530
+ function reportPromptGuardFindings(findings) {
3531
+ const stamp = () => `[${(/* @__PURE__ */ new Date()).toISOString()}]`;
3532
+ for (const finding of findings) if (finding.action === "healed") console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词损坏,已按内置模板回填:${finding.advisorId}(${finding.reason})`);
3533
+ else if (finding.action === "stripped") console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词开头含 BOM,已剥除、正文未改动:${finding.advisorId}(${finding.reason})`);
3534
+ else console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词损坏且无法无歧义回填,未改动、需人工处理:${finding.advisorId}(${finding.reason})`);
3535
+ }
1959
3536
  //#endregion
1960
3537
  //#region src/settings-api.ts
1961
3538
  function sendJson(res, status, body) {
1962
3539
  res.writeHead(status, { "content-type": "application/json" });
1963
3540
  res.end(JSON.stringify(body));
1964
3541
  }
3542
+ function normalizeJevConfig(input) {
3543
+ const apiKey = typeof input?.apiKey === "string" ? input.apiKey : input?.apiKey && typeof input.apiKey === "object" && "value" in input.apiKey ? String(input.apiKey.value ?? "") : void 0;
3544
+ return {
3545
+ enabled: input?.enabled ?? false,
3546
+ provider: input?.provider ?? "typesafe",
3547
+ model: input?.model ?? "jev-latest",
3548
+ ...input?.baseURL ? { baseURL: input.baseURL } : {},
3549
+ ...apiKey ? { apiKey } : {},
3550
+ ...input?.apiKeyEnv ? { apiKeyEnv: input.apiKeyEnv } : {},
3551
+ timeoutMs: input?.timeoutMs ?? 1e4,
3552
+ confidenceThreshold: input?.confidenceThreshold ?? .6,
3553
+ useEnglishState: input?.useEnglishState ?? false,
3554
+ ...typeof input?.highRiskThreshold === "number" ? { highRiskThreshold: input.highRiskThreshold } : {}
3555
+ };
3556
+ }
1965
3557
  /** Mask a stored API key so it never leaves the host in plain text. */
1966
3558
  function maskApiKey(apiKey) {
1967
3559
  if (!apiKey) return apiKey;
@@ -1984,15 +3576,27 @@ function isMaskedApiKey(incoming, previous) {
1984
3576
  /**
1985
3577
  * Return a copy of the config safe to send to the browser.
1986
3578
  *
1987
- * 0.1.2-rc.1 SecretField convergence: no key material (not even a mask) leaves
3579
+ * SecretField convergence: no key material (not even a mask) leaves
1988
3580
  * the host. `apiKey` is returned empty and per-provider presence facts
1989
3581
  * (`configured` + `last4`) ride in `apiKeyMetaByProvider`, so the card can
1990
3582
  * render the official "已配置/未配置" badge and publish new/cleared keys
1991
3583
  * without ever round-tripping a mask.
1992
3584
  */
1993
3585
  function sanitizeConfig(config) {
3586
+ const jev = normalizeJevConfig(config.trigger.jev);
1994
3587
  return {
1995
3588
  ...config,
3589
+ trigger: {
3590
+ ...config.trigger,
3591
+ jev: {
3592
+ ...jev,
3593
+ apiKey: "",
3594
+ ...jev?.apiKey ? { apiKeyMeta: {
3595
+ configured: true,
3596
+ last4: jev.apiKey.slice(-4)
3597
+ } } : {}
3598
+ }
3599
+ },
1996
3600
  advisors: config.advisors.map((advisor) => {
1997
3601
  const { apiKeysByProvider: _apiKeysByProvider, clearApiKey: _clearApiKey, apiKeyMetaByProvider: _meta, ...rest } = advisor;
1998
3602
  const meta = {};
@@ -2032,8 +3636,19 @@ function hasSameCredentialScope(a, b) {
2032
3636
  */
2033
3637
  function reconcileApiKeys(incoming, previous) {
2034
3638
  const previousById = new Map(previous.advisors.map((advisor) => [advisor.id, advisor]));
3639
+ const incomingJev = normalizeJevConfig(incoming.trigger.jev);
3640
+ const previousJev = normalizeJevConfig(previous.trigger.jev);
3641
+ const jevApiKey = incomingJev?.apiKey || previousJev?.apiKey;
2035
3642
  return {
2036
3643
  ...incoming,
3644
+ trigger: {
3645
+ ...incoming.trigger,
3646
+ jev: {
3647
+ ...incomingJev,
3648
+ ...jevApiKey ? { apiKey: jevApiKey } : {},
3649
+ apiKeyMeta: void 0
3650
+ }
3651
+ },
2037
3652
  advisors: incoming.advisors.map((advisor) => {
2038
3653
  const { clearApiKey, ...advisorRest } = advisor;
2039
3654
  const prev = previousById.get(advisor.id);
@@ -2101,7 +3716,14 @@ async function parseLookupParams(req) {
2101
3716
  return params;
2102
3717
  }
2103
3718
  const ALLOWED_API_KEY_ENVS = new Set(Object.values(PROVIDER_PRESETS).map((preset) => preset.apiKeyEnv));
3719
+ ALLOWED_API_KEY_ENVS.add("TYPESAFE_API_KEY");
3720
+ ALLOWED_API_KEY_ENVS.add("OPENROUTER_API_KEY");
2104
3721
  function validateSecurity(input) {
3722
+ if (input.trigger.jev?.baseURL) {
3723
+ const url = input.trigger.jev.baseURL.trim();
3724
+ if (!(url.startsWith("https://") || url.startsWith("http://127.0.0.1") || url.startsWith("http://localhost"))) return "Jev API 地址仅允许 https:// 或本机 http://127.0.0.1 / http://localhost";
3725
+ }
3726
+ if (input.trigger.jev?.apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.trigger.jev.apiKeyEnv.trim())) return `Jev API Key 环境变量名无效:${input.trigger.jev.apiKeyEnv}`;
2105
3727
  const ids = /* @__PURE__ */ new Set();
2106
3728
  for (const advisor of input.advisors) {
2107
3729
  if (ids.has(advisor.id)) return `顾问 id 重复:${advisor.id}`;
@@ -2293,10 +3915,21 @@ async function handleModelsRequest(ctx, service, req, res) {
2293
3915
  } catch (error) {
2294
3916
  sendJson(res, 400, {
2295
3917
  ok: false,
2296
- error: error instanceof Error ? error.message : String(error)
3918
+ error: describeConnectionError(error)
2297
3919
  });
2298
3920
  }
2299
3921
  }
3922
+ /** 网络/HTTP 类失败的设置页可读文案;原始信息保留在括号里。 */
3923
+ function describeConnectionError(error) {
3924
+ const message = error instanceof Error ? error.message : String(error);
3925
+ if (/HTTP (401|403)\b/i.test(message)) return `密钥无效或没有访问权限(${message})`;
3926
+ if (/HTTP 404\b/i.test(message)) return `地址不存在,检查 baseURL 是否写对(${message})`;
3927
+ if (/ENOTFOUND|EAI_AGAIN/i.test(message)) return `找不到这个地址,检查域名拼写与网络(${message})`;
3928
+ if (/ECONNREFUSED/i.test(message)) return `对方拒绝了连接,检查地址与端口(${message})`;
3929
+ if (/ETIMEDOUT|\btimeout\b|aborted/i.test(message)) return `连接超时(${message})`;
3930
+ if (/fetch failed/i.test(message)) return `网络连接失败,检查网络后重试(${message})`;
3931
+ return `连接失败(${message})`;
3932
+ }
2300
3933
  async function handleTestConnectionRequest(ctx, service, req, res) {
2301
3934
  const params = await parseLookupParams(req);
2302
3935
  const provider = params.provider ?? "";
@@ -2373,7 +4006,7 @@ async function handleTestConnectionRequest(ctx, service, req, res) {
2373
4006
  } catch (error) {
2374
4007
  sendJson(res, 400, {
2375
4008
  ok: false,
2376
- error: error instanceof Error ? error.message : String(error)
4009
+ error: describeConnectionError(error)
2377
4010
  });
2378
4011
  }
2379
4012
  }
@@ -2408,7 +4041,7 @@ function handleStreamRequest(req, res) {
2408
4041
  });
2409
4042
  }
2410
4043
  function handleConfigRequest(ctx, scopedCtx, service, authToken) {
2411
- const namespace = "advisor-group";
4044
+ const namespace = "dsh-advisor-group";
2412
4045
  return async (req, res) => {
2413
4046
  const rawUrl = req.url ?? "";
2414
4047
  const url = new URL(rawUrl, "http://localhost");
@@ -2437,6 +4070,55 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
2437
4070
  await sendProviders(ctx, res);
2438
4071
  return;
2439
4072
  }
4073
+ if (req.method === "POST" && (pathname === "/advisor-group/stop" || pathname === "/advisor-group/stop/")) {
4074
+ try {
4075
+ const raw = await readBody(req);
4076
+ const parsed = JSON.parse(raw);
4077
+ if (typeof parsed.sessionId !== "string" || !parsed.sessionId) {
4078
+ sendJson(res, 400, {
4079
+ ok: false,
4080
+ error: "缺少 sessionId"
4081
+ });
4082
+ return;
4083
+ }
4084
+ sendJson(res, 200, {
4085
+ ok: true,
4086
+ stopped: service.stopConsultation(parsed.sessionId)
4087
+ });
4088
+ } catch (error) {
4089
+ console.warn("[dsh-advisor-group] stop 请求处理失败:", error instanceof Error ? error.message : String(error));
4090
+ sendJson(res, 400, {
4091
+ ok: false,
4092
+ error: error instanceof Error ? error.message : String(error)
4093
+ });
4094
+ }
4095
+ return;
4096
+ }
4097
+ if (req.method === "POST" && (pathname === "/advisor-group/resume" || pathname === "/advisor-group/resume/")) {
4098
+ try {
4099
+ const raw = await readBody(req);
4100
+ const parsed = JSON.parse(raw);
4101
+ if (typeof parsed.sessionId !== "string" || !parsed.sessionId) {
4102
+ sendJson(res, 400, {
4103
+ ok: false,
4104
+ error: "缺少 sessionId"
4105
+ });
4106
+ return;
4107
+ }
4108
+ const result = service.resumeConsultation(parsed.sessionId);
4109
+ sendJson(res, result.ok ? 200 : 400, {
4110
+ ok: result.ok,
4111
+ ...result.reason ? { error: result.reason } : {}
4112
+ });
4113
+ } catch (error) {
4114
+ console.warn("[dsh-advisor-group] resume 请求处理失败:", error instanceof Error ? error.message : String(error));
4115
+ sendJson(res, 400, {
4116
+ ok: false,
4117
+ error: error instanceof Error ? error.message : String(error)
4118
+ });
4119
+ }
4120
+ return;
4121
+ }
2440
4122
  if (req.method === "GET" && (pathname === "/advisor-group/shadow" || pathname === "/advisor-group/shadow/")) {
2441
4123
  sendJson(res, 200, {
2442
4124
  ok: true,
@@ -2445,12 +4127,20 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
2445
4127
  return;
2446
4128
  }
2447
4129
  if (req.method === "GET" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
2448
- sendJson(res, 200, {
2449
- ok: true,
2450
- config: sanitizeConfig(service.getConfig()),
2451
- revision: currentRevision(scopedCtx, namespace),
2452
- dailyGuard: service.getDailyGuard()
2453
- });
4130
+ try {
4131
+ sendJson(res, 200, {
4132
+ ok: true,
4133
+ config: sanitizeConfig(service.getConfig()),
4134
+ revision: currentRevision(scopedCtx, namespace),
4135
+ dailyGuard: service.getDailyGuard()
4136
+ });
4137
+ } catch (error) {
4138
+ console.error("[dsh-advisor-group] config read failed:", error instanceof Error ? error.message : String(error));
4139
+ sendJson(res, 500, {
4140
+ ok: false,
4141
+ error: error instanceof Error ? error.message : String(error)
4142
+ });
4143
+ }
2454
4144
  return;
2455
4145
  }
2456
4146
  if (req.method === "POST" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
@@ -2459,7 +4149,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
2459
4149
  const parsed = JSON.parse(raw);
2460
4150
  const configInput = parsed.config ?? parsed;
2461
4151
  const expectedRevision = typeof parsed.expectedRevision === "number" ? parsed.expectedRevision : currentRevision(scopedCtx, namespace);
2462
- const reconciled = reconcileApiKeys(Config(configInput), service.getConfig());
4152
+ const reconciled = reconcileApiKeys(unwrapVolatileConfig(Config(configInput)), service.getConfig());
2463
4153
  const securityError = validateSecurity(reconciled);
2464
4154
  if (securityError) {
2465
4155
  sendJson(res, 400, {
@@ -2468,8 +4158,10 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
2468
4158
  });
2469
4159
  return;
2470
4160
  }
2471
- await scopedCtx.settings.replace(namespace, reconciled, expectedRevision);
2472
- service.setConfig(reconciled);
4161
+ const guarded = guardAdvisorPrompts(reconciled);
4162
+ reportPromptGuardFindings(guarded.findings);
4163
+ await scopedCtx.settings.replace(namespace, guarded.config, expectedRevision);
4164
+ service.setConfig(guarded.config);
2473
4165
  sendJson(res, 200, {
2474
4166
  ok: true,
2475
4167
  config: sanitizeConfig(service.getConfig()),
@@ -2485,7 +4177,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
2485
4177
  }
2486
4178
  sendJson(res, 400, {
2487
4179
  ok: false,
2488
- error: error instanceof Error ? error.message : String(error)
4180
+ error: `保存失败:请检查刚才修改项的格式(原始信息:${error instanceof Error ? error.message : String(error)})`
2489
4181
  });
2490
4182
  }
2491
4183
  return;
@@ -2533,14 +4225,16 @@ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
2533
4225
  if (!inject) return;
2534
4226
  inject(["settings", "webServer"], (scoped) => {
2535
4227
  const scopedCtx = scoped;
2536
- const namespace = "advisor-group";
2537
- const scope = scopedCtx.settings.register(namespace, Config, { base: config });
2538
- service.setConfig(scope.get());
4228
+ const namespace = "dsh-advisor-group";
4229
+ service.setConfig(config);
4230
+ const presentationDisposer = scopedCtx.settings.configure({ auto: false }, ctx.fiber);
2539
4231
  service.setPersistEnabled(async (enabled) => {
2540
4232
  await scopedCtx.settings.update(namespace, { enabled });
2541
4233
  });
2542
- const watchDisposer = scope.watch((next) => {
2543
- service.setConfig(next);
4234
+ const watchDisposer = ctx.on("loader/volatile-update", () => {
4235
+ const guarded = guardAdvisorPrompts(unwrapVolatileConfig(ctx.fiber.config));
4236
+ reportPromptGuardFindings(guarded.findings);
4237
+ service.setConfig(guarded.config);
2544
4238
  });
2545
4239
  const authToken = randomUUID();
2546
4240
  const indexTapDisposer = scopedCtx.webServer.tapIndex ? scopedCtx.webServer.tapIndex((html) => html.replace("<head>", `<head><script>globalThis.__ADVISOR_GROUP_TOKEN__ = ${JSON.stringify(authToken)}<\/script>`)) : null;
@@ -2553,6 +4247,7 @@ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
2553
4247
  watchDisposer();
2554
4248
  routeDisposer();
2555
4249
  indexTapDisposer?.();
4250
+ presentationDisposer();
2556
4251
  }, "dsh-advisor-group: settings watcher + config routes");
2557
4252
  });
2558
4253
  }
@@ -2598,7 +4293,7 @@ function buildAdvisorPrompt(config, repeatPressure) {
2598
4293
  /**
2599
4294
  * Host-only registration for the advisor-group session event vocabulary.
2600
4295
  *
2601
- * The official 0.1.2-rc.1 compatibility mechanism is the per-event
4296
+ * The official compatibility mechanism is the per-event
2602
4297
  * `SessionEvent.ignorable: true` envelope marker: `dsh-session-persistence`
2603
4298
  * refuses unknown event types unless `KNOWN_SESSION_EVENT_TYPES.has(type)`
2604
4299
  * or the stored event is marked ignorable. However `Session.append()` exposes
@@ -2615,7 +4310,8 @@ const ADVISOR_GROUP_EVENT_TYPES = [
2615
4310
  "advisor-group/start",
2616
4311
  "advisor-group/message",
2617
4312
  "advisor-group/delta",
2618
- "advisor-group/end"
4313
+ "advisor-group/end",
4314
+ "advisor-group/resume"
2619
4315
  ];
2620
4316
  function ensureAdvisorGroupSessionEventTypes() {
2621
4317
  const known = KNOWN_SESSION_EVENT_TYPES;
@@ -2630,11 +4326,14 @@ const name = "dsh-advisor-group";
2630
4326
  const inject = [
2631
4327
  "tools",
2632
4328
  "llm",
2633
- "systemPrompt"
4329
+ "systemPrompt",
4330
+ "sessions"
2634
4331
  ];
2635
4332
  function apply(ctx, config) {
2636
- const service = new AdvisorGroupService(ctx, config);
2637
- registerAdvisorSettingsAndRoutes(ctx, config, service);
4333
+ const guarded = guardAdvisorPrompts(unwrapVolatileConfig(config));
4334
+ reportPromptGuardFindings(guarded.findings);
4335
+ const service = new AdvisorGroupService(ctx, guarded.config);
4336
+ registerAdvisorSettingsAndRoutes(ctx, guarded.config, service);
2638
4337
  for (const tool of registerAdvisorTools(service)) ctx.tools.register(tool);
2639
4338
  const systemPrompt = ctx.systemPrompt;
2640
4339
  if (systemPrompt) ctx.effect(() => systemPrompt.section({