dsh-advisor-group 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -83,7 +83,23 @@ const DiscussionConfig = Schema.object({
83
83
  const TriggerConfig = Schema.object({
84
84
  requireClassifier: Schema.boolean().default(true).description("Run the pre-classifier before starting a consultation."),
85
85
  allowWebFallback: Schema.boolean().default(true).description("When classifier says web search is better, return that hint to the main model."),
86
- confidenceThreshold: Schema.number().min(0).max(1).default(.6).description("Below this confidence score, ask_advisors should escalate even without domain keywords.")
86
+ confidenceThreshold: Schema.number().min(0).max(1).default(.6).description("Below this confidence score, ask_advisors should escalate even without domain keywords."),
87
+ jev: Schema.object({
88
+ enabled: Schema.boolean().default(false).description("Use Jev for semantic pre-classification when configured."),
89
+ provider: Schema.union([Schema.const("typesafe"), Schema.const("openrouter")]).default("typesafe"),
90
+ model: Schema.string().default("jev-latest"),
91
+ baseURL: Schema.string().description("Optional custom Jev API endpoint."),
92
+ apiKey: Schema.string().role("secret").description("Jev API key; prefer apiKeyEnv when possible."),
93
+ apiKeyEnv: Schema.string().description("Optional environment variable containing the Jev API key."),
94
+ timeoutMs: Schema.number().min(1e3).max(6e4).default(1e4),
95
+ confidenceThreshold: Schema.number().min(0).max(1).default(.6),
96
+ useEnglishState: Schema.boolean().default(false).description("Judge with the caller-provided English gist (questionEn) as the Jev state instead of the Chinese question; display and session records stay Chinese."),
97
+ highRiskThreshold: Schema.number().min(0).max(1).description("Threshold for the high_risk question; unset means follow confidenceThreshold."),
98
+ apiKeyMeta: Schema.object({
99
+ configured: Schema.boolean(),
100
+ last4: Schema.string()
101
+ }).description("Server-only API key presence metadata.")
102
+ }).default({})
87
103
  });
88
104
  const UiConfig = Schema.object({
89
105
  theme: Schema.union([
@@ -99,13 +115,19 @@ const QuotaConfig = Schema.object({
99
115
  maxPerDay: Schema.natural().min(1).max(1e5).default(50).description("Max new consultations per UTC day (1–100000); ignored when enabled is false.")
100
116
  });
101
117
  const Config = Schema.object({
102
- enabled: Schema.boolean().default(true),
103
- discussion: DiscussionConfig,
104
- trigger: TriggerConfig,
105
- ui: UiConfig,
106
- quota: QuotaConfig,
107
- advisors: Schema.array(AdvisorConfig).default([])
118
+ enabled: Schema.boolean().default(true).volatile(),
119
+ discussion: DiscussionConfig.volatile(),
120
+ trigger: TriggerConfig.volatile(),
121
+ ui: UiConfig.volatile(),
122
+ quota: QuotaConfig.volatile(),
123
+ advisors: Schema.array(AdvisorConfig).default([]).volatile()
108
124
  });
125
+ function unwrapVolatileConfig(value) {
126
+ if (value && typeof value === "object" && "get" in value && typeof value.get === "function") return unwrapVolatileConfig(value.get());
127
+ if (Array.isArray(value)) return value.map((item) => unwrapVolatileConfig(item));
128
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrapVolatileConfig(item)]));
129
+ return value;
130
+ }
109
131
  //#endregion
110
132
  //#region src/providers/advisor-prompt.ts
111
133
  /**
@@ -206,10 +228,7 @@ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutM
206
228
  type: "text",
207
229
  text: userText
208
230
  }],
209
- source: {
210
- kind: "plugin",
211
- plugin: "dsh-advisor-group"
212
- }
231
+ source: { kind: "user" }
213
232
  })];
214
233
  const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
215
234
  const reasoningEffort = advisor.thinking === "disabled" ? "off" : advisor.reasoningEffort;
@@ -982,7 +1001,7 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
982
1001
  } catch (error) {
983
1002
  const errName = error?.name ?? "UnknownError";
984
1003
  const errMessage = error instanceof Error ? error.message : String(error);
985
- console.warn(`[dsh-advisor-group] direct-http stream read error: name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
1004
+ console.warn(`[dsh-advisor-group] direct-http stream read error: advisor=${advisor.name} base=${baseURL} name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
986
1005
  if (pair.isTimeout()) return {
987
1006
  content,
988
1007
  thinking,
@@ -1112,7 +1131,7 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
1112
1131
  } catch (error) {
1113
1132
  const errName = error?.name ?? "UnknownError";
1114
1133
  const errMessage = error instanceof Error ? error.message : String(error);
1115
- console.warn(`[dsh-advisor-group] anthropic stream read error: name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
1134
+ console.warn(`[dsh-advisor-group] anthropic stream read error: advisor=${advisor.name} base=${baseURL} name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
1116
1135
  if (pair.isTimeout()) return {
1117
1136
  content,
1118
1137
  thinking,
@@ -1249,7 +1268,7 @@ function transcriptOf(session) {
1249
1268
  return `[${message.role === "main" ? "主模型" : message.advisorName ?? message.advisorId ?? "顾问"}]\n${message.content}`;
1250
1269
  }).join("\n\n").slice(-24e3);
1251
1270
  }
1252
- async function generateWith(ctx, source, system, prompt, signal, timeoutMs = 6e5) {
1271
+ async function generateWith(ctx, source, sessionId, system, prompt, signal, timeoutMs = 6e5) {
1253
1272
  try {
1254
1273
  const options = {
1255
1274
  provider: source.provider,
@@ -1260,10 +1279,7 @@ async function generateWith(ctx, source, system, prompt, signal, timeoutMs = 6e5
1260
1279
  type: "text",
1261
1280
  text: prompt
1262
1281
  }],
1263
- source: {
1264
- kind: "plugin",
1265
- plugin: "dsh-advisor-group"
1266
- }
1282
+ source: { kind: "user" }
1267
1283
  })],
1268
1284
  temperature: .7,
1269
1285
  maxTokens: 2048,
@@ -1281,23 +1297,38 @@ async function generateWith(ctx, source, system, prompt, signal, timeoutMs = 6e5
1281
1297
  } else reasoningDeltas += 1;
1282
1298
  }
1283
1299
  const trimmed = text.trim();
1284
- if (!trimmed) console.warn(`[dsh-advisor-group] 驱动模型空响应(${source.provider}/${source.model}):chunks=${chunks} textDeltas=${textDeltas} reasoningDeltas=${reasoningDeltas};建议配置 discussion.driverModel 或提高 maxTokens`);
1300
+ if (!trimmed) console.warn(`[dsh-advisor-group] 驱动模型空响应(${source.provider}/${source.model}):`, sessionId, `chunks=${chunks} textDeltas=${textDeltas} reasoningDeltas=${reasoningDeltas};建议配置 discussion.driverModel 或提高 maxTokens`);
1285
1301
  return trimmed || void 0;
1286
1302
  } catch (error) {
1287
1303
  if (signal?.aborted) throw error;
1288
- console.warn(`[dsh-advisor-group] 驱动模型生成失败(${source.provider}/${source.model}):`, error instanceof Error ? error.message : String(error));
1304
+ console.warn(`[dsh-advisor-group] 驱动模型生成失败(${source.provider}/${source.model}):`, sessionId, error instanceof Error ? error.message : String(error));
1289
1305
  return;
1290
1306
  }
1291
1307
  }
1292
1308
  /** One deepening question for the next round (never throws; returns fallback text). */
1293
1309
  async function generateDeepenQuestion(ctx, session, source, signal, timeoutMs) {
1294
1310
  if (!source) return FALLBACK_DEEPEN_QUESTION;
1295
- return await generateWith(ctx, source, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1311
+ return await generateWith(ctx, source, session.id, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1296
1312
  }
1297
1313
  /** Final synthesis after all rounds (never throws; returns fallback text). */
1298
1314
  async function generateConclusion(ctx, session, source, signal, timeoutMs) {
1299
1315
  if (!source) return FALLBACK_CONCLUSION;
1300
- return await generateWith(ctx, source, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1316
+ return await generateWith(ctx, source, session.id, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1317
+ }
1318
+ //#endregion
1319
+ //#region src/risk-notes.ts
1320
+ const TRUNCATION_NOTE = "有顾问输出在流式过程中被截断(超时或网络中断),其正文可能不完整,且该顾问本轮可能只提供了部分意见。";
1321
+ const CANCEL_NOTE = "本次顾问群讨论已被用户或主模型取消,结论可能不完整。";
1322
+ /**
1323
+ * 汇总风险提示(两条规则:截断 / 取消)。
1324
+ * 判定只看消息上的截断标志与 cancelled 标志,**不读正文**——没有基于文本的猜测,
1325
+ * 因此不会误报。content 字段保留在入参类型里只为兼容既有调用方(本函数不再读它)。
1326
+ */
1327
+ function buildRiskNotes(messages, cancelled) {
1328
+ const notes = [];
1329
+ if (messages.some((message) => message.truncated !== void 0)) notes.push(TRUNCATION_NOTE);
1330
+ if (cancelled) notes.push(CANCEL_NOTE);
1331
+ return notes;
1301
1332
  }
1302
1333
  //#endregion
1303
1334
  //#region src/session-log.ts
@@ -1625,7 +1656,8 @@ async function runAdvisorToolLoop(tools, streamOnce, executeTool, onToolStep) {
1625
1656
  if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
1626
1657
  const { outcome, reused } = await executeDeduped(call);
1627
1658
  if (outcome.trim()) {
1628
- const preview = outcome.replace(/\n+/g, " ").slice(0, 240).trim();
1659
+ const flat = outcome.replace(/\n+/g, " ").trim();
1660
+ const preview = flat.length > 240 ? `${flat.slice(0, 240)}…` : flat;
1629
1661
  onToolStep({
1630
1662
  kind: "result",
1631
1663
  name: call.name,
@@ -1649,7 +1681,8 @@ async function runAdvisorToolLoop(tools, streamOnce, executeTool, onToolStep) {
1649
1681
  if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
1650
1682
  const { outcome, reused } = await executeDeduped(call);
1651
1683
  if (outcome.trim()) {
1652
- const preview = outcome.replace(/\n+/g, " ").slice(0, 240).trim();
1684
+ const flat = outcome.replace(/\n+/g, " ").trim();
1685
+ const preview = flat.length > 240 ? `${flat.slice(0, 240)}…` : flat;
1653
1686
  onToolStep({
1654
1687
  kind: "result",
1655
1688
  name: call.name,
@@ -1798,12 +1831,14 @@ var AdvisorGroupService = class {
1798
1831
  cwd: raw.cwd,
1799
1832
  dshSessionId: raw.dshSessionId,
1800
1833
  driverSource: raw.driverSource,
1801
- stopReason: raw.stopReason,
1834
+ stopReason: raw.stopReason ?? "abort-error",
1802
1835
  createdAt: raw.createdAt,
1803
1836
  updatedAt: raw.updatedAt
1804
1837
  };
1805
1838
  this.sessions.set(session.id, session);
1806
- } catch {}
1839
+ } catch (error) {
1840
+ console.warn("[dsh-advisor-group] 会话快照读取失败,已跳过:", file, error instanceof Error ? error.message : String(error));
1841
+ }
1807
1842
  }
1808
1843
  /** Serialization-safe snapshot: advisor identity only, NEVER credentials. */
1809
1844
  snapshotPayload(session) {
@@ -1850,9 +1885,18 @@ var AdvisorGroupService = class {
1850
1885
  await promises.writeFile(tmp, payload, "utf8");
1851
1886
  await promises.rename(tmp, target);
1852
1887
  }).catch((error) => {
1853
- console.warn("[dsh-advisor-group] 咨询快照持久化失败:", error instanceof Error ? error.message : String(error));
1888
+ console.warn("[dsh-advisor-group] 咨询快照持久化失败:", session.id, error instanceof Error ? error.message : String(error));
1854
1889
  });
1855
1890
  }
1891
+ /**
1892
+ * Card-facing advisor failure text: credentials redacted, collapsed to one
1893
+ * line, length-capped. The full raw error is logged server-side instead.
1894
+ */
1895
+ summarizeAdvisorError(raw) {
1896
+ const singleLine = raw.replace(/\b(api[_-]?key["']?\s*[:=]\s*["']?)[^\s"',}&]+/gi, "$1[已隐藏]").replace(/([?&]key=)[^\s&"']+/gi, "$1[已隐藏]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [已隐藏]").replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "[已隐藏]").replace(/\s+/g, " ").trim();
1897
+ if (singleLine.length <= 300) return singleLine;
1898
+ return `${singleLine.slice(0, 300)}…(完整错误已记录到服务端日志)`;
1899
+ }
1856
1900
  /** Wait for queued durable writes before a host or test tears down storage. */
1857
1901
  async flushPersistence() {
1858
1902
  await Promise.all([this.dailyWriteChain, this.sessionWriteChain]);
@@ -1950,7 +1994,7 @@ var AdvisorGroupService = class {
1950
1994
  store.enter(prepared);
1951
1995
  return prepared;
1952
1996
  } catch (error) {
1953
- console.warn("[dsh-advisor-group] 重建会话句柄失败(降级为 detached):", error instanceof Error ? error.message : String(error));
1997
+ console.warn("[dsh-advisor-group] 重建会话句柄失败(降级为 detached):", session.id, error instanceof Error ? error.message : String(error));
1954
1998
  const again = store.get(resumeId);
1955
1999
  if (again) return again;
1956
2000
  return this.buildDetachedSession(session, resumeId);
@@ -1996,7 +2040,9 @@ var AdvisorGroupService = class {
1996
2040
  this.setEnabled(enabled);
1997
2041
  if (this.persistEnabledCallback) try {
1998
2042
  await this.persistEnabledCallback(enabled);
1999
- } catch {}
2043
+ } catch (error) {
2044
+ console.warn("[dsh-advisor-group] 启用开关保存失败(内存态未回滚):", error instanceof Error ? error.message : String(error));
2045
+ }
2000
2046
  return this.enabled;
2001
2047
  }
2002
2048
  /**
@@ -2214,6 +2260,10 @@ var AdvisorGroupService = class {
2214
2260
  stop.abort();
2215
2261
  return true;
2216
2262
  }
2263
+ /** True while a pipeline run (auto-deepen or a follow-up round) is in flight. */
2264
+ isSessionRunning(sessionId) {
2265
+ return this.activeAborts.has(sessionId);
2266
+ }
2217
2267
  /**
2218
2268
  * Resume a stopped/interrupted consultation from its interruption point.
2219
2269
  * Works for a user stop (live session) and after a dsh restart (snapshot
@@ -2252,7 +2302,9 @@ var AdvisorGroupService = class {
2252
2302
  this.persistSession(session);
2253
2303
  if (dshSession) appendAdvisorResume(dshSession, session.id);
2254
2304
  this.ctx.emit("advisor-group/resume", { sessionId: session.id });
2255
- this.runAutoPipeline(session, void 0, dshSession, resumeAgent).catch(() => {}).finally(() => this.releaseResumeLock(session.id));
2305
+ this.runAutoPipeline(session, void 0, dshSession, resumeAgent).catch((error) => {
2306
+ console.error("[dsh-advisor-group] 续跑流水线失败:", session.id, error instanceof Error ? error.message : String(error));
2307
+ }).finally(() => this.releaseResumeLock(session.id));
2256
2308
  return { ok: true };
2257
2309
  }
2258
2310
  /** Atomic cross-instance lock (mkdir-based, with stale takeover). */
@@ -2327,18 +2379,37 @@ var AdvisorGroupService = class {
2327
2379
  * advisor models talking to themselves back-to-back.
2328
2380
  */
2329
2381
  async runOneRoundAndSummarize(session, signal, sessionLog, agent) {
2330
- await this.runOneRound(session, signal, sessionLog, agent);
2331
- session.status = "completed";
2332
- session.updatedAt = Date.now();
2333
- const summary = this.buildSummary(session, false);
2334
- if (sessionLog) appendAdvisorEnd(sessionLog, session, summary);
2335
- this.persistSession(session);
2336
- await this.flushLog(sessionLog);
2337
- this.ctx.emit("advisor-group/session-end", {
2338
- sessionId: session.id,
2339
- summary
2340
- });
2341
- return summary;
2382
+ const stop = new AbortController();
2383
+ this.activeAborts.set(session.id, stop);
2384
+ const combined = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
2385
+ try {
2386
+ await this.runOneRound(session, combined, sessionLog, agent);
2387
+ session.status = "completed";
2388
+ session.updatedAt = Date.now();
2389
+ const summary = this.buildSummary(session, false);
2390
+ if (sessionLog) appendAdvisorEnd(sessionLog, session, summary);
2391
+ this.persistSession(session);
2392
+ await this.flushLog(sessionLog);
2393
+ this.ctx.emit("advisor-group/session-end", {
2394
+ sessionId: session.id,
2395
+ summary
2396
+ });
2397
+ return summary;
2398
+ } catch (error) {
2399
+ if (isAbortError(error, combined) || stop.signal.aborted) {
2400
+ if (stop.signal.aborted) session.stopReason = "user-stop";
2401
+ else if (signal?.aborted) session.stopReason = "exec-cancel";
2402
+ else session.stopReason = "abort-error";
2403
+ session.status = "cancelled";
2404
+ this.persistSession(session);
2405
+ const summary = await this.generateFinalSummary(session, void 0, sessionLog, true);
2406
+ await this.flushLog(sessionLog);
2407
+ return summary;
2408
+ }
2409
+ throw error;
2410
+ } finally {
2411
+ this.activeAborts.delete(session.id);
2412
+ }
2342
2413
  }
2343
2414
  getRoundCount(session) {
2344
2415
  const advisorCount = Math.max(1, session.advisors.length);
@@ -2512,7 +2583,9 @@ var AdvisorGroupService = class {
2512
2583
  if (actionDescriptions.length > 0) message.actionDescriptions = actionDescriptions;
2513
2584
  } catch (error) {
2514
2585
  if (isAbortError(error, signal)) throw error;
2515
- message.content = `(顾问调用失败:${error instanceof Error ? error.message : String(error)})`;
2586
+ const rawMessage = error instanceof Error ? error.message : String(error);
2587
+ console.warn("[dsh-advisor-group] 顾问调用失败:", session.id, advisor.name, rawMessage);
2588
+ message.content = `(顾问调用失败:${this.summarizeAdvisorError(rawMessage)})`;
2516
2589
  }
2517
2590
  publish(session.id, {
2518
2591
  advisorId: advisor.id,
@@ -2547,9 +2620,7 @@ var AdvisorGroupService = class {
2547
2620
  corePoints: last ? this.extractCorePoints(last.content) : []
2548
2621
  };
2549
2622
  });
2550
- const riskNotes = advisorMessages.some((message) => /风险|注意|不确定|risk|uncertain|confidence/i.test(message.content)) ? ["部分顾问提到了风险、不确定性或置信度较低,请主模型谨慎采用。"] : [];
2551
- if (advisorMessages.some((message) => message.truncated !== void 0)) riskNotes.push("有顾问输出在流式过程中被截断(超时或网络中断),其正文可能不完整,且该顾问本轮可能只提供了部分意见。");
2552
- if (cancelled) riskNotes.push("本次顾问群讨论已被用户或主模型取消,结论可能不完整。");
2623
+ const riskNotes = buildRiskNotes(advisorMessages, cancelled);
2553
2624
  return {
2554
2625
  sessionId: session.id,
2555
2626
  question: session.question,
@@ -2904,6 +2975,167 @@ function matchAdvisors(text, advisors) {
2904
2975
  return [...matches];
2905
2976
  }
2906
2977
  //#endregion
2978
+ //#region src/providers/jev.ts
2979
+ const DEFAULT_ENDPOINTS = {
2980
+ typesafe: "https://api.typesafe.ai/v1/systemone",
2981
+ openrouter: "https://openrouter.ai/api/alpha/decisions"
2982
+ };
2983
+ function numberValue(value) {
2984
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2985
+ }
2986
+ function answerIsTrue(answer, threshold) {
2987
+ const noul = numberValue(answer?.noul);
2988
+ if (noul !== void 0) return noul >= threshold;
2989
+ const choice = typeof answer?.choice === "string" ? answer.choice.toLowerCase() : "";
2990
+ if (choice === "yes" || choice === "true") return true;
2991
+ if (choice === "no" || choice === "false") return false;
2992
+ return false;
2993
+ }
2994
+ /**
2995
+ * An answer is usable only when it carries a finite noul or an explicit
2996
+ * yes/no choice. Anything else (missing key, string noul, "maybe") would be
2997
+ * read as `false` by answerIsTrue — which for needs_advisor/high_risk means a
2998
+ * silent rejection with no fallback to the local classifier.
2999
+ */
3000
+ function answerUsable(answer) {
3001
+ if (numberValue(answer?.noul) !== void 0) return true;
3002
+ const choice = typeof answer?.choice === "string" ? answer.choice.toLowerCase() : "";
3003
+ return choice === "yes" || choice === "true" || choice === "no" || choice === "false";
3004
+ }
3005
+ function parseResponse(body, config, advisors) {
3006
+ if (!body || typeof body !== "object") throw new Error("Jev 响应不是对象");
3007
+ const answers = body.answers;
3008
+ if (!answers || typeof answers !== "object") throw new Error("Jev 响应缺少 answers");
3009
+ const needsAdvisor = answers.needs_advisor;
3010
+ const webSearch = answers.web_search;
3011
+ const risk = answers.high_risk;
3012
+ const domainAnswer = answers.domain;
3013
+ if (!answerUsable(needsAdvisor)) throw new Error("Jev 响应缺少 needs_advisor 的有效判定");
3014
+ if (!answerUsable(risk)) throw new Error("Jev 响应缺少 high_risk 的有效判定");
3015
+ const domain = typeof domainAnswer?.choice === "string" ? domainAnswer.choice : "general";
3016
+ const needsAdvisorNoul = numberValue(needsAdvisor?.noul);
3017
+ const confidence = numberValue(needsAdvisor?.confidence) ?? needsAdvisorNoul;
3018
+ const shouldEscalate = answerIsTrue(needsAdvisor, config.confidenceThreshold);
3019
+ const suggestWebSearch = answerIsTrue(webSearch, config.confidenceThreshold);
3020
+ const highRisk = answerIsTrue(risk, config.highRiskThreshold ?? config.confidenceThreshold);
3021
+ const reportedModel = body.model;
3022
+ const webSearchNoul = numberValue(webSearch?.noul);
3023
+ const riskNoul = numberValue(risk?.noul);
3024
+ const rawAnswers = {
3025
+ ...needsAdvisorNoul === void 0 ? {} : { needsAdvisor: needsAdvisorNoul },
3026
+ ...webSearchNoul === void 0 ? {} : { webSearch: webSearchNoul },
3027
+ ...riskNoul === void 0 ? {} : { highRisk: riskNoul },
3028
+ domain
3029
+ };
3030
+ return {
3031
+ shouldEscalate: shouldEscalate || highRisk,
3032
+ suggestWebSearch: !shouldEscalate && !highRisk && suggestWebSearch,
3033
+ suggestedAdvisors: matchAdvisors(`${domain}\n${domainAnswer?.choice ?? ""}`, advisors),
3034
+ reason: highRisk ? "Jev 判断该问题包含高风险因素,建议启动顾问群。" : shouldEscalate ? `Jev 判断该问题需要顾问群(${confidence === void 0 ? "无置信度" : `置信度 ${confidence}`})。` : suggestWebSearch ? "Jev 判断该问题更适合联网搜索。" : "Jev 判断该问题不需要启动顾问群。",
3035
+ ...confidence === void 0 ? {} : { confidence },
3036
+ rawAnswers,
3037
+ ...typeof reportedModel === "string" ? { model: reportedModel } : {},
3038
+ provider: config.provider
3039
+ };
3040
+ }
3041
+ async function classifyWithJev(question, context, advisors, config, signal, questionEn) {
3042
+ const endpoint = config.baseURL?.trim() || DEFAULT_ENDPOINTS[config.provider];
3043
+ const storedKey = typeof config.apiKey === "string" ? config.apiKey : config.apiKey && typeof config.apiKey === "object" && "value" in config.apiKey ? String(config.apiKey.value ?? "") : void 0;
3044
+ const apiKey = (config.apiKeyEnv ? process.env[config.apiKeyEnv] : void 0) || storedKey;
3045
+ if (!apiKey) throw new Error("Jev 未配置 API key");
3046
+ const controller = new AbortController();
3047
+ const timeout = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`Jev 超时(${config.timeoutMs}ms)`)), config.timeoutMs);
3048
+ const abort = () => controller.abort();
3049
+ signal?.addEventListener("abort", abort, { once: true });
3050
+ try {
3051
+ const response = await fetch(endpoint, {
3052
+ method: "POST",
3053
+ headers: {
3054
+ authorization: `Bearer ${apiKey}`,
3055
+ "content-type": "application/json"
3056
+ },
3057
+ body: JSON.stringify({
3058
+ state: config.useEnglishState && questionEn?.trim() ? questionEn.trim() : `${question}\n\n背景:${context || "无"}`,
3059
+ model: config.model || "jev-latest",
3060
+ questions: {
3061
+ needs_advisor: {
3062
+ type: "noul",
3063
+ instructions: "Does answering this question require expert-level knowledge?",
3064
+ criteria: {
3065
+ true: {
3066
+ what: "The subject matter is professional, specialist, or long-tail knowledge a competent generalist would not reliably get right, or the field itself has no single settled answer without specialist judgment.",
3067
+ examples: [
3068
+ "professional-domain judgment such as accounting treatment, engineering trade-offs, or contract drafting",
3069
+ "rare or long-tail situations with little public consensus",
3070
+ "topics where reasonable experts disagree and picking correctly matters"
3071
+ ]
3072
+ },
3073
+ false: {
3074
+ what: "A competent generalist can answer from common knowledge, widely documented facts, or ordinary reasoning without specialist judgment.",
3075
+ examples: ["everyday facts, definitions, or casual opinions", "well-documented mainstream topics with an agreed answer"]
3076
+ }
3077
+ }
3078
+ },
3079
+ web_search: {
3080
+ type: "noul",
3081
+ instructions: "Does answering this question primarily require current, real-time, or externally verifiable information?",
3082
+ criteria: {
3083
+ true: {
3084
+ what: "The correct answer depends on facts that change over time or live outside the provided state, so it must be looked up rather than reasoned out.",
3085
+ examples: [
3086
+ "today’s weather, prices, exchange rates, or quotes",
3087
+ "recent releases, news, or version-specific changelogs",
3088
+ "local or niche facts with no authoritative source in memory"
3089
+ ]
3090
+ },
3091
+ false: {
3092
+ what: "The answer follows from stable knowledge or reasoning already available in the state.",
3093
+ examples: ["concepts, definitions, and established best practices", "questions fully answerable from the provided background"]
3094
+ }
3095
+ }
3096
+ },
3097
+ high_risk: {
3098
+ type: "noul",
3099
+ instructions: "Does this question involve medical, legal, financial, safety, compliance, or other high-risk advice?",
3100
+ criteria: {
3101
+ true: {
3102
+ what: "A wrong or careless answer could materially harm the asker or a third party in health, money, safety, or legal standing.",
3103
+ examples: [
3104
+ "medical symptoms, medication, diagnosis, or treatment decisions",
3105
+ "legal rights, contracts, litigation, or regulatory compliance",
3106
+ "investment, insurance, tax, or other money decisions with real downside",
3107
+ "physical safety, hazardous actions, or regulated activities"
3108
+ ]
3109
+ },
3110
+ false: {
3111
+ what: "Being wrong carries no material harm to health, money, safety, or legal standing.",
3112
+ examples: ["general knowledge, opinions, or entertainment questions", "technical details whose worst case is wasted time"]
3113
+ }
3114
+ }
3115
+ },
3116
+ domain: {
3117
+ type: "choice",
3118
+ instructions: "Which domain best describes this question?",
3119
+ criteria: {
3120
+ legal: "law, contracts, litigation, compliance, or taxation",
3121
+ medical: "medicine, symptoms, medication, diagnosis, or treatment",
3122
+ finance: "investment, insurance, accounting, or financial risk",
3123
+ code: "software, programming, architecture, debugging, or APIs",
3124
+ general: "none of the listed domains"
3125
+ }
3126
+ }
3127
+ }
3128
+ }),
3129
+ signal: controller.signal
3130
+ });
3131
+ if (!response.ok) throw new Error(`Jev HTTP ${response.status}`);
3132
+ return parseResponse(await response.json(), config, advisors);
3133
+ } finally {
3134
+ clearTimeout(timeout);
3135
+ signal?.removeEventListener("abort", abort);
3136
+ }
3137
+ }
3138
+ //#endregion
2907
3139
  //#region src/tools.ts
2908
3140
  function registerAdvisorTools(service) {
2909
3141
  return [defineTool({
@@ -2934,6 +3166,10 @@ function registerAdvisorTools(service) {
2934
3166
  confidence: {
2935
3167
  type: "number",
2936
3168
  description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
3169
+ },
3170
+ questionEn: {
3171
+ type: "string",
3172
+ description: "Optional self-contained English gist of the question plus key background. Used as the Jev pre-classification input only when the「Jev 英文判定」setting is enabled; the displayed question, session records, and duplicate detection always keep using question/context in Chinese. Omit it otherwise."
2937
3173
  }
2938
3174
  },
2939
3175
  output: {
@@ -2989,7 +3225,18 @@ function registerAdvisorTools(service) {
2989
3225
  const questionText = typeof args.question === "string" ? args.question : "";
2990
3226
  const contextText = typeof args.context === "string" ? args.context : "";
2991
3227
  const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
2992
- const forcedByRepeat = (service.getRepeatPressure(sessionLog?.id)?.count ?? 0) >= 3;
3228
+ const repeatCount = service.getRepeatPressure(sessionLog?.id)?.count ?? 0;
3229
+ const forcedByRepeat = repeatCount >= 3;
3230
+ let shadow;
3231
+ let shadowWritten = false;
3232
+ const recordShadow = (launched) => {
3233
+ if (!shadow || shadowWritten) return;
3234
+ shadowWritten = true;
3235
+ appendShadowSample({
3236
+ ...shadow,
3237
+ launched
3238
+ });
3239
+ };
2993
3240
  if (args.sessionId) {
2994
3241
  if (!args.followUp) return {
2995
3242
  sessionId: args.sessionId,
@@ -3004,6 +3251,12 @@ function registerAdvisorTools(service) {
3004
3251
  skipped: true,
3005
3252
  reason: "session-not-found"
3006
3253
  };
3254
+ if (service.isSessionRunning(session.id)) return {
3255
+ sessionId: session.id,
3256
+ advice: "该顾问群会话正在运行中,请等本轮结束后再追问。",
3257
+ skipped: true,
3258
+ reason: "session-running"
3259
+ };
3007
3260
  if (service.hasReachedMaxRounds(session)) return {
3008
3261
  sessionId: session.id,
3009
3262
  advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
@@ -3011,11 +3264,11 @@ function registerAdvisorTools(service) {
3011
3264
  reason: "max-rounds-reached"
3012
3265
  };
3013
3266
  service.appendFollowUp(session.id, args.followUp, sessionLog);
3014
- await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
3267
+ const summary = await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
3015
3268
  const canContinue = !service.hasReachedMaxRounds(session);
3016
3269
  return {
3017
3270
  sessionId: session.id,
3018
- advice: formatConversation(session) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
3271
+ advice: formatConversation(session) + formatRiskNotes(summary) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
3019
3272
  skipped: false
3020
3273
  };
3021
3274
  }
@@ -3026,17 +3279,41 @@ function registerAdvisorTools(service) {
3026
3279
  reason: "missing-question"
3027
3280
  };
3028
3281
  if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
3029
- const classification = classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
3030
- appendShadowSample({
3282
+ let classification;
3283
+ let jevResult;
3284
+ let jevError;
3285
+ let jevLatencyMs;
3286
+ const jev = cfg.trigger.jev;
3287
+ if (jev?.enabled) {
3288
+ const jevStartedAt = Date.now();
3289
+ try {
3290
+ jevResult = await classifyWithJev(args.question, args.context, cfg.advisors, jev, exec.signal, typeof args.questionEn === "string" ? args.questionEn : void 0);
3291
+ classification = jevResult;
3292
+ } catch (error) {
3293
+ if (exec.signal.aborted) throw error;
3294
+ jevError = error instanceof Error ? error.message : String(error);
3295
+ console.warn("[dsh-advisor-group] Jev 分类失败,回退到本地分类器:", sessionLog?.id ?? "no-session", jevError);
3296
+ } finally {
3297
+ jevLatencyMs = Date.now() - jevStartedAt;
3298
+ }
3299
+ }
3300
+ classification ??= classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
3301
+ shadow = {
3031
3302
  ts: Date.now(),
3032
3303
  question: args.question,
3033
3304
  ...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
3034
3305
  shouldEscalate: classification.shouldEscalate,
3035
3306
  reason: classification.reason,
3036
3307
  suggestWebSearch: classification.suggestWebSearch,
3037
- launched: classification.shouldEscalate
3038
- });
3308
+ provider: jevResult ? "jev" : "local",
3309
+ ...jevError === void 0 ? {} : { jevError },
3310
+ ...jevLatencyMs === void 0 ? {} : { jevLatencyMs },
3311
+ ...repeatCount > 0 ? { repeatCount } : {},
3312
+ ...jevResult?.rawAnswers ? { scores: jevResult.rawAnswers } : {},
3313
+ ...jevResult?.model ? { model: jevResult.model } : {}
3314
+ };
3039
3315
  if (!classification.shouldEscalate) {
3316
+ recordShadow(false);
3040
3317
  if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
3041
3318
  sessionId: "",
3042
3319
  advice: `前置分类器建议不启动顾问群:${classification.reason}`,
@@ -3050,29 +3327,45 @@ function registerAdvisorTools(service) {
3050
3327
  reason: "classifier-rejected"
3051
3328
  };
3052
3329
  }
3053
- }
3330
+ } else if (cfg.trigger.requireClassifier) shadow = {
3331
+ ts: Date.now(),
3332
+ question: args.question,
3333
+ shouldEscalate: true,
3334
+ reason: mentionedAdvisorGroup ? "用户点名 @顾问群,跳过前置分类器。" : `同一问题已重复 ${repeatCount} 次,强制升级跳过前置分类器。`,
3335
+ suggestWebSearch: false,
3336
+ provider: "bypass",
3337
+ bypass: mentionedAdvisorGroup ? "mention" : "repeat",
3338
+ repeatCount
3339
+ };
3054
3340
  if (args.advisorIds && args.advisorIds.length > 0) {
3055
3341
  const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
3056
- if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) return {
3342
+ if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) {
3343
+ recordShadow(false);
3344
+ return {
3345
+ sessionId: "",
3346
+ advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
3347
+ skipped: true,
3348
+ reason: "no-matching-advisors"
3349
+ };
3350
+ }
3351
+ }
3352
+ const costGuard = service.tryStartConsultation();
3353
+ if (!costGuard.ok) {
3354
+ recordShadow(false);
3355
+ return {
3057
3356
  sessionId: "",
3058
- advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
3357
+ advice: costGuard.reason,
3059
3358
  skipped: true,
3060
- reason: "no-matching-advisors"
3359
+ reason: "daily-limit-reached"
3061
3360
  };
3062
3361
  }
3063
- const costGuard = service.tryStartConsultation();
3064
- if (!costGuard.ok) return {
3065
- sessionId: "",
3066
- advice: costGuard.reason,
3067
- skipped: true,
3068
- reason: "daily-limit-reached"
3069
- };
3070
3362
  const session = service.createSession(args.question, args.context, args.advisorIds, sessionLog ? sessionLog.header.cwd : void 0, sessionLog ? sessionLog.id : void 0);
3071
3363
  if (sessionLog) appendAdvisorStart(sessionLog, session);
3364
+ recordShadow(true);
3072
3365
  const summary = await service.runAutoPipeline(session, exec.signal, sessionLog, exec.agent);
3073
3366
  return {
3074
3367
  sessionId: session.id,
3075
- advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
3368
+ advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + formatRiskNotes(summary) + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
3076
3369
  skipped: false
3077
3370
  };
3078
3371
  }
@@ -3103,6 +3396,9 @@ function registerAdvisorTools(service) {
3103
3396
  }
3104
3397
  })];
3105
3398
  }
3399
+ function formatRiskNotes(summary) {
3400
+ return summary.riskNotes.length > 0 ? `\n\n【风险提示】\n${summary.riskNotes.join("\n")}` : "";
3401
+ }
3106
3402
  function formatConversation(session) {
3107
3403
  const lines = [`顾问群对话(session: ${session.id})`, ""];
3108
3404
  for (const message of session.messages) {
@@ -3129,11 +3425,135 @@ const STOP_REASON_TEXT = {
3129
3425
  network: "网络中断"
3130
3426
  };
3131
3427
  //#endregion
3428
+ //#region src/prompt-guard.ts
3429
+ const REPLACEMENT = "�";
3430
+ const BOM = "";
3431
+ /**
3432
+ * Unambiguity check: same line count, and every character either equals the
3433
+ * template or is a replacement char (the byte-loss corruption signature).
3434
+ */
3435
+ function matchesTemplateWithReplacement(storedLines, templateLines) {
3436
+ for (let i = 0; i < storedLines.length; i++) {
3437
+ const stored = storedLines[i];
3438
+ const template = templateLines[i];
3439
+ if (stored === template) continue;
3440
+ if (stored.length !== template.length) return false;
3441
+ for (let j = 0; j < stored.length; j++) if (stored[j] !== template[j] && stored[j] !== REPLACEMENT) return false;
3442
+ }
3443
+ return true;
3444
+ }
3445
+ /**
3446
+ * Advisor system-prompt corruption guard (P1, 2026-09-27).
3447
+ * Fast path is free: prompts without replacement chars pass through
3448
+ * untouched (a customized prompt is legitimate and must never be overwritten).
3449
+ * A leading BOM alone is stripped (action 'stripped', no template involved).
3450
+ * Only on a replacement char do we compare against the built-in template:
3451
+ * unambiguous (line count + per-character match) -> heal to the template;
3452
+ * anything else keeps the stored value and reports it for manual handling.
3453
+ */
3454
+ function healAdvisorSystemPrompt(stored, template = DEFAULT_ADVISOR_PROMPT) {
3455
+ let value = stored;
3456
+ let hadBom = false;
3457
+ if (value.startsWith(BOM)) {
3458
+ value = value.slice(1);
3459
+ hadBom = true;
3460
+ }
3461
+ const replacements = (value.match(/\uFFFD/g) ?? []).length;
3462
+ if (replacements === 0) {
3463
+ if (hadBom) return {
3464
+ action: "stripped",
3465
+ value,
3466
+ reason: "开头含 BOM,已剥除"
3467
+ };
3468
+ return {
3469
+ action: "ok",
3470
+ value: stored
3471
+ };
3472
+ }
3473
+ const storedLines = value.split("\n");
3474
+ const templateLines = template.split("\n");
3475
+ if (storedLines.length !== templateLines.length) return {
3476
+ action: "needs-manual",
3477
+ value: stored,
3478
+ reason: `含 ${replacements} 个替换符,行数 ${storedLines.length} 与模板 ${templateLines.length} 不一致,无法无歧义回填`
3479
+ };
3480
+ if (!matchesTemplateWithReplacement(storedLines, templateLines)) return {
3481
+ action: "needs-manual",
3482
+ value: stored,
3483
+ reason: `含 ${replacements} 个替换符,但与模板逐位比对存在不一致(可能含自定义内容),未回填`
3484
+ };
3485
+ return {
3486
+ action: "healed",
3487
+ value: template,
3488
+ reason: `含 ${replacements} 个替换符${hadBom ? ",另开头含 BOM" : ""},行数与逐位比对均与模板一致,已回填内置模板`
3489
+ };
3490
+ }
3491
+ /**
3492
+ * Load-time guard over every advisor prompt. Returns the original config
3493
+ * object untouched when nothing was healed, so callers can rely on identity
3494
+ * checks to detect "no change".
3495
+ */
3496
+ function guardAdvisorPrompts(config) {
3497
+ const findings = [];
3498
+ let healedAny = false;
3499
+ const advisors = config.advisors.map((advisor) => {
3500
+ const result = healAdvisorSystemPrompt(advisor.systemPrompt);
3501
+ if (result.action === "ok") return advisor;
3502
+ findings.push({
3503
+ advisorId: advisor.id,
3504
+ action: result.action,
3505
+ reason: result.reason ?? ""
3506
+ });
3507
+ if (result.action === "healed" || result.action === "stripped") {
3508
+ healedAny = true;
3509
+ return {
3510
+ ...advisor,
3511
+ systemPrompt: result.value
3512
+ };
3513
+ }
3514
+ return advisor;
3515
+ });
3516
+ return {
3517
+ config: healedAny ? {
3518
+ ...config,
3519
+ advisors
3520
+ } : config,
3521
+ findings
3522
+ };
3523
+ }
3524
+ /**
3525
+ * Shared logger for both call sites: one line per finding, stating the action
3526
+ * actually taken (healed vs left untouched) plus the concrete reason.
3527
+ * Every line carries an ISO-8601 timestamp so the host console redirect
3528
+ * (which does not timestamp lines itself) stays forensically usable.
3529
+ */
3530
+ function reportPromptGuardFindings(findings) {
3531
+ const stamp = () => `[${(/* @__PURE__ */ new Date()).toISOString()}]`;
3532
+ for (const finding of findings) if (finding.action === "healed") console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词损坏,已按内置模板回填:${finding.advisorId}(${finding.reason})`);
3533
+ else if (finding.action === "stripped") console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词开头含 BOM,已剥除、正文未改动:${finding.advisorId}(${finding.reason})`);
3534
+ else console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词损坏且无法无歧义回填,未改动、需人工处理:${finding.advisorId}(${finding.reason})`);
3535
+ }
3536
+ //#endregion
3132
3537
  //#region src/settings-api.ts
3133
3538
  function sendJson(res, status, body) {
3134
3539
  res.writeHead(status, { "content-type": "application/json" });
3135
3540
  res.end(JSON.stringify(body));
3136
3541
  }
3542
+ function normalizeJevConfig(input) {
3543
+ const apiKey = typeof input?.apiKey === "string" ? input.apiKey : input?.apiKey && typeof input.apiKey === "object" && "value" in input.apiKey ? String(input.apiKey.value ?? "") : void 0;
3544
+ return {
3545
+ enabled: input?.enabled ?? false,
3546
+ provider: input?.provider ?? "typesafe",
3547
+ model: input?.model ?? "jev-latest",
3548
+ ...input?.baseURL ? { baseURL: input.baseURL } : {},
3549
+ ...apiKey ? { apiKey } : {},
3550
+ ...input?.apiKeyEnv ? { apiKeyEnv: input.apiKeyEnv } : {},
3551
+ timeoutMs: input?.timeoutMs ?? 1e4,
3552
+ confidenceThreshold: input?.confidenceThreshold ?? .6,
3553
+ useEnglishState: input?.useEnglishState ?? false,
3554
+ ...typeof input?.highRiskThreshold === "number" ? { highRiskThreshold: input.highRiskThreshold } : {}
3555
+ };
3556
+ }
3137
3557
  /** Mask a stored API key so it never leaves the host in plain text. */
3138
3558
  function maskApiKey(apiKey) {
3139
3559
  if (!apiKey) return apiKey;
@@ -3156,15 +3576,27 @@ function isMaskedApiKey(incoming, previous) {
3156
3576
  /**
3157
3577
  * Return a copy of the config safe to send to the browser.
3158
3578
  *
3159
- * 0.1.2-rc.1 SecretField convergence: no key material (not even a mask) leaves
3579
+ * SecretField convergence: no key material (not even a mask) leaves
3160
3580
  * the host. `apiKey` is returned empty and per-provider presence facts
3161
3581
  * (`configured` + `last4`) ride in `apiKeyMetaByProvider`, so the card can
3162
3582
  * render the official "已配置/未配置" badge and publish new/cleared keys
3163
3583
  * without ever round-tripping a mask.
3164
3584
  */
3165
3585
  function sanitizeConfig(config) {
3586
+ const jev = normalizeJevConfig(config.trigger.jev);
3166
3587
  return {
3167
3588
  ...config,
3589
+ trigger: {
3590
+ ...config.trigger,
3591
+ jev: {
3592
+ ...jev,
3593
+ apiKey: "",
3594
+ ...jev?.apiKey ? { apiKeyMeta: {
3595
+ configured: true,
3596
+ last4: jev.apiKey.slice(-4)
3597
+ } } : {}
3598
+ }
3599
+ },
3168
3600
  advisors: config.advisors.map((advisor) => {
3169
3601
  const { apiKeysByProvider: _apiKeysByProvider, clearApiKey: _clearApiKey, apiKeyMetaByProvider: _meta, ...rest } = advisor;
3170
3602
  const meta = {};
@@ -3204,8 +3636,19 @@ function hasSameCredentialScope(a, b) {
3204
3636
  */
3205
3637
  function reconcileApiKeys(incoming, previous) {
3206
3638
  const previousById = new Map(previous.advisors.map((advisor) => [advisor.id, advisor]));
3639
+ const incomingJev = normalizeJevConfig(incoming.trigger.jev);
3640
+ const previousJev = normalizeJevConfig(previous.trigger.jev);
3641
+ const jevApiKey = incomingJev?.apiKey || previousJev?.apiKey;
3207
3642
  return {
3208
3643
  ...incoming,
3644
+ trigger: {
3645
+ ...incoming.trigger,
3646
+ jev: {
3647
+ ...incomingJev,
3648
+ ...jevApiKey ? { apiKey: jevApiKey } : {},
3649
+ apiKeyMeta: void 0
3650
+ }
3651
+ },
3209
3652
  advisors: incoming.advisors.map((advisor) => {
3210
3653
  const { clearApiKey, ...advisorRest } = advisor;
3211
3654
  const prev = previousById.get(advisor.id);
@@ -3273,7 +3716,14 @@ async function parseLookupParams(req) {
3273
3716
  return params;
3274
3717
  }
3275
3718
  const ALLOWED_API_KEY_ENVS = new Set(Object.values(PROVIDER_PRESETS).map((preset) => preset.apiKeyEnv));
3719
+ ALLOWED_API_KEY_ENVS.add("TYPESAFE_API_KEY");
3720
+ ALLOWED_API_KEY_ENVS.add("OPENROUTER_API_KEY");
3276
3721
  function validateSecurity(input) {
3722
+ if (input.trigger.jev?.baseURL) {
3723
+ const url = input.trigger.jev.baseURL.trim();
3724
+ if (!(url.startsWith("https://") || url.startsWith("http://127.0.0.1") || url.startsWith("http://localhost"))) return "Jev API 地址仅允许 https:// 或本机 http://127.0.0.1 / http://localhost";
3725
+ }
3726
+ if (input.trigger.jev?.apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.trigger.jev.apiKeyEnv.trim())) return `Jev API Key 环境变量名无效:${input.trigger.jev.apiKeyEnv}`;
3277
3727
  const ids = /* @__PURE__ */ new Set();
3278
3728
  for (const advisor of input.advisors) {
3279
3729
  if (ids.has(advisor.id)) return `顾问 id 重复:${advisor.id}`;
@@ -3465,10 +3915,21 @@ async function handleModelsRequest(ctx, service, req, res) {
3465
3915
  } catch (error) {
3466
3916
  sendJson(res, 400, {
3467
3917
  ok: false,
3468
- error: error instanceof Error ? error.message : String(error)
3918
+ error: describeConnectionError(error)
3469
3919
  });
3470
3920
  }
3471
3921
  }
3922
+ /** 网络/HTTP 类失败的设置页可读文案;原始信息保留在括号里。 */
3923
+ function describeConnectionError(error) {
3924
+ const message = error instanceof Error ? error.message : String(error);
3925
+ if (/HTTP (401|403)\b/i.test(message)) return `密钥无效或没有访问权限(${message})`;
3926
+ if (/HTTP 404\b/i.test(message)) return `地址不存在,检查 baseURL 是否写对(${message})`;
3927
+ if (/ENOTFOUND|EAI_AGAIN/i.test(message)) return `找不到这个地址,检查域名拼写与网络(${message})`;
3928
+ if (/ECONNREFUSED/i.test(message)) return `对方拒绝了连接,检查地址与端口(${message})`;
3929
+ if (/ETIMEDOUT|\btimeout\b|aborted/i.test(message)) return `连接超时(${message})`;
3930
+ if (/fetch failed/i.test(message)) return `网络连接失败,检查网络后重试(${message})`;
3931
+ return `连接失败(${message})`;
3932
+ }
3472
3933
  async function handleTestConnectionRequest(ctx, service, req, res) {
3473
3934
  const params = await parseLookupParams(req);
3474
3935
  const provider = params.provider ?? "";
@@ -3545,7 +4006,7 @@ async function handleTestConnectionRequest(ctx, service, req, res) {
3545
4006
  } catch (error) {
3546
4007
  sendJson(res, 400, {
3547
4008
  ok: false,
3548
- error: error instanceof Error ? error.message : String(error)
4009
+ error: describeConnectionError(error)
3549
4010
  });
3550
4011
  }
3551
4012
  }
@@ -3580,7 +4041,7 @@ function handleStreamRequest(req, res) {
3580
4041
  });
3581
4042
  }
3582
4043
  function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3583
- const namespace = "advisor-group";
4044
+ const namespace = "dsh-advisor-group";
3584
4045
  return async (req, res) => {
3585
4046
  const rawUrl = req.url ?? "";
3586
4047
  const url = new URL(rawUrl, "http://localhost");
@@ -3625,6 +4086,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3625
4086
  stopped: service.stopConsultation(parsed.sessionId)
3626
4087
  });
3627
4088
  } catch (error) {
4089
+ console.warn("[dsh-advisor-group] stop 请求处理失败:", error instanceof Error ? error.message : String(error));
3628
4090
  sendJson(res, 400, {
3629
4091
  ok: false,
3630
4092
  error: error instanceof Error ? error.message : String(error)
@@ -3649,6 +4111,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3649
4111
  ...result.reason ? { error: result.reason } : {}
3650
4112
  });
3651
4113
  } catch (error) {
4114
+ console.warn("[dsh-advisor-group] resume 请求处理失败:", error instanceof Error ? error.message : String(error));
3652
4115
  sendJson(res, 400, {
3653
4116
  ok: false,
3654
4117
  error: error instanceof Error ? error.message : String(error)
@@ -3664,12 +4127,20 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3664
4127
  return;
3665
4128
  }
3666
4129
  if (req.method === "GET" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
3667
- sendJson(res, 200, {
3668
- ok: true,
3669
- config: sanitizeConfig(service.getConfig()),
3670
- revision: currentRevision(scopedCtx, namespace),
3671
- dailyGuard: service.getDailyGuard()
3672
- });
4130
+ try {
4131
+ sendJson(res, 200, {
4132
+ ok: true,
4133
+ config: sanitizeConfig(service.getConfig()),
4134
+ revision: currentRevision(scopedCtx, namespace),
4135
+ dailyGuard: service.getDailyGuard()
4136
+ });
4137
+ } catch (error) {
4138
+ console.error("[dsh-advisor-group] config read failed:", error instanceof Error ? error.message : String(error));
4139
+ sendJson(res, 500, {
4140
+ ok: false,
4141
+ error: error instanceof Error ? error.message : String(error)
4142
+ });
4143
+ }
3673
4144
  return;
3674
4145
  }
3675
4146
  if (req.method === "POST" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
@@ -3678,7 +4149,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3678
4149
  const parsed = JSON.parse(raw);
3679
4150
  const configInput = parsed.config ?? parsed;
3680
4151
  const expectedRevision = typeof parsed.expectedRevision === "number" ? parsed.expectedRevision : currentRevision(scopedCtx, namespace);
3681
- const reconciled = reconcileApiKeys(Config(configInput), service.getConfig());
4152
+ const reconciled = reconcileApiKeys(unwrapVolatileConfig(Config(configInput)), service.getConfig());
3682
4153
  const securityError = validateSecurity(reconciled);
3683
4154
  if (securityError) {
3684
4155
  sendJson(res, 400, {
@@ -3687,8 +4158,10 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3687
4158
  });
3688
4159
  return;
3689
4160
  }
3690
- await scopedCtx.settings.replace(namespace, reconciled, expectedRevision);
3691
- service.setConfig(reconciled);
4161
+ const guarded = guardAdvisorPrompts(reconciled);
4162
+ reportPromptGuardFindings(guarded.findings);
4163
+ await scopedCtx.settings.replace(namespace, guarded.config, expectedRevision);
4164
+ service.setConfig(guarded.config);
3692
4165
  sendJson(res, 200, {
3693
4166
  ok: true,
3694
4167
  config: sanitizeConfig(service.getConfig()),
@@ -3704,7 +4177,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
3704
4177
  }
3705
4178
  sendJson(res, 400, {
3706
4179
  ok: false,
3707
- error: error instanceof Error ? error.message : String(error)
4180
+ error: `保存失败:请检查刚才修改项的格式(原始信息:${error instanceof Error ? error.message : String(error)})`
3708
4181
  });
3709
4182
  }
3710
4183
  return;
@@ -3752,14 +4225,16 @@ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
3752
4225
  if (!inject) return;
3753
4226
  inject(["settings", "webServer"], (scoped) => {
3754
4227
  const scopedCtx = scoped;
3755
- const namespace = "advisor-group";
3756
- const scope = scopedCtx.settings.register(namespace, Config, { base: config });
3757
- service.setConfig(scope.get());
4228
+ const namespace = "dsh-advisor-group";
4229
+ service.setConfig(config);
4230
+ const presentationDisposer = scopedCtx.settings.configure({ auto: false }, ctx.fiber);
3758
4231
  service.setPersistEnabled(async (enabled) => {
3759
4232
  await scopedCtx.settings.update(namespace, { enabled });
3760
4233
  });
3761
- const watchDisposer = scope.watch((next) => {
3762
- service.setConfig(next);
4234
+ const watchDisposer = ctx.on("loader/volatile-update", () => {
4235
+ const guarded = guardAdvisorPrompts(unwrapVolatileConfig(ctx.fiber.config));
4236
+ reportPromptGuardFindings(guarded.findings);
4237
+ service.setConfig(guarded.config);
3763
4238
  });
3764
4239
  const authToken = randomUUID();
3765
4240
  const indexTapDisposer = scopedCtx.webServer.tapIndex ? scopedCtx.webServer.tapIndex((html) => html.replace("<head>", `<head><script>globalThis.__ADVISOR_GROUP_TOKEN__ = ${JSON.stringify(authToken)}<\/script>`)) : null;
@@ -3772,6 +4247,7 @@ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
3772
4247
  watchDisposer();
3773
4248
  routeDisposer();
3774
4249
  indexTapDisposer?.();
4250
+ presentationDisposer();
3775
4251
  }, "dsh-advisor-group: settings watcher + config routes");
3776
4252
  });
3777
4253
  }
@@ -3817,7 +4293,7 @@ function buildAdvisorPrompt(config, repeatPressure) {
3817
4293
  /**
3818
4294
  * Host-only registration for the advisor-group session event vocabulary.
3819
4295
  *
3820
- * The official 0.1.2-rc.1 compatibility mechanism is the per-event
4296
+ * The official compatibility mechanism is the per-event
3821
4297
  * `SessionEvent.ignorable: true` envelope marker: `dsh-session-persistence`
3822
4298
  * refuses unknown event types unless `KNOWN_SESSION_EVENT_TYPES.has(type)`
3823
4299
  * or the stored event is marked ignorable. However `Session.append()` exposes
@@ -3854,8 +4330,10 @@ const inject = [
3854
4330
  "sessions"
3855
4331
  ];
3856
4332
  function apply(ctx, config) {
3857
- const service = new AdvisorGroupService(ctx, config);
3858
- registerAdvisorSettingsAndRoutes(ctx, config, service);
4333
+ const guarded = guardAdvisorPrompts(unwrapVolatileConfig(config));
4334
+ reportPromptGuardFindings(guarded.findings);
4335
+ const service = new AdvisorGroupService(ctx, guarded.config);
4336
+ registerAdvisorSettingsAndRoutes(ctx, guarded.config, service);
3859
4337
  for (const tool of registerAdvisorTools(service)) ctx.tools.register(tool);
3860
4338
  const systemPrompt = ctx.systemPrompt;
3861
4339
  if (systemPrompt) ctx.effect(() => systemPrompt.section({