dsh-advisor-group 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs ADDED
@@ -0,0 +1,2650 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promises, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
9
+ import { KNOWN_SESSION_EVENT_TYPES } from "@deepseek-ai/dsh-session";
10
+ //#region src/defaults.ts
11
+ const DEFAULT_ADVISOR_PROMPT = `
12
+ 你是顾问群中的资深专家。你的任务不是替用户执行,而是提供高质量的专业意见供主模型参考。
13
+
14
+ 回答要求:
15
+ 1. 先给出明确结论,再说明理由、依据或经验;
16
+ 2. 内容要具体、可执行,避免空话套话;
17
+ 3. 明确区分事实、推测和建议;
18
+ 4. 如果信息不足,明确说明缺少什么、需要什么信息,不要强行编造;
19
+ 5. 涉及专业风险时,主动提示风险点和适用边界;
20
+ 6. 在讨论轮次中:
21
+ - 第 1 轮:独立给出你的专业意见;
22
+ - 后续轮次:参考其他顾问的观点,明确表示同意/补充/反对,不要简单重复;
23
+ 7. 尽量用分点/结构化输出,方便主模型综合。
24
+
25
+ 结尾按约定标注置信度或不确定性。
26
+ `.trim();
27
+ //#endregion
28
+ //#region src/config.ts
29
+ const ProviderProtocol = Schema.union([
30
+ Schema.const("openai"),
31
+ Schema.const("anthropic"),
32
+ Schema.const("gemini")
33
+ ]);
34
+ const ProviderAuthMode = Schema.union([Schema.const("x-api-key"), Schema.const("bearer")]);
35
+ const AdvisorConfig = Schema.object({
36
+ id: Schema.string().required().description("Unique advisor id used by ask_advisors."),
37
+ name: Schema.string().required().description("Display name shown in the chat group."),
38
+ avatar: Schema.string().description("Short emoji or ASCII avatar."),
39
+ provider: Schema.string().required().description("Provider route passed to ctx.llm, e.g. deepseek-official or openai."),
40
+ model: Schema.string().required().description("Model id on that provider route."),
41
+ systemPrompt: Schema.string().default(DEFAULT_ADVISOR_PROMPT),
42
+ temperature: Schema.number().min(0).max(2),
43
+ maxTokens: Schema.number().min(1),
44
+ baseURL: Schema.string().description("Optional direct-http fallback base URL."),
45
+ apiKey: Schema.string().role("secret").description("Direct API key used for direct-http fallback. Stored in local config; prefer apiKeyEnv if you do not want the key in settings.yaml."),
46
+ apiKeysByProvider: Schema.dict(Schema.string()).role("secret").default({}).description("Server-side per-provider key history; never returned to the browser. Marked secret so the official settings redaction (describe) never sends it over any wire."),
47
+ clearApiKey: Schema.boolean().description("Transient client intent: clear the current provider direct key. Stripped before persistence."),
48
+ apiKeyEnv: Schema.string().description("Optional env var name for direct-http fallback."),
49
+ protocol: ProviderProtocol,
50
+ authMode: ProviderAuthMode.description("Anthropic auth header style: x-api-key or bearer.")
51
+ });
52
+ const DriverModel = Schema.object({
53
+ provider: Schema.string().required().description("Provider route of the fallback driver model."),
54
+ model: Schema.string().required().description("Model id of the fallback driver model.")
55
+ });
56
+ const DiscussionConfig = Schema.object({
57
+ maxRounds: Schema.natural().min(1).max(5).default(2).description("Total rounds of the auto-deepen consultation (each round = driver deep-question + all advisors in relay order)."),
58
+ maxAdvisorsPerCall: Schema.natural().min(1).max(10).default(3).description("Maximum number of advisors consulted in one ask_advisors call."),
59
+ parallel: Schema.boolean().default(true).description("Deprecated: rounds are a sequential A→B→C relay; kept for stored-config compatibility."),
60
+ 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
+ driverModel: Schema.union([DriverModel, Schema.const(void 0)]).description("Fallback driver model (provider/model) used when the session header cannot be read."),
62
+ 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).")
64
+ });
65
+ const TriggerConfig = Schema.object({
66
+ requireClassifier: Schema.boolean().default(true).description("Run the pre-classifier before starting a consultation."),
67
+ 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.")
69
+ });
70
+ const UiConfig = Schema.object({
71
+ theme: Schema.union([
72
+ Schema.const("retro-green"),
73
+ Schema.const("retro-amber"),
74
+ Schema.const("retro-blue")
75
+ ]).default("retro-green"),
76
+ showTimestamps: Schema.boolean().default(true),
77
+ autoExpand: Schema.boolean().default(true)
78
+ });
79
+ 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([])
85
+ });
86
+ //#endregion
87
+ //#region src/providers/advisor-prompt.ts
88
+ /**
89
+ * Standard suffix appended to every advisor system prompt.
90
+ *
91
+ * It asks the advisor to state its own confidence/uncertainty so the main
92
+ * model can weigh the answer instead of treating all advisor output equally.
93
+ */
94
+ const ADVISOR_OUTPUT_POLICY = `
95
+
96
+ 回答要求:
97
+ - 先给结论,再简要说明依据;
98
+ - 在回答末尾用一行标注:置信度(0-10)或不确定性;
99
+ - 如果没有把握,请明确写“不确定”,不要强行编造。
100
+ `;
101
+ /**
102
+ * Sequential-relay role hint appended to one advisor's system prompt.
103
+ *
104
+ * The first advisor answers the (main-model) question directly; every later
105
+ * advisor joins the same turn AFTER its predecessors and is told to give its
106
+ * OWN view against the full conversation (agree / complement / rebut), so the
107
+ * turn reads as main-question → A → B(sees A) → C(sees A+B).
108
+ */
109
+ function advisorJoinPrompt(joinIndex, advisorCount) {
110
+ if (joinIndex <= 1) return `
111
+ 本次你作为第 1 位接入的顾问:请直接、完整地回答主模型的问题;你在本轮中会最先发言,后续其他顾问会基于你的回答继续补充。
112
+ `;
113
+ return `
114
+ 本次你作为第 ${joinIndex} / ${advisorCount} 位接入的顾问。你可以在上下文中看到:
115
+ - 项目背景与主模型的问题;
116
+ - 前面已接入顾问(第 1 至第 ${joinIndex - 1} 位)对本问题的完整回答。
117
+
118
+ 请给出你**自己的独立见解**:可以认同、补充、质疑或反驳前面顾问的观点,但请明确点出你的回答与前序观点的差异和理由;不要简单复述前序内容。
119
+ `;
120
+ }
121
+ //#endregion
122
+ //#region src/providers/timeout.ts
123
+ /**
124
+ * Combine an optional caller signal with a hard timeout.
125
+ * Node 22+ provides AbortSignal.any and AbortSignal.timeout.
126
+ */
127
+ function withTimeout(ms, signal) {
128
+ const timeout = AbortSignal.timeout(ms);
129
+ if (!signal) return timeout;
130
+ return AbortSignal.any([signal, timeout]);
131
+ }
132
+ //#endregion
133
+ //#region src/providers/ctx-llm.ts
134
+ function formatTranscript(entries) {
135
+ return entries.map((entry) => `[${entry.role === "main" ? "主模型" : entry.name}]\n${entry.content}`).join("\n\n");
136
+ }
137
+ /**
138
+ * Call an advisor through DSH's built-in ctx.llm seam.
139
+ *
140
+ * This is the primary path: it reuses whatever provider routes the user has
141
+ * already configured in DSH (deepseek-official, llm-pi-ai providers, etc.),
142
+ * including credentials resolved by the harness.
143
+ */
144
+ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs) {
145
+ const userText = formatTranscript(transcript);
146
+ const messages = [createUserMessage({
147
+ content: [{
148
+ type: "text",
149
+ text: userText
150
+ }],
151
+ source: {
152
+ kind: "plugin",
153
+ plugin: "dsh-advisor-group"
154
+ }
155
+ })];
156
+ const options = {
157
+ provider: advisor.provider,
158
+ model: advisor.model,
159
+ system: `${advisor.systemPrompt}${ADVISOR_OUTPUT_POLICY}`,
160
+ messages,
161
+ temperature: advisor.temperature,
162
+ maxTokens: advisor.maxTokens,
163
+ signal: withTimeout(timeoutMs ?? 12e4, signal)
164
+ };
165
+ 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;
171
+ }
172
+ //#endregion
173
+ //#region src/providers/presets.ts
174
+ const PROVIDER_PRESETS = {
175
+ deepseek: {
176
+ id: "deepseek",
177
+ label: "DeepSeek",
178
+ protocol: "openai",
179
+ baseURL: "https://api.deepseek.com",
180
+ apiKeyEnv: "DEEPSEEK_API_KEY",
181
+ defaultModels: ["deepseek-v4-flash", "deepseek-v4-pro"],
182
+ notes: "Also available as deepseek-official via dsh-llm-deepseek, and Anthropic-compatible at https://api.deepseek.com/anthropic."
183
+ },
184
+ "deepseek-anthropic": {
185
+ id: "deepseek-anthropic",
186
+ label: "DeepSeek(Anthropic 兼容)",
187
+ protocol: "anthropic",
188
+ baseURL: "https://api.deepseek.com/anthropic",
189
+ apiKeyEnv: "DEEPSEEK_API_KEY",
190
+ defaultModels: ["deepseek-v4-flash", "deepseek-v4-pro"],
191
+ authMode: "x-api-key",
192
+ modelsBaseURL: "https://api.deepseek.com",
193
+ modelsAuthMode: "bearer",
194
+ notes: "DeepSeek Anthropic-compatible endpoint."
195
+ },
196
+ moonshot: {
197
+ id: "moonshot",
198
+ label: "月之暗面 Kimi",
199
+ protocol: "openai",
200
+ baseURL: "https://api.moonshot.cn/v1",
201
+ apiKeyEnv: "MOONSHOT_API_KEY",
202
+ defaultModels: ["kimi-k2.6", "kimi-k2.5"],
203
+ notes: "OpenAI-compatible; Anthropic-compatible endpoint at https://api.moonshot.cn/anthropic"
204
+ },
205
+ "moonshot-anthropic": {
206
+ id: "moonshot-anthropic",
207
+ label: "月之暗面 Kimi(Anthropic 兼容)",
208
+ protocol: "anthropic",
209
+ baseURL: "https://api.moonshot.cn/anthropic",
210
+ apiKeyEnv: "MOONSHOT_API_KEY",
211
+ defaultModels: ["kimi-k2.6", "kimi-k2.5"],
212
+ authMode: "bearer",
213
+ modelsBaseURL: "https://api.moonshot.cn/v1",
214
+ modelsAuthMode: "bearer",
215
+ notes: "Kimi Anthropic-compatible endpoint; 实际端点 /anthropic/v1/messages(官方 Claude Code 文档)。"
216
+ },
217
+ "kimi-code": {
218
+ id: "kimi-code",
219
+ label: "Kimi Code(OpenAI 兼容)",
220
+ protocol: "openai",
221
+ baseURL: "https://api.kimi.com/coding/v1",
222
+ apiKeyEnv: "KIMI_CODE_API_KEY",
223
+ defaultModels: [
224
+ "k3",
225
+ "k3-256k",
226
+ "kimi-for-coding",
227
+ "kimi-for-coding-highspeed"
228
+ ],
229
+ modelsBaseURL: "https://api.kimi.com/coding/v1",
230
+ modelsAuthMode: "bearer",
231
+ notes: "Kimi Code 会员 API;模型 ID 为 k3 / k3-256k / kimi-for-coding / kimi-for-coding-highspeed。"
232
+ },
233
+ "kimi-code-anthropic": {
234
+ id: "kimi-code-anthropic",
235
+ label: "Kimi Code(Anthropic 兼容)",
236
+ protocol: "anthropic",
237
+ baseURL: "https://api.kimi.com/coding",
238
+ apiKeyEnv: "KIMI_CODE_API_KEY",
239
+ defaultModels: [
240
+ "k3",
241
+ "k3-256k",
242
+ "kimi-for-coding",
243
+ "kimi-for-coding-highspeed"
244
+ ],
245
+ authMode: "bearer",
246
+ modelsBaseURL: "https://api.kimi.com/coding/v1",
247
+ modelsAuthMode: "bearer",
248
+ notes: "Kimi Code 会员 Anthropic 兼容端点;实际端点 /coding/v1/messages。"
249
+ },
250
+ bailian: {
251
+ id: "bailian",
252
+ label: "阿里云百炼",
253
+ protocol: "openai",
254
+ baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
255
+ apiKeyEnv: "DASHSCOPE_API_KEY",
256
+ defaultModels: [
257
+ "qwen3.7-plus",
258
+ "qwen3.7-max",
259
+ "qwen-max"
260
+ ],
261
+ notes: "DashScope OpenAI-compatible endpoint(北京公共域名)。"
262
+ },
263
+ "bailian-openai-singapore": {
264
+ id: "bailian-openai-singapore",
265
+ label: "阿里云百炼(OpenAI 兼容 · 新加坡公共)",
266
+ protocol: "openai",
267
+ baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
268
+ apiKeyEnv: "DASHSCOPE_API_KEY",
269
+ defaultModels: [
270
+ "qwen3.7-plus",
271
+ "qwen3.7-max",
272
+ "qwen-max"
273
+ ],
274
+ notes: "DashScope 新加坡公共域名 OpenAI 兼容端点。"
275
+ },
276
+ "bailian-openai-us": {
277
+ id: "bailian-openai-us",
278
+ label: "阿里云百炼(OpenAI 兼容 · 美国公共)",
279
+ protocol: "openai",
280
+ baseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
281
+ apiKeyEnv: "DASHSCOPE_API_KEY",
282
+ defaultModels: [
283
+ "qwen3.7-plus",
284
+ "qwen3.7-max",
285
+ "qwen-max"
286
+ ],
287
+ notes: "DashScope 美国(弗吉尼亚)公共域名 OpenAI 兼容端点。"
288
+ },
289
+ "bailian-anthropic": {
290
+ id: "bailian-anthropic",
291
+ label: "阿里云百炼(Anthropic 兼容 · 按量付费 · 业务空间)",
292
+ protocol: "anthropic",
293
+ baseURL: "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/apps/anthropic",
294
+ apiKeyEnv: "DASHSCOPE_API_KEY",
295
+ defaultModels: [
296
+ "qwen3.7-max",
297
+ "qwen3.7-plus",
298
+ "qwen3.6-plus",
299
+ "deepseek-v4-pro",
300
+ "deepseek-v4-flash",
301
+ "kimi-k2.6",
302
+ "glm-5.1"
303
+ ],
304
+ authMode: "bearer",
305
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
306
+ modelsAuthMode: "bearer",
307
+ notes: "需要把 {WorkspaceId} 替换为你的百炼业务空间 ID;API Key 为 sk- 开头(按量付费)。"
308
+ },
309
+ "bailian-anthropic-public": {
310
+ id: "bailian-anthropic-public",
311
+ label: "阿里云百炼(Anthropic 兼容 · 北京公共)",
312
+ protocol: "anthropic",
313
+ baseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
314
+ apiKeyEnv: "DASHSCOPE_API_KEY",
315
+ defaultModels: [
316
+ "qwen3.7-max",
317
+ "qwen3.7-plus",
318
+ "qwen3.6-plus",
319
+ "deepseek-v4-pro",
320
+ "deepseek-v4-flash",
321
+ "kimi-k2.6",
322
+ "glm-5.1"
323
+ ],
324
+ authMode: "bearer",
325
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
326
+ modelsAuthMode: "bearer",
327
+ notes: "DashScope 北京公共域名 Anthropic 兼容端点。"
328
+ },
329
+ "bailian-anthropic-singapore-public": {
330
+ id: "bailian-anthropic-singapore-public",
331
+ label: "阿里云百炼(Anthropic 兼容 · 新加坡公共)",
332
+ protocol: "anthropic",
333
+ baseURL: "https://dashscope-intl.aliyuncs.com/apps/anthropic",
334
+ apiKeyEnv: "DASHSCOPE_API_KEY",
335
+ defaultModels: [
336
+ "qwen3.7-max",
337
+ "qwen3.7-plus",
338
+ "qwen3.6-plus",
339
+ "deepseek-v4-pro",
340
+ "deepseek-v4-flash",
341
+ "kimi-k2.6",
342
+ "glm-5.1"
343
+ ],
344
+ authMode: "bearer",
345
+ modelsBaseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
346
+ modelsAuthMode: "bearer",
347
+ notes: "DashScope 新加坡公共域名 Anthropic 兼容端点。"
348
+ },
349
+ "bailian-anthropic-us-public": {
350
+ id: "bailian-anthropic-us-public",
351
+ label: "阿里云百炼(Anthropic 兼容 · 美国公共)",
352
+ protocol: "anthropic",
353
+ baseURL: "https://dashscope-us.aliyuncs.com/apps/anthropic",
354
+ apiKeyEnv: "DASHSCOPE_API_KEY",
355
+ defaultModels: [
356
+ "qwen3.7-max",
357
+ "qwen3.7-plus",
358
+ "qwen3.6-plus",
359
+ "deepseek-v4-pro",
360
+ "deepseek-v4-flash",
361
+ "kimi-k2.6",
362
+ "glm-5.1"
363
+ ],
364
+ authMode: "bearer",
365
+ modelsBaseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
366
+ modelsAuthMode: "bearer",
367
+ notes: "DashScope 美国(弗吉尼亚)公共域名 Anthropic 兼容端点。"
368
+ },
369
+ "bailian-token-plan-anthropic": {
370
+ id: "bailian-token-plan-anthropic",
371
+ label: "阿里云百炼(Anthropic 兼容 · Token Plan)",
372
+ protocol: "anthropic",
373
+ baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
374
+ apiKeyEnv: "DASHSCOPE_TOKEN_PLAN_API_KEY",
375
+ defaultModels: [
376
+ "qwen3.7-max",
377
+ "qwen3.7-plus",
378
+ "qwen3.6-plus",
379
+ "deepseek-v4-pro",
380
+ "deepseek-v4-flash",
381
+ "kimi-k2.6",
382
+ "glm-5.1"
383
+ ],
384
+ authMode: "bearer",
385
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
386
+ modelsAuthMode: "bearer",
387
+ notes: "API Key 为 sk-sp- 开头,必须与 Token Plan Base URL 配套使用。"
388
+ },
389
+ "bailian-coding-plan-anthropic": {
390
+ id: "bailian-coding-plan-anthropic",
391
+ label: "阿里云百炼(Anthropic 兼容 · Coding Plan)",
392
+ protocol: "anthropic",
393
+ baseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
394
+ apiKeyEnv: "DASHSCOPE_CODING_PLAN_API_KEY",
395
+ defaultModels: [
396
+ "qwen3.7-max",
397
+ "qwen3.7-plus",
398
+ "qwen3.6-plus",
399
+ "deepseek-v4-pro",
400
+ "deepseek-v4-flash",
401
+ "kimi-k2.6",
402
+ "glm-5.1"
403
+ ],
404
+ authMode: "bearer",
405
+ modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
406
+ modelsAuthMode: "bearer",
407
+ notes: "API Key 为 sk-ws- 开头,必须与 Coding Plan Base URL 配套使用。"
408
+ },
409
+ zhipu: {
410
+ id: "zhipu",
411
+ label: "智谱 AI",
412
+ protocol: "openai",
413
+ baseURL: "https://open.bigmodel.cn/api/paas/v4",
414
+ apiKeyEnv: "ZHIPU_API_KEY",
415
+ defaultModels: [
416
+ "glm-5.3",
417
+ "glm-5.2",
418
+ "glm-5",
419
+ "glm-4.6",
420
+ "glm-4.6v",
421
+ "glm-4.6v-flashx",
422
+ "glm-4.5v"
423
+ ],
424
+ modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
425
+ modelsAuthMode: "bearer",
426
+ notes: "智谱 AI OpenAI 兼容端点;chat completions 为 /v4/chat/completions,模型列表为 /v4/models。"
427
+ },
428
+ "zhipu-anthropic": {
429
+ id: "zhipu-anthropic",
430
+ label: "智谱 AI(Anthropic 兼容)",
431
+ protocol: "anthropic",
432
+ baseURL: "https://open.bigmodel.cn/api/anthropic",
433
+ apiKeyEnv: "ZHIPU_API_KEY",
434
+ defaultModels: [
435
+ "glm-5.3",
436
+ "glm-5.2",
437
+ "glm-5",
438
+ "glm-4.6",
439
+ "glm-4.6v",
440
+ "glm-4.6v-flashx",
441
+ "glm-4.5v"
442
+ ],
443
+ authMode: "bearer",
444
+ modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
445
+ modelsAuthMode: "bearer",
446
+ notes: "智谱 AI Claude API 兼容端点;ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic。"
447
+ },
448
+ openai: {
449
+ id: "openai",
450
+ label: "OpenAI",
451
+ protocol: "openai",
452
+ baseURL: "https://api.openai.com/v1",
453
+ apiKeyEnv: "OPENAI_API_KEY",
454
+ defaultModels: ["gpt-5", "gpt-5-mini"],
455
+ notes: "Official OpenAI API."
456
+ },
457
+ anthropic: {
458
+ id: "anthropic",
459
+ label: "Claude",
460
+ protocol: "anthropic",
461
+ baseURL: "https://api.anthropic.com",
462
+ apiKeyEnv: "ANTHROPIC_API_KEY",
463
+ defaultModels: ["claude-sonnet-4-5", "claude-opus-4-1"],
464
+ authMode: "x-api-key",
465
+ notes: "Anthropic Messages API; docs in D:/X/X/14-claude."
466
+ },
467
+ gemini: {
468
+ id: "gemini",
469
+ label: "Gemini",
470
+ protocol: "gemini",
471
+ baseURL: "https://generativelanguage.googleapis.com",
472
+ apiKeyEnv: "GEMINI_API_KEY",
473
+ defaultModels: ["gemini-3-flash-preview", "gemini-3-pro-preview"],
474
+ notes: "Google Gemini API; docs in D:/X/X/13-gemini."
475
+ },
476
+ siliconflow: {
477
+ id: "siliconflow",
478
+ label: "硅基流动",
479
+ protocol: "openai",
480
+ baseURL: "https://api.siliconflow.cn/v1",
481
+ apiKeyEnv: "SILICONFLOW_API_KEY",
482
+ defaultModels: ["deepseek-ai/DeepSeek-V3", "Qwen/Qwen3-235B-A22B"],
483
+ notes: "OpenAI-compatible; also supports Anthropic-compatible at /v1/messages."
484
+ },
485
+ "siliconflow-anthropic": {
486
+ id: "siliconflow-anthropic",
487
+ label: "硅基流动(Anthropic 兼容)",
488
+ protocol: "anthropic",
489
+ baseURL: "https://api.siliconflow.cn/v1",
490
+ apiKeyEnv: "SILICONFLOW_API_KEY",
491
+ defaultModels: [
492
+ "deepseek-ai/DeepSeek-V3.2",
493
+ "moonshotai/Kimi-K2-Instruct",
494
+ "Qwen/Qwen3-32B"
495
+ ],
496
+ authMode: "bearer",
497
+ modelsBaseURL: "https://api.siliconflow.cn/v1",
498
+ modelsAuthMode: "bearer",
499
+ notes: "SiliconFlow Anthropic-compatible endpoint: ANTHROPIC_BASE_URL=https://api.siliconflow.cn/ 或 /v1。"
500
+ },
501
+ aihubmix: {
502
+ id: "aihubmix",
503
+ label: "AIHubMix",
504
+ protocol: "openai",
505
+ baseURL: "https://aihubmix.com/v1",
506
+ apiKeyEnv: "AIHUBMIX_API_KEY",
507
+ defaultModels: [
508
+ "gpt-5",
509
+ "claude-sonnet-4-5",
510
+ "gemini-3-flash-preview"
511
+ ],
512
+ notes: "Aggregator; also supports Anthropic-compatible at https://aihubmix.com."
513
+ },
514
+ "aihubmix-anthropic": {
515
+ id: "aihubmix-anthropic",
516
+ label: "AIHubMix(Anthropic 兼容)",
517
+ protocol: "anthropic",
518
+ baseURL: "https://aihubmix.com",
519
+ apiKeyEnv: "AIHUBMIX_API_KEY",
520
+ defaultModels: [
521
+ "claude-sonnet-4-5",
522
+ "claude-opus-4-1",
523
+ "gpt-5"
524
+ ],
525
+ authMode: "x-api-key",
526
+ modelsBaseURL: "https://aihubmix.com/v1",
527
+ modelsAuthMode: "bearer",
528
+ notes: "AIHubMix Anthropic-compatible endpoint."
529
+ },
530
+ openrouter: {
531
+ id: "openrouter",
532
+ label: "OpenRouter",
533
+ protocol: "openai",
534
+ baseURL: "https://openrouter.ai/api/v1",
535
+ apiKeyEnv: "OPENROUTER_API_KEY",
536
+ defaultModels: [
537
+ "openai/gpt-5",
538
+ "anthropic/claude-sonnet-4.5",
539
+ "deepseek/deepseek-chat"
540
+ ],
541
+ notes: "OpenAI-compatible; docs in D:/X/X/06-OpenRouter."
542
+ },
543
+ "openrouter-anthropic": {
544
+ id: "openrouter-anthropic",
545
+ label: "OpenRouter(Anthropic 兼容)",
546
+ protocol: "anthropic",
547
+ baseURL: "https://openrouter.ai/api",
548
+ apiKeyEnv: "OPENROUTER_API_KEY",
549
+ defaultModels: [
550
+ "anthropic/claude-sonnet-4.5",
551
+ "anthropic/claude-opus-4-1",
552
+ "openai/gpt-5",
553
+ "deepseek/deepseek-chat"
554
+ ],
555
+ authMode: "bearer",
556
+ modelsBaseURL: "https://openrouter.ai/api/v1",
557
+ modelsAuthMode: "bearer",
558
+ notes: "OpenRouter Anthropic Skin;使用 ANTHROPIC_BASE_URL=https://openrouter.ai/api + ANTHROPIC_AUTH_TOKEN=OpenRouter Key(Bearer)。"
559
+ }
560
+ };
561
+ function getProviderPreset(id) {
562
+ return PROVIDER_PRESETS[id];
563
+ }
564
+ //#endregion
565
+ //#region src/providers/direct-http.ts
566
+ /** Stream an advisor response through direct HTTP (OpenAI/Anthropic SSE). */
567
+ async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs) {
568
+ const preset = advisor.provider ? getProviderPreset(advisor.provider) : void 0;
569
+ const baseURL = (advisor.baseURL ?? preset?.baseURL ?? "").replace(/\/+$/, "");
570
+ const apiKey = advisor.apiKey ?? process.env[advisor.apiKeyEnv ?? preset?.apiKeyEnv ?? ""] ?? "";
571
+ const protocol = advisor.protocol ?? preset?.protocol ?? "openai";
572
+ const authMode = advisor.authMode ?? preset?.authMode ?? "x-api-key";
573
+ 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
+ const transcriptText = transcript.map((entry) => `[${entry.role === "main" ? "主模型" : entry.name}]\n${entry.content}`).join("\n\n");
575
+ 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);
578
+ case "gemini": {
579
+ const content = await callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs);
580
+ onDelta({ text: content });
581
+ return {
582
+ content,
583
+ thinking: ""
584
+ };
585
+ }
586
+ default: throw new Error(`Unsupported protocol: ${String(protocol)}`);
587
+ }
588
+ }
589
+ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs) {
590
+ const endpoint = baseURL.endsWith("/chat/completions") ? baseURL : `${baseURL.replace(/\/+$/, "")}/chat/completions`;
591
+ const response = await fetch(endpoint, {
592
+ method: "POST",
593
+ headers: {
594
+ "content-type": "application/json",
595
+ authorization: `Bearer ${apiKey}`
596
+ },
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)
611
+ });
612
+ if (!response.ok) throw new Error(`OpenAI-compatible request failed: ${response.status} ${await response.text()}`);
613
+ if (!response.body) throw new Error("OpenAI-compatible response has no body");
614
+ const reader = response.body.getReader();
615
+ const decoder = new TextDecoder();
616
+ let buffer = "";
617
+ let content = "";
618
+ let thinking = "";
619
+ while (true) {
620
+ let done;
621
+ let value;
622
+ try {
623
+ const read = await reader.read();
624
+ done = read.done;
625
+ value = read.value;
626
+ } catch (error) {
627
+ if (signal?.aborted || error?.name === "AbortError") throw error;
628
+ break;
629
+ }
630
+ if (done) break;
631
+ buffer += decoder.decode(value, { stream: true });
632
+ let sep;
633
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
634
+ const raw = buffer.slice(0, sep);
635
+ buffer = buffer.slice(sep + 2);
636
+ for (const line of raw.split("\n")) {
637
+ if (!line.startsWith("data:")) continue;
638
+ const data = line.slice(5).trim();
639
+ if (!data || data === "[DONE]") continue;
640
+ try {
641
+ const delta = JSON.parse(data).choices?.[0]?.delta ?? {};
642
+ const text = typeof delta.content === "string" ? delta.content : "";
643
+ const think = typeof delta.reasoning_content === "string" ? delta.reasoning_content : typeof delta.reasoning === "string" ? delta.reasoning : "";
644
+ if (text) {
645
+ content += text;
646
+ onDelta({ text });
647
+ }
648
+ if (think) {
649
+ thinking += think;
650
+ onDelta({ thinking: think });
651
+ }
652
+ } catch {}
653
+ }
654
+ }
655
+ }
656
+ return {
657
+ content,
658
+ thinking
659
+ };
660
+ }
661
+ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs) {
662
+ const endpoint = baseURL.endsWith("/v1/messages") ? baseURL : baseURL.endsWith("/v1") ? `${baseURL}/messages` : `${baseURL.replace(/\/+$/, "")}/v1/messages`;
663
+ const headers = {
664
+ "content-type": "application/json",
665
+ "anthropic-version": "2023-06-01"
666
+ };
667
+ if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
668
+ else headers["x-api-key"] = apiKey;
669
+ const response = await fetch(endpoint, {
670
+ method: "POST",
671
+ 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)
684
+ });
685
+ if (!response.ok) throw new Error(`Anthropic request failed: ${response.status} ${await response.text()}`);
686
+ if (!response.body) throw new Error("Anthropic response has no body");
687
+ const reader = response.body.getReader();
688
+ const decoder = new TextDecoder();
689
+ let buffer = "";
690
+ let content = "";
691
+ let thinking = "";
692
+ while (true) {
693
+ let done;
694
+ let value;
695
+ try {
696
+ const read = await reader.read();
697
+ done = read.done;
698
+ value = read.value;
699
+ } catch (error) {
700
+ if (signal?.aborted || error?.name === "AbortError") throw error;
701
+ break;
702
+ }
703
+ if (done) break;
704
+ buffer += decoder.decode(value, { stream: true });
705
+ let sep;
706
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
707
+ const raw = buffer.slice(0, sep);
708
+ buffer = buffer.slice(sep + 2);
709
+ for (const line of raw.split("\n")) {
710
+ if (!line.startsWith("data:")) continue;
711
+ const data = line.slice(5).trim();
712
+ if (!data) continue;
713
+ try {
714
+ const json = JSON.parse(data);
715
+ if (json.type === "content_block_delta" && json.delta) {
716
+ if (json.delta.type === "text_delta" && typeof json.delta.text === "string") {
717
+ content += json.delta.text;
718
+ onDelta({ text: json.delta.text });
719
+ } else if (json.delta.type === "thinking_delta" && typeof json.delta.thinking === "string") {
720
+ thinking += json.delta.thinking;
721
+ onDelta({ thinking: json.delta.thinking });
722
+ }
723
+ }
724
+ } catch {}
725
+ }
726
+ }
727
+ }
728
+ return {
729
+ content,
730
+ thinking
731
+ };
732
+ }
733
+ async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs) {
734
+ const model = encodeURIComponent(advisor.model);
735
+ const base = baseURL.replace(/\/+$/, "");
736
+ const endpoint = base.endsWith("/v1beta") ? `${base}/models/${model}:generateContent?key=${apiKey}` : `${base}/v1beta/models/${model}:generateContent?key=${apiKey}`;
737
+ const response = await fetch(endpoint, {
738
+ method: "POST",
739
+ headers: { "content-type": "application/json" },
740
+ body: JSON.stringify({
741
+ contents: [{
742
+ role: "user",
743
+ parts: [{ text: transcriptText }]
744
+ }],
745
+ systemInstruction: { parts: [{ text: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY }] },
746
+ generationConfig: {
747
+ temperature: advisor.temperature ?? .3,
748
+ maxOutputTokens: advisor.maxTokens ?? 16384
749
+ }
750
+ }),
751
+ signal: withTimeout(timeoutMs ?? 12e4, signal)
752
+ });
753
+ if (!response.ok) throw new Error(`Gemini request failed: ${response.status} ${await response.text()}`);
754
+ return (await response.json()).candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("") ?? "";
755
+ }
756
+ //#endregion
757
+ //#region src/driver.ts
758
+ /**
759
+ * The "driver model": a small internal client that reuses the CURRENT agent's
760
+ * configured provider/model (read from the session's latest `request/header`
761
+ * LlmCallConfig) to (a) deepen the consultation between rounds and (b) write
762
+ * the final conclusion. If no header exists and no `discussion.driverModel`
763
+ * fallback is configured, a static fallback text is used so the pipeline never
764
+ * stalls. Any generation error degrades to the fallback text, never throws.
765
+ */
766
+ const FALLBACK_DEEPEN_QUESTION = "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
767
+ const FALLBACK_CONCLUSION = "请综合以上全部讨论,给出结论:各位顾问的共识、分歧,以及最可靠的行动建议。";
768
+ const DRIVER_SYSTEM_PROMPT = `
769
+ 你是顾问群的主持人(驱动模型)。你的职责:
770
+ 1. 阅读项目背景、主模型问题与全部顾问回答;
771
+ 2. 提出一个更深入、聚焦的追问,推动讨论走向更细的根因、分歧或行动方案;
772
+ 3. 只输出追问本身(一句话,不超过 80 字),不要回答、不要打招呼、不要解释。
773
+ `;
774
+ const CONCLUSION_SYSTEM_PROMPT = `
775
+ 你是顾问群的主持人(驱动模型)。请综合全部讨论,输出最终结论:
776
+ - 用 3 段以内:共识、关键分歧(简要列出)、最可靠的行动建议;
777
+ - 不要复述每个顾问的完整回答,只提炼差异与要点。
778
+ `;
779
+ /** Resolve the current agent's model config from the session header, or the config fallback. */
780
+ function resolveDriverSource(sessionLog, driverModel) {
781
+ if (sessionLog) try {
782
+ const header = sessionLog.requestHeader();
783
+ if (header?.config?.provider && header?.config?.model) return {
784
+ provider: header.config.provider,
785
+ model: header.config.model
786
+ };
787
+ } catch {}
788
+ if (driverModel?.provider && driverModel?.model) return {
789
+ provider: driverModel.provider,
790
+ model: driverModel.model
791
+ };
792
+ }
793
+ function transcriptOf(session) {
794
+ return session.messages.filter((message) => message.role !== "system").map((message) => {
795
+ return `[${message.role === "main" ? "主模型" : message.advisorName ?? message.advisorId ?? "顾问"}]\n${message.content}`;
796
+ }).join("\n\n").slice(-24e3);
797
+ }
798
+ async function generateWith(ctx, source, system, prompt, signal) {
799
+ try {
800
+ const options = {
801
+ provider: source.provider,
802
+ model: source.model,
803
+ system,
804
+ messages: [createUserMessage({
805
+ content: [{
806
+ type: "text",
807
+ text: prompt
808
+ }],
809
+ source: {
810
+ kind: "plugin",
811
+ plugin: "dsh-advisor-group"
812
+ }
813
+ })],
814
+ temperature: .7,
815
+ maxTokens: 600,
816
+ signal: withTimeout(6e4, signal)
817
+ };
818
+ 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 {
822
+ return;
823
+ }
824
+ }
825
+ /** One deepening question for the next round (never throws; returns fallback text). */
826
+ async function generateDeepenQuestion(ctx, session, source, signal) {
827
+ if (!source) return FALLBACK_DEEPEN_QUESTION;
828
+ return await generateWith(ctx, source, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
829
+ }
830
+ /** Final synthesis after all rounds (never throws; returns fallback text). */
831
+ async function generateConclusion(ctx, session, source, signal) {
832
+ 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) || "请综合以上全部讨论,给出结论:各位顾问的共识、分歧,以及最可靠的行动建议。";
834
+ }
835
+ //#endregion
836
+ //#region src/session-log.ts
837
+ /**
838
+ * Derive the latest turn/step from the durable session log.
839
+ *
840
+ * ToolRunContext does not expose turn/step directly. The agent loop logs
841
+ * `turn/start` and `step/start` before tool execution, so scanning backwards
842
+ * gives the closest coordinates; fallback to 0 when none exist.
843
+ */
844
+ function latestTurnStep(log) {
845
+ const events = log.snapshotEvents();
846
+ for (let i = events.length - 1; i >= 0; i--) {
847
+ const event = events[i];
848
+ if (event.type === "step/start") return {
849
+ turn: event.data.turn,
850
+ step: event.data.step
851
+ };
852
+ if (event.type === "turn/start") return {
853
+ turn: event.data.turn,
854
+ step: 0
855
+ };
856
+ }
857
+ return {
858
+ turn: 0,
859
+ step: 0
860
+ };
861
+ }
862
+ function appendAdvisorStart(log, session) {
863
+ const { turn, step } = latestTurnStep(log);
864
+ const advisors = session.advisors.map(({ id, name, avatar }) => avatar === void 0 ? {
865
+ id,
866
+ name
867
+ } : {
868
+ id,
869
+ name,
870
+ avatar
871
+ });
872
+ log.append("advisor-group/start", {
873
+ sessionId: session.id,
874
+ turn,
875
+ step,
876
+ question: session.question,
877
+ ...session.context === void 0 ? {} : { context: session.context },
878
+ advisors
879
+ });
880
+ }
881
+ function appendAdvisorMessage(log, sessionId, message) {
882
+ const { turn, step } = latestTurnStep(log);
883
+ const data = {
884
+ sessionId,
885
+ turn,
886
+ step,
887
+ role: message.role,
888
+ content: message.content,
889
+ ...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
890
+ ...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
891
+ ...message.round === void 0 ? {} : { round: message.round },
892
+ ...message.thinking === void 0 ? {} : { thinking: message.thinking }
893
+ };
894
+ log.append("advisor-group/message", data);
895
+ }
896
+ function appendAdvisorDelta(log, sessionId, delta) {
897
+ const { turn, step } = latestTurnStep(log);
898
+ const data = {
899
+ sessionId,
900
+ turn,
901
+ step,
902
+ advisorId: delta.advisorId,
903
+ ...delta.advisorName === void 0 ? {} : { advisorName: delta.advisorName },
904
+ ...delta.round === void 0 ? {} : { round: delta.round },
905
+ ...delta.contentDelta === void 0 ? {} : { contentDelta: delta.contentDelta },
906
+ ...delta.thinkingDelta === void 0 ? {} : { thinkingDelta: delta.thinkingDelta },
907
+ ...delta.done === void 0 ? {} : { done: delta.done }
908
+ };
909
+ log.append("advisor-group/delta", data);
910
+ }
911
+ function appendAdvisorEnd(log, session, summary) {
912
+ const { turn, step } = latestTurnStep(log);
913
+ const data = {
914
+ sessionId: session.id,
915
+ turn,
916
+ step,
917
+ summary: {
918
+ question: summary.question,
919
+ advisors: summary.advisors.map(({ advisorId, advisorName, corePoints }) => ({
920
+ advisorId,
921
+ advisorName,
922
+ corePoints
923
+ })),
924
+ ...summary.riskNotes.length > 0 ? { riskNotes: summary.riskNotes } : {}
925
+ }
926
+ };
927
+ log.append("advisor-group/end", data);
928
+ }
929
+ //#endregion
930
+ //#region src/stream-channel.ts
931
+ const BOOT_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
932
+ const subscribers = /* @__PURE__ */ new Map();
933
+ const buffers = /* @__PURE__ */ new Map();
934
+ const lastIds = /* @__PURE__ */ new Map();
935
+ const REPLAY_LIMIT = 500;
936
+ const BUFFER_TTL_MS = 3e5;
937
+ function pruneBuffer(sessionId, buffer) {
938
+ const cutoff = Date.now() - BUFFER_TTL_MS;
939
+ const pruned = buffer.filter((frame) => frame.ts >= cutoff);
940
+ if (pruned.length === 0) {
941
+ buffers.delete(sessionId);
942
+ return [];
943
+ }
944
+ buffers.set(sessionId, pruned);
945
+ return pruned;
946
+ }
947
+ function writeFrame(res, frame) {
948
+ try {
949
+ res.write(`id: ${BOOT_ID}:${frame.id}\n${frame.payload}`);
950
+ } catch {}
951
+ }
952
+ function writeResync(res, reason) {
953
+ try {
954
+ res.write(`event: resync\ndata: ${JSON.stringify({ reason })}\n\n`);
955
+ } catch {}
956
+ }
957
+ function subscribe(sessionId, res, lastEventId = 0, bootId) {
958
+ const rawBuffer = buffers.get(sessionId);
959
+ const buffer = rawBuffer ? pruneBuffer(sessionId, rawBuffer) : void 0;
960
+ const firstBufferedId = buffer?.[0]?.id;
961
+ if (bootId && bootId !== BOOT_ID) writeResync(res, "restart");
962
+ else if (lastEventId > 0 && firstBufferedId !== void 0 && lastEventId < firstBufferedId - 1) writeResync(res, "gap");
963
+ else if (buffer) {
964
+ for (const frame of buffer) if (frame.id > lastEventId) writeFrame(res, frame);
965
+ }
966
+ let set = subscribers.get(sessionId);
967
+ if (!set) {
968
+ set = /* @__PURE__ */ new Set();
969
+ subscribers.set(sessionId, set);
970
+ }
971
+ set.add(res);
972
+ const cleanup = () => {
973
+ set?.delete(res);
974
+ if (set?.size === 0) subscribers.delete(sessionId);
975
+ };
976
+ res.on("close", cleanup);
977
+ return cleanup;
978
+ }
979
+ function publish(sessionId, delta) {
980
+ const id = (lastIds.get(sessionId) ?? 0) + 1;
981
+ lastIds.set(sessionId, id);
982
+ const frame = {
983
+ id,
984
+ payload: `data: ${JSON.stringify({
985
+ ...delta,
986
+ eventId: id,
987
+ bootId: BOOT_ID
988
+ })}\n\n`,
989
+ ts: Date.now()
990
+ };
991
+ const rawBuffer = buffers.get(sessionId);
992
+ const buffer = rawBuffer ? pruneBuffer(sessionId, rawBuffer) : [];
993
+ buffer.push(frame);
994
+ if (buffer.length > REPLAY_LIMIT) buffer.shift();
995
+ buffers.set(sessionId, buffer);
996
+ const set = subscribers.get(sessionId);
997
+ if (!set || set.size === 0) return;
998
+ for (const res of set) writeFrame(res, frame);
999
+ }
1000
+ //#endregion
1001
+ //#region src/service.ts
1002
+ function isAbortError(error, signal) {
1003
+ if (signal?.aborted) return true;
1004
+ return error instanceof Error && error.name === "AbortError";
1005
+ }
1006
+ const MAX_DAILY_CONSULTATIONS = 50;
1007
+ function extractUserMessageText(event) {
1008
+ if (event.type !== "user/message") return null;
1009
+ const data = event.data;
1010
+ if (!data?.content) return null;
1011
+ const text = data.content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join(" ").trim();
1012
+ return text.length > 0 ? text : null;
1013
+ }
1014
+ var AdvisorGroupService = class {
1015
+ ctx;
1016
+ sessions = /* @__PURE__ */ new Map();
1017
+ repeatTracker = /* @__PURE__ */ new Map();
1018
+ persistEnabledCallback;
1019
+ dailyConsultationDate = "";
1020
+ dailyConsultationCount = 0;
1021
+ /** Durable daily-guard counter fixture under the DSH home storage area. */
1022
+ dailyGuardPath;
1023
+ dailyWriteChain = Promise.resolve();
1024
+ enabled;
1025
+ config;
1026
+ constructor(ctx, config) {
1027
+ this.ctx = ctx;
1028
+ this.config = config;
1029
+ this.enabled = config.enabled;
1030
+ const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
1031
+ this.dailyGuardPath = join(dshHome, "storages", "advisor-group", "daily-guard.json");
1032
+ this.loadDailyGuard();
1033
+ ctx.on("session/event", (session, event) => {
1034
+ const text = extractUserMessageText(event);
1035
+ if (!text) return;
1036
+ const tracked = this.repeatTracker.get(session.id);
1037
+ if (!tracked || tracked.text !== text) this.repeatTracker.set(session.id, {
1038
+ text,
1039
+ count: 1
1040
+ });
1041
+ else tracked.count += 1;
1042
+ });
1043
+ }
1044
+ loadDailyGuard() {
1045
+ try {
1046
+ const parsed = JSON.parse(readFileSync(this.dailyGuardPath, "utf8"));
1047
+ if (typeof parsed.date === "string" && typeof parsed.count === "number" && Number.isSafeInteger(parsed.count) && parsed.count >= 0) {
1048
+ this.dailyConsultationDate = parsed.date;
1049
+ this.dailyConsultationCount = parsed.count;
1050
+ }
1051
+ } catch {}
1052
+ }
1053
+ /** Serialized, atomic (tmp + rename) daily-guard persistence; never blocks a consultation. */
1054
+ persistDailyGuard() {
1055
+ const payload = JSON.stringify({
1056
+ date: this.dailyConsultationDate,
1057
+ count: this.dailyConsultationCount
1058
+ });
1059
+ this.dailyWriteChain = this.dailyWriteChain.then(async () => {
1060
+ await promises.mkdir(dirname(this.dailyGuardPath), { recursive: true });
1061
+ const tmp = `${this.dailyGuardPath}.tmp`;
1062
+ await promises.writeFile(tmp, payload, "utf8");
1063
+ await promises.rename(tmp, this.dailyGuardPath);
1064
+ }).catch((error) => {
1065
+ console.warn("[dsh-advisor-group] 每日护栏持久化失败:", error instanceof Error ? error.message : String(error));
1066
+ });
1067
+ }
1068
+ getRepeatPressure(sessionId) {
1069
+ if (!sessionId) return void 0;
1070
+ const tracked = this.repeatTracker.get(sessionId);
1071
+ if (!tracked) return void 0;
1072
+ return {
1073
+ count: tracked.count,
1074
+ text: tracked.text
1075
+ };
1076
+ }
1077
+ getConfig() {
1078
+ return this.config;
1079
+ }
1080
+ setConfig(config) {
1081
+ this.config = config;
1082
+ this.enabled = config.enabled;
1083
+ }
1084
+ isEnabled() {
1085
+ return this.enabled;
1086
+ }
1087
+ setEnabled(enabled) {
1088
+ this.enabled = enabled;
1089
+ this.config = {
1090
+ ...this.config,
1091
+ enabled
1092
+ };
1093
+ }
1094
+ setPersistEnabled(callback) {
1095
+ this.persistEnabledCallback = callback;
1096
+ }
1097
+ async toggleEnabled(enabled) {
1098
+ this.setEnabled(enabled);
1099
+ if (this.persistEnabledCallback) try {
1100
+ await this.persistEnabledCallback(enabled);
1101
+ } catch {}
1102
+ return this.enabled;
1103
+ }
1104
+ /**
1105
+ * Current daily-guard facts for display (does NOT increment — read-only).
1106
+ * Rolls the in-memory counter to a new UTC day view without persisting.
1107
+ */
1108
+ getDailyGuard() {
1109
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1110
+ const used = this.dailyConsultationDate === today ? this.dailyConsultationCount : 0;
1111
+ return {
1112
+ used,
1113
+ limit: MAX_DAILY_CONSULTATIONS,
1114
+ remaining: Math.max(0, MAX_DAILY_CONSULTATIONS - used)
1115
+ };
1116
+ }
1117
+ /**
1118
+ * Minimal cost guard: atomically check and increment the per-day new
1119
+ * consultation counter. Node is single-threaded, so doing both inside one
1120
+ * synchronous block closes the TOCTOU window a two-step check would leave.
1121
+ * The counter is persisted (UTC day key) so a harness restart does not
1122
+ * reset the quota; persistence is fire-and-forget after the sync block.
1123
+ */
1124
+ tryStartConsultation() {
1125
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1126
+ if (this.dailyConsultationDate !== today) {
1127
+ this.dailyConsultationDate = today;
1128
+ this.dailyConsultationCount = 0;
1129
+ }
1130
+ if (this.dailyConsultationCount >= MAX_DAILY_CONSULTATIONS) return {
1131
+ ok: false,
1132
+ reason: "今日顾问咨询次数已达上限,请明天再试或调整配置。"
1133
+ };
1134
+ this.dailyConsultationCount += 1;
1135
+ this.persistDailyGuard();
1136
+ return { ok: true };
1137
+ }
1138
+ getSession(id) {
1139
+ return this.sessions.get(id);
1140
+ }
1141
+ createSession(question, context, advisorIds = []) {
1142
+ const maxAdvisors = this.config.discussion.maxAdvisorsPerCall;
1143
+ const advisors = this.resolveAdvisors(advisorIds).slice(0, maxAdvisors);
1144
+ const session = {
1145
+ id: randomUUID(),
1146
+ status: "active",
1147
+ question,
1148
+ context,
1149
+ advisors,
1150
+ maxRounds: this.config.discussion.maxRounds,
1151
+ messages: [{
1152
+ role: "system",
1153
+ content: `顾问群已建立,共 ${advisors.length} 位顾问。`,
1154
+ ts: Date.now()
1155
+ }, {
1156
+ role: "main",
1157
+ content: context ? `${context}\n\n${question}` : question,
1158
+ ts: Date.now()
1159
+ }],
1160
+ createdAt: Date.now(),
1161
+ updatedAt: Date.now()
1162
+ };
1163
+ if (this.sessions.size >= 100) {
1164
+ const oldest = this.sessions.keys().next().value;
1165
+ if (oldest !== void 0) this.sessions.delete(oldest);
1166
+ }
1167
+ this.sessions.set(session.id, session);
1168
+ this.ctx.emit("advisor-group/session-start", session);
1169
+ return session;
1170
+ }
1171
+ appendFollowUp(sessionId, followUp, sessionLog) {
1172
+ const session = this.sessions.get(sessionId);
1173
+ if (!session) return void 0;
1174
+ const message = {
1175
+ role: "main",
1176
+ content: followUp,
1177
+ ts: Date.now()
1178
+ };
1179
+ session.messages.push(message);
1180
+ session.updatedAt = Date.now();
1181
+ if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
1182
+ this.ctx.emit("advisor-group/message", {
1183
+ sessionId: session.id,
1184
+ message
1185
+ });
1186
+ return session;
1187
+ }
1188
+ async runOneRound(session, signal, sessionLog) {
1189
+ const advisorCount = Math.max(1, session.advisors.length);
1190
+ const round = Math.floor(session.messages.filter((message) => message.role === "advisor").length / advisorCount) + 1;
1191
+ for (let index = 0; index < session.advisors.length; index++) {
1192
+ const advisor = session.advisors[index];
1193
+ const transcript = this.buildTranscript(session);
1194
+ await this.callAdvisor(session, advisor, transcript, round, index + 1, signal, sessionLog);
1195
+ }
1196
+ session.status = "active";
1197
+ session.updatedAt = Date.now();
1198
+ }
1199
+ /**
1200
+ * Auto-deepen pipeline (2026-09-05): one ask_advisors call runs the whole
1201
+ * consultation — up to `maxRounds` rounds, each round being
1202
+ * [driver deep-question (after the first)] → advisor A → advisor B (sees A)
1203
+ * → advisor C (sees A+B) → …
1204
+ * and closes with a driver-generated conclusion. The pipeline is synchronous
1205
+ * (SSE keeps the card streaming); `exec.signal` aborts the whole run.
1206
+ */
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);
1212
+ }
1213
+ await this.runOneRound(session, signal, sessionLog);
1214
+ }
1215
+ session.status = "completed";
1216
+ 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 = {
1220
+ ...summary,
1221
+ conclusion
1222
+ };
1223
+ if (sessionLog) appendAdvisorEnd(sessionLog, session, finalSummary);
1224
+ this.ctx.emit("advisor-group/session-end", {
1225
+ sessionId: session.id,
1226
+ summary: finalSummary
1227
+ });
1228
+ return finalSummary;
1229
+ }
1230
+ appendMainMessage(session, content, sessionLog) {
1231
+ const message = {
1232
+ role: "main",
1233
+ content,
1234
+ ts: Date.now()
1235
+ };
1236
+ session.messages.push(message);
1237
+ if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
1238
+ this.ctx.emit("advisor-group/message", {
1239
+ sessionId: session.id,
1240
+ message
1241
+ });
1242
+ }
1243
+ /**
1244
+ * Run exactly ONE discussion round: the main model's question -> each
1245
+ * advisor answers once. Cross-advisor discussion is driven by the main model
1246
+ * calling ask_advisors again with sessionId + followUp, never by the
1247
+ * advisor models talking to themselves back-to-back.
1248
+ */
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;
1260
+ }
1261
+ getRoundCount(session) {
1262
+ const advisorCount = Math.max(1, session.advisors.length);
1263
+ return Math.floor(session.messages.filter((message) => message.role === "advisor").length / advisorCount);
1264
+ }
1265
+ hasReachedMaxRounds(session) {
1266
+ const maxRounds = Math.max(1, session.maxRounds);
1267
+ return this.getRoundCount(session) >= maxRounds;
1268
+ }
1269
+ resolveAdvisors(advisorIds) {
1270
+ if (advisorIds.length === 0) return this.config.advisors;
1271
+ return this.config.advisors.filter((advisor) => advisorIds.includes(advisor.id));
1272
+ }
1273
+ buildTranscript(session) {
1274
+ return session.messages.filter((message) => message.role !== "system").slice(-20).map((message) => {
1275
+ if (message.role === "main") return {
1276
+ role: "main",
1277
+ name: "主模型",
1278
+ content: message.content.slice(0, 4e3)
1279
+ };
1280
+ if (message.role === "advisor" && message.advisorName) return {
1281
+ role: "advisor",
1282
+ name: message.advisorName,
1283
+ content: message.content.slice(0, 4e3)
1284
+ };
1285
+ return null;
1286
+ }).filter((entry) => entry !== null);
1287
+ }
1288
+ async callAdvisor(session, advisor, transcript, round, joinIndex, signal, sessionLog) {
1289
+ const relational = { ...advisor };
1290
+ relational.systemPrompt = `${advisor.systemPrompt}\n${advisorJoinPrompt(joinIndex, session.advisors.length)}`;
1291
+ const message = {
1292
+ role: "advisor",
1293
+ advisorId: advisor.id,
1294
+ advisorName: advisor.name,
1295
+ content: "",
1296
+ round,
1297
+ ts: Date.now()
1298
+ };
1299
+ try {
1300
+ let content;
1301
+ let isDshLlmProvider = false;
1302
+ try {
1303
+ isDshLlmProvider = this.ctx.llm.listProviders().some((item) => item.id === advisor.provider);
1304
+ } catch {}
1305
+ let pendingText = "";
1306
+ let pendingThinking = "";
1307
+ let timer = null;
1308
+ const flush = () => {
1309
+ if (timer) {
1310
+ clearTimeout(timer);
1311
+ timer = null;
1312
+ }
1313
+ if (!pendingText && !pendingThinking) return;
1314
+ if (sessionLog) appendAdvisorDelta(sessionLog, session.id, {
1315
+ sessionId: session.id,
1316
+ advisorId: advisor.id,
1317
+ advisorName: advisor.name,
1318
+ round,
1319
+ contentDelta: pendingText || void 0,
1320
+ thinkingDelta: pendingThinking || void 0,
1321
+ done: false
1322
+ });
1323
+ pendingText = "";
1324
+ pendingThinking = "";
1325
+ };
1326
+ const emitDelta = (delta) => {
1327
+ if (delta.text) {
1328
+ message.content += delta.text;
1329
+ pendingText += delta.text;
1330
+ }
1331
+ if (delta.thinking) {
1332
+ message.thinking = (message.thinking ?? "") + delta.thinking;
1333
+ pendingThinking += delta.thinking;
1334
+ }
1335
+ publish(session.id, {
1336
+ advisorId: advisor.id,
1337
+ advisorName: advisor.name,
1338
+ contentDelta: delta.text,
1339
+ thinkingDelta: delta.thinking,
1340
+ done: false
1341
+ });
1342
+ if (!timer) timer = setTimeout(flush, 200);
1343
+ };
1344
+ const advisorTimeoutMs = this.config.discussion.advisorTimeoutMs;
1345
+ 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;
1350
+ } else {
1351
+ content = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs);
1352
+ flush();
1353
+ }
1354
+ message.content = content;
1355
+ } catch (error) {
1356
+ if (isAbortError(error, signal)) throw error;
1357
+ message.content = `(顾问调用失败:${error instanceof Error ? error.message : String(error)})`;
1358
+ }
1359
+ publish(session.id, {
1360
+ advisorId: advisor.id,
1361
+ advisorName: advisor.name,
1362
+ done: true
1363
+ });
1364
+ session.messages.push(message);
1365
+ if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
1366
+ session.updatedAt = Date.now();
1367
+ this.ctx.emit("advisor-group/message", {
1368
+ sessionId: session.id,
1369
+ message
1370
+ });
1371
+ }
1372
+ buildSummary(session, cancelled = false) {
1373
+ const advisorMessages = session.messages.filter((message) => message.role === "advisor");
1374
+ const byAdvisor = /* @__PURE__ */ new Map();
1375
+ for (const message of advisorMessages) {
1376
+ const list = byAdvisor.get(message.advisorId ?? "") ?? [];
1377
+ list.push(message);
1378
+ byAdvisor.set(message.advisorId ?? "", list);
1379
+ }
1380
+ const advisors = [...byAdvisor.entries()].map(([id, messages]) => {
1381
+ const advisor = session.advisors.find((item) => item.id === id);
1382
+ const meaningful = messages.filter((message) => message.content && message.content.trim() && !message.content.startsWith("(顾问调用失败"));
1383
+ const last = meaningful[meaningful.length - 1] ?? messages[messages.length - 1];
1384
+ return {
1385
+ advisorId: id,
1386
+ advisorName: advisor?.name ?? id,
1387
+ corePoints: last ? this.extractCorePoints(last.content) : []
1388
+ };
1389
+ });
1390
+ const riskNotes = advisorMessages.some((message) => /风险|注意|不确定|risk|uncertain|confidence/i.test(message.content)) ? ["部分顾问提到了风险、不确定性或置信度较低,请主模型谨慎采用。"] : [];
1391
+ if (cancelled) riskNotes.push("本次顾问群讨论已被用户或主模型取消,结论可能不完整。");
1392
+ return {
1393
+ sessionId: session.id,
1394
+ question: session.question,
1395
+ advisors,
1396
+ consensus: [],
1397
+ disagreements: [],
1398
+ riskNotes
1399
+ };
1400
+ }
1401
+ extractCorePoints(content) {
1402
+ return content.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 5);
1403
+ }
1404
+ };
1405
+ //#endregion
1406
+ //#region src/model-validation.ts
1407
+ /**
1408
+ * Advisors whose model is empty or whitespace-only.
1409
+ *
1410
+ * The ask_advisors tool refuses to start a consultation while any advisor is
1411
+ * missing a model (fast fail with a precise reason instead of a mid-round
1412
+ * failure); the settings card marks the field red in the same condition.
1413
+ */
1414
+ function advisorsMissingModel(config) {
1415
+ return (Array.isArray(config) ? config : config.advisors).filter((advisor) => !advisor.model || !advisor.model.trim());
1416
+ }
1417
+ /** Pure projection used by tests and storage: truncates and normalizes a sample. */
1418
+ function formatShadowSample(sample) {
1419
+ return {
1420
+ ...sample,
1421
+ ts: Number.isSafeInteger(sample.ts) ? sample.ts : Date.now(),
1422
+ question: sample.question.length > 200 ? `${sample.question.slice(0, 200)}…` : sample.question
1423
+ };
1424
+ }
1425
+ function shadowLogPath() {
1426
+ const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
1427
+ return join(dshHome, "storages", "advisor-group", "classifier-shadow.jsonl");
1428
+ }
1429
+ let writeChain = Promise.resolve();
1430
+ /** Append one shadow sample (serialized; failures degrade to a silent no-op). */
1431
+ function appendShadowSample(sample) {
1432
+ const line = `${JSON.stringify(formatShadowSample(sample))}\n`;
1433
+ writeChain = writeChain.then(async () => {
1434
+ const path = shadowLogPath();
1435
+ await promises.mkdir(dirname(path), { recursive: true });
1436
+ await promises.appendFile(path, line, "utf8");
1437
+ }).catch((error) => {
1438
+ console.warn("[dsh-advisor-group] 分类器影子样本写入失败:", error instanceof Error ? error.message : String(error));
1439
+ });
1440
+ }
1441
+ /** Read the most recent `limit` samples plus lightweight statistics. */
1442
+ async function readShadowSamples(limit = 200) {
1443
+ let raw = "";
1444
+ try {
1445
+ raw = await promises.readFile(shadowLogPath(), "utf8");
1446
+ } catch {
1447
+ return {
1448
+ total: 0,
1449
+ escalated: 0,
1450
+ launched: 0,
1451
+ samples: []
1452
+ };
1453
+ }
1454
+ const all = [];
1455
+ for (const line of raw.split("\n")) {
1456
+ if (!line.trim()) continue;
1457
+ try {
1458
+ const parsed = JSON.parse(line);
1459
+ if (typeof parsed.ts !== "number" || typeof parsed.question !== "string") continue;
1460
+ all.push(formatShadowSample(parsed));
1461
+ } catch {}
1462
+ }
1463
+ const tail = all.slice(-Math.max(1, Math.min(limit, 500)));
1464
+ const withConfidence = all.filter((s) => typeof s.confidence === "number");
1465
+ return {
1466
+ total: all.length,
1467
+ escalated: all.filter((s) => s.shouldEscalate).length,
1468
+ launched: all.filter((s) => s.launched).length,
1469
+ ...withConfidence.length > 0 ? { avgConfidence: withConfidence.reduce((sum, s) => sum + (s.confidence ?? 0), 0) / withConfidence.length } : {},
1470
+ samples: tail
1471
+ };
1472
+ }
1473
+ //#endregion
1474
+ //#region src/classifier.ts
1475
+ const HIGH_RISK_KEYWORDS = [
1476
+ "医疗",
1477
+ "药物",
1478
+ "剂量",
1479
+ "手术",
1480
+ "诊断",
1481
+ "法律",
1482
+ "合同",
1483
+ "诉讼",
1484
+ "合规",
1485
+ "税务",
1486
+ "投资",
1487
+ "理财",
1488
+ "保险",
1489
+ "安全漏洞",
1490
+ "漏洞利用",
1491
+ "核保",
1492
+ "判例",
1493
+ "法条",
1494
+ "责任",
1495
+ "赔偿",
1496
+ "监管",
1497
+ "刑事",
1498
+ "民事",
1499
+ "仲裁",
1500
+ "劳动法",
1501
+ "知识产权"
1502
+ ];
1503
+ const WEB_SEARCH_KEYWORDS = [
1504
+ "最新",
1505
+ "新闻",
1506
+ "天气",
1507
+ "汇率",
1508
+ "股价",
1509
+ "价格",
1510
+ "API文档",
1511
+ "版本",
1512
+ "更新",
1513
+ "公告",
1514
+ "活动",
1515
+ "时间",
1516
+ "日期",
1517
+ "在哪",
1518
+ "怎么下载",
1519
+ "官网",
1520
+ "仓库地址",
1521
+ "当前",
1522
+ "实时",
1523
+ "今日",
1524
+ "本周",
1525
+ "排行",
1526
+ "销量",
1527
+ "行情"
1528
+ ];
1529
+ const WORLD_KNOWLEDGE_KEYWORDS = [
1530
+ "历史",
1531
+ "哲学",
1532
+ "理论",
1533
+ "学派",
1534
+ "文化",
1535
+ "术语",
1536
+ "概念",
1537
+ "原理",
1538
+ "行业惯例",
1539
+ "经验",
1540
+ "经典",
1541
+ "法理",
1542
+ "判例",
1543
+ "方法论",
1544
+ "框架",
1545
+ "最佳实践",
1546
+ "架构评审",
1547
+ "技术选型",
1548
+ "代码审查",
1549
+ "疑难",
1550
+ "根因",
1551
+ "学术",
1552
+ "论文",
1553
+ "研究",
1554
+ "文献",
1555
+ "政策",
1556
+ "制度",
1557
+ "机制",
1558
+ "商业模式",
1559
+ "战略",
1560
+ "组织",
1561
+ "管理",
1562
+ "数学",
1563
+ "证明",
1564
+ "推导",
1565
+ "统计",
1566
+ "回归",
1567
+ "概率",
1568
+ "算法",
1569
+ "复杂度",
1570
+ "优化",
1571
+ "物理",
1572
+ "化学",
1573
+ "生物",
1574
+ "工程",
1575
+ "材料",
1576
+ "量子"
1577
+ ];
1578
+ const DOMAIN_KEYWORDS = [
1579
+ {
1580
+ domain: "legal",
1581
+ keywords: [
1582
+ "法律",
1583
+ "合同",
1584
+ "诉讼",
1585
+ "合规",
1586
+ "税务",
1587
+ "法条",
1588
+ "判例",
1589
+ "责任",
1590
+ "仲裁",
1591
+ "劳动法",
1592
+ "知识产权"
1593
+ ]
1594
+ },
1595
+ {
1596
+ domain: "code",
1597
+ keywords: [
1598
+ "代码",
1599
+ "架构",
1600
+ "技术选型",
1601
+ "bug",
1602
+ "调试",
1603
+ "性能",
1604
+ "安全漏洞",
1605
+ "API",
1606
+ "系统设计",
1607
+ "数据库",
1608
+ "前端",
1609
+ "后端",
1610
+ "微服务"
1611
+ ]
1612
+ },
1613
+ {
1614
+ domain: "finance",
1615
+ keywords: [
1616
+ "投资",
1617
+ "理财",
1618
+ "金融",
1619
+ "税务",
1620
+ "保险",
1621
+ "估值",
1622
+ "财报",
1623
+ "现金流",
1624
+ "风险",
1625
+ "股票",
1626
+ "基金",
1627
+ "债券"
1628
+ ]
1629
+ },
1630
+ {
1631
+ domain: "medical",
1632
+ keywords: [
1633
+ "医疗",
1634
+ "药物",
1635
+ "剂量",
1636
+ "症状",
1637
+ "手术",
1638
+ "疾病",
1639
+ "诊断",
1640
+ "治疗",
1641
+ "康复"
1642
+ ]
1643
+ },
1644
+ {
1645
+ domain: "domain",
1646
+ keywords: [
1647
+ "行业惯例",
1648
+ "经验",
1649
+ "历史",
1650
+ "文化",
1651
+ "术语",
1652
+ "概念",
1653
+ "原理",
1654
+ "战略",
1655
+ "商业模式",
1656
+ "组织",
1657
+ "管理"
1658
+ ]
1659
+ },
1660
+ {
1661
+ domain: "technical",
1662
+ keywords: [
1663
+ "数学",
1664
+ "证明",
1665
+ "推导",
1666
+ "统计",
1667
+ "回归",
1668
+ "概率",
1669
+ "算法",
1670
+ "复杂度",
1671
+ "优化",
1672
+ "物理",
1673
+ "化学",
1674
+ "生物",
1675
+ "工程",
1676
+ "材料",
1677
+ "量子"
1678
+ ]
1679
+ },
1680
+ {
1681
+ domain: "academic",
1682
+ keywords: [
1683
+ "学术",
1684
+ "论文",
1685
+ "研究",
1686
+ "文献",
1687
+ "理论",
1688
+ "方法论"
1689
+ ]
1690
+ }
1691
+ ];
1692
+ function classifyRequest(question, context, advisors = [], confidence, confidenceThreshold = .6) {
1693
+ const text = `${question}\n${context ?? ""}`;
1694
+ const lowerText = text.toLowerCase();
1695
+ if (text.includes("@顾问群") || text.includes("@顧問群")) return {
1696
+ shouldEscalate: true,
1697
+ reason: "用户点名顾问群,强制启动顾问咨询。",
1698
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
1699
+ suggestWebSearch: false
1700
+ };
1701
+ if (HIGH_RISK_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase()))) return {
1702
+ shouldEscalate: true,
1703
+ reason: "命中高风险关键词,需要专业顾问参与并输出风险提示。",
1704
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
1705
+ suggestWebSearch: false
1706
+ };
1707
+ if (WORLD_KNOWLEDGE_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase()))) return {
1708
+ shouldEscalate: true,
1709
+ reason: "命中世界知识/专业经验关键词,适合由顾问模型提供多视角解答。",
1710
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
1711
+ suggestWebSearch: false
1712
+ };
1713
+ if (typeof confidence === "number" && confidence < confidenceThreshold) return {
1714
+ shouldEscalate: true,
1715
+ reason: `主模型置信度 ${confidence} 低于阈值 ${confidenceThreshold},建议升级到顾问群。`,
1716
+ suggestedAdvisors: matchAdvisors(lowerText, advisors),
1717
+ suggestWebSearch: false
1718
+ };
1719
+ if (WEB_SEARCH_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase()))) return {
1720
+ shouldEscalate: false,
1721
+ reason: "该问题更依赖实时/事实信息,建议先联网搜索而不是启动顾问群。",
1722
+ suggestedAdvisors: [],
1723
+ suggestWebSearch: true
1724
+ };
1725
+ return {
1726
+ shouldEscalate: false,
1727
+ reason: "未识别出需要顾问参与的强信号,可由主模型自行处理。",
1728
+ suggestedAdvisors: [],
1729
+ suggestWebSearch: false
1730
+ };
1731
+ }
1732
+ function matchAdvisors(text, advisors) {
1733
+ if (advisors.length === 0) return [];
1734
+ const matches = /* @__PURE__ */ new Set();
1735
+ const lowerText = text.toLowerCase();
1736
+ for (const advisor of advisors) {
1737
+ const haystack = `${advisor.id} ${advisor.name} ${advisor.systemPrompt}`.toLowerCase();
1738
+ for (const { domain, keywords } of DOMAIN_KEYWORDS) if (haystack.includes(domain)) {
1739
+ if (keywords.some((keyword) => lowerText.includes(keyword.toLowerCase()))) matches.add(advisor.id);
1740
+ }
1741
+ if (lowerText.includes(advisor.name.toLowerCase()) || lowerText.includes(advisor.id.toLowerCase())) matches.add(advisor.id);
1742
+ }
1743
+ return [...matches];
1744
+ }
1745
+ //#endregion
1746
+ //#region src/tools.ts
1747
+ function registerAdvisorTools(service) {
1748
+ return [defineTool({
1749
+ name: "ask_advisors",
1750
+ description: "Consult configured expert advisor models when the question is professional, long-tail world knowledge, high-risk, or the main model is uncertain. ALWAYS provide context with (a) a brief overview of the current project and (b) concrete details of the problem, including what has been tried and what went wrong. Also call immediately when the user @顾问群 or repeats the same question three times without resolution. Do NOT use for simple facts that web search can answer. Starting a new consultation runs the auto-deepen pipeline: up to maxRounds rounds of [driver deep-question → advisor A → advisor B (sees A) → advisor C (sees A+B) → …], closed by a driver-generated conclusion.",
1751
+ parameters: {
1752
+ question: {
1753
+ type: "string",
1754
+ description: "The question to ask the advisors. Required when starting a new consultation."
1755
+ },
1756
+ context: {
1757
+ type: "string",
1758
+ description: "Required background: (a) current project overview (domain/goal/stack/constraints) and (b) problem details (steps tried, expected vs actual, errors). Write \"该项背景未知\" for anything unknown; never leave it empty."
1759
+ },
1760
+ sessionId: {
1761
+ type: "string",
1762
+ description: "Existing consultation session id to continue."
1763
+ },
1764
+ followUp: {
1765
+ type: "string",
1766
+ description: "Follow-up question to send to the advisors in an existing session."
1767
+ },
1768
+ advisorIds: {
1769
+ type: "array",
1770
+ items: { type: "string" },
1771
+ description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
1772
+ },
1773
+ confidence: {
1774
+ type: "number",
1775
+ description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
1776
+ }
1777
+ },
1778
+ output: {
1779
+ schema: {
1780
+ type: "object",
1781
+ additionalProperties: false,
1782
+ properties: {
1783
+ sessionId: {
1784
+ type: "string",
1785
+ required: true
1786
+ },
1787
+ advice: {
1788
+ type: "string",
1789
+ required: true
1790
+ },
1791
+ skipped: { type: "boolean" },
1792
+ reason: { type: "string" }
1793
+ }
1794
+ },
1795
+ render: (_args, value) => [{
1796
+ type: "text",
1797
+ text: value.advice
1798
+ }]
1799
+ },
1800
+ async execute(args, exec) {
1801
+ const cfg = service.getConfig();
1802
+ if (args.confidence !== void 0 && (args.confidence < 0 || args.confidence > 1)) return {
1803
+ sessionId: "",
1804
+ advice: "confidence 参数必须在 0-1 之间。",
1805
+ skipped: true,
1806
+ reason: "invalid-confidence"
1807
+ };
1808
+ if (!service.isEnabled()) return {
1809
+ sessionId: "",
1810
+ advice: "顾问群插件已禁用。请先调用 toggle_advisor_group 启用。",
1811
+ skipped: true,
1812
+ reason: "disabled"
1813
+ };
1814
+ if (cfg.advisors.length === 0) return {
1815
+ sessionId: "",
1816
+ advice: "顾问群尚未配置任何顾问。请先到 设置 → 插件 → 顾问群 添加顾问。",
1817
+ skipped: true,
1818
+ reason: "no-advisors"
1819
+ };
1820
+ const withoutModel = advisorsMissingModel(cfg);
1821
+ if (withoutModel.length > 0) return {
1822
+ sessionId: "",
1823
+ advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
1824
+ skipped: true,
1825
+ reason: "advisor-model-missing"
1826
+ };
1827
+ const sessionLog = exec.agent?.session;
1828
+ const questionText = typeof args.question === "string" ? args.question : "";
1829
+ const contextText = typeof args.context === "string" ? args.context : "";
1830
+ const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
1831
+ const forcedByRepeat = (service.getRepeatPressure(sessionLog?.id)?.count ?? 0) >= 3;
1832
+ if (args.sessionId) {
1833
+ if (!args.followUp) return {
1834
+ sessionId: args.sessionId,
1835
+ advice: "继续追问需要提供 followUp 参数。",
1836
+ skipped: true,
1837
+ reason: "missing-follow-up"
1838
+ };
1839
+ const session = service.getSession(args.sessionId);
1840
+ if (!session) return {
1841
+ sessionId: args.sessionId,
1842
+ advice: "顾问群会话不存在或已过期,请重新发起 ask_advisors。",
1843
+ skipped: true,
1844
+ reason: "session-not-found"
1845
+ };
1846
+ if (service.hasReachedMaxRounds(session)) return {
1847
+ sessionId: session.id,
1848
+ advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
1849
+ skipped: true,
1850
+ reason: "max-rounds-reached"
1851
+ };
1852
+ service.appendFollowUp(session.id, args.followUp, sessionLog);
1853
+ await service.runOneRoundAndSummarize(session, exec.signal, sessionLog);
1854
+ const canContinue = !service.hasReachedMaxRounds(session);
1855
+ return {
1856
+ sessionId: session.id,
1857
+ advice: formatConversation(session) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
1858
+ skipped: false
1859
+ };
1860
+ }
1861
+ if (!args.question) return {
1862
+ sessionId: "",
1863
+ advice: "发起新讨论需要提供 question 参数。",
1864
+ skipped: true,
1865
+ reason: "missing-question"
1866
+ };
1867
+ if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
1868
+ const classification = classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
1869
+ appendShadowSample({
1870
+ ts: Date.now(),
1871
+ question: args.question,
1872
+ ...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
1873
+ shouldEscalate: classification.shouldEscalate,
1874
+ reason: classification.reason,
1875
+ suggestWebSearch: classification.suggestWebSearch,
1876
+ launched: classification.shouldEscalate
1877
+ });
1878
+ if (!classification.shouldEscalate) {
1879
+ if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
1880
+ sessionId: "",
1881
+ advice: `前置分类器建议不启动顾问群:${classification.reason}`,
1882
+ skipped: true,
1883
+ reason: "web-search-recommended"
1884
+ };
1885
+ return {
1886
+ sessionId: "",
1887
+ advice: `前置分类器建议不启动顾问群:${classification.reason}`,
1888
+ skipped: true,
1889
+ reason: "classifier-rejected"
1890
+ };
1891
+ }
1892
+ }
1893
+ if (args.advisorIds && args.advisorIds.length > 0) {
1894
+ const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
1895
+ if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) return {
1896
+ sessionId: "",
1897
+ advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
1898
+ skipped: true,
1899
+ reason: "no-matching-advisors"
1900
+ };
1901
+ }
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);
1910
+ if (sessionLog) appendAdvisorStart(sessionLog, session);
1911
+ const summary = await service.runAutoPipeline(session, exec.signal, sessionLog);
1912
+ return {
1913
+ sessionId: session.id,
1914
+ advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
1915
+ skipped: false
1916
+ };
1917
+ }
1918
+ }), defineTool({
1919
+ name: "toggle_advisor_group",
1920
+ description: "Enable or disable the advisor group plugin at runtime. Omit enabled to toggle the current state.",
1921
+ parameters: { enabled: {
1922
+ type: "boolean",
1923
+ description: "Optional desired state."
1924
+ } },
1925
+ output: {
1926
+ schema: {
1927
+ type: "object",
1928
+ additionalProperties: false,
1929
+ properties: { enabled: {
1930
+ type: "boolean",
1931
+ required: true
1932
+ } }
1933
+ },
1934
+ render: (_args, value) => [{
1935
+ type: "text",
1936
+ text: `顾问群插件已${value.enabled ? "启用" : "禁用"}。`
1937
+ }]
1938
+ },
1939
+ async execute(args) {
1940
+ const next = typeof args.enabled === "boolean" ? args.enabled : !service.isEnabled();
1941
+ return { enabled: await service.toggleEnabled(next) };
1942
+ }
1943
+ })];
1944
+ }
1945
+ function formatConversation(session) {
1946
+ const lines = [`顾问群对话(session: ${session.id})`, ""];
1947
+ for (const message of session.messages) {
1948
+ if (message.role === "system") continue;
1949
+ if (message.role === "main") lines.push(`主模型:${message.content}`);
1950
+ else if (message.role === "advisor") {
1951
+ const body = message.content && message.content.trim() ? message.content : message.thinking && message.thinking.trim() ? `(思维链)\n${message.thinking}` : "(顾问未返回正文)";
1952
+ lines.push(`【${message.advisorName ?? message.advisorId ?? "顾问"}】:${body}`);
1953
+ }
1954
+ lines.push("");
1955
+ }
1956
+ lines.push("(以上是顾问模型与主模型的对话记录,主模型可直接基于其中内容继续思考或追问。)");
1957
+ return lines.join("\n");
1958
+ }
1959
+ //#endregion
1960
+ //#region src/settings-api.ts
1961
+ function sendJson(res, status, body) {
1962
+ res.writeHead(status, { "content-type": "application/json" });
1963
+ res.end(JSON.stringify(body));
1964
+ }
1965
+ /** Mask a stored API key so it never leaves the host in plain text. */
1966
+ function maskApiKey(apiKey) {
1967
+ if (!apiKey) return apiKey;
1968
+ if (apiKey.length <= 4) return "*".repeat(apiKey.length);
1969
+ return `${"*".repeat(apiKey.length - 4)}${apiKey.slice(-4)}`;
1970
+ }
1971
+ /**
1972
+ * Whether an inbound apiKey is a mask of the previously stored key.
1973
+ *
1974
+ * Accepts both the current length-preserving mask and the legacy 8-character
1975
+ * `****<last4>` mask so an unrefreshed settings page does not overwrite the
1976
+ * real key with a mask string after upgrading.
1977
+ */
1978
+ function isMaskedApiKey(incoming, previous) {
1979
+ if (!incoming || !previous) return false;
1980
+ if (incoming === maskApiKey(previous)) return true;
1981
+ if (previous.length <= 4) return incoming === "*".repeat(previous.length);
1982
+ return incoming === `****${previous.slice(-4)}`;
1983
+ }
1984
+ /**
1985
+ * Return a copy of the config safe to send to the browser.
1986
+ *
1987
+ * 0.1.2-rc.1 SecretField convergence: no key material (not even a mask) leaves
1988
+ * the host. `apiKey` is returned empty and per-provider presence facts
1989
+ * (`configured` + `last4`) ride in `apiKeyMetaByProvider`, so the card can
1990
+ * render the official "已配置/未配置" badge and publish new/cleared keys
1991
+ * without ever round-tripping a mask.
1992
+ */
1993
+ function sanitizeConfig(config) {
1994
+ return {
1995
+ ...config,
1996
+ advisors: config.advisors.map((advisor) => {
1997
+ const { apiKeysByProvider: _apiKeysByProvider, clearApiKey: _clearApiKey, apiKeyMetaByProvider: _meta, ...rest } = advisor;
1998
+ const meta = {};
1999
+ for (const [provider, key] of Object.entries(advisor.apiKeysByProvider ?? {})) if (key) meta[provider] = {
2000
+ configured: true,
2001
+ last4: key.slice(-4)
2002
+ };
2003
+ if (advisor.apiKey && advisor.provider) meta[advisor.provider] = {
2004
+ configured: true,
2005
+ last4: advisor.apiKey.slice(-4)
2006
+ };
2007
+ return {
2008
+ ...rest,
2009
+ apiKey: "",
2010
+ ...Object.keys(meta).length > 0 ? { apiKeyMetaByProvider: meta } : {}
2011
+ };
2012
+ })
2013
+ };
2014
+ }
2015
+ /** Credential-affecting fields: changing any of these invalidates a stored key. */
2016
+ function hasSameCredentialScope(a, b) {
2017
+ return a.provider === b.provider && normalizeBase(a.baseURL ?? "") === normalizeBase(b.baseURL ?? "") && (a.apiKeyEnv ?? "") === (b.apiKeyEnv ?? "") && (a.protocol ?? "openai") === (b.protocol ?? "openai") && (a.authMode ?? "x-api-key") === (b.authMode ?? "x-api-key");
2018
+ }
2019
+ /**
2020
+ * Reconcile inbound advisor config with the currently stored config.
2021
+ *
2022
+ * SecretField semantics (adopted from the official settings-card model):
2023
+ * - `clearApiKey: true` → drop the active key AND this provider's archived
2024
+ * copy (the explicit "清除" action).
2025
+ * - An empty/undefined apiKey on the same credential scope → KEEP the stored
2026
+ * key (an empty field never clears; the old mask-echo path is retained as a
2027
+ * defensive fallback only).
2028
+ * - A new plain-text key wins and is remembered for the current provider.
2029
+ * - Credential scope changed with no new key → restore from per-provider
2030
+ * history when one exists, otherwise the active key is undefined.
2031
+ * The transient `clearApiKey` flag is stripped before persistence.
2032
+ */
2033
+ function reconcileApiKeys(incoming, previous) {
2034
+ const previousById = new Map(previous.advisors.map((advisor) => [advisor.id, advisor]));
2035
+ return {
2036
+ ...incoming,
2037
+ advisors: incoming.advisors.map((advisor) => {
2038
+ const { clearApiKey, ...advisorRest } = advisor;
2039
+ const prev = previousById.get(advisor.id);
2040
+ if (!prev) return {
2041
+ ...advisorRest,
2042
+ apiKeysByProvider: advisorRest.apiKeysByProvider ?? {}
2043
+ };
2044
+ const history = { ...prev.apiKeysByProvider ?? {} };
2045
+ if (prev.apiKey && prev.provider) history[prev.provider] = prev.apiKey;
2046
+ const maskedSame = isMaskedApiKey(advisorRest.apiKey ?? void 0, prev.apiKey ?? void 0);
2047
+ const sameScope = hasSameCredentialScope(advisorRest, prev);
2048
+ let nextApiKey;
2049
+ if (clearApiKey === true) {
2050
+ nextApiKey = void 0;
2051
+ if (advisorRest.provider) delete history[advisorRest.provider];
2052
+ } else if (sameScope && maskedSame) nextApiKey = prev.apiKey;
2053
+ else if (advisorRest.apiKey && !isMaskedApiKey(advisorRest.apiKey ?? void 0, prev.apiKey ?? void 0)) {
2054
+ nextApiKey = advisorRest.apiKey;
2055
+ if (advisorRest.provider) history[advisorRest.provider] = advisorRest.apiKey;
2056
+ } else if (!sameScope) nextApiKey = advisorRest.provider ? history[advisorRest.provider] : void 0;
2057
+ else nextApiKey = prev.apiKey;
2058
+ const { apiKeyMetaByProvider: _inputMeta, ...clean } = advisorRest;
2059
+ return {
2060
+ ...clean,
2061
+ apiKey: nextApiKey,
2062
+ apiKeysByProvider: history
2063
+ };
2064
+ })
2065
+ };
2066
+ }
2067
+ function readBody(req, limit = 1048576) {
2068
+ return new Promise((resolve, reject) => {
2069
+ let data = "";
2070
+ req.on("data", (chunk) => {
2071
+ data += String(chunk);
2072
+ if (data.length > limit) {
2073
+ req.destroy();
2074
+ reject(/* @__PURE__ */ new Error("body too large"));
2075
+ }
2076
+ });
2077
+ req.on("end", () => resolve(data));
2078
+ req.on("error", reject);
2079
+ });
2080
+ }
2081
+ const LOOKUP_KEYS = [
2082
+ "provider",
2083
+ "baseURL",
2084
+ "apiKey",
2085
+ "apiKeyEnv",
2086
+ "protocol",
2087
+ "authMode",
2088
+ "advisorId"
2089
+ ];
2090
+ async function parseLookupParams(req) {
2091
+ const url = new URL(req.url ?? "", "http://localhost");
2092
+ const params = {};
2093
+ for (const key of LOOKUP_KEYS) params[key] = url.searchParams.get(key) ?? "";
2094
+ if (req.method === "POST") {
2095
+ const raw = await readBody(req, 1048576);
2096
+ if (raw) try {
2097
+ const body = JSON.parse(raw);
2098
+ for (const key of LOOKUP_KEYS) if (typeof body[key] === "string") params[key] = body[key];
2099
+ } catch {}
2100
+ }
2101
+ return params;
2102
+ }
2103
+ const ALLOWED_API_KEY_ENVS = new Set(Object.values(PROVIDER_PRESETS).map((preset) => preset.apiKeyEnv));
2104
+ function validateSecurity(input) {
2105
+ const ids = /* @__PURE__ */ new Set();
2106
+ for (const advisor of input.advisors) {
2107
+ if (ids.has(advisor.id)) return `顾问 id 重复:${advisor.id}`;
2108
+ ids.add(advisor.id);
2109
+ if (advisor.baseURL) {
2110
+ const url = advisor.baseURL.trim();
2111
+ if (!(url.startsWith("https://") || url.startsWith("http://127.0.0.1") || url.startsWith("http://localhost"))) return `顾问 ${advisor.id} 的 API 地址仅允许 https:// 或本机 http://127.0.0.1 / http://localhost`;
2112
+ }
2113
+ if (advisor.apiKeyEnv) {
2114
+ const env = advisor.apiKeyEnv.trim();
2115
+ if (!ALLOWED_API_KEY_ENVS.has(env)) return `顾问 ${advisor.id} 的 API Key 不被允许:${env}。请使用预设供应商对应的 API Key。`;
2116
+ }
2117
+ }
2118
+ return null;
2119
+ }
2120
+ function normalizeBase(baseURL) {
2121
+ return (baseURL ?? "").replace(/\/+$/, "");
2122
+ }
2123
+ /** Loopback hostnames explicitly permitted for local diagnostic HTTP. */
2124
+ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
2125
+ "127.0.0.1",
2126
+ "localhost",
2127
+ "::1"
2128
+ ]);
2129
+ /** IPv4 literals in non-public ranges (private, loopback, link-local, CGNAT meta). */
2130
+ const NON_PUBLIC_IPV4 = /^(?:10\.|127\.|169\.254\.|172\.(?:1[6-9]|2\d|3[0-1])\.|192\.168\.|0\.)/i;
2131
+ /** Well-known cloud metadata endpoints that must never be probed. */
2132
+ const METADATA_HOSTNAME = /(?:^|\.)(?:metadata\.google\.internal|metadata\.gcp\.internal|metadata\.azure\.com)$/i;
2133
+ function isIpLiteral(host) {
2134
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
2135
+ }
2136
+ /**
2137
+ * SSRF guard for the diagnostics endpoints (models list / connection test).
2138
+ *
2139
+ * Diagnostics accept a client-supplied baseURL at request time, so the same
2140
+ * https/loopback rule as `validateSecurity` is enforced HERE as well, plus
2141
+ * stricter host rules: no IP literals (private/loopback/link-local/metadata)
2142
+ * under https, and no cloud metadata hostnames. The fetch layer additionally
2143
+ * refuses redirects (`redirect: 'error'`). DNS-rebinding protection (a domain
2144
+ * resolving to an internal address) is out of scope for a local single-user
2145
+ * host and is documented as a known limit.
2146
+ *
2147
+ * @returns the rejection reason, or null when the base URL is acceptable.
2148
+ */
2149
+ function assertSafeDiagnosticBase(raw) {
2150
+ const trimmed = (raw ?? "").trim();
2151
+ if (!trimmed) return "缺少 baseURL";
2152
+ let url;
2153
+ try {
2154
+ url = new URL(trimmed);
2155
+ } catch {
2156
+ return "baseURL 不是合法 URL";
2157
+ }
2158
+ const host = url.hostname.toLowerCase();
2159
+ if (url.protocol === "https:") {
2160
+ if (isIpLiteral(host) || NON_PUBLIC_IPV4.test(host)) return "baseURL 不允许使用 IP 字面量";
2161
+ if (METADATA_HOSTNAME.test(host)) return "baseURL 不允许访问云元数据地址";
2162
+ return null;
2163
+ }
2164
+ if (url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(host)) return null;
2165
+ return "baseURL 仅允许 https:// 或本机 http://127.0.0.1 / http://localhost";
2166
+ }
2167
+ /**
2168
+ * Resolve the real API key for diagnostics (models list / connection test).
2169
+ *
2170
+ * The browser form can only ever hold a masked or empty key, so a direct
2171
+ * pass-through would send `****xxxx` (or nothing) to the provider and fail
2172
+ * with HTTP 401 — exactly what happens after switching a provider away and
2173
+ * back. The host keeps the real key (`advisor.apiKey` for the active scope +
2174
+ * `apiKeysByProvider` history), so when the inbound key is empty or a mask of
2175
+ * the stored key, substitute the stored one. A fresh plaintext key typed into
2176
+ * the form always wins.
2177
+ */
2178
+ function resolveDiagnosticApiKey(service, advisorId, provider, baseURL, apiKeyEnv, protocol, incomingApiKey) {
2179
+ if (!advisorId) return incomingApiKey;
2180
+ const stored = service.getConfig().advisors.find((advisor) => advisor.id === advisorId);
2181
+ if (!stored) return incomingApiKey;
2182
+ const storedKey = stored.provider === provider && normalizeBase(stored.baseURL ?? "") === normalizeBase(baseURL) && (stored.apiKeyEnv ?? "") === apiKeyEnv && (stored.protocol ?? "openai") === (protocol || "openai") ? stored.apiKey : stored.apiKeysByProvider?.[provider];
2183
+ if (!storedKey) return incomingApiKey;
2184
+ if (!incomingApiKey || isMaskedApiKey(incomingApiKey, storedKey)) return storedKey;
2185
+ return incomingApiKey;
2186
+ }
2187
+ async function queryOpenAiModels(baseURL, apiKey) {
2188
+ const base = normalizeBase(baseURL);
2189
+ const endpoint = base.endsWith("/models") ? base : `${base}/models`;
2190
+ const response = await fetch(endpoint, {
2191
+ headers: { authorization: `Bearer ${apiKey}` },
2192
+ signal: AbortSignal.timeout(15e3),
2193
+ redirect: "error"
2194
+ });
2195
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2196
+ return ((await response.json()).data ?? []).map((item) => item.id ?? "").filter(Boolean);
2197
+ }
2198
+ async function queryAnthropicModels(baseURL, apiKey, authMode = "x-api-key") {
2199
+ const base = normalizeBase(baseURL);
2200
+ const endpoint = base.endsWith("/v1/models") ? base : base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`;
2201
+ const headers = { "anthropic-version": "2023-06-01" };
2202
+ if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
2203
+ else headers["x-api-key"] = apiKey;
2204
+ const response = await fetch(endpoint, {
2205
+ headers,
2206
+ signal: AbortSignal.timeout(15e3),
2207
+ redirect: "error"
2208
+ });
2209
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2210
+ return ((await response.json()).data ?? []).map((item) => item.id ?? "").filter(Boolean);
2211
+ }
2212
+ async function queryGeminiModels(baseURL, apiKey) {
2213
+ const base = normalizeBase(baseURL);
2214
+ const endpoint = base.endsWith("/v1beta") ? `${base}/models?key=${apiKey}` : `${base}/v1beta/models?key=${apiKey}`;
2215
+ const response = await fetch(endpoint, {
2216
+ signal: AbortSignal.timeout(15e3),
2217
+ redirect: "error"
2218
+ });
2219
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2220
+ return ((await response.json()).models ?? []).map((item) => (item.name ?? "").replace(/^models\//, "")).filter(Boolean);
2221
+ }
2222
+ async function handleModelsRequest(ctx, service, req, res) {
2223
+ const params = await parseLookupParams(req);
2224
+ const provider = params.provider ?? "";
2225
+ const baseURL = params.baseURL ?? "";
2226
+ const apiKey = params.apiKey ?? "";
2227
+ const apiKeyEnv = params.apiKeyEnv ?? "";
2228
+ const protocol = params.protocol ?? "openai";
2229
+ const advisorId = params.advisorId ?? "";
2230
+ if (!provider) {
2231
+ sendJson(res, 400, {
2232
+ ok: false,
2233
+ error: "缺少 provider 参数"
2234
+ });
2235
+ return;
2236
+ }
2237
+ try {
2238
+ if ((await ctx.llm.listProviders()).some((item) => item.id === provider)) {
2239
+ sendJson(res, 200, {
2240
+ ok: true,
2241
+ models: (await ctx.llm.listModels(provider)).map((item) => item.id)
2242
+ });
2243
+ return;
2244
+ }
2245
+ } catch {}
2246
+ const preset = Object.values(PROVIDER_PRESETS).find((item) => item.id === provider);
2247
+ const effectiveBase = normalizeBase(baseURL || preset?.baseURL);
2248
+ const effectiveEnv = apiKeyEnv || preset?.apiKeyEnv || "";
2249
+ const effectiveProtocol = protocol || preset?.protocol || "openai";
2250
+ const effectiveAuthMode = preset?.authMode ?? "x-api-key";
2251
+ const fetchBase = normalizeBase(preset?.modelsBaseURL || effectiveBase);
2252
+ const fetchProtocol = preset?.modelsBaseURL ? "openai" : effectiveProtocol;
2253
+ const fetchAuthMode = preset?.modelsAuthMode ?? (preset?.modelsBaseURL ? "bearer" : effectiveAuthMode);
2254
+ if (!effectiveBase || !apiKey && !effectiveEnv) {
2255
+ sendJson(res, 400, {
2256
+ ok: false,
2257
+ error: "该预设缺少 baseURL 或 API Key"
2258
+ });
2259
+ return;
2260
+ }
2261
+ if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
2262
+ sendJson(res, 400, {
2263
+ ok: false,
2264
+ error: "API Key 不能使用该环境变量名"
2265
+ });
2266
+ return;
2267
+ }
2268
+ const baseError = assertSafeDiagnosticBase(effectiveBase);
2269
+ if (baseError) {
2270
+ sendJson(res, 400, {
2271
+ ok: false,
2272
+ error: baseError
2273
+ });
2274
+ return;
2275
+ }
2276
+ const resolvedApiKey = resolveDiagnosticApiKey(service, advisorId, provider, baseURL, apiKeyEnv, protocol, apiKey) || (effectiveEnv ? process.env[effectiveEnv] ?? "" : "");
2277
+ if (!resolvedApiKey) {
2278
+ sendJson(res, 400, {
2279
+ ok: false,
2280
+ error: "API Key 未设置"
2281
+ });
2282
+ return;
2283
+ }
2284
+ try {
2285
+ let models = [];
2286
+ if (fetchProtocol === "anthropic") models = await queryAnthropicModels(fetchBase, resolvedApiKey, fetchAuthMode);
2287
+ else if (fetchProtocol === "gemini") models = await queryGeminiModels(fetchBase, resolvedApiKey);
2288
+ else models = await queryOpenAiModels(fetchBase, resolvedApiKey);
2289
+ sendJson(res, 200, {
2290
+ ok: true,
2291
+ models
2292
+ });
2293
+ } catch (error) {
2294
+ sendJson(res, 400, {
2295
+ ok: false,
2296
+ error: error instanceof Error ? error.message : String(error)
2297
+ });
2298
+ }
2299
+ }
2300
+ async function handleTestConnectionRequest(ctx, service, req, res) {
2301
+ const params = await parseLookupParams(req);
2302
+ const provider = params.provider ?? "";
2303
+ const baseURL = params.baseURL ?? "";
2304
+ const apiKey = params.apiKey ?? "";
2305
+ const apiKeyEnv = params.apiKeyEnv ?? "";
2306
+ const protocol = params.protocol ?? "openai";
2307
+ const advisorId = params.advisorId ?? "";
2308
+ if (!provider) {
2309
+ sendJson(res, 400, {
2310
+ ok: false,
2311
+ error: "缺少 provider 参数"
2312
+ });
2313
+ return;
2314
+ }
2315
+ try {
2316
+ if ((await ctx.llm.listProviders()).some((item) => item.id === provider)) {
2317
+ sendJson(res, 200, {
2318
+ ok: true,
2319
+ message: "连接成功",
2320
+ modelCount: (await ctx.llm.listModels(provider)).length
2321
+ });
2322
+ return;
2323
+ }
2324
+ } catch {}
2325
+ const preset = Object.values(PROVIDER_PRESETS).find((item) => item.id === provider);
2326
+ const effectiveBase = normalizeBase(baseURL || preset?.baseURL);
2327
+ const effectiveEnv = apiKeyEnv || preset?.apiKeyEnv || "";
2328
+ const effectiveProtocol = protocol || preset?.protocol || "openai";
2329
+ const effectiveAuthMode = preset?.authMode ?? "x-api-key";
2330
+ const fetchBase = normalizeBase(preset?.modelsBaseURL || effectiveBase);
2331
+ const fetchProtocol = preset?.modelsBaseURL ? "openai" : effectiveProtocol;
2332
+ const fetchAuthMode = preset?.modelsAuthMode ?? (preset?.modelsBaseURL ? "bearer" : effectiveAuthMode);
2333
+ if (!effectiveBase || !apiKey && !effectiveEnv) {
2334
+ sendJson(res, 400, {
2335
+ ok: false,
2336
+ error: "该预设缺少 baseURL 或 API Key"
2337
+ });
2338
+ return;
2339
+ }
2340
+ if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
2341
+ sendJson(res, 400, {
2342
+ ok: false,
2343
+ error: "API Key 不能使用该环境变量名"
2344
+ });
2345
+ return;
2346
+ }
2347
+ const baseError = assertSafeDiagnosticBase(effectiveBase);
2348
+ if (baseError) {
2349
+ sendJson(res, 400, {
2350
+ ok: false,
2351
+ error: baseError
2352
+ });
2353
+ return;
2354
+ }
2355
+ const resolvedApiKey = resolveDiagnosticApiKey(service, advisorId, provider, baseURL, apiKeyEnv, protocol, apiKey) || (effectiveEnv ? process.env[effectiveEnv] ?? "" : "");
2356
+ if (!resolvedApiKey) {
2357
+ sendJson(res, 400, {
2358
+ ok: false,
2359
+ error: "API Key 未设置"
2360
+ });
2361
+ return;
2362
+ }
2363
+ try {
2364
+ let models = [];
2365
+ if (fetchProtocol === "anthropic") models = await queryAnthropicModels(fetchBase, resolvedApiKey, fetchAuthMode);
2366
+ else if (fetchProtocol === "gemini") models = await queryGeminiModels(fetchBase, resolvedApiKey);
2367
+ else models = await queryOpenAiModels(fetchBase, resolvedApiKey);
2368
+ sendJson(res, 200, {
2369
+ ok: true,
2370
+ message: "连接成功",
2371
+ modelCount: models.length
2372
+ });
2373
+ } catch (error) {
2374
+ sendJson(res, 400, {
2375
+ ok: false,
2376
+ error: error instanceof Error ? error.message : String(error)
2377
+ });
2378
+ }
2379
+ }
2380
+ function currentRevision(scopedCtx, namespace) {
2381
+ return scopedCtx.settings.describe().find((descriptor) => descriptor.ns === namespace)?.revision ?? 0;
2382
+ }
2383
+ function handleStreamRequest(req, res) {
2384
+ const url = new URL(req.url ?? "", "http://localhost");
2385
+ const sessionId = url.searchParams.get("sessionId") ?? "";
2386
+ if (!sessionId) {
2387
+ res.writeHead(400, { "content-type": "application/json" });
2388
+ res.end(JSON.stringify({
2389
+ ok: false,
2390
+ error: "缺少 sessionId"
2391
+ }));
2392
+ return Promise.resolve();
2393
+ }
2394
+ const lastEventIdRaw = url.searchParams.get("lastEventId") ?? req.headers["last-event-id"] ?? "0";
2395
+ const lastEventId = Number.parseInt(String(lastEventIdRaw), 10) || 0;
2396
+ const bootId = url.searchParams.get("bootId") ?? void 0;
2397
+ res.writeHead(200, {
2398
+ "content-type": "text/event-stream",
2399
+ "cache-control": "no-cache",
2400
+ connection: "keep-alive",
2401
+ "access-control-allow-origin": "*",
2402
+ "x-accel-buffering": "no"
2403
+ });
2404
+ res.write(": connected\n\n");
2405
+ return new Promise((resolve) => {
2406
+ res.on("close", resolve);
2407
+ subscribe(sessionId, res, lastEventId, bootId);
2408
+ });
2409
+ }
2410
+ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
2411
+ const namespace = "advisor-group";
2412
+ return async (req, res) => {
2413
+ const rawUrl = req.url ?? "";
2414
+ const url = new URL(rawUrl, "http://localhost");
2415
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
2416
+ const token = String(req.headers["x-advisor-group-token"] ?? url.searchParams.get("token") ?? "");
2417
+ if (authToken && token !== authToken) {
2418
+ sendJson(res, 401, {
2419
+ ok: false,
2420
+ error: "unauthorized"
2421
+ });
2422
+ return;
2423
+ }
2424
+ if (req.method === "GET" && (pathname === "/advisor-group/stream" || pathname === "/advisor-group/stream/")) {
2425
+ await handleStreamRequest(req, res);
2426
+ return;
2427
+ }
2428
+ if ((req.method === "GET" || req.method === "POST") && (pathname === "/advisor-group/test-connection" || pathname === "/advisor-group/test-connection/")) {
2429
+ await handleTestConnectionRequest(ctx, service, req, res);
2430
+ return;
2431
+ }
2432
+ if ((req.method === "GET" || req.method === "POST") && (pathname === "/advisor-group/models" || pathname === "/advisor-group/models/")) {
2433
+ await handleModelsRequest(ctx, service, req, res);
2434
+ return;
2435
+ }
2436
+ if (req.method === "GET" && (pathname === "/advisor-group/providers" || pathname === "/advisor-group/providers/")) {
2437
+ await sendProviders(ctx, res);
2438
+ return;
2439
+ }
2440
+ if (req.method === "GET" && (pathname === "/advisor-group/shadow" || pathname === "/advisor-group/shadow/")) {
2441
+ sendJson(res, 200, {
2442
+ ok: true,
2443
+ ...await readShadowSamples(200)
2444
+ });
2445
+ return;
2446
+ }
2447
+ 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
+ });
2454
+ return;
2455
+ }
2456
+ if (req.method === "POST" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
2457
+ try {
2458
+ const raw = await readBody(req);
2459
+ const parsed = JSON.parse(raw);
2460
+ const configInput = parsed.config ?? parsed;
2461
+ const expectedRevision = typeof parsed.expectedRevision === "number" ? parsed.expectedRevision : currentRevision(scopedCtx, namespace);
2462
+ const reconciled = reconcileApiKeys(Config(configInput), service.getConfig());
2463
+ const securityError = validateSecurity(reconciled);
2464
+ if (securityError) {
2465
+ sendJson(res, 400, {
2466
+ ok: false,
2467
+ error: securityError
2468
+ });
2469
+ return;
2470
+ }
2471
+ await scopedCtx.settings.replace(namespace, reconciled, expectedRevision);
2472
+ service.setConfig(reconciled);
2473
+ sendJson(res, 200, {
2474
+ ok: true,
2475
+ config: sanitizeConfig(service.getConfig()),
2476
+ revision: currentRevision(scopedCtx, namespace)
2477
+ });
2478
+ } catch (error) {
2479
+ if (error instanceof SettingsConflictError) {
2480
+ sendJson(res, 409, {
2481
+ ok: false,
2482
+ error: "配置已被其他窗口修改,请刷新后重试。"
2483
+ });
2484
+ return;
2485
+ }
2486
+ sendJson(res, 400, {
2487
+ ok: false,
2488
+ error: error instanceof Error ? error.message : String(error)
2489
+ });
2490
+ }
2491
+ return;
2492
+ }
2493
+ sendJson(res, 404, {
2494
+ ok: false,
2495
+ error: "not found"
2496
+ });
2497
+ };
2498
+ }
2499
+ async function sendProviders(ctx, res) {
2500
+ const providers = [];
2501
+ try {
2502
+ const llmProviders = await ctx.llm.listProviders();
2503
+ const llmResults = await Promise.all(llmProviders.map(async (provider) => {
2504
+ let models = [];
2505
+ try {
2506
+ models = (await ctx.llm.listModels(provider.id)).map((model) => model.id);
2507
+ } catch {}
2508
+ return {
2509
+ id: provider.id,
2510
+ name: provider.name,
2511
+ kind: "llm",
2512
+ models
2513
+ };
2514
+ }));
2515
+ for (const item of llmResults) if (!providers.some((entry) => entry.id === item.id)) providers.push(item);
2516
+ } catch {}
2517
+ for (const preset of Object.values(PROVIDER_PRESETS)) if (!providers.some((item) => item.id === preset.id)) providers.push({
2518
+ id: preset.id,
2519
+ name: preset.label,
2520
+ kind: "preset",
2521
+ models: preset.defaultModels,
2522
+ baseURL: preset.baseURL,
2523
+ apiKeyEnv: preset.apiKeyEnv,
2524
+ protocol: preset.protocol
2525
+ });
2526
+ sendJson(res, 200, {
2527
+ ok: true,
2528
+ providers
2529
+ });
2530
+ }
2531
+ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
2532
+ const inject = ctx.inject;
2533
+ if (!inject) return;
2534
+ inject(["settings", "webServer"], (scoped) => {
2535
+ const scopedCtx = scoped;
2536
+ const namespace = "advisor-group";
2537
+ const scope = scopedCtx.settings.register(namespace, Config, { base: config });
2538
+ service.setConfig(scope.get());
2539
+ service.setPersistEnabled(async (enabled) => {
2540
+ await scopedCtx.settings.update(namespace, { enabled });
2541
+ });
2542
+ const watchDisposer = scope.watch((next) => {
2543
+ service.setConfig(next);
2544
+ });
2545
+ const authToken = randomUUID();
2546
+ const indexTapDisposer = scopedCtx.webServer.tapIndex ? scopedCtx.webServer.tapIndex((html) => html.replace("<head>", `<head><script>globalThis.__ADVISOR_GROUP_TOKEN__ = ${JSON.stringify(authToken)}<\/script>`)) : null;
2547
+ const routeDisposer = scopedCtx.webServer.register({
2548
+ kind: "prefix",
2549
+ path: "/advisor-group",
2550
+ handler: handleConfigRequest(ctx, scopedCtx, service, authToken)
2551
+ });
2552
+ ctx.effect(() => () => {
2553
+ watchDisposer();
2554
+ routeDisposer();
2555
+ indexTapDisposer?.();
2556
+ }, "dsh-advisor-group: settings watcher + config routes");
2557
+ });
2558
+ }
2559
+ //#endregion
2560
+ //#region src/prompt.ts
2561
+ /**
2562
+ * Build the system-prompt section that teaches the main model when to call
2563
+ * ask_advisors. Keep it concise: it is injected into every model step.
2564
+ */
2565
+ function buildAdvisorPrompt(config, repeatPressure) {
2566
+ if (!config.enabled || config.advisors.length === 0) return "";
2567
+ const advisorNames = config.advisors.slice(0, 3).map((advisor) => advisor.name).join("、");
2568
+ const lines = [
2569
+ "## 顾问群使用边界",
2570
+ "你可以调用 ask_advisors 向专家顾问请教。请遵守以下边界:",
2571
+ "",
2572
+ "1. 优先调用 ask_advisors 的情况:",
2573
+ " - 专业/长尾世界知识:法律、医疗、金融、税务、行业经验、复杂理论等;",
2574
+ " - 高风险判断:医疗建议、法律承诺、投资决策、安全漏洞评估等;",
2575
+ " - 你对自己的答案没有把握(confidence 低于阈值);",
2576
+ " - 需要多个视角或资深经验才能给出可靠结论;",
2577
+ " - 用户消息中明确 @顾问群、点名“顾问群/顾问模型”,或要求专家会诊:必须立即调用,不要跳过。",
2578
+ "2. 不要调用 ask_advisors 的情况:",
2579
+ " - 可以直接联网查证的事实:新闻、价格、天气、API 文档、版本号等;",
2580
+ " - 简单常识、日常闲聊、纯创意;",
2581
+ " - 用户明确要求快速回答、不需要专家讨论。",
2582
+ "3. 如果前置分类器建议先联网搜索,请先执行 web search;只有搜索无法解决或属于专业/高风险时再调用 ask_advisors。",
2583
+ "4. 当你对自己没有把握时:先自评一个 0-1 的 confidence,并在调用 ask_advisors 时传入 confidence 字段,例如 confidence: 0.4。",
2584
+ "5. 调用 ask_advisors 时,context 参数必须包含两部分背景信息:",
2585
+ " a) 当前项目的大致情况(领域/目标/技术栈/关键约束,一到三句话概括);",
2586
+ " b) 遇到问题的具体细节(做了什么、期望什么、实际发生了什么、已尝试哪些步骤、相关报错或现象)。",
2587
+ " 如果某项信息确实未知,明确写“该项背景未知”,不要留空 context,也不要让顾问在缺少背景的情况下猜测。",
2588
+ "6. 拿到顾问结论后:自行判断、综合并执行,不要原样复制整段顾问回复。",
2589
+ "7. 发起新咨询:插件会自动进行多轮深挖(默认 autoDeepen:每轮结束由驱动模型基于全部讨论生成更深入的追问,顾问按顺序 A→B→C 接力回答,直到 maxRounds 轮),并自动产出综合结论——你无需再手动 followUp;只有想追加一轮固定主题时才传 sessionId+followUp。"
2590
+ ];
2591
+ if (repeatPressure && repeatPressure.count >= 3) lines.push("", `【强制升级】当前会话中同一问题已被用户重复 ${repeatPressure.count} 次仍未解决。你必须立即调用 ask_advisors,把用户最新一次的问题原样作为 question 传入,并在 context 中补充项目概况与已尝试步骤;不得再让用户重复或自行空转。`);
2592
+ else if (repeatPressure && repeatPressure.count > 1) lines.push("", `【提醒】当前会话中同一问题已出现 ${repeatPressure.count} 次。若本轮仍无法解决,必须调用 ask_advisors 升级到顾问群。`);
2593
+ lines.push(`当前可用的顾问:${advisorNames}(完整列表见设置页)。`);
2594
+ return lines.join("\n");
2595
+ }
2596
+ //#endregion
2597
+ //#region src/session-events-host.ts
2598
+ /**
2599
+ * Host-only registration for the advisor-group session event vocabulary.
2600
+ *
2601
+ * The official 0.1.2-rc.1 compatibility mechanism is the per-event
2602
+ * `SessionEvent.ignorable: true` envelope marker: `dsh-session-persistence`
2603
+ * refuses unknown event types unless `KNOWN_SESSION_EVENT_TYPES.has(type)`
2604
+ * or the stored event is marked ignorable. However `Session.append()` exposes
2605
+ * options only for surface events, so a plugin writer currently cannot set
2606
+ * `ignorable` on its own log-only events through the public API.
2607
+ *
2608
+ * This module mutates the runtime `KNOWN_SESSION_EVENT_TYPES` Set so
2609
+ * advisor-group events survive restart/resume. This is a pragmatic workaround
2610
+ * for the current rc API; if a future DSH version lets plugin appends carry
2611
+ * `ignorable` (or adds an official plugin-event registration surface), replace
2612
+ * this module instead of mutating shared state.
2613
+ */
2614
+ const ADVISOR_GROUP_EVENT_TYPES = [
2615
+ "advisor-group/start",
2616
+ "advisor-group/message",
2617
+ "advisor-group/delta",
2618
+ "advisor-group/end"
2619
+ ];
2620
+ function ensureAdvisorGroupSessionEventTypes() {
2621
+ const known = KNOWN_SESSION_EVENT_TYPES;
2622
+ if (!known || typeof known !== "object" || typeof known.add !== "function" || Object.isFrozen(known)) throw new Error("dsh-advisor-group: KNOWN_SESSION_EVENT_TYPES is not mutable. The plugin cannot safely register durable advisor-group events. Please update the plugin when DSH provides an official plugin-event registration API.");
2623
+ const set = known;
2624
+ for (const type of ADVISOR_GROUP_EVENT_TYPES) set.add(type);
2625
+ }
2626
+ ensureAdvisorGroupSessionEventTypes();
2627
+ //#endregion
2628
+ //#region src/index.ts
2629
+ const name = "dsh-advisor-group";
2630
+ const inject = [
2631
+ "tools",
2632
+ "llm",
2633
+ "systemPrompt"
2634
+ ];
2635
+ function apply(ctx, config) {
2636
+ const service = new AdvisorGroupService(ctx, config);
2637
+ registerAdvisorSettingsAndRoutes(ctx, config, service);
2638
+ for (const tool of registerAdvisorTools(service)) ctx.tools.register(tool);
2639
+ const systemPrompt = ctx.systemPrompt;
2640
+ if (systemPrompt) ctx.effect(() => systemPrompt.section({
2641
+ name: "advisor-group-boundary",
2642
+ order: 150,
2643
+ text: (assembly) => {
2644
+ const sessionId = assembly?.agent?.session?.id;
2645
+ return buildAdvisorPrompt(service.getConfig(), service.getRepeatPressure(sessionId));
2646
+ }
2647
+ }), "dsh-advisor-group: system prompt section");
2648
+ }
2649
+ //#endregion
2650
+ export { Config, apply, inject, name };