@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -2
  3. package/README.zh.md +57 -0
  4. package/config.example.yml +436 -0
  5. package/cordis.patch.yml +14 -0
  6. package/docs/assets/config-page.png +0 -0
  7. package/docs/assets/decisions-tab.png +0 -0
  8. package/docs/assets/hero.jpeg +0 -0
  9. package/docs/assets/stats-charts-preview.png +0 -0
  10. package/docs/assets/stats-donut-toggle.png +0 -0
  11. package/docs/assets/stats-interactive-charts.png +0 -0
  12. package/docs/assets/stats-point-donut.png +0 -0
  13. package/docs/assets/stats-ranking-bars.png +0 -0
  14. package/docs/auto-review-hotfix.md +33 -0
  15. package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
  16. package/docs/lessons/cold-start-timeout.md +134 -0
  17. package/docs/lessons/endpoint-path-vs-model.md +26 -0
  18. package/docs/lessons/model-field-required-by-provider.md +31 -0
  19. package/docs/local-dev-link.md +54 -0
  20. package/docs/releasing.md +69 -0
  21. package/docs/security-revision.md +130 -0
  22. package/icon.svg +6 -0
  23. package/lib/adapters/agent.js +314 -0
  24. package/lib/adapters/route.js +144 -0
  25. package/lib/adapters/tools.js +186 -0
  26. package/lib/adapters/translate.js +143 -0
  27. package/lib/approval-detail.js +325 -0
  28. package/lib/client.charts.js +36 -0
  29. package/lib/client.js +8715 -0
  30. package/lib/config-file.js +136 -0
  31. package/lib/credentials.js +187 -0
  32. package/lib/decision-cache.js +188 -0
  33. package/lib/decision-points.js +205 -0
  34. package/lib/decision-store.js +774 -0
  35. package/lib/endpoint-fallback.js +143 -0
  36. package/lib/engine.js +312 -0
  37. package/lib/guard/advisory.js +100 -0
  38. package/lib/guard/audit.js +81 -0
  39. package/lib/guard/context.js +128 -0
  40. package/lib/guard/effects.js +104 -0
  41. package/lib/guard/fallback.js +139 -0
  42. package/lib/guard/listener.js +214 -0
  43. package/lib/guard/messages.js +31 -0
  44. package/lib/guard/policy.js +28 -0
  45. package/lib/home-paths.js +86 -0
  46. package/lib/index.js +3823 -0
  47. package/lib/outcomes.js +169 -0
  48. package/lib/points/spec.js +645 -0
  49. package/lib/providers.js +958 -0
  50. package/lib/resilience.js +308 -0
  51. package/lib/scope.js +67 -0
  52. package/lib/server-config.js +351 -0
  53. package/lib/session-events.js +75 -0
  54. package/lib/stats-store.js +916 -0
  55. package/lib/stats.js +1293 -0
  56. package/lib/telemetry.js +465 -0
  57. package/lib/tokenizer.js +201 -0
  58. package/lib/tolerance.js +498 -0
  59. package/lib/vendor/microcharts-charts.css +47 -0
  60. package/lib/vendor-src/entry.js +13 -0
  61. package/lib/vendor-src/jsx-shim.js +34 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/package.json +101 -3
  65. package/scripts/migrate-profile.mjs +102 -0
package/lib/index.js ADDED
@@ -0,0 +1,3823 @@
1
+ /**
2
+ * dsh-decision-engine — 便宜的决策层:在主模型动手前问一个决策小模型。
3
+ * 可接任意提供决策端点的服务(`/v1/decisions` 或 `/v1/systemone`,路径由服务商决定)。
4
+ *
5
+ * 与 dsh-jev-interceptor 的三处关键差异:
6
+ *
7
+ * 1. **零可选 API 依赖**。不调用 `settings.installSection`(该 API 在 DSH 中不存在),
8
+ * 不依赖 `@deepseek-ai/dsh-credentials`、`launchEnvironmentOf`、`dshHomePath`。
9
+ * 密钥走 config → 环境变量两级,路径走 `home-paths.js`(与宿主同一套规则)。
10
+ * 宿主 API 缺失不会炸成静默失效。
11
+ *
12
+ * 2. **endpoint 每次请求重新解析**。`classify()` 现读 `cfg` 并重新解析 provider,
13
+ * 改配置立刻对下一次决策生效,无需重启(旧插件在 apply 期把 endpoint 固化进
14
+ * JevClient,之后改 Base URL 永远不生效)。
15
+ *
16
+ * 3. **provider 抽象 + URL 规范化**。地址、鉴权变量名、默认模型全部来自
17
+ * `providers.js` 的预设表,本文件不再出现任何具体服务商的域名。
18
+ * `.../v1` 与 `.../v1/decisions` 等价——自动补端点段,不重复也不缺失。
19
+ *
20
+ * 省 token 的手段:结果缓存、think_mode 默认 never、只读工具跳过、
21
+ * state 截断、失败冷却。所有决策先过 shadow,默认绝不阻塞。
22
+ *
23
+ * @module dsh-decision-engine
24
+ */
25
+
26
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
27
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
28
+ import { dirname, join } from "node:path";
29
+ import z from "@deepseek-ai/schemastery";
30
+
31
+ import { DecisionCache } from "./decision-cache.js";
32
+ import { DECISION_POINTS, POINT_MODES, readPointModes } from "./decision-points.js";
33
+ import { createDecisionRunner } from "./engine.js";
34
+ import { POINT_SPECS } from "./points/spec.js";
35
+ import { DEFAULT_RETRIEVAL_TOOLS, mountToolPoints } from "./adapters/tools.js";
36
+ import { mountRoutePoint } from "./adapters/route.js";
37
+ import { mountAgentPoints } from "./adapters/agent.js";
38
+ import { mountCotTranslatePoint } from "./adapters/translate.js";
39
+ import { applyConfigUpdates, parseConfigText } from "./config-file.js";
40
+ import { EndpointMemory, sendWithEndpointFallback } from "./endpoint-fallback.js";
41
+ // 韧性策略:哪些失败值得重试、冷却如何退避、失败如何翻译给用户看。
42
+ // 三个判断都放在零依赖的纯函数模块里(时间由调用方传入),因此本文件只负责
43
+ // 「照它执行」——热路径上的分支不再各写一份,避免两处规则漂移。
44
+ import { describeFailure, isRetryableReason, nextCooldownMs, shouldRetry } from "./resilience.js";
45
+ import { resolveDshHome } from "./home-paths.js";
46
+ import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
47
+ import { RANGE_LABELS, RANGE_PRESETS, aggregate, buildRecord, resolveRange, sortRows } from "./stats.js";
48
+ import { StatsStore } from "./stats-store.js";
49
+ import { buildDecisionRecord, DecisionStore, MAX_ENTRIES_PER_QUERY } from "./decision-store.js";
50
+ // 作用域边界(DEC-045):越出工作区的写入、模型给用户挑方案、模型等用户
51
+ // 澄清——这三类决定不属于本插件,遇到时让位给用户。
52
+ import { GUARD_FALLBACKS, guardModelCatalog } from "./guard/fallback.js";
53
+ import { mountGuard } from "./guard/listener.js";
54
+ import { guardQuestions as safetyGuardQuestions, extractGuardAnswers as strictGuardAnswers, decideModelAdvisory } from "./guard/advisory.js";
55
+ import { TELEMETRY_LEVELS, TelemetryReporter } from "./telemetry.js";
56
+ import { estimateRequestTokens } from "./tokenizer.js";
57
+ // 人工审核比例(DEC-035):模型无关的打扰频率设定。与现有阈值分开——
58
+ // 阈值回答「这个模型说什么算高」,tolerance 回答「大概每多少次问一次」。
59
+ import {
60
+ DEFAULT_TOLERANCE,
61
+ TOLERANCE_LEVELS,
62
+ TOLERANCE_PRESETS,
63
+ ToleranceCalibrator,
64
+ resolveTolerance,
65
+ severityOf,
66
+ toleranceCutoff,
67
+ tolerancePreset,
68
+ } from "./tolerance.js";
69
+ // 服务端下发的取数与内存缓存(DEC-030)。刻意用零 fs 的独立模块:下发的
70
+ // key 只活在 ServerConfigSource 实例里,本文件只负责把它接进各解析点。
71
+ import { SERVER_CONFIG_ENDPOINT, ServerConfigSource } from "./server-config.js";
72
+ import {
73
+ DEFAULT_PROVIDER,
74
+ DEFAULT_THINK_MODE,
75
+ ENDPOINT_STYLES,
76
+ PROVIDER_IDS,
77
+ PROVIDER_PRESETS,
78
+ SERVER_PROVIDER_ID,
79
+ SERVER_PROVIDER_LABEL,
80
+ THINK_MODES,
81
+ buildEndpoint,
82
+ buildHeaders,
83
+ buildRequestBody,
84
+ endpointStyleOfPath,
85
+ normalizeBaseUrl,
86
+ normalizeThinkMode,
87
+ parseCustomHeaders,
88
+ providerCatalog,
89
+ resolveKeyFor,
90
+ resolveProvider,
91
+ validateBaseUrl,
92
+ } from "./providers.js";
93
+
94
+ export const name = "dsh-decision-engine";
95
+ export const inject = [];
96
+
97
+ /**
98
+ * 默认端点:由 provider 预设推导,而不是写死域名。
99
+ *
100
+ * 保留这个导出是为了让 `scripts/verify-endpoint.mjs` 等外部脚本仍能取到
101
+ * 「默认往哪发」,同时保证它只在一个地方定义(providers.js)。
102
+ */
103
+ const DEFAULT_BASE_URL = buildEndpoint(
104
+ PROVIDER_PRESETS[DEFAULT_PROVIDER].baseUrl,
105
+ PROVIDER_PRESETS[DEFAULT_PROVIDER].endpointStyle,
106
+ );
107
+ /** 默认 provider 的默认模型 id。 */
108
+ const DEFAULT_MODEL = PROVIDER_PRESETS[DEFAULT_PROVIDER].defaultModel;
109
+ /** 实测稳态约 2s、抖动可达 9.5s,默认给足 20s。旧插件默认 1500ms 必然超时。 */
110
+ const DEFAULT_TIMEOUT_MS = 20000;
111
+
112
+ const DEFAULTS = {
113
+ enabled: false,
114
+ mode: "shadow",
115
+ // ---- provider ---------------------------------------------------------
116
+ // 内置预设与 custom 的字段全部并列在顶层:本插件的配置读取器是极简
117
+ // YAML 子集(只认标量),嵌套对象读不出来;并列 + 前缀是本文件里
118
+ // 唯一既能手写、又能被面板回显、还不互相覆盖的形态。
119
+ provider: DEFAULT_PROVIDER,
120
+ /** bamboonode 预设的地址覆盖(老版本唯一的地址字段,必须继续生效)。 */
121
+ baseUrl: PROVIDER_PRESETS.bamboonode.baseUrl,
122
+ /** bamboonode 的模型字段(老版本唯一的模型键,默认值与历史一致)。 */
123
+ model: DEFAULT_MODEL,
124
+ /** system one 的模型字段;留空 = 由服务端选默认(jev-latest)。 */
125
+ systemoneModel: "",
126
+ /**
127
+ * 硅基流动 / Silicon Flow 的模型字段。
128
+ *
129
+ * 刻意留空、不在这里钉模型名:这两个端点把 `model` 列为必填(实测省略或
130
+ * 空串都回 400 `Model field is required.`),「留空」的语义由
131
+ * `resolveProvider` 解释为「用预设默认 Kev-4B」。填了就以填的为准
132
+ * (想换 SemIf 填 SemIf),两条路都不会发出注定失败的请求。
133
+ */
134
+ siliconflowModel: "",
135
+ siliconflowIntlModel: "",
136
+ /**
137
+ * Perplexity Decider / Openrouter 的模型字段。
138
+ *
139
+ * 与硅基流动同理:这两家的端点不接受省略 model,留空由 `resolveProvider`
140
+ * 回落到预设默认(见 providers.js)。默认值本身未实测,接通后按需覆盖。
141
+ */
142
+ perplexityModel: "",
143
+ openrouterModel: "",
144
+ /** 自定义 provider:地址、端点风格、鉴权、请求头、模型。 */
145
+ customBaseUrl: "",
146
+ customEndpointStyle: "auto",
147
+ customApiKey: "",
148
+ customApiKeyEnv: "",
149
+ /** 各 provider 的显式 key(与变量名分开存,互不覆盖)。 */
150
+ systemoneApiKey: "",
151
+ bamboonodeApiKey: "",
152
+ siliconflowApiKey: "",
153
+ siliconflowIntlApiKey: "",
154
+ perplexityApiKey: "",
155
+ openrouterApiKey: "",
156
+ customModel: "",
157
+ customHeaders: "",
158
+ /** 各预设的密钥来源变量名,留空即用预设内置值。 */
159
+ bamboonodeApiKeyEnv: "",
160
+ systemoneApiKeyEnv: "",
161
+ siliconflowApiKeyEnv: "",
162
+ siliconflowIntlApiKeyEnv: "",
163
+ perplexityApiKeyEnv: "",
164
+ openrouterApiKeyEnv: "",
165
+ /**
166
+ * 密钥保存位置:
167
+ * credentials = ~/.dsh/.credentials.yaml 的 refs 段(默认,与其他服务统一托管)
168
+ * config = 插件自己的 config.yml(自包含,便于随目录备份/迁移)
169
+ */
170
+ keyStorage: "credentials",
171
+ /**
172
+ * 使用免费模型(DEC-030,原名「跟随服务端下发」;DEC-037 改名)。
173
+ *
174
+ * 默认 **false**:这是一个「显式选择要联系授权服务端」的开关,升级后
175
+ * 不该悄悄发起任何新请求。打开后下发的地址/模型/key 只在内存里覆盖本地
176
+ * 配置,**永不写进 cfg / config.yml / 凭据文件**(实现与理由见
177
+ * lib/server-config.js 顶部三条设计红线)。
178
+ *
179
+ * 配置键名保持 `serverProvider`(改名不改行为,改的是用户看到的文案;
180
+ * 键名是已落盘的契约,改名会让老配置静默失效)。
181
+ */
182
+ serverProvider: false,
183
+ apiKeyEnv: "BAMBOO_API_KEY",
184
+ timeoutMs: DEFAULT_TIMEOUT_MS,
185
+ /**
186
+ * 超时 / 网络错误的重试次数。**只对这两类重试**:4xx 是配置错(地址、模型、
187
+ * key),5xx 是服务端自己的故障,重发只会拿回同一个错误、多烧一次额度。
188
+ *
189
+ * 为什么默认 1:服务端冷启动时首发实测要 7–16 秒,而**首发即使被超时掐断,
190
+ * 也已经把服务端焐热了**——紧接着的第二发 1.1–1.7 秒必中(4 组独立实验
191
+ * 一致)。生产遥测里 timeout 83 次、network-error 84 次,其中相当一部分正是
192
+ * 「首发被 8s 预算掐断」。只重试一次而不是更多:第二发仍失败说明不是冷启动
193
+ * 问题,继续重试只是把一次失败拖成一次更慢的失败。
194
+ */
195
+ retryAttempts: 1,
196
+ // 思考模式:取值与默认值都来自 providers.js,避免两处各写一份而漂移。
197
+ thinkMode: DEFAULT_THINK_MODE,
198
+ cooldownMs: 60000,
199
+ /**
200
+ * 冷却期内的试探间隔(毫秒)——冷却从「整段全黑」改成半开。
201
+ *
202
+ * 为什么是 10000:连续 3 败后的 60 秒冷却在生产遥测里制造了 **93 次**
203
+ * cooldown 降级,它们不是真的失败,而是「冷却期间所有决策直接快速失败」
204
+ * (0–1ms)——这是前几类失败的放大器。而冷启动抖动实测往往 3 秒就恢复,
205
+ * 60 秒全黑等于把一次瞬时失败放大成整整一分钟不工作。每 10 秒放行一次
206
+ * 试探,把恢复时间从「最多 60s」压到「最多 10s」;间隔又不能太小,否则
207
+ * 服务真故障时会变成重试风暴。
208
+ */
209
+ cooldownProbeMs: 10000,
210
+ failureThreshold: 3,
211
+ cacheSize: 256,
212
+ recentMessages: 4,
213
+ recentMessageChars: 240,
214
+ argsHeadChars: 1024,
215
+ argsTailChars: 256,
216
+ maxStateChars: 6000,
217
+ guardEnabled: true,
218
+ guardPolicyMode: "enforce",
219
+ guardFallback: "session",
220
+ guardFallbackProvider: "",
221
+ guardFallbackModel: "",
222
+ guardFallbackReasoningEffort: "",
223
+ guardReadOnlyTools: ["read", "read_image", "grep", "glob", "list_agents"],
224
+ guardExcludeTools: [],
225
+ guardConfidenceMin: 0.6,
226
+ guardDenyConfidence: 0.85,
227
+ guardIrreversibleCeiling: 0.2,
228
+ /**
229
+ * 任务相关性否决线:低风险 + 可能不可逆的调用,只有当「服务于当前任务」
230
+ * 的概率低于此线时才升级询问。实测(DEC-008)决策模型对普通 bash 命令的
231
+ * irreversible 输出基线在 0.2–0.4,单看该分数会把大量良性调用推进人工
232
+ * 确认;而 taskP 与 ask 组高度相关(ask 组里 100% taskP≥0.5),作为否决
233
+ * 条件能把误打扰砍掉约六成,同时保留真正「与任务无关且可能不可逆」的可疑调用。
234
+ */
235
+ guardTaskRelevanceFloor: 0.5,
236
+ guardIrreversibleDenyFloor: 0.5,
237
+ guardInjectionAskThreshold: 0.8,
238
+ // ---- 模型路由(pointRoute)--------------------------------------------
239
+ // 下沉目标:留空表示「不路由」(即便点被打开也不换模型)。
240
+ routeCheapProvider: "",
241
+ routeCheapModel: "",
242
+ routeMaxStepsPerTurn: 3,
243
+ routeMaxScore: 0.4,
244
+ routeMechanicalFloor: 0.6,
245
+ routeRiskFloor: 0.3,
246
+ // ---- 子任务分派(pointDelegate)---------------------------------------
247
+ delegateConfidenceFloor: 0.5,
248
+ delegateBlockingFloor: 0.6,
249
+ delegateParallelFloor: 0.5,
250
+ // ---- 上下文压缩(pointCompact)----------------------------------------
251
+ // contextWindow 拿不到时用 compactTriggerTokens 作绝对阈值。
252
+ compactContextWindow: 0,
253
+ compactThresholdRatio: 0.7,
254
+ compactTriggerTokens: 100000,
255
+ compactToolResultChars: 8192,
256
+ // 实测:结论已落地 → distilled 0.81;结论未落地 → 0.41。
257
+ compactDistilledFloor: 0.6,
258
+ // 安全否决:仍需逐字原文时绝不裁剪(实测两类均 0.26,故门槛取高)。
259
+ compactVerbatinFloor: 0.6,
260
+ // ---- 轮次延续(pointTurnStop)-----------------------------------------
261
+ turnStopMaxSteers: 1,
262
+ // 实测:已完成 → closed 0.74 / unfin 0.09 / value 0.09;
263
+ // 未完成 → closed 0.28 / unfin 0.83 / value 0.39。
264
+ // value 取 0.35 而不是更高:「未完成」那组的 value 只有 0.39,门槛设到 0.4
265
+ // 会把它判成不续轮。value 在这里只是「有增量」的弱确认,主判据是 unfin。
266
+ turnStopClosedFloor: 0.6,
267
+ turnStopUnfinishedFloor: 0.6,
268
+ turnStopValueFloor: 0.35,
269
+ /**
270
+ * 「这一轮在等用户」的判定门槛(DEC-045)。
271
+ *
272
+ * 达线即让位:不续轮,把决定权交还用户。取 0.5 是因为**两类错代价不对等**:
273
+ * 漏判(把「在等用户」当成「还有活没干完」)= 替用户把选择顶掉,这是本
274
+ * 插件最不该犯的错;误判(把「干完了」当成「在等用户」)= 少续一轮,代价小得多。
275
+ *
276
+ * 注意这条**只用于让位**:模型判出「在等用户」的结果**不会**变成
277
+ * 「所以替用户选了方案 A」——插件没有任何「代替用户决定」的路。
278
+ */
279
+ turnStopAwaitingUserFloor: 0.5,
280
+ /**
281
+ * 是否让守卫退出「越出工作区」这件事(DEC-045)。
282
+ *
283
+ * 默认 **true**:越界写入的判定权属于用户与宿主沙箱,不属于本插件。
284
+ * 打开后,守卫遇到目标确定在写根之外的调用时**直接放行**(不询问、不拦下、
285
+ * 不发请求),把「能不能写到工作区外面」这个决定完整地留给宿主沙箱与用户。
286
+ *
287
+ * 关掉它不会让插件去**管**这件事,只会让它回到旧行为(照常问模型)。
288
+ */
289
+ guardDeferOutsideWorkspace: true,
290
+ // ---- 检索必要性(pointRetrieval)--------------------------------------
291
+ retrievalTools: [...DEFAULT_RETRIEVAL_TOOLS],
292
+ // 实测(问法见 points/spec.js):
293
+ // 已覆盖(完整问答)covered 0.87 / would_add 0.53
294
+ // 已覆盖(简短) covered 0.53 / would_add 0.79
295
+ // 未覆盖 covered 0.03 / would_add 0.93
296
+ // covered 取 0.5 以容纳「简短覆盖」那种情形;would_add 上限取 0.8,
297
+ // 使「已覆盖且再搜基本没新增」才跳过。两个条件必须同时满足。
298
+ retrievalCoveredFloor: 0.5,
299
+ retrievalWouldAddCeiling: 0.8,
300
+ // ---- 技能选择(pointSkillPick)----------------------------------------
301
+ skillPickCandidates: 8,
302
+ skillPickConfidenceFloor: 0.5,
303
+ // ---- 思维链翻译门槛(pointCotTranslate)-------------------------------
304
+ cotProseFloor: 0.5,
305
+ cotSameLanguageFloor: 0.7,
306
+ // ---- 决策点参与模式(off | observe | enforce)--------------------------
307
+ // 未显式设置时,`pointAuthorize` 回退到旧的顶层 `mode`(shadow→observe、
308
+ // enforce→enforce),其余决策点默认不参与。这样升级不会改变现有行为。
309
+ pointAuthorize: "auto",
310
+ pointRoute: "off",
311
+ pointDelegate: "off",
312
+ pointCompact: "off",
313
+ pointTurnStop: "off",
314
+ pointRetrieval: "off",
315
+ pointSkillPick: "off",
316
+ pointCotTranslate: "off",
317
+ // ---- 自身开销护栏 ----------------------------------------------------
318
+ cacheTtlMs: 600000,
319
+ // ---- 匿名使用统计(DEC-020)-------------------------------------------
320
+ // 默认 daily:每天都上报一次。为什么不选 install(只报首次)——那样
321
+ // daily_active 只会在安装当天有一行,之后 DAU/WAU 恒为 0,等于统计失效。
322
+ // 「默认不擅自介入」约束的是守卫(改裁决),不是这条旁路。
323
+ telemetryLevel: "daily",
324
+ /** 上报地址;留空用 lib/telemetry.js 的默认端点(dsh.zuos.us)。 */
325
+ telemetryEndpoint: "",
326
+ // ---- 决策历史(DEC-022)-------------------------------------------------
327
+ /**
328
+ * 是否记录决策明细并在会话里挂「决策历史」标签页。
329
+ *
330
+ * 默认 **true**:这是用户要的功能,不记录就等于这个页面永远空着。
331
+ * 但它仍然是个开关——关掉之后**不写任何明细文件**(不采集内容、不占
332
+ * 磁盘),而不是「写了再藏起来」。理由:明细里可能有会话正文与命令
333
+ * 片段,用户说不记录就该真的不落盘。
334
+ *
335
+ * 注意它与 `enabled` 无关:守卫没启用时明细也照记,因为「记录」本身
336
+ * 正是这个页面的用途(观察模式下尤其如此)。
337
+ */
338
+ decisionsTabEnabled: true,
339
+ /**
340
+ * 接管宿主审批卡的正文块(`conversation.approval.detail`),把它从「命令原文
341
+ * 原样铺一整块」改成「摘要 + 可展开的原文」(DEC-042,DEC-043 起默认开启)。
342
+ *
343
+ * 默认 **true**:宿主把 `args.command` 塞进一个 `word-break: break-all` 的
344
+ * 块里,且没有 `white-space: pre-wrap`——换行因此全部被折叠,一段 heredoc
345
+ * 脚本会变成一整面读不出结构的字墙。这就是不打任何补丁时的真实体验,而
346
+ * 「读得懂」正是这个插件存在的理由,所以可读卡片才是默认。
347
+ *
348
+ * 但它仍然是个开关:关掉之后回到宿主的渲染,与 1.5.x **逐字节一致**。
349
+ * 需要说清的代价是——接管意味着这块 UI 由我们渲染,宿主将来对它的改进
350
+ * 不会自动落到我们身上。更认宿主那套呈现的用户,把它关掉即可。
351
+ *
352
+ * 与 `decisionsTabEnabled` 无关:那个管「记录」,这个管「审批卡长什么样」。
353
+ */
354
+ approvalDetailReadable: true,
355
+ /** 单条内容的序列化长度上限;超了按「参数 → 摘要 → 整个 state」依次裁剪。 */
356
+ decisionsMaxContentChars: 4000,
357
+ /** 明细文件体积上限(字节);到顶轮转成 `decisions.jsonl.1`(只留一代)。 */
358
+ decisionsMaxBytes: 8 * 1024 * 1024,
359
+ /**
360
+ * 人工审核比例(tolerance,DEC-035)。
361
+ *
362
+ * 与上面那些 `guard*` 阈值是**两件事**:
363
+ *
364
+ * - `guard*` 阈值是在**某一家决策模型**的输出分布上校准的绝对分数,
365
+ * 换模型就失去意义;
366
+ * - `tolerance` 是**模型无关**的体验设定:用户说的是「大概每多少次调用
367
+ * 来问我一次」,而不是「阈值取多少」。
368
+ *
369
+ * 默认 `default` = 当前的默认行为(逐字不变),升级不改变任何东西。
370
+ * 四档:autonomous(0%)/ relaxed(最前 1%)/ default(当前)/ cautious(更保守)。
371
+ */
372
+ tolerance: DEFAULT_TOLERANCE,
373
+ };
374
+
375
+ /**
376
+ * 插件配置。
377
+ *
378
+ * **刻意不使用 `.volatile()`**:本插件的配置只来自 profile 的
379
+ * `cordis.patch.yml`,不进 Settings 面板(只有一个 api key,放面板里没必要)。
380
+ * 而 `.volatile()` 会让 Schemastery 把值包装成 `{ get(), [volatile.write] }`
381
+ * 对象,`cfg.enabled === true` 之类判断就会失效——除非处处 `get()`。
382
+ * 不走 settings 服务就不用付这个代价,配置读出来全是普通值。
383
+ */
384
+ export const Config = z.object({
385
+ enabled: z.boolean().default(DEFAULTS.enabled),
386
+ mode: z.union(["shadow", "enforce"]).default(DEFAULTS.mode),
387
+ // ---- provider 选择 -----------------------------------------------------
388
+ provider: z.union([...PROVIDER_IDS]).default(DEFAULTS.provider)
389
+ .description(
390
+ "systemone = TypeSafe 官方端点(POST /v1/systemone);"
391
+ + "bamboonode = StartLux 官方端点(POST /v1/decisions,原有默认);"
392
+ + "siliconflow = 硅基流动(api.siliconflow.cn,POST /v1/systemone);"
393
+ + "siliconflow-intl = Silicon Flow 国际站(api.siliconflow.com);"
394
+ + "perplexity = Perplexity Decider(api.perplexity.ai,待实测);"
395
+ + "openrouter = OpenRouter(openrouter.ai/api/v1,待实测);"
396
+ + "custom = 自己填地址(任意提供决策端点的服务,路径可自动回退)",
397
+ ),
398
+ baseUrl: z.string().default(DEFAULTS.baseUrl)
399
+ .description("bamboonode 预设的地址覆盖;/v1 与 /v1/decisions 两种写法都接受"),
400
+ model: z.string().default(DEFAULTS.model)
401
+ .description("bamboonode 预设的模型 id;留空表示由服务端选默认"),
402
+ systemoneModel: z.string().default(DEFAULTS.systemoneModel)
403
+ .description("system one 预设的模型 id;留空表示由服务端选默认(jev-latest)"),
404
+ siliconflowModel: z.string().default(DEFAULTS.siliconflowModel)
405
+ .description("硅基流动预设的模型 id;留空用预设默认 Kev-4B(该端点 model 必填,不可省略)"),
406
+ siliconflowIntlModel: z.string().default(DEFAULTS.siliconflowIntlModel)
407
+ .description("Silicon Flow 国际站预设的模型 id;留空用预设默认 Kev-4B(该端点 model 必填)"),
408
+ perplexityModel: z.string().default(DEFAULTS.perplexityModel)
409
+ .description("Perplexity Decider 预设的模型 id;留空用预设默认 sonar(端点 model 必填)"),
410
+ openrouterModel: z.string().default(DEFAULTS.openrouterModel)
411
+ .description("OpenRouter 预设的模型 slug;留空用预设默认 openai/gpt-4o-mini(请求必带 model)"),
412
+ customBaseUrl: z.string().default(DEFAULTS.customBaseUrl)
413
+ .description("provider=custom 时的 base URL,必须是 http/https 地址"),
414
+ customEndpointStyle: z.union([...ENDPOINT_STYLES]).default(DEFAULTS.customEndpointStyle)
415
+ .description("custom 的端点风格:auto 已带端点就原样用 / decisions / systemone"),
416
+ customApiKey: z.string().role("secret").default(DEFAULTS.customApiKey)
417
+ .description("custom 的 API key;留空则读 customApiKeyEnv 指定的 refs 键/环境变量"),
418
+ systemoneApiKey: z.string().role("secret").default(DEFAULTS.systemoneApiKey)
419
+ .description("system one 的 API key;留空则读 TYPESAFE_API_KEY"),
420
+ bamboonodeApiKey: z.string().role("secret").default(DEFAULTS.bamboonodeApiKey)
421
+ .description("bamboonode 的 API key;留空则读 BAMBOO_API_KEY"),
422
+ siliconflowApiKey: z.string().role("secret").default(DEFAULTS.siliconflowApiKey)
423
+ .description("硅基流动的 API key;留空则读 SILICONFLOW_API_KEY"),
424
+ siliconflowIntlApiKey: z.string().role("secret").default(DEFAULTS.siliconflowIntlApiKey)
425
+ .description("Silicon Flow 国际站的 API key;留空则读 SILICONFLOW_INTL_API_KEY"),
426
+ perplexityApiKey: z.string().role("secret").default(DEFAULTS.perplexityApiKey)
427
+ .description("Perplexity Decider 的 API key;留空则读 PERPLEXITY_API_KEY"),
428
+ openrouterApiKey: z.string().role("secret").default(DEFAULTS.openrouterApiKey)
429
+ .description("OpenRouter 的 API key;留空则读 OPENROUTER_API_KEY"),
430
+ customApiKeyEnv: z.string().default(DEFAULTS.customApiKeyEnv)
431
+ .description("custom 的密钥来源变量名(环境变量或 .credentials.yaml 的 refs 键)"),
432
+ customModel: z.string().default(DEFAULTS.customModel)
433
+ .description("custom 的模型 id;留空表示由服务端选默认"),
434
+ customHeaders: z.string().default(DEFAULTS.customHeaders)
435
+ .description("custom 的附加请求头,形如 `X-Tenant: a; X-Trace: b`"),
436
+ bamboonodeApiKeyEnv: z.string().default(DEFAULTS.bamboonodeApiKeyEnv)
437
+ .description("bamboonode 预设的密钥变量名覆盖;留空用 BAMBOO_API_KEY"),
438
+ systemoneApiKeyEnv: z.string().default(DEFAULTS.systemoneApiKeyEnv)
439
+ .description("systemone 预设的密钥变量名覆盖;留空用 TYPESAFE_API_KEY"),
440
+ siliconflowApiKeyEnv: z.string().default(DEFAULTS.siliconflowApiKeyEnv)
441
+ .description("siliconflow 预设的密钥变量名覆盖;留空用 SILICONFLOW_API_KEY"),
442
+ siliconflowIntlApiKeyEnv: z.string().default(DEFAULTS.siliconflowIntlApiKeyEnv)
443
+ .description("siliconflow-intl 预设的密钥变量名覆盖;留空用 SILICONFLOW_INTL_API_KEY"),
444
+ perplexityApiKeyEnv: z.string().default(DEFAULTS.perplexityApiKeyEnv)
445
+ .description("perplexity 预设的密钥变量名覆盖;留空用 PERPLEXITY_API_KEY"),
446
+ openrouterApiKeyEnv: z.string().default(DEFAULTS.openrouterApiKeyEnv)
447
+ .description("openrouter 预设的密钥变量名覆盖;留空用 OPENROUTER_API_KEY"),
448
+ apiKey: z.string().role("secret")
449
+ .description("当前 provider 的 API key;留空则读 apiKeyEnv 指定的环境变量"),
450
+ apiKeyEnv: z.string().default(DEFAULTS.apiKeyEnv)
451
+ .description("bamboonode 预设的密钥变量名覆盖;留空用 BAMBOO_API_KEY"),
452
+ keyStorage: z.union(["credentials", "config"]).default(DEFAULTS.keyStorage)
453
+ .description(
454
+ "密钥保存位置:credentials = ~/.dsh/.credentials.yaml(默认,与其他服务统一托管);"
455
+ + "config = 插件配置目录下的 config.yml",
456
+ ),
457
+ serverProvider: z.boolean().default(DEFAULTS.serverProvider)
458
+ .description(
459
+ "使用免费模型(DEC-030,原名「跟随服务端下发」):开启后从授权服务获取地址/模型/密钥并在内存中覆盖本地配置;"
460
+ + "下发的密钥只在内存里,永不写入 config.yml 或凭据文件。默认关闭(不主动联系该端点)。"
461
+ + "免费通道可能会失效——不可用时插件回落到本地手动配置",
462
+ ),
463
+ timeoutMs: z.number().step(1).min(1000).default(DEFAULTS.timeoutMs),
464
+ retryAttempts: z.number().step(1).min(0).max(3).default(DEFAULTS.retryAttempts)
465
+ .description("仅对超时与网络错误重试的次数;4xx/5xx 不重试(配置错与服务端故障,重发只会多烧额度)"),
466
+ thinkMode: z.union(["never", "auto", "always"]).default(DEFAULTS.thinkMode)
467
+ .description("never 最省 token;auto 仅在不确定时思考"),
468
+ cooldownMs: z.number().step(1).min(1000).default(DEFAULTS.cooldownMs),
469
+ cooldownProbeMs: z.number().step(1).min(1000).default(DEFAULTS.cooldownProbeMs)
470
+ .description("冷却期内每隔这么久放行一次试探请求;试探成功即彻底解除冷却"),
471
+ failureThreshold: z.number().step(1).min(1).default(DEFAULTS.failureThreshold),
472
+ cacheSize: z.number().step(1).min(0).default(DEFAULTS.cacheSize),
473
+ recentMessages: z.number().step(1).min(0).max(12).default(DEFAULTS.recentMessages),
474
+ recentMessageChars: z.number().step(1).min(40).default(DEFAULTS.recentMessageChars),
475
+ argsHeadChars: z.number().step(1).min(64).default(DEFAULTS.argsHeadChars),
476
+ argsTailChars: z.number().step(1).min(0).default(DEFAULTS.argsTailChars),
477
+ maxStateChars: z.number().step(1).min(500).default(DEFAULTS.maxStateChars),
478
+ guardEnabled: z.boolean().default(DEFAULTS.guardEnabled),
479
+ guardPolicyMode: z.union(["observe", "enforce"]).default(DEFAULTS.guardPolicyMode),
480
+ guardFallback: z.union(GUARD_FALLBACKS).default(DEFAULTS.guardFallback),
481
+ guardFallbackProvider: z.string().default(DEFAULTS.guardFallbackProvider),
482
+ guardFallbackModel: z.string().default(DEFAULTS.guardFallbackModel),
483
+ guardFallbackReasoningEffort: z.string().default(DEFAULTS.guardFallbackReasoningEffort),
484
+ guardReadOnlyTools: z.array(z.string()).default([...DEFAULTS.guardReadOnlyTools]),
485
+ guardExcludeTools: z.array(z.string()).default([...DEFAULTS.guardExcludeTools]),
486
+ guardConfidenceMin: z.number().min(0).max(1).default(DEFAULTS.guardConfidenceMin),
487
+ guardDenyConfidence: z.number().min(0).max(1).default(DEFAULTS.guardDenyConfidence),
488
+ guardIrreversibleCeiling: z.number().min(0).max(1).default(DEFAULTS.guardIrreversibleCeiling),
489
+ guardTaskRelevanceFloor: z.number().min(0).max(1).default(DEFAULTS.guardTaskRelevanceFloor)
490
+ .description("任务相关性否决线:低风险且可能不可逆的调用,仅当 taskP 低于此值才升级询问"),
491
+ guardIrreversibleDenyFloor: z.number().min(0).max(1).default(DEFAULTS.guardIrreversibleDenyFloor),
492
+ guardInjectionAskThreshold: z.number().min(0).max(1).default(DEFAULTS.guardInjectionAskThreshold),
493
+ // ---- 模型路由 --------------------------------------------------------
494
+ routeCheapProvider: z.string().default(DEFAULTS.routeCheapProvider)
495
+ .description("下沉目标的 provider;留空表示不路由"),
496
+ routeCheapModel: z.string().default(DEFAULTS.routeCheapModel)
497
+ .description("下沉目标的 model;与 provider 一起留空即不路由"),
498
+ routeMaxStepsPerTurn: z.number().step(1).min(1).max(20).default(DEFAULTS.routeMaxStepsPerTurn)
499
+ .description("每轮最多判断几步路由;后续步骤沿用本轮裁决"),
500
+ routeMaxScore: z.number().min(0).max(1).default(DEFAULTS.routeMaxScore),
501
+ routeMechanicalFloor: z.number().min(0).max(1).default(DEFAULTS.routeMechanicalFloor),
502
+ routeRiskFloor: z.number().min(0).max(1).default(DEFAULTS.routeRiskFloor),
503
+ // ---- 子任务分派 ------------------------------------------------------
504
+ delegateConfidenceFloor: z.number().min(0).max(1).default(DEFAULTS.delegateConfidenceFloor)
505
+ .description("分派倾向的置信门槛;三选项均分为 0.33,实测需 ≥0.5 才算有倾向"),
506
+ delegateBlockingFloor: z.number().min(0).max(1).default(DEFAULTS.delegateBlockingFloor),
507
+ delegateParallelFloor: z.number().min(0).max(1).default(DEFAULTS.delegateParallelFloor),
508
+ // ---- 上下文压缩 ------------------------------------------------------
509
+ compactContextWindow: z.number().step(1).min(0).default(DEFAULTS.compactContextWindow)
510
+ .description("上下文窗口 token 数;0 表示改用 compactTriggerTokens 绝对阈值"),
511
+ compactThresholdRatio: z.number().min(0.1).max(1).default(DEFAULTS.compactThresholdRatio),
512
+ compactTriggerTokens: z.number().step(1).min(1000).default(DEFAULTS.compactTriggerTokens),
513
+ compactToolResultChars: z.number().step(1).min(1024).default(DEFAULTS.compactToolResultChars),
514
+ compactDistilledFloor: z.number().min(0).max(1).default(DEFAULTS.compactDistilledFloor)
515
+ .description("判定「工具结果的信息已落地」的门槛;达线才允许只裁剪"),
516
+ compactVerbatinFloor: z.number().min(0).max(1).default(DEFAULTS.compactVerbatinFloor)
517
+ .description("仍需逐字原文的门槛;达线一律改走摘要"),
518
+ // ---- 轮次延续 --------------------------------------------------------
519
+ turnStopMaxSteers: z.number().step(1).min(0).max(5).default(DEFAULTS.turnStopMaxSteers),
520
+ turnStopClosedFloor: z.number().min(0).max(1).default(DEFAULTS.turnStopClosedFloor),
521
+ turnStopUnfinishedFloor: z.number().min(0).max(1).default(DEFAULTS.turnStopUnfinishedFloor),
522
+ turnStopValueFloor: z.number().min(0).max(1).default(DEFAULTS.turnStopValueFloor),
523
+ turnStopAwaitingUserFloor: z.number().min(0).max(1).default(DEFAULTS.turnStopAwaitingUserFloor)
524
+ .description("判定「这一轮在等用户」的门槛;达线即让位,不续轮"),
525
+ guardDeferOutsideWorkspace: z.boolean().default(DEFAULTS.guardDeferOutsideWorkspace)
526
+ .description("越出工作区的写入不由本插件裁决:放行并把决定权留给宿主沙箱与用户"),
527
+ // ---- 检索必要性 ------------------------------------------------------
528
+ retrievalTools: z.array(z.string()).default([...DEFAULTS.retrievalTools]),
529
+ retrievalCoveredFloor: z.number().min(0).max(1).default(DEFAULTS.retrievalCoveredFloor)
530
+ .description("上下文已覆盖该事实的门槛"),
531
+ retrievalWouldAddCeiling: z.number().min(0).max(1).default(DEFAULTS.retrievalWouldAddCeiling)
532
+ .description("再搜也拿不到新信息的门槛上限"),
533
+ // ---- 技能选择 --------------------------------------------------------
534
+ skillPickCandidates: z.number().step(1).min(2).max(20).default(DEFAULTS.skillPickCandidates),
535
+ skillPickConfidenceFloor: z.number().min(0).max(1).default(DEFAULTS.skillPickConfidenceFloor),
536
+ // ---- 会话标题 --------------------------------------------------------
537
+ // ---- 思维链翻译门槛 --------------------------------------------------
538
+ cotProseFloor: z.number().min(0).max(1).default(DEFAULTS.cotProseFloor),
539
+ cotSameLanguageFloor: z.number().min(0).max(1).default(DEFAULTS.cotSameLanguageFloor),
540
+ // 决策点参与模式由注册表生成,保证配置项与 DECISION_POINTS 永不脱节。
541
+ // "auto" 仅对 authorize 有意义:跟随旧的顶层 `mode`。
542
+ ...Object.fromEntries(
543
+ DECISION_POINTS.map((point) => [
544
+ point.key,
545
+ z
546
+ .union(point.id === "authorize" ? [...POINT_MODES, "auto"] : POINT_MODES)
547
+ .default(DEFAULTS[point.key])
548
+ .description(`${point.label}:off 不参与 / observe 只记录 / enforce 生效`),
549
+ ]),
550
+ ),
551
+ cacheTtlMs: z.number().step(1).min(1000).default(DEFAULTS.cacheTtlMs)
552
+ .description("缓存存活时间;改策略或配置后旧裁决不应继续被复用"),
553
+ telemetryLevel: z.union([...TELEMETRY_LEVELS]).default(DEFAULTS.telemetryLevel)
554
+ .description(
555
+ "匿名使用统计:off 完全不联网 / install 仅首次运行报一次(默认)/ "
556
+ + "daily 每天报一次(含当日决策数)。只上报插件版本、平台、架构与计数,"
557
+ + "永不上报 prompt、密钥、路径或会话内容",
558
+ ),
559
+ telemetryEndpoint: z.string().default(DEFAULTS.telemetryEndpoint)
560
+ .description("统计上报地址;留空用内置端点(https://dsh.zuos.us/api/e)"),
561
+ decisionsTabEnabled: z.boolean().default(DEFAULTS.decisionsTabEnabled)
562
+ .description(
563
+ "决策历史:在本会话里新增一个「决策历史」标签页,按时间列出经本插件做出的每次决策。"
564
+ + "关闭后不写任何明细文件(不采集内容、不占磁盘)",
565
+ ),
566
+ approvalDetailReadable: z.boolean().default(DEFAULTS.approvalDetailReadable)
567
+ .description(
568
+ "审批卡正文可读化:接管宿主的命令块,显示「几步、哪几步危险」的摘要,"
569
+ + "原文默认收起、可展开(保留换行、限高滚动)。默认开启;"
570
+ + "关闭后完全沿用宿主的渲染,与升级前逐字节一致",
571
+ ),
572
+ decisionsMaxContentChars: z.number().step(1).min(200).default(DEFAULTS.decisionsMaxContentChars)
573
+ .description("单条决策内容的字符上限;超出时依次裁剪工具参数、对话摘要、整个 state"),
574
+ decisionsMaxBytes: z.number().step(1).min(1024 * 1024).default(DEFAULTS.decisionsMaxBytes)
575
+ .description("明细文件体积上限(字节);到顶轮转成 decisions.jsonl.1,只留一代"),
576
+ tolerance: z.union([...TOLERANCE_LEVELS]).default(DEFAULTS.tolerance)
577
+ .description(
578
+ "人工审核比例(模型无关):autonomous 从不问用户 / relaxed 只问最前 1% / "
579
+ + "default 按决策模型自己的判断(当前默认)/ cautious 在默认之外再问最前 25%",
580
+ ),
581
+ });
582
+
583
+ /**
584
+ * 解析每个决策点此刻的生效模式。
585
+ *
586
+ * `auto` 是向后兼容用的:它把旧的顶层 `mode` 映射成三态
587
+ * (shadow → observe、enforce → enforce)。这样从 0.1.x 升上来时,
588
+ * 已经写了 `mode: enforce` 的用户行为不变,不必再配一遍。
589
+ *
590
+ * 其余决策点没有历史包袱,`auto` 一律按 off 处理。
591
+ *
592
+ * @param cfg - 已合并的配置。
593
+ * @returns id → 模式 的表。
594
+ */
595
+ export function effectivePointModes(cfg) {
596
+ const legacy = cfg?.mode === "enforce" ? "enforce" : cfg?.mode === "shadow" ? "observe" : "off";
597
+ /** @type {Record<string, string>} */
598
+ const modes = readPointModes({});
599
+ for (const point of DECISION_POINTS) {
600
+ const raw = cfg?.[point.key];
601
+ if (POINT_MODES.includes(raw)) {
602
+ modes[point.id] = raw;
603
+ } else if (raw === "auto") {
604
+ modes[point.id] = point.id === "authorize" ? legacy : "off";
605
+ } else {
606
+ modes[point.id] = "off";
607
+ }
608
+ }
609
+ return modes;
610
+ }
611
+
612
+ /**
613
+ * 把用户填的 base URL 规范化为完整端点。
614
+ *
615
+ * 保留这个导出的理由:它是最早的公开契约(`verify-wiring.mjs` 与用户脚本
616
+ * 都在用它)。实现已经下沉到 `providers.buildEndpoint`——本文件不再持有
617
+ * 任何具体服务商的域名。
618
+ *
619
+ * 语义向后兼容:不传 style 时按 `decisions` 处理,与旧行为一致;
620
+ * `.../v1` 与 `.../v1/decisions` 仍等价。
621
+ *
622
+ * @param raw - 配置里的 baseUrl。
623
+ * @param style - `decisions` / `systemone` / `auto`,默认 decisions。
624
+ * @returns 可直接 POST 的完整 URL;输入为空时返回默认端点。
625
+ */
626
+ export function normalizeEndpoint(raw, style = "decisions") {
627
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
628
+ if (trimmed.length === 0) return DEFAULT_BASE_URL;
629
+ // 老配置允许省略 scheme(`api.x.dev/v1`),这里补齐后再交给 buildEndpoint。
630
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
631
+ return buildEndpoint(withScheme, style);
632
+ }
633
+
634
+ /**
635
+ * 校验一次 provider 相关的配置改动。
636
+ *
637
+ * 刻意在**落盘之前**校验:先写 yml 再报错,等于把坏配置留在文件里,
638
+ * 用户重启后就带着它跑(而降级是静默的,只会表现为「守卫没反应」)。
639
+ *
640
+ * 校验对象是「合并后的结果」而不是 patch 本身——因为必填项依赖当前选择:
641
+ * 切到 custom 但 customBaseUrl 为空时必须拒绝,而单看 patch 里只有 provider
642
+ * 一个键,是看不出这个冲突的。
643
+ *
644
+ * @param patch - 待写入的键值。
645
+ * @param current - 当前生效配置。
646
+ * @returns `{ ok: true }` 或 `{ ok: false, error }`(error 可直接展示)。
647
+ */
648
+ export function validateProviderPatch(patch, current) {
649
+ if (Object.hasOwn(patch, "provider")) {
650
+ if (!PROVIDER_IDS.includes(patch.provider)) {
651
+ return { ok: false, error: `未知 provider: ${String(patch.provider)}(可选 ${PROVIDER_IDS.join(" / ")})` };
652
+ }
653
+ }
654
+ if (Object.hasOwn(patch, "customEndpointStyle") && !ENDPOINT_STYLES.includes(patch.customEndpointStyle)) {
655
+ return {
656
+ ok: false,
657
+ error: `未知端点风格: ${String(patch.customEndpointStyle)}(可选 ${ENDPOINT_STYLES.join(" / ")})`,
658
+ };
659
+ }
660
+ // serverProvider 只认布尔(DEC-030):生效判定全部是 `=== true` 的严格比较,
661
+ // 字符串 "true" 或 1 落盘后会得到「界面显示开、解析永远走本地」的半真状态,
662
+ // 而这里又是「使用免费模型」(原名「跟随服务端下发」)这条隐私开关的唯一写入口
663
+ // ——坏值必须拦在落盘前。
664
+ if (Object.hasOwn(patch, "serverProvider") && typeof patch.serverProvider !== "boolean") {
665
+ return {
666
+ ok: false,
667
+ error: `serverProvider 必须是布尔值: ${String(patch.serverProvider)}`,
668
+ };
669
+ }
670
+
671
+ for (const key of ["enabled", "guardEnabled"]) {
672
+ if (Object.hasOwn(patch, key) && typeof patch[key] !== "boolean") return { ok: false, error: `${key} 必须是布尔值` };
673
+ }
674
+ if (Object.hasOwn(patch, "pointAuthorize") && ![...POINT_MODES, "auto"].includes(patch.pointAuthorize)) {
675
+ return { ok: false, error: "无效的工具授权模式" };
676
+ }
677
+ if (Object.hasOwn(patch, "mode") && !["shadow", "enforce"].includes(patch.mode)) return { ok: false, error: "无效的守卫模式" };
678
+ if (Object.hasOwn(patch, "guardPolicyMode") && !["observe", "enforce"].includes(patch.guardPolicyMode)) {
679
+ return { ok: false, error: "无效的安全策略模式" };
680
+ }
681
+ if (Object.hasOwn(patch, "guardFallback") && !GUARD_FALLBACKS.includes(patch.guardFallback)) {
682
+ return { ok: false, error: "无效的自动审查 Fallback 策略" };
683
+ }
684
+ for (const key of ["guardFallbackProvider", "guardFallbackModel", "guardFallbackReasoningEffort"]) {
685
+ if (Object.hasOwn(patch, key) && (typeof patch[key] !== "string" || patch[key].length > 256 || /[\r\n\0]/.test(patch[key]))) {
686
+ return { ok: false, error: `${key} 必须是单行模型标识(最长 256 字符)` };
687
+ }
688
+ }
689
+ const merged = { ...current, ...patch };
690
+ const providerId = PROVIDER_IDS.includes(merged.provider) ? merged.provider : DEFAULT_PROVIDER;
691
+
692
+ // 自定义地址只要出现就必须合法——哪怕当前 provider 不是 custom,
693
+ // 否则用户先存一个错地址、再切过去,就得到一次静默失效。
694
+ if (typeof merged.customBaseUrl === "string" && merged.customBaseUrl.trim().length > 0) {
695
+ const checked = validateBaseUrl(merged.customBaseUrl);
696
+ if (!checked.ok) return { ok: false, error: `自定义 base URL 不合法:${checked.error}` };
697
+ } else if (providerId === "custom") {
698
+ return {
699
+ ok: false,
700
+ error: "选择 custom provider 时必须填写 base URL(形如 https://api.example.com/v1)",
701
+ };
702
+ }
703
+
704
+ // 内置预设的地址覆盖同样要校验:老字段 baseUrl 仍可指向自建兼容服务。
705
+ if (Object.hasOwn(patch, "baseUrl") && typeof patch.baseUrl === "string" && patch.baseUrl.trim().length > 0) {
706
+ const text = patch.baseUrl.trim();
707
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`;
708
+ const checked = validateBaseUrl(withScheme);
709
+ if (!checked.ok) return { ok: false, error: `base URL 不合法:${checked.error}` };
710
+ }
711
+
712
+ // 请求头格式:只要写了内容就必须至少解析出一项,否则用户会以为头已生效。
713
+ if (Object.hasOwn(patch, "customHeaders") && typeof patch.customHeaders === "string") {
714
+ const raw = patch.customHeaders.trim();
715
+ if (raw.length > 0 && Object.keys(parseCustomHeaders(raw)).length === 0) {
716
+ return {
717
+ ok: false,
718
+ error: "自定义请求头格式不正确:应为 `名称: 值`,多项用分号分隔(如 `X-Tenant: a; X-Trace: b`)",
719
+ };
720
+ }
721
+ }
722
+
723
+ if (Object.hasOwn(patch, "keyStorage") && !["credentials", "config"].includes(patch.keyStorage)) {
724
+ return {
725
+ ok: false,
726
+ error: `未知密钥保存位置: ${String(patch.keyStorage)}(可选 credentials / config)`,
727
+ };
728
+ }
729
+
730
+ // 思考模式同样在落盘前拦下:写进 yml 再报错等于让用户带着坏配置重启,
731
+ // 而坏值只会得到一次静默降级。
732
+ if (Object.hasOwn(patch, "thinkMode") && !THINK_MODES.includes(patch.thinkMode)) {
733
+ return {
734
+ ok: false,
735
+ error: `未知思考模式: ${String(patch.thinkMode)}(可选 ${THINK_MODES.join(" / ")})`,
736
+ };
737
+ }
738
+
739
+ // 人工审核比例(DEC-035):四档之外的值一律拒在落盘前。
740
+ //
741
+ // 必须拦在这里:tolerance 的每一处判定都是「档位 id 精确匹配」,收到一个
742
+ // relax(少了 ed)之后所有分支都会退回默认档——用户看到「轻松」被打勾,
743
+ // 实际仍按默认在问,且没有任何报错。
744
+ if (Object.hasOwn(patch, "tolerance") && !TOLERANCE_LEVELS.includes(patch.tolerance)) {
745
+ return {
746
+ ok: false,
747
+ error: `未知的人工审核比例: ${String(patch.tolerance)}(可选 ${TOLERANCE_LEVELS.join(" / ")})`,
748
+ };
749
+ }
750
+
751
+ // 变量名必须能当环境变量用,否则凭据永远读不到。
752
+ // 逐个预设取它自己的变量名覆盖字段,而不是写死三个名字——否则新增预设时
753
+ // 漏加这一项,用户填的变量名会被原样写进 yml,然后永远读不到 key
754
+ //(守卫是 fail-open 的,症状又是「配了没反应」)。
755
+ for (const preset of Object.values(PROVIDER_PRESETS)) {
756
+ const key = preset.keyEnvField;
757
+ if (!Object.hasOwn(patch, key)) continue;
758
+ const value = typeof patch[key] === "string" ? patch[key].trim() : "";
759
+ if (value.length === 0) continue;
760
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
761
+ return { ok: false, error: `${key} 不是合法的变量名:${value}` };
762
+ }
763
+ }
764
+ return { ok: true };
765
+ }
766
+
767
+ /**
768
+ * 解析生效的 API key。优先级:
769
+ *
770
+ * 0. `options.memoryKey`——服务端随配置一起下发的 key(DEC-030,最高优先级)
771
+ * 1. 当前 provider 的显式 key(`apiKey` 或 `customApiKey`,适合临机覆盖)
772
+ * 2. `~/.dsh/.credentials.yaml` 的 `refs:` 段(**推荐**,与其他服务统一管理)
773
+ * 3. 环境变量(该 provider 自己的变量名,如 BAMBOO_API_KEY / TYPESAFE_API_KEY)
774
+ *
775
+ * 第 2 项是本插件的默认路径:key 和 DeepSeek / ModelScope 等放在一起,
776
+ * 由 DSH 统一托管,不必在插件目录里另存一份明文。
777
+ *
778
+ * 变量名由 provider 决定,因此「换 provider」也会自动换到它自己的凭据,
779
+ * 不会误用上一个 provider 的 key。
780
+ *
781
+ * 第 0 项为什么排最前:服务端下发是一整套配置(地址 + key),只取地址而
782
+ * 拿本地 key 去打,等于用另一个账号的凭据请求,几乎必然 401(见
783
+ * providers.js 的 resolveKeyFor)。memoryKey 只从内存来、绝不进 cfg——
784
+ * 这是「下发的 key 永不落盘」不变量的一部分(DEC-030)。
785
+ *
786
+ * @param cfg - 当前配置。
787
+ * @param provider - 可选的已解析 provider(避免重复解析)。
788
+ * @param options - 透传给 `resolveKeyFor`(memoryKey / env 等);provider 缺省时
789
+ * 同时透给 `resolveProvider`,这样 `{ server }` 也能在这里生效。
790
+ * @returns 密钥,或 undefined。
791
+ */
792
+ export function resolveKey(cfg, provider, options = {}) {
793
+ const resolved = provider ?? resolveProvider(cfg, options);
794
+ return resolveKeyFor(resolved, { credentialReader: readCredentialFromDsh, ...options });
795
+ }
796
+
797
+ /**
798
+ * 从 DSH 统一的 `~/.dsh/.credentials.yaml`(`refs:` 段)读取密钥。
799
+ *
800
+ * 这是本插件的首选来源:与 DeepSeek / ModelScope 等其他服务的 key 放在一起,
801
+ * 由 DSH 统一管理,不必在插件私有配置里存第二份明文。
802
+ *
803
+ * 刻意直读文件而不依赖 `credentials` 服务:该服务是可选注入的,且在插件
804
+ * 加载早期不一定就绪;直读只是一次同步 I/O,且失败即返回 undefined,
805
+ * 不会让补全路径抛错。
806
+ *
807
+ * @param ref - refs 段的键名(如 BAMBOO_API_KEY / TYPESAFE_API_KEY)。
808
+ * @returns 密钥,或 undefined(文件缺失 / 键不存在 / 值为空)。
809
+ */
810
+ export function readCredentialFromDsh(ref) {
811
+ const name = typeof ref === "string" ? ref.trim() : "";
812
+ if (name.length === 0) return undefined;
813
+ // 读侧与写侧必须用**同一套**规则,否则会出现"存了却读不回"。
814
+ // 两边都交给 resolveDshHome(),候选只剩一个路径。
815
+ const path = join(resolveDshHome(), ".credentials.yaml");
816
+ const raw = readCredentialText(path);
817
+ return raw === undefined ? undefined : pickRefValue(raw, name);
818
+ }
819
+
820
+ /**
821
+ * 凭据文件文本缓存:只缓存「读到的文本」,不缓存「结论」。
822
+ *
823
+ * 为什么要有它:这个函数在**每一次决策**上都会被调用(resolveKeyFor 传进来
824
+ * 当 credentialReader,bamboonode 这类 provider 有 2 个候选变量名),而
825
+ * 过去每次调用都 readFileSync 整个文件、再从头解析一遍 refs。实测单次
826
+ * resolveKeyFor 因此要 16.6–33 µs,其中大头就是这些同步读盘。
827
+ *
828
+ * 为什么仍然「改了配置立刻生效」:每次调用都先 stat 一次,键是
829
+ * **(路径, mtimeMs, size)**。用户改文件 → mtime 或 size 变 → 自动重读。
830
+ * 这一点必须保住——「不重启就生效」是本插件的设计前提。
831
+ *
832
+ * 两个刻意的例外:
833
+ *
834
+ * 1. **不缓存失败**。文件不存在 / 读不了时不写缓存,下次仍然重新 stat
835
+ * 与读取:用户完全可能先开插件、后补 key,那条路径必须能通。
836
+ * 2. stat 本身失败时退回直接读(与旧行为一致),绝不让缓存层把
837
+ * 「能读」变成「读不到」。
838
+ */
839
+ let credentialTextCache = { path: "", mtimeMs: 0, size: 0, text: undefined };
840
+
841
+ /**
842
+ * 读取凭据文件文本(带 mtime+size 失效)。
843
+ *
844
+ * @param path - 凭据文件路径。
845
+ * @returns 文件文本;读不到时 undefined。
846
+ */
847
+ function readCredentialText(path) {
848
+ let stat;
849
+ try {
850
+ stat = statSync(path);
851
+ } catch {
852
+ // 文件不存在:清掉缓存(可能刚被删),保持「读不到」的语义。
853
+ credentialTextCache = { path: "", mtimeMs: 0, size: 0, text: undefined };
854
+ return undefined;
855
+ }
856
+ if (
857
+ credentialTextCache.path === path &&
858
+ credentialTextCache.mtimeMs === stat.mtimeMs &&
859
+ credentialTextCache.size === stat.size
860
+ ) {
861
+ return credentialTextCache.text;
862
+ }
863
+ let raw;
864
+ try {
865
+ raw = readFileSync(path, "utf8");
866
+ } catch {
867
+ return undefined;
868
+ }
869
+ credentialTextCache = { path, mtimeMs: stat.mtimeMs, size: stat.size, text: raw };
870
+ return raw;
871
+ }
872
+
873
+ /**
874
+ * 读服务端下发的共享门禁令牌(DEC-030)。
875
+ *
876
+ * 顺序:环境变量 `DECISION_SERVER_CONFIG_TOKEN` 优先,其次
877
+ * `.credentials.yaml` 的同名 refs 键——与 API key 的既有取法一致,
878
+ * 管理员部署时既可以写进 launchd 环境,也可以放进统一凭据文件。
879
+ *
880
+ * 为什么要单独抽出来:这个令牌**不是**任何 provider 的 API key,它只是
881
+ * 领取配置的准入凭据;取回后原样交给 ServerConfigSource(非空才会附
882
+ * `x-provider-token` 头)。它同样只在内存里,绝不进 cfg / config.yml。
883
+ *
884
+ * @returns 令牌字符串;没有时为空串。
885
+ */
886
+ function readServerToken() {
887
+ const fromEnv = typeof process.env.DECISION_SERVER_CONFIG_TOKEN === "string"
888
+ ? process.env.DECISION_SERVER_CONFIG_TOKEN.trim()
889
+ : "";
890
+ if (fromEnv.length > 0) return fromEnv;
891
+ const fromCredentials = readCredentialFromDsh("DECISION_SERVER_CONFIG_TOKEN");
892
+ return typeof fromCredentials === "string" ? fromCredentials.trim() : "";
893
+ }
894
+
895
+ /**
896
+ * DSH 统一凭据文件的路径。
897
+ *
898
+ * 优先 `$DSH_HOME/.credentials.yaml`,其次 `~/.dsh/.credentials.yaml`——
899
+ * 与读取侧保持同一套候选,避免"读得到但写不进去"。
900
+ *
901
+ * @returns 凭据文件绝对路径(不保证已存在)。
902
+ */
903
+ export function credentialFilePath() {
904
+ // 读取侧(readCredentialFromDsh)与写入侧必须用**同一套**规则,否则会
905
+ // 出现"保存成功但读不回来"——写到了 A 文件、读的是 B 文件。
906
+ // 早先写入侧只认 ~/.dsh,就是在这一步把 key 写进真实家目录的。
907
+ // 现在两侧都从 resolveDshHome() 派生,规则只有一份。
908
+ return join(resolveDshHome(), ".credentials.yaml");
909
+ }
910
+
911
+ /**
912
+ * 把密钥写进 `~/.dsh/.credentials.yaml` 的 `refs:` 段。
913
+ *
914
+ * 为什么需要写:本插件允许把 key 托管在 DSH 的统一凭据文件里,而面板上的
915
+ * "粘贴 key → 保存"必须真的落盘。只读不写会让用户以为保存了,实际没存。
916
+ *
917
+ * 安全性上的取舍:
918
+ *
919
+ * - 只改 `refs:` 段,`records:`(授权记录)与注释一律不动;
920
+ * - 写入前先整份读出来做文本变换,再一次性覆盖,不做增量 append
921
+ * (append 会在键已存在时产生重复键);
922
+ * - 权限固定 600,与 DSH 自己创建该文件时保持一致;
923
+ * - 值为空字符串表示**删除**该键(用户清空即撤销)。
924
+ *
925
+ * @param ref - 键名。
926
+ * @param value - 密钥;传 "" 删除。
927
+ * @returns `{ ok: true }` 或 `{ ok: false, error }`。
928
+ */
929
+ export async function writeCredentialToDsh(ref, value) {
930
+ const name = typeof ref === "string" ? ref.trim() : "";
931
+ if (name.length === 0) return { ok: false, error: "密钥变量名为空,无法保存" };
932
+ const { writeFile } = await import("node:fs/promises");
933
+ const path = credentialFilePath();
934
+ let raw = "";
935
+ try {
936
+ raw = readFileSync(path, "utf8");
937
+ } catch {
938
+ // 文件不存在就新建一个最小骨架(version + refs)。
939
+ raw = "version: 1\nrefs:\n";
940
+ }
941
+ try {
942
+ const next = applyRefUpdates(raw, { [name]: typeof value === "string" ? value.trim() : "" });
943
+ await writeFile(path, next, { encoding: "utf8", mode: 0o600 });
944
+ return { ok: true };
945
+ } catch (error) {
946
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
947
+ }
948
+ }
949
+
950
+ /**
951
+ * 列出凭据文件里已配置的键名(不返回值)。
952
+ *
953
+ * @returns 键名数组;读不到文件时为空数组。
954
+ */
955
+ export function listCredentialRefs() {
956
+ try {
957
+ return listRefNames(readFileSync(credentialFilePath(), "utf8"));
958
+ } catch {
959
+ return [];
960
+ }
961
+ }
962
+
963
+ /** 插件更名后的数据目录名(`~/.dsh/<name>/`)。 */
964
+ const DATA_DIR_NAME = "dsh-decision-engine";
965
+
966
+ /**
967
+ * 当前配置文件名。
968
+ *
969
+ * 早期叫 `startlux.yml`——那时本插件只服务 startlux 一家。现在它是
970
+ * 「对任意决策服务的决策层」,名字里再挂某个服务商就会误导
971
+ * (用户会以为换 provider 要换文件),所以改叫 `config.yml`。
972
+ *
973
+ * **旧名字必须继续读**(见 `LEGACY_CONFIG_FILE_NAMES`):改文件名本身
974
+ * 不影响任何配置语义,一旦处理不当,用户唯一感知到的就是「我的 key 没了」。
975
+ */
976
+ const CONFIG_FILE_NAME = "config.yml";
977
+
978
+ /**
979
+ * 历史配置文件名,按新→旧优先级排列。
980
+ *
981
+ * 只做**一次性迁移 + 回退读取**,绝不删除旧文件:配置里可能有明文 key,
982
+ * 删错一次就要用户重填。留在原地的成本只有 2KB。
983
+ */
984
+ const LEGACY_CONFIG_FILE_NAMES = ["startlux.yml"];
985
+
986
+ /**
987
+ * 读本插件自己的版本号。
988
+ *
989
+ * 为什么不 import package.json:`engines.node >= 20.3.0` 早于 import attributes
990
+ * 的稳定版本;用 `import ... with { type: "json" }` 在目标 Node 上会直接语法
991
+ * 报错,而不用 attributes 又会被当成 JS 解析。`package.json` 一定随包发布
992
+ * (在 files 白名单里),所以运行期按需同步读一次即可;读失败返回空串,
993
+ * 由 `normalizeVersion` 兜成 "unknown"——版本号缺失不能变成插件故障。
994
+ *
995
+ * @returns 版本号字符串,读不到时为空串。
996
+ */
997
+ function readOwnPackage() {
998
+ try {
999
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) ?? {};
1000
+ } catch {
1001
+ return {};
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * 本插件自己的版本号。
1007
+ *
1008
+ * @returns 版本号字符串,读不到时为空串。
1009
+ */
1010
+ function readOwnVersion() {
1011
+ const pkg = readOwnPackage();
1012
+ return typeof pkg.version === "string" ? pkg.version : "";
1013
+ }
1014
+
1015
+ /**
1016
+ * 本插件自己的标识(npm 包名)。上报体靠它自报家门,服务端按它分插件统计。
1017
+ *
1018
+ * 为什么从 package.json 读而不是写死:将来这个仓库若拆出子包、或有人 fork 后
1019
+ * 改名,写死的常量会把两个不同的插件混成同一条统计线——而这类脏数据只有在
1020
+ * 出现之后才会被发现,回溯成本极高。
1021
+ *
1022
+ * @returns 插件标识;读不到时为空串(上报会在 prepare 阶段被拦下)。
1023
+ */
1024
+ function readOwnPluginId() {
1025
+ const pkg = readOwnPackage();
1026
+ return typeof pkg.name === "string" ? pkg.name : "";
1027
+ }
1028
+
1029
+ /**
1030
+ * 插件数据目录:`~/.dsh/dsh-decision-engine/`。
1031
+ * 与插件源码分离——源码可以 clone 到任意位置,运行期数据只在 DSH 家目录下。
1032
+ */
1033
+ function defaultTelemetryDir() {
1034
+ // 家目录解析统一走 home-paths.js:优先级、空白值处理、~ 展开与
1035
+ // 绝对路径规范化都与宿主 @deepseek-ai/dsh-home-paths 一致。
1036
+ // 早期各处手写一份 `DSH_HOME || ~/.dsh`,既重复又漏了规范化。
1037
+ return join(resolveDshHome(), DATA_DIR_NAME);
1038
+ }
1039
+
1040
+ /**
1041
+ * 旧版本(更名前)使用的配置目录。
1042
+ *
1043
+ * 本项目改过两次名:`dsh-startlux-guard` → `dsh-startlux-decision` →
1044
+ * `dsh-decision-engine`。若只认新目录,用户升级后已保存的 API key 与决策点
1045
+ * 开关会突然「消失」(表现为守卫静默不工作)。
1046
+ *
1047
+ * 处理方式是**一次性迁移 + 保留回退**:启动时把旧目录里的配置拷到新目录
1048
+ *(见 `migrateLegacyData`),搬完之后只读写新目录;只有在搬迁失败
1049
+ *(目录只读等)时才回退到旧位置,避免"因为搬不了就彻底不用配置"。
1050
+ *
1051
+ * **这里的目录名必须是历史真实名字,不能跟着更名一起改**——改了就等于
1052
+ * 找不到老配置。顺序即优先级:越新的旧位置越靠前。
1053
+ */
1054
+ function legacyConfigPaths() {
1055
+ const base = resolveDshHome();
1056
+ return [
1057
+ // 上一代数据目录(本项目改名前的正式位置)
1058
+ join(base, "dsh-startlux-decision", "startlux.yml"),
1059
+ // 更名前的旧位置
1060
+ join(base, "plugins", "dsh-startlux-guard", "startlux.yml"),
1061
+ // 当前目录结构确立前的位置(配置曾与源码同层)
1062
+ join(base, "plugins", "dsh-startlux-decision", "startlux.yml"),
1063
+ ];
1064
+ }
1065
+
1066
+ /**
1067
+ * 把旧数据目录里的数据一次性搬到新目录。
1068
+ *
1069
+ * 为什么是「拷贝」而不是「改名」:
1070
+ *
1071
+ * 1. 改名跨卷会失败,而 `~/.dsh` 与其它位置未必同卷;
1072
+ * 2. 旧文件留着没有代价(配置只有 2KB),搬丢了却要用户重填 key;
1073
+ * 3. 迁移失败必须能安静降级——所有异常都吞掉,由调用方回退。
1074
+ *
1075
+ * 搬两类东西:配置(含本次 `startlux.yml` → `config.yml` 的改名)与
1076
+ * `telemetry.jsonl`。只搬这些已知文件、不递归整个目录:里面可能有用户
1077
+ * 自己放的笔记或备份,插件无权擅自处理。
1078
+ *
1079
+ * @returns 是否真的搬了东西(供启动日志说明)。
1080
+ */
1081
+ function migrateLegacyData() {
1082
+ const targetDir = defaultTelemetryDir();
1083
+ const targetConfig = join(targetDir, CONFIG_FILE_NAME);
1084
+ try {
1085
+ // 新目录已有配置 → 之前迁移过,或用户主动建过。一律不再动它。
1086
+ if (existsSync(targetConfig)) return false;
1087
+
1088
+ // 候选来源按「越新越靠前」排列:
1089
+ // 1) 新目录里的旧文件名(本次改名的迁移来源,最常见)
1090
+ // 2) 历史数据目录里的旧文件名
1091
+ const candidates = [
1092
+ ...LEGACY_CONFIG_FILE_NAMES.map((name) => join(targetDir, name)),
1093
+ ...legacyConfigPaths(),
1094
+ ];
1095
+ const legacyConfig = candidates.find((path) => existsSync(path));
1096
+ if (legacyConfig === undefined) return false;
1097
+
1098
+ // 同目录改名时直接 rename:保留 mtime 之外的一切,且不会因为
1099
+ // 「拷贝 + 旧文件仍在」造成两份配置并存(并存会导致读哪份说不清)。
1100
+ // 跨目录/跨卷时 rename 会抛错,回退到拷贝。
1101
+ mkdirSync(targetDir, { recursive: true });
1102
+ if (join(legacyConfig, "..") === targetDir) {
1103
+ try {
1104
+ renameSync(legacyConfig, targetConfig);
1105
+ } catch {
1106
+ copyFileSync(legacyConfig, targetConfig);
1107
+ }
1108
+ } else {
1109
+ copyFileSync(legacyConfig, targetConfig);
1110
+ }
1111
+ try {
1112
+ // 配置里可能有明文 key,权限保持 600。
1113
+ chmodSync(targetConfig, 0o600);
1114
+ } catch {
1115
+ /* 改不了权限不算失败,文件已经在了 */
1116
+ }
1117
+ // 遥测一起搬:否则「按点分账」在新目录里从零开始,看起来像数据丢了。
1118
+ const legacyTelemetry = join(legacyConfig, "..", "telemetry.jsonl");
1119
+ const targetTelemetry = join(targetDir, "telemetry.jsonl");
1120
+ if (existsSync(legacyTelemetry) && !existsSync(targetTelemetry)) {
1121
+ try {
1122
+ copyFileSync(legacyTelemetry, targetTelemetry);
1123
+ } catch {
1124
+ /* 遥测是旁路数据,搬不动不影响决策路径 */
1125
+ }
1126
+ }
1127
+ return true;
1128
+ } catch {
1129
+ // 目录不可写等情况:保持回退行为,绝不因此让插件失效。
1130
+ return false;
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * 在多个候选位置中挑出第一个存在的配置文件。
1136
+ *
1137
+ * 顺序:新文件名 → 新目录里的旧文件名 → 历史目录里的旧文件名。
1138
+ * 后两者只在迁移没成功(目录只读等)时才可能命中,此时继续读写旧文件,
1139
+ * 好过「因为搬不动就彻底不用配置」。
1140
+ *
1141
+ * @returns 配置文件绝对路径(都不存在时返回新路径,供首次写入使用)。
1142
+ */
1143
+ function resolveConfigPath() {
1144
+ const targetDir = defaultTelemetryDir();
1145
+ const preferred = join(targetDir, CONFIG_FILE_NAME);
1146
+ try {
1147
+ if (existsSync(preferred)) return preferred;
1148
+ // 迁移失败但旧文件名还在新目录里:继续用它,避免配置凭空消失。
1149
+ for (const name of LEGACY_CONFIG_FILE_NAMES) {
1150
+ const stale = join(targetDir, name);
1151
+ if (existsSync(stale)) return stale;
1152
+ }
1153
+ for (const legacy of legacyConfigPaths()) {
1154
+ if (existsSync(legacy)) return legacy;
1155
+ }
1156
+ } catch {
1157
+ /* 探测失败就用首选路径 */
1158
+ }
1159
+ return preferred;
1160
+ }
1161
+
1162
+ /**
1163
+ * 遥测目录:正常情况下就是新目录(迁移已把历史记录搬过来)。
1164
+ *
1165
+ * 仅当新目录写不进去、而旧目录已有历史记录时才回退到旧目录,
1166
+ * 避免"写不了新地方,又把旧记录弄丢"。
1167
+ */
1168
+ function resolveTelemetryDir() {
1169
+ const preferred = defaultTelemetryDir();
1170
+ try {
1171
+ if (existsSync(join(preferred, "telemetry.jsonl"))) return preferred;
1172
+ // 新目录里还没有记录:只有「迁移失败」才继续用旧目录,
1173
+ // 否则应当写进新目录(迁移成功但还没有新记录时属于这种情形)。
1174
+ // 新旧两个文件名都要认:迁移失败时配置仍留在旧文件名下。
1175
+ if (existsSync(join(preferred, CONFIG_FILE_NAME))) return preferred;
1176
+ for (const name of LEGACY_CONFIG_FILE_NAMES) {
1177
+ if (existsSync(join(preferred, name))) return preferred;
1178
+ }
1179
+ for (const legacy of legacyConfigPaths()) {
1180
+ const dir = join(legacy, "..");
1181
+ if (existsSync(join(dir, "telemetry.jsonl"))) return dir;
1182
+ }
1183
+ } catch {
1184
+ /* 探测失败就用首选路径 */
1185
+ }
1186
+ return preferred;
1187
+ }
1188
+
1189
+ /**
1190
+ * 已经确认存在的遥测目录。
1191
+ *
1192
+ * 为什么要有它:`recordTelemetry` 在每一次决策上都会被 await 一次,而
1193
+ * `mkdir(dir, {recursive:true})` 每次都是一次系统调用——目录建好之后它
1194
+ * 什么也没做,却照样花了时间。这里记住「已经建好的目录」,后续追加直接写。
1195
+ *
1196
+ * 为什么要配一次重试:缓存可能与真实文件系统脱节(用户删了数据目录、
1197
+ * 或测试里把临时目录清掉重建)。追加失败时**忘掉这次记忆**并重试一次,
1198
+ * 语义就退回旧行为:该建的目录照样会建出来。
1199
+ */
1200
+ const readyTelemetryDirs = new Set();
1201
+
1202
+ /** 追加一条 JSONL 遥测。永远不抛:遥测失败不能影响决策路径。 */
1203
+ async function recordTelemetry(dir, entry) {
1204
+ const line = `${JSON.stringify(entry)}\n`;
1205
+ const file = join(dir, "telemetry.jsonl");
1206
+ try {
1207
+ if (!readyTelemetryDirs.has(dir)) {
1208
+ await mkdir(dir, { recursive: true });
1209
+ readyTelemetryDirs.add(dir);
1210
+ }
1211
+ await appendFile(file, line, "utf8");
1212
+ return true;
1213
+ } catch {
1214
+ // 目录可能刚被删掉:忘掉记忆、重建一次再试。仍失败就放弃(遥测是旁路)。
1215
+ readyTelemetryDirs.delete(dir);
1216
+ try {
1217
+ await mkdir(dir, { recursive: true });
1218
+ readyTelemetryDirs.add(dir);
1219
+ await appendFile(file, line, "utf8");
1220
+ return true;
1221
+ } catch {
1222
+ return false;
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ /** 截断到指定长度,保留头尾(中段省略),避免 state 过大浪费 token。 */
1228
+ function headTail(text, headChars, tailChars) {
1229
+ const value = typeof text === "string" ? text : String(text ?? "");
1230
+ if (value.length <= headChars + tailChars) return value;
1231
+ const tail = tailChars > 0 ? value.slice(-tailChars) : "";
1232
+ return `${value.slice(0, headChars)}\n...[省略 ${value.length - headChars - tail.length} 字符]...\n${tail}`;
1233
+ }
1234
+
1235
+ /** 把工具参数压成一段紧凑预览。 */
1236
+ function previewArguments(args, headChars, tailChars) {
1237
+ let text;
1238
+ try {
1239
+ text = JSON.stringify(args ?? {});
1240
+ } catch {
1241
+ text = String(args);
1242
+ }
1243
+ return headTail(text, headChars, tailChars);
1244
+ }
1245
+
1246
+ /** 从会话消息里摘出最近若干条对话,压缩为单行摘要。 */
1247
+ function digestRecent(session, count, chars) {
1248
+ if (count <= 0) return "";
1249
+ let messages;
1250
+ try {
1251
+ messages = session.deriveMessages?.() ?? [];
1252
+ } catch {
1253
+ return "";
1254
+ }
1255
+ const tail = messages.slice(-count);
1256
+ const lines = [];
1257
+ for (const message of tail) {
1258
+ const role = message?.role ?? "unknown";
1259
+ let text = "";
1260
+ const content = message?.content;
1261
+ if (typeof content === "string") text = content;
1262
+ else if (Array.isArray(content)) {
1263
+ text = content
1264
+ .map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : ""))
1265
+ .filter((part) => part.length > 0)
1266
+ .join(" ");
1267
+ }
1268
+ if (text.length > 0) lines.push(`${role}: ${headTail(text.trim(), chars, 0)}`);
1269
+ }
1270
+ return lines.join("\n");
1271
+ }
1272
+
1273
+ /** 守卫的四问:风险等级、不可逆性、任务相关性、注入嫌疑。 */
1274
+ function guardQuestions() {
1275
+ return safetyGuardQuestions();
1276
+ }
1277
+
1278
+ function extractGuardAnswers(answers) {
1279
+ return strictGuardAnswers(answers);
1280
+ }
1281
+
1282
+ /**
1283
+ * ask 裁决的两张文案表:审计原文(DEC-019)与人类标题(DEC-041)。
1284
+ *
1285
+ * 为什么 `reason` 之外还要 `displayReason`:`reason` 是**审计与遥测的原文**,
1286
+ * 一直保持中文——宿主把它写进 `approval/asked` 审计事件、本插件把它写进遥测的
1287
+ * `detail`,改它会让历史记录的口径漂移。而宿主审批卡渲染的是
1288
+ * `displayReason`(优先)而非 `reason`:`@deepseek-ai/dsh-client-ui-approval`
1289
+ * 的 ApprovalPanel 取 `displayReason === undefined ? reason : resolveReason(displayReason)`,
1290
+ * 再用 `ctx.locale.resolveText()` 按当前宿主语言解析。没有它,标题就永远
1291
+ * 回退到中文 `reason`——宿主界面是英文时就成了「英文卡壳 + 中文标题」。
1292
+ *
1293
+ * 映射必须**中英成对**:`resolveText` 的契约要求带 `en` 回退值。
1294
+ *
1295
+ * DEC-041 起拆成两张表:`reason` 与 `displayReason.zh` **不再逐字相同**。
1296
+ * 原先把整条 reason(含 `risk=` / `p=` / `irrev=` / `task=` / `inj=` 遥测字段
1297
+ * 名与裸小数)直接当标题,用户反馈「审批内容对人不具可读性」——那些是给
1298
+ * 事后核对用的审计口径,不是给人看的一行中文。于是:
1299
+ *
1300
+ * - `GUARD_ASK_REASON`:**只**进 `reason`,逐字保持改动前的样子(历史口径不漂移);
1301
+ * - `GUARD_ASK_TITLE`:**只**进 `displayReason`,日常语言、不含字段名与小数。
1302
+ */
1303
+ const GUARD_ASK_REASON = {
1304
+ injection: "工具输入疑似提示注入,升级人工确认",
1305
+ high: "高风险调用,需人工确认",
1306
+ medium: "中风险调用,需人工确认",
1307
+ lowIrreversible: "风险低但可能不可逆且与任务相关性低,需人工确认",
1308
+ };
1309
+
1310
+ /** 审批卡标题正文(DEC-041):给人读的一句话,不含遥测字段名与小数。 */
1311
+ const GUARD_ASK_TITLE = {
1312
+ injection: {
1313
+ zh: "输入里疑似藏着给 AI 的指令(提示注入)",
1314
+ en: "the input looks like it hides instructions aimed at an AI (prompt injection)",
1315
+ },
1316
+ high: {
1317
+ zh: "高风险,可能删除内容、外传数据或越出工作区",
1318
+ en: "high risk — may delete content, leak data, or escape the workspace",
1319
+ },
1320
+ medium: {
1321
+ zh: "中风险,会改动可恢复的状态",
1322
+ en: "medium risk — modifies recoverable state",
1323
+ },
1324
+ lowIrreversible: {
1325
+ zh: "风险低,但可能改不回来,也不像在服务当前任务",
1326
+ en: "low risk — possibly irreversible, and not serving the current task",
1327
+ },
1328
+ };
1329
+
1330
+ /** 标题里路径最多显示多少个字符,超出加省略号。 */
1331
+ const TITLE_PATH_MAX = 60;
1332
+
1333
+ /** 把一条路径压到标题能放下的长度。 */
1334
+ function shortPath(path) {
1335
+ return path.length > TITLE_PATH_MAX ? `${path.slice(0, TITLE_PATH_MAX - 1)}…` : path;
1336
+ }
1337
+
1338
+ /**
1339
+ * 从工具参数里取一个用于标题的路径。
1340
+ *
1341
+ * `write` / `edit` / `read` 的参数是 `file_path`;`apply_patch` **没有**路径
1342
+ * 字段——它的路径藏在 patch 文本里(`*** Update File: …` / `*** Add File: …` /
1343
+ * `*** Delete File: …`,宿主 dsh-tool-fs 的 schema 已核对)。取不到就返回
1344
+ * undefined,标题里省掉路径那一节,绝不编造。
1345
+ *
1346
+ * @param tool - 工具名。
1347
+ * @param args - 该次工具调用的参数对象。
1348
+ * @returns 路径字符串或 undefined。
1349
+ */
1350
+ function titlePathOf(tool, args) {
1351
+ if (args === null || typeof args !== "object") return undefined;
1352
+ const direct = args.file_path ?? args.path ?? args.file;
1353
+ if (typeof direct === "string" && direct.length > 0) return direct;
1354
+ if (tool === "apply_patch" && typeof args.input === "string") {
1355
+ const match = args.input.match(/^\*\*\* (?:Update|Add|Delete|Move) File: (.+)$/m);
1356
+ if (match !== null && match[1].trim().length > 0) return match[1].trim();
1357
+ }
1358
+ return undefined;
1359
+ }
1360
+
1361
+ /** 标题里工具名最多显示多少个字符,超出加省略号。 */
1362
+ const TITLE_TOOL_MAX = 40;
1363
+
1364
+ /**
1365
+ * 把工具名压成标题能安全承载的形式。
1366
+ *
1367
+ * 工具名来自宿主注册表,正常情况下是短的标识符;但标题是**一行文本**,
1368
+ * 一个带换行的名字会把标题劈成两行、一个超长的名字会把「为什么问」那节
1369
+ * 挤到看不见的地方。这里只做纯展示层的收敛(去控制字符 + 截断),不影响
1370
+ * 任何裁决逻辑。
1371
+ *
1372
+ * @param name - 原始工具名。
1373
+ * @returns 可安全放进标题的工具名。
1374
+ */
1375
+ function titleToolName(name) {
1376
+ // 控制字符(含换行/制表)在 CSS 可能被折叠或撑破布局,统一换成空格。
1377
+ const flat = name.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
1378
+ return flat.length > TITLE_TOOL_MAX ? `${flat.slice(0, TITLE_TOOL_MAX - 1)}…` : flat;
1379
+ }
1380
+
1381
+ /**
1382
+ * 标题里的「这次是什么操作」短句;不需要就不给(标题里省掉这一节)。
1383
+ *
1384
+ * 为什么需要它(DEC-041):宿主审批卡的正文 detail 行**只显示 bash 的
1385
+ * `args.command`**,`write` / `edit` / `apply_patch` 那边是空白——对写文件这类
1386
+ * 操作,整张卡除了我们的标题就只剩按钮。所以标题得自己点名「要改哪个文件」,
1387
+ * 否则一行「中风险」等于没说。
1388
+ *
1389
+ * `bash` / `shell` 一律返回 undefined:命令原文已经由宿主那行 detail 显示在
1390
+ * 标题正下方,标题再写一句「命令行操作」纯属重复。
1391
+ *
1392
+ * @param tool - 工具名(如 `bash` / `write` / `edit`)。
1393
+ * @param args - 该次工具调用的参数对象(已是对象,不需要再 parse)。
1394
+ * @returns `{zh, en}` 短句,或 undefined。
1395
+ */
1396
+ function toolHint(tool, args) {
1397
+ const name = typeof tool === "string" ? titleToolName(tool) : "";
1398
+ if (name.length === 0) return undefined;
1399
+ // bash 的命令由宿主 detail 行负责,标题不加重复的一句。
1400
+ if (name === "bash" || name === "shell") return undefined;
1401
+ if (name === "write" || name === "edit" || name === "apply_patch") {
1402
+ const path = titlePathOf(name, args);
1403
+ return path === undefined
1404
+ ? { zh: "修改文件", en: "modifying a file" }
1405
+ : { zh: `修改文件 ${shortPath(path)}`, en: `modifying ${shortPath(path)}` };
1406
+ }
1407
+ if (name === "read") {
1408
+ const path = titlePathOf(name, args);
1409
+ return path === undefined
1410
+ ? { zh: "读取文件", en: "reading a file" }
1411
+ : { zh: `读取文件 ${shortPath(path)}`, en: `reading ${shortPath(path)}` };
1412
+ }
1413
+ return { zh: `使用 ${name} 工具`, en: `the ${name} tool` };
1414
+ }
1415
+
1416
+ /**
1417
+ * 把 toolHint 与标题正文拼成一行;两语言同一结构。
1418
+ *
1419
+ * @param titleZh - 中文正文(不含插件前缀)。
1420
+ * @param titleEn - 英文正文(不含插件前缀)。
1421
+ * @param context - `{tool, args}`;缺省时省掉工具那一节。
1422
+ * @param options.askWords - 是否在末尾加「需要你确认」;tolerance 的标题自带
1423
+ * 这个语义,传 false 以免变成「确认……需要你确认」的重复。
1424
+ * @returns `{zh, en}`。
1425
+ */
1426
+ function askTitle(titleZh, titleEn, context, { askWords = true } = {}) {
1427
+ const hint = toolHint(context?.tool, context?.args);
1428
+ const sep = " · ";
1429
+ const tailZh = askWords ? `${sep}需要你确认` : "";
1430
+ const tailEn = askWords ? `${sep}needs your confirmation` : "";
1431
+ return {
1432
+ zh: `decision-engine:${hint === undefined ? "" : hint.zh + sep}${titleZh}${tailZh}`,
1433
+ en: `decision-engine: ${hint === undefined ? "" : hint.en + sep}${titleEn}${tailEn}`,
1434
+ };
1435
+ }
1436
+
1437
+ /**
1438
+ * 组装一条 ask 裁决:`reason` 保持审计原文(DEC-019,逐字不变),
1439
+ * `displayReason` 给出中英成对、给人读的审批卡标题(DEC-041)。
1440
+ *
1441
+ * @param key - `GUARD_ASK_REASON` / `GUARD_ASK_TITLE` 的键。
1442
+ * @param evidence - 四个概率的可读摘要;**只**进 `reason`,两种语言都不再展示。
1443
+ * @param context - `{tool, args}`,可选;缺省时标题省掉工具那一节。
1444
+ * @returns `{kind:'ask', reason, displayReason}`。
1445
+ */
1446
+ function askOutcome(key, evidence, context) {
1447
+ const reason = `decision-engine: ${GUARD_ASK_REASON[key]}(${evidence})`;
1448
+ const title = GUARD_ASK_TITLE[key];
1449
+ return {
1450
+ kind: "ask",
1451
+ reason,
1452
+ displayReason: askTitle(title.zh, title.en, context),
1453
+ };
1454
+ }
1455
+
1456
+ /**
1457
+ * 「人工审核比例」把一次默认放行的调用升级成 ask 时的文案(DEC-035)。
1458
+ *
1459
+ * 与 `askOutcome` 分开是因为语义不同:那四条文案描述**模型发现了什么**,
1460
+ * 这一条描述**用户选的档位要求问**。混用会让审计记录说谎——「高风险调用」
1461
+ * 这个理由出现在一条模型判定为低风险的记录上,事后核对时无法解释。
1462
+ *
1463
+ * DEC-041 只改标题:`reason` 仍逐字保持原样(含 `severity=` 与阈值小数),
1464
+ * 标题则只点名用户选的档位,不出现字段名与 0.xxx 数字。
1465
+ *
1466
+ * @param severity - 本次严重度(0–1)。
1467
+ * @param cutoff - 该档的严重度阈值;未校准好时为 null。
1468
+ * @param context - `{tool, args, level}`,可选;`level` 是档位 id,用于取档位名。
1469
+ * @returns `{kind:'ask', reason, displayReason}`,中英成对(DEC-019)。
1470
+ */
1471
+ function askOutcomeForSeverity(severity, cutoff, context) {
1472
+ const evidence =
1473
+ `severity=${severity.toFixed(3)} ` +
1474
+ `(档位阈值 ${cutoff === null ? "未校准" : cutoff.toFixed(3)})`;
1475
+ const reason = `decision-engine: 按「人工审核比例」设定,这次调用需人工确认(${evidence})`;
1476
+ // 档位名从预设表取;`tolerancePreset()` 对未知值退回默认档且会做档位合法性
1477
+ // 检查,比直接 `TOLERANCE_PRESETS[level]` 稳:后者在 level 为 `__proto__`
1478
+ // 这类键时会把原型链上的东西当档位读出来。不传第三参时同样回落默认档。
1479
+ const preset = tolerancePreset(context?.level);
1480
+ const label = preset.label;
1481
+ const labelEn = preset.labelEn;
1482
+ return {
1483
+ kind: "ask",
1484
+ reason,
1485
+ displayReason: askTitle(
1486
+ `你选的「${label}」档要求这次也确认一下`,
1487
+ `your "${labelEn}" review setting asks to confirm this call`,
1488
+ context,
1489
+ { askWords: false },
1490
+ ),
1491
+ };
1492
+ }
1493
+
1494
+ /**
1495
+ * 四问 → 处置。纯函数,便于单测与校准。阈值全部来自配置。
1496
+ *
1497
+ * 第三参 `context` 是可选的(DEC-041):它只影响审批卡标题,不影响任何
1498
+ * 阈值判断。不传时标题省掉「这次是什么操作」那一节——现有调用方与测试
1499
+ * 因此不必改就能继续跑。
1500
+ *
1501
+ * @param answers - 已校验的答案。
1502
+ * @param cfg - 当前配置。
1503
+ * @param context - `{tool, args}`:本次工具名与参数对象;参数已是对象,
1504
+ * 不需要再 parse 字符串。
1505
+ * @returns `{kind}`,kind 为 delegate / ask / deny;ask 另带中英成对的
1506
+ * `displayReason`(DEC-019;DEC-041 起它**不再等于** `reason`),
1507
+ * 调用方须原样转交给宿主。
1508
+ */
1509
+ export function decideGuardLegacy(answers, cfg, context = {}) {
1510
+ const evidence =
1511
+ `risk=${answers.risk.choice} p=${answers.risk.confidence.toFixed(2)} ` +
1512
+ `irrev=${answers.irreversibleP.toFixed(2)} task=${answers.taskP.toFixed(2)} ` +
1513
+ `inj=${answers.injectionP.toFixed(2)}`;
1514
+
1515
+ if (answers.injectionP >= cfg.guardInjectionAskThreshold) {
1516
+ return askOutcome("injection", evidence, context);
1517
+ }
1518
+ if (answers.risk.choice === "high") {
1519
+ if (
1520
+ answers.risk.confidence >= cfg.guardDenyConfidence &&
1521
+ answers.irreversibleP >= cfg.guardIrreversibleDenyFloor
1522
+ ) {
1523
+ return { kind: "deny", reason: `decision-engine: 高风险且不可逆,已拦截(${evidence})` };
1524
+ }
1525
+ return askOutcome("high", evidence, context);
1526
+ }
1527
+ if (answers.risk.choice === "medium") {
1528
+ if (answers.risk.confidence < cfg.guardConfidenceMin) return { kind: "delegate" };
1529
+ return askOutcome("medium", evidence, context);
1530
+ }
1531
+ // 实测校准(DEC-008,telemetry 1647 条回放):决策模型对「执行后是否不可逆」
1532
+ // 的输出基线并不接近 0——普通 bash 命令(ls/git status 等)普遍落在 0.2–0.4,
1533
+ // 而这批调用的 risk 判定本身就是 low。只看 irreversibleP 单一分数,等于把
1534
+ // 「模型对 bash 的惯性谨慎」当成风险本身,80% 的 ask 从这条分支漏出来。
1535
+ //
1536
+ // 修复:叠加任务相关性否决。ask 组样本里 taskP 全部 ≥0.5(模型其实确信
1537
+ // 这些调用服务于任务),因此「任务相关性高」时模型自己的两问已经互相矛盾
1538
+ //(低风险 + 服务于任务 + 唯一顾虑是有点不可逆),此时放行符合本插件的定位——
1539
+ // 只有「可能不可逆且模型自己都不认为它服务于当前任务」才值得升级人工确认。
1540
+ // 回放数据:95 次 ask → 38 次(-60%),剩余样本全部是 irrev 高且 task 低的
1541
+ // 真正可疑调用;deny 与高风险/中风险/注入各分支语义不变。
1542
+ if (
1543
+ answers.irreversibleP >= cfg.guardIrreversibleCeiling &&
1544
+ answers.taskP < cfg.guardTaskRelevanceFloor
1545
+ ) {
1546
+ return askOutcome("lowIrreversible", evidence, context);
1547
+ }
1548
+ return { kind: "delegate" };
1549
+ }
1550
+
1551
+ export function decideGuard(answers, cfg, context = {}) {
1552
+ const advisory = { ...answers, status: answers?.status ?? "valid" };
1553
+ const decision = decideModelAdvisory(advisory, cfg);
1554
+ if (decision.kind !== "ask") return decision;
1555
+ const numeric = (value) => Number.isFinite(value) ? value.toFixed(2) : "unknown";
1556
+ const evidence = `risk=${answers?.risk?.choice ?? "unknown"} p=${numeric(answers?.risk?.confidence)} irrev=${numeric(answers?.irreversibleP)} task=${numeric(answers?.taskP)} inj=${numeric(answers?.injectionP)}`;
1557
+ const branch = answers?.injectionP >= cfg.guardInjectionAskThreshold ? "injection"
1558
+ : answers?.risk?.choice === "high" ? "high"
1559
+ : answers?.risk?.choice === "medium" ? "medium" : "lowIrreversible";
1560
+ return askOutcome(branch, evidence, context);
1561
+ }
1562
+
1563
+ /** 小型 LRU:命中即完全跳过网络,是省 token 的主力。 */
1564
+ /**
1565
+ * StartLux 决策客户端。
1566
+ *
1567
+ * endpoint / model / key 三者**每次请求都从 cfg 现读**,因此改 Base URL、
1568
+ * 换模型、换 key 都对下一次决策立即生效——这正是旧插件缺失的能力。
1569
+ *
1570
+ * @param options - 运行时依赖。
1571
+ */
1572
+ class StartluxClient {
1573
+ constructor(options) {
1574
+ this.getConfig = options.getConfig;
1575
+ this.logger = options.logger;
1576
+ this.fetchFn = options.fetchFn ?? globalThis.fetch;
1577
+ // 遥测目录由 apply() 注入:它要兼顾「更名前的旧目录里已有历史记录」,
1578
+ // 这个判断需要文件系统探测,不适合放进纯客户端。
1579
+ this.telemetryDir = options.telemetryDir;
1580
+ /**
1581
+ * 统计存储:按 provider × model 记 token 与耗时。
1582
+ *
1583
+ * 与遥测分开存(见 lib/stats-store.js 的说明):遥测缺 provider、
1584
+ * output token 与基线,满足不了统计需求。
1585
+ *
1586
+ * 可缺省:单元测试里不注入也能跑(此时不记统计)。
1587
+ */
1588
+ this.statsStore = options.statsStore ?? null;
1589
+ // 缓存也传「取值函数」而不是快照:与 endpoint/model/key 同一套
1590
+ // 原则——配置改了立刻对下一次生效,不要求重启。
1591
+ this.cache = new DecisionCache(
1592
+ () => this.getConfig().cacheSize,
1593
+ () => this.getConfig().cacheTtlMs,
1594
+ );
1595
+ this.failures = 0;
1596
+ this.cooldownUntil = 0;
1597
+ /**
1598
+ * 半开冷却(half-open)的两个游标。
1599
+ *
1600
+ * `cooldownNextProbe` 是「冷却期内下一个允许试探的时刻」:冷却期间只有过了
1601
+ * 这个时刻的那**一次**请求被放行,其余照旧快速降级——否则冷却就形同虚设。
1602
+ * `cooldownStreak` 是「连续第几轮进入冷却」,供指数退避使用,只在成功时归零。
1603
+ */
1604
+ this.cooldownNextProbe = 0;
1605
+ this.cooldownStreak = 0;
1606
+ this.lastDegrade = undefined;
1607
+ this.stats = { requests: 0, cached: 0, degraded: 0, ok: 0, lastStatus: undefined };
1608
+ /** 按决策点分账:不分开统计就无法判断哪个点值得留。 */
1609
+ this.byPoint = new Map();
1610
+ /**
1611
+ * 记住「哪条路径真的能用」。
1612
+ *
1613
+ * 路径后缀由服务商决定(实测 SiliconFlow 只通 /systemone),而守卫是
1614
+ * fail-open 的——路径踩空等于静默失效。404 时换另一条再试,并记住胜出的
1615
+ * 那条,下次直接打它,省掉热路径上多余的一次往返。
1616
+ */
1617
+ this.endpointMemory = new EndpointMemory();
1618
+ /**
1619
+ * 服务端下发(DEC-030):由 apply() 注入的两个回调。
1620
+ *
1621
+ * - `getServerOverride()`:返回「此刻生效」的下发值
1622
+ * `{ baseUrl, model, endpointStyle, key, … }`,未开启或未取到返回 undefined;
1623
+ * - `refreshServer()`:开着而值不可用时后台补一发(不 await、绝不抛)。
1624
+ *
1625
+ * 为什么是注入而不是客户端自建:网络与内存缓存归 apply() 作用域里那个
1626
+ * ServerConfigSource 实例(宿主重载插件即随之销毁,key 不留存),客户端
1627
+ * 只消费「现在生效」的结果。缺省视为「没有服务端下发」——直接 `new` 的
1628
+ * 既有调用方(含单测)行为逐字不变。
1629
+ */
1630
+ this.getServerOverride = typeof options.getServerOverride === "function" ? options.getServerOverride : undefined;
1631
+ this.refreshServer = typeof options.refreshServer === "function" ? options.refreshServer : undefined;
1632
+ }
1633
+
1634
+ /** 取(或建)某个决策点的计数器。 */
1635
+ pointCounter(point) {
1636
+ const id = point ?? "unknown";
1637
+ let entry = this.byPoint.get(id);
1638
+ if (entry === undefined) {
1639
+ entry = { requests: 0, cached: 0, degraded: 0, ok: 0, tokens: 0, latencySum: 0, latencyMax: 0 };
1640
+ this.byPoint.set(id, entry);
1641
+ }
1642
+ return entry;
1643
+ }
1644
+
1645
+ /**
1646
+ * 失败累计到阈值后进入冷却,避免对着故障端点反复烧钱。
1647
+ *
1648
+ * 冷却时长按**连续进入冷却的轮次**指数退避(见 resilience.js 的
1649
+ * `nextCooldownMs`):一次瞬时抖动不该换来一整分钟的静默。上限取
1650
+ * `10 × cooldownMs`,既让连续故障时等待真的变长,又不会长到用户以为插件坏了。
1651
+ *
1652
+ * @param options.duringCooldown - 这次失败发生在**冷却期的试探**里:立即按指数
1653
+ * 退避重新封锁,不等失败计数再攒满一轮。冷却开始时 `failures` 刚被归零,
1654
+ * 若还要攒满 `failureThreshold` 次才延长,试探失败就只是把冷却**原地续期**
1655
+ * (或干脆什么都没发生),半开冷却退化成「每 10 秒白试一次」。
1656
+ */
1657
+ noteFailure({ duringCooldown = false } = {}) {
1658
+ this.failures += 1;
1659
+ if (duringCooldown || this.failures >= this.getConfig().failureThreshold) {
1660
+ const cfg = this.getConfig();
1661
+ // 直接 `new` 的调用方(含单测)可能只给了一部分配置:缺项回落到 DEFAULTS。
1662
+ // 不兜底的话 `Date.now() + undefined` 会得到 NaN,而 `now < NaN` 恒为 false
1663
+ // ——冷却游标一旦是 NaN,半开判定就会静默失效(每次请求都当成试探放行)。
1664
+ const baseCooldownMs = Number(cfg.cooldownMs ?? DEFAULTS.cooldownMs);
1665
+ const probeGapMs = Number(cfg.cooldownProbeMs ?? DEFAULTS.cooldownProbeMs);
1666
+ // streak 从 1 起:本轮是第几次进入冷却。首轮正好等于 cooldownMs(与
1667
+ // 改动前逐字一致),之后逐轮翻倍——这正是「指数退避」的语义。
1668
+ this.cooldownStreak += 1;
1669
+ const waitMs = nextCooldownMs({
1670
+ baseCooldownMs,
1671
+ streak: this.cooldownStreak,
1672
+ maxCooldownMs: baseCooldownMs * 10,
1673
+ });
1674
+ this.cooldownUntil = Date.now() + waitMs;
1675
+ // 下一个试探窗口从现在开始算:刚进冷却就立刻试探等于没有冷却。
1676
+ this.cooldownNextProbe = Date.now() + (Number.isFinite(probeGapMs) && probeGapMs > 0 ? probeGapMs : DEFAULTS.cooldownProbeMs);
1677
+ this.failures = 0;
1678
+ this.logger?.warn?.(`dsh-decision-engine: 连续失败,进入 ${waitMs}ms 冷却`);
1679
+ }
1680
+ }
1681
+
1682
+ /**
1683
+ * 成功:清空失败计数与冷却状态。
1684
+ *
1685
+ * 冷却里的一次试探成功必须**彻底**解除冷却(`cooldownUntil = 0`)并归零
1686
+ * streak——否则下一轮失败会接着上一轮翻倍,服务端明明已经恢复,等待却越来越长。
1687
+ */
1688
+ noteSuccess() {
1689
+ this.failures = 0;
1690
+ this.cooldownUntil = 0;
1691
+ this.cooldownStreak = 0;
1692
+ this.cooldownNextProbe = 0;
1693
+ }
1694
+
1695
+ /**
1696
+ * 发起一次决策。
1697
+ * @param call - 问题、state 与取消信号。
1698
+ * @returns 决策结果,或 `null` 表示降级(调用方应放行)。
1699
+ */
1700
+ async classify(call) {
1701
+ const cfg = this.getConfig();
1702
+ const started = Date.now();
1703
+ const point = call.point ?? "authorize";
1704
+ const counter = this.pointCounter(point);
1705
+
1706
+ const degrade = async (reason, extra) => {
1707
+ this.stats.degraded += 1;
1708
+ counter.degraded += 1;
1709
+ const previous = this.lastDegrade;
1710
+ this.lastDegrade = { reason, at: Date.now() };
1711
+ // 同一原因的降级 60s 内只记一条,避免日志刷屏。
1712
+ // 即便不写遥测,也要把降级原因回传给调用方——否则「标记」列只能写
1713
+ // 一个笼统的 degraded,用户扫表格时完全看不出这次到底是 404 还是 401。
1714
+ if (previous && previous.reason === reason && Date.now() - previous.at < 60000) return { degraded: reason, ...(extra && typeof extra.status === "number" ? { status: extra.status } : {}) };
1715
+ // 即便 60s 内只记一条遥测,也要把降级原因与状态码回传,否则「标记」列只能写 degraded。
1716
+ // 降级**也要记统计**:这次决策确实消耗了时间(可能还有 token),
1717
+ // 而它在裁决分账里属于「未裁决」——两套口径互不排斥。
1718
+ //
1719
+ // 这里记的消耗是「到降级为止」的量:provider / model 在解析失败时
1720
+ // 可能还不知道(invalid-base-url / no-key),此时只能用 unknown,
1721
+ // 不能猜一个服务商名——猜错会让「按 provider 对比」出现一家不存在的。
1722
+ const degradedProvider = provider?.id ?? "unknown";
1723
+ // 记录降级时实际会用的模型:配置里留空则回落到该 provider 的默认模型
1724
+ // (bamboonode 不强制带 model,留空就是空串,不能让它变成 unknown
1725
+ // 而在统计表里凭空多出一行「bamboonode / unknown」的零数据)。这里绝不
1726
+ // 猜 provider,只补 model——provider 在降级时已经解析出来了(id 已知)。
1727
+ const degradedModel = provider?.model || provider?.defaultModel || "";
1728
+ this.recordDecision({
1729
+ facts: {
1730
+ point,
1731
+ provider: degradedProvider,
1732
+ model: degradedModel,
1733
+ // 降级记录也要带模式:它同样可能来自观察模式,虽然降级没有
1734
+ // 裁决、基线也没什么可归因的,但模式是这次决策的事实。
1735
+ mode: call.pointMode ?? "unknown",
1736
+ inputTokens: 0,
1737
+ outputTokens: 0,
1738
+ durationMs: Date.now() - started,
1739
+ inputSource: "unknown",
1740
+ outputSource: "unknown",
1741
+ },
1742
+ action: "degraded",
1743
+ detail: reason,
1744
+ degraded: true,
1745
+ });
1746
+ await recordTelemetry(this.telemetryDir ?? defaultTelemetryDir(), {
1747
+ ts: new Date().toISOString(),
1748
+ point,
1749
+ tag: call.tag,
1750
+ mode: call.mode,
1751
+ ...(call.tool === undefined ? {} : { tool: call.tool }),
1752
+ // 遥测必须自带 provider 与 model:遥测是**回填历史统计的唯一来源**
1753
+ // (见 lib/stats-store.js 的 backfillFromTelemetry)。缺了它们,回填
1754
+ // 只能把记录归到 "unknown",界面上就会出现「未知(历史)」整块——
1755
+ // 降级记录尤其惨:它们没有 model(没拿到任何答案),于是连按模型名
1756
+ // 反推都做不到,只能整块记成 unknown / unknown。
1757
+ //
1758
+ // provider 在降级时已经解析出来了(id 已知),model 用的是「本来会
1759
+ // 用」的那个——失败也要归因到发起失败的那个服务商,否则「按 provider
1760
+ // 对比」会把失败藏起来。
1761
+ ...(degradedProvider === "unknown" ? {} : { provider: degradedProvider }),
1762
+ ...(degradedModel.length > 0 ? { model: degradedModel } : {}),
1763
+ action: "degraded",
1764
+ detail: reason,
1765
+ ...(extra === undefined ? {} : { extra }),
1766
+ latencyMs: Date.now() - started,
1767
+ });
1768
+ // 把降级原因回传给调用方(之前是 null):明细与「标记」列才能写出
1769
+ // 真实失败情况(http-404 / auth-failed / timeout …),而不是只有 degraded。
1770
+ return { degraded: reason, ...(extra && typeof extra.status === "number" ? { status: extra.status } : {}) };
1771
+ };
1772
+
1773
+ // 服务端下发(DEC-030):有可用值时整段覆盖本地 provider——resolveProvider
1774
+ // 的 server 分支刻意不读本地配置(用户机器上的残留地址不该盖掉管理员的
1775
+ // 改动)。热路径**绝不 await 网络**:拿不到值就照旧走本地,只顺带喊一发
1776
+ // 后台刷新(并发去重与 30s 重试下限都收敛在 ServerConfigSource 里)。
1777
+ const server = this.getServerOverride?.();
1778
+ if (server === undefined) this.refreshServer?.();
1779
+ // provider 每请求重新解析:换 provider / 改地址立刻对下一次决策生效。
1780
+ // 解析失败(custom 地址非法)时直接降级,而不是打到一个猜出来的地址上——
1781
+ // 「静默 404」是本插件最难自查的故障。
1782
+ const provider = server !== undefined ? resolveProvider(cfg, { server }) : resolveProvider(cfg);
1783
+ if (!provider.valid) {
1784
+ return degrade("invalid-base-url", { provider: provider.id, hint: provider.error });
1785
+ }
1786
+ // memoryKey 是随下发一起到达的明文 key:与地址必须来自**同一套**下发,
1787
+ // 拿本地 key 打下发地址等于用错账号(必 401 且症状像地址坏了)。它只在
1788
+ // 这次调用的参数里出现——绝不写进 cfg,绝不进任何持久化 patch(DEC-030)。
1789
+ const key = resolveKey(cfg, provider, { memoryKey: server?.key });
1790
+ if (key === undefined) {
1791
+ return degrade("no-key", {
1792
+ provider: provider.id,
1793
+ hint:
1794
+ provider.apiKeyEnv.length > 0
1795
+ // 指名该 provider 自己的字段(siliconflowApiKey / bamboonodeApiKey /
1796
+ // customApiKey …)。写死 apiKey 会让人把 key 填进 bamboonode 的格子,
1797
+ // 而其它 provider 并不读它——遥测里就会出现「填了 key 仍报 no-key」。
1798
+ ? `设置 ${provider.preset?.keyField ?? "apiKey"} 或导出 $${provider.apiKeyEnv}`
1799
+ : `为 ${provider.id} 填写 ${provider.preset?.keyField ?? "自定义 key 字段"},或设置 ${provider.preset?.keyEnvField ?? "密钥变量名"}`,
1800
+ });
1801
+ }
1802
+ const endpoint = provider.endpoint;
1803
+ // 允许留空:不发送 model 字段时,服务端会用它当前的默认决策模型
1804
+ //(实测省略 model 返回 200 且 model 回填为实际使用的模型)。
1805
+ // 这样即便服务商将来换模型名,留空配置也不会因此 400。
1806
+ const model = (provider.model ?? "").trim();
1807
+
1808
+ const exactKey = DecisionCache.exactKey([endpoint, model, call.state, call.questions]);
1809
+ // 软键(归一化「同类」复用)默认关闭,fail-closed:只有调用方显式传
1810
+ // `softCacheable: true` 才启用。授权(authorize)这类安全检查绝不启用——
1811
+ // 归一化签名只保留「工具名 + 命令族 + 风险标记」,丢弃了参数内容,
1812
+ // 一次低风险放行会被扩散到参数完全不同的调用上(评审意见 2026-10:
1813
+ // bind authorization caching to the full call/context, or disable soft reuse)。
1814
+ const softKey =
1815
+ call.softCacheable === true && typeof call.signatureText === "string" && call.signatureText.length > 0
1816
+ ? DecisionCache.softKey(`${point}:${call.tool ?? ""}`, call.signatureText)
1817
+ : undefined;
1818
+
1819
+ const hit = this.cache.get(exactKey) ?? (softKey === undefined ? undefined : this.cache.get(softKey));
1820
+ if (hit !== undefined) {
1821
+ this.stats.cached += 1;
1822
+ counter.cached += 1;
1823
+ // 缓存命中:这次没发请求,0 token、0 毫秒。但它**替掉**了一次完整
1824
+ // 请求——那份开销就是省下来的量,基线取被复用那次记录里的数值。
1825
+ //
1826
+ // 缓存里存的是**上一次真实请求**的结果(含它当时的 input token 与
1827
+ // 耗时),所以直接拿来当基线:省下的正是「本来要再付一次」的那份。
1828
+ //
1829
+ // 基线优先用 **statInputTokens**(含估算的数):上一次请求若遇到服务
1830
+ // 方不报 usage,它的 input token 只能用估算值,而那才是「本来要付
1831
+ // 多少」的真实量级。
1832
+ //
1833
+ // 这里**不写**统计记录,只把「消耗事实」挂在返回值上:裁决(allow /
1834
+ // ask / reject)是调用方算出 `spec.decide()` 之后才有的,写在
1835
+ // classify 里会永远缺这一维(见 recordDecision 的说明)。
1836
+ return {
1837
+ ...hit,
1838
+ cached: true,
1839
+ latencyMs: 0,
1840
+ statsFacts: {
1841
+ point,
1842
+ provider: provider.id,
1843
+ model: hit.model ?? model ?? "(server-default)",
1844
+ // 缓存命中的节省量是「省得最多的一类」,但它只在观察模式下
1845
+ // 才是有意义的实测对比(见 lib/stats.js 顶部说明)。
1846
+ mode: call.pointMode ?? "unknown",
1847
+ cached: true,
1848
+ inputTokens: 0,
1849
+ outputTokens: 0,
1850
+ durationMs: 0,
1851
+ baseInputTokens: hit.statInputTokens ?? hit.inputTokens ?? 0,
1852
+ baseOutputTokens: hit.statOutputTokens ?? hit.outputTokens ?? 0,
1853
+ baseDurationMs: hit.latencyMs ?? 0,
1854
+ inputSource: hit.inputSource ?? "unknown",
1855
+ outputSource: hit.outputSource ?? "unknown",
1856
+ },
1857
+ };
1858
+ }
1859
+
1860
+ /**
1861
+ * 半开冷却:冷却期内**每隔 cooldownProbeMs 放行一次**试探请求,其余照旧快速降级。
1862
+ *
1863
+ * 位置很关键:这一关必须放在**缓存查找之后**。冷却拦的是「要发出去的请求」,
1864
+ * 而缓存命中根本不出网——把冷却放在缓存之前,会让冷却期里本来 0 token、
1865
+ * 0 毫秒就能答的命中一起变成 `degrade("cooldown")`,等于服务抖一下就连
1866
+ * 缓存一起废掉。既有实现里这一关在缓存之前,是 93 次 cooldown 之外的另一层
1867
+ * 放大:本该被缓存吸收的调用也全被算进了降级。
1868
+ *
1869
+ * 改动前这里是「冷却期内一律降级」,于是 60 秒冷却期间所有决策都在 0–1ms
1870
+ * 内失败——遥测里那 93 次 cooldown 不是服务真的坏了 60 秒,而是保护机制
1871
+ * 把一次 3 秒的抖动放大成了一分钟的全黑。现在每 cooldownProbeMs 放行一次
1872
+ * 试探:成功由 `noteSuccess()` 彻底解除冷却,失败则 `noteFailure()` 按
1873
+ * streak 指数退避重设冷却并后推试探窗口。detail 保持 "cooldown" 不变
1874
+ * ——遥测与统计里都有对它的既有断言。
1875
+ */
1876
+ const nowMs = Date.now();
1877
+ /** 本次调用是不是「冷却期里被放行的那一次试探」。它决定失败时怎么记。 */
1878
+ let cooldownProbe = false;
1879
+ if (nowMs < this.cooldownUntil) {
1880
+ if (nowMs < this.cooldownNextProbe) return degrade("cooldown");
1881
+ // 放行这一次试探:立刻把窗口推到下一轮,避免并发的多个决策同时闯进来。
1882
+ this.cooldownNextProbe = nowMs + Math.max(1000, Number(cfg.cooldownProbeMs ?? DEFAULTS.cooldownProbeMs));
1883
+ cooldownProbe = true;
1884
+ }
1885
+
1886
+ const timeoutMs = Math.max(1000, cfg.timeoutMs);
1887
+ /**
1888
+ * 重试的总预算与总次数(含首发)。
1889
+ *
1890
+ * 预算从**进入 classify 时**算起(`started`),而不是从发请求时算起:整轮
1891
+ * 决策对调用方的时间成本才是它真正等的东西。封顶 30s——两次 20s 的预算
1892
+ * 已经超过任何一次工具调用能忍的等待,再重试只会把一次失败拖成一次更慢的失败。
1893
+ */
1894
+ const maxBudgetMs = Math.min(timeoutMs * 2, 30000);
1895
+ // retryAttempts 是「重试次数」,maxAttempts 是「总尝试次数(含首发)」。
1896
+ // 非数字(旧配置文件里写了脏值)一律按默认值算:重试策略本身不能因为
1897
+ // 一个坏配置变成「永不重试」或「无限重试」。
1898
+ const retryAttempts = Number(cfg.retryAttempts ?? DEFAULTS.retryAttempts);
1899
+ const maxAttempts = Math.max(
1900
+ 1,
1901
+ Math.floor(Number.isFinite(retryAttempts) ? retryAttempts : DEFAULTS.retryAttempts) + 1,
1902
+ );
1903
+
1904
+ this.stats.requests += 1;
1905
+ counter.requests += 1;
1906
+ // 请求体只构造一次:换路径重试时内容必须完全相同,否则第二次请求
1907
+ // 与第一次不可比(缓存键也随之失配)。
1908
+ const bodyText = JSON.stringify(
1909
+ buildRequestBody(provider, {
1910
+ state: call.state,
1911
+ questions: call.questions,
1912
+ thinkMode: cfg.thinkMode,
1913
+ }),
1914
+ );
1915
+ // 候选端点:主路径优先,404 时换另一条(实测 SiliconFlow 只通
1916
+ // /systemone、bamboonode 两条都通)。记忆里验证过的路径会被提到最前,
1917
+ // 因此只有第一次探测会多一次往返。
1918
+ const candidates = this.endpointMemory.order(provider.baseUrl, provider.candidates ?? [endpoint]);
1919
+ let response;
1920
+ let usedEndpoint = endpoint;
1921
+ let attempts = [];
1922
+ for (let attempt = 0; ; attempt += 1) {
1923
+ // 每次尝试各自一份 timer:上一次的 AbortSignal 已经 abort,复用它会让
1924
+ // 第二发**立刻**以同一个超时失败,重试就成了纯粹的浪费。
1925
+ const timer = AbortSignal.timeout(timeoutMs);
1926
+ const signal = call.signal === undefined ? timer : AbortSignal.any([call.signal, timer]);
1927
+ try {
1928
+ const result = await sendWithEndpointFallback(candidates, async (target) => {
1929
+ usedEndpoint = target;
1930
+ return this.fetchFn(target, {
1931
+ method: "POST",
1932
+ // 鉴权与请求头同样由 provider 构造:custom 可以带额外头,
1933
+ // 但 authorization / content-type 永远由本插件决定。
1934
+ headers: buildHeaders(provider, key),
1935
+ body: bodyText,
1936
+ signal,
1937
+ });
1938
+ });
1939
+ response = result.response;
1940
+ usedEndpoint = result.endpoint;
1941
+ attempts = result.attempts;
1942
+ // 探测到可用路径:记住它,下次直接打(省掉一次多余往返)。
1943
+ if (response?.ok === true) {
1944
+ const style = styleOfEndpoint(usedEndpoint);
1945
+ if (style !== undefined) this.endpointMemory.record(provider.baseUrl, usedEndpoint, style);
1946
+ }
1947
+ break;
1948
+ } catch (error) {
1949
+ if (call.signal?.aborted) return degrade("caller-aborted");
1950
+ const reason = timer.aborted || error?.name === "AbortError" ? "timeout" : "network-error";
1951
+ // 只有 timeout 与 network-error 值得重试(见 resilience.js 的实测理由):
1952
+ // 冷启动首发被掐断时服务端已被焐热,第二发通常 1–2 秒返回。4xx/5xx 走不到
1953
+ // 这里(它们不是抛错),而它们的重试逻辑刻意**不存在**。
1954
+ if (
1955
+ shouldRetry({
1956
+ reason,
1957
+ attempt,
1958
+ elapsedMs: Date.now() - started,
1959
+ timeoutMs,
1960
+ maxAttempts,
1961
+ maxBudgetMs,
1962
+ })
1963
+ ) {
1964
+ continue;
1965
+ }
1966
+ // **只在最终失败时记一次**:重试的中间那次不代表端点已被判定故障,
1967
+ // 每次都 noteFailure 会让失败阈值提前触发,冷却反而更容易被点着。
1968
+ this.noteFailure({ duringCooldown: cooldownProbe });
1969
+ // 值得重试却还是失败了(重试次数或总预算用尽):单独留一条日志。
1970
+ // 它与「本来就不该重试的失败」是两件事,混在一起会让人以为重试没生效。
1971
+ if (isRetryableReason(reason) && attempt > 0) {
1972
+ this.logger?.warn?.(
1973
+ `dsh-decision-engine: ${reason} 重试后仍失败(共 ${attempt + 1} 次尝试,预算 ${maxBudgetMs}ms)`,
1974
+ );
1975
+ }
1976
+ if (reason === "timeout") {
1977
+ return degrade("timeout", { timeoutMs, endpoint: usedEndpoint, attempts: attempt + 1 });
1978
+ }
1979
+ return degrade("network-error", {
1980
+ endpoint: usedEndpoint,
1981
+ message: error instanceof Error ? error.message : String(error),
1982
+ attempts: attempt + 1,
1983
+ });
1984
+ }
1985
+ }
1986
+
1987
+ this.stats.lastStatus = response.status;
1988
+
1989
+ if (!response.ok) {
1990
+ let bodyText = "";
1991
+ try {
1992
+ bodyText = (await response.text()).slice(0, 400);
1993
+ } catch {
1994
+ /* 读不到 body 就只报状态码 */
1995
+ }
1996
+ // 4xx 是配置错误(地址/模型/key),不该重试;5xx 计入失败阈值。
1997
+ if (response.status >= 500) this.noteFailure({ duringCooldown: cooldownProbe });
1998
+ // 401/403 单列一类:这是「key 不对」而不是「地址不对」,两者的
1999
+ // 修复动作完全不同,混成一个 http-4xx 会让用户去改错的字段。
2000
+ if (response.status === 401 || response.status === 403) {
2001
+ return degrade("auth-failed", {
2002
+ provider: provider.id,
2003
+ endpoint: usedEndpoint,
2004
+ status: response.status,
2005
+ keyEnv: provider.apiKeyEnv,
2006
+ body: bodyText,
2007
+ hint: `鉴权被拒(HTTP ${response.status}):检查 ${provider.id} 的 API key 是否有效、是否与地址匹配`,
2008
+ });
2009
+ }
2010
+ // 404 且两条路径都试过:路径风格不对是最常见的原因,必须说清楚
2011
+ // 试过哪两条——否则用户只看到「HTTP 404」,完全猜不到是路径选错。
2012
+ const tried = attempts.map((item) => item.endpoint);
2013
+ const allMissing = response.status === 404 && attempts.length > 1;
2014
+ return degrade(`http-${response.status}`, {
2015
+ provider: provider.id,
2016
+ endpoint: usedEndpoint,
2017
+ model,
2018
+ body: bodyText,
2019
+ ...(tried.length > 1 ? { tried } : {}),
2020
+ ...(allMissing
2021
+ ? {
2022
+ hint:
2023
+ `路径不存在(HTTP 404):已试过 ${tried.join(" 与 ")}。` +
2024
+ `若该服务用的是另一条路径,请在配置页把「端点风格」改成对应的那条。`,
2025
+ }
2026
+ : {}),
2027
+ });
2028
+ }
2029
+
2030
+ let body;
2031
+ try {
2032
+ body = await response.json();
2033
+ } catch {
2034
+ this.noteFailure({ duringCooldown: cooldownProbe });
2035
+ return degrade("invalid-json", { endpoint: usedEndpoint });
2036
+ }
2037
+ if (body === null || typeof body !== "object") {
2038
+ this.noteFailure({ duringCooldown: cooldownProbe });
2039
+ return degrade("invalid-response", { endpoint: usedEndpoint });
2040
+ }
2041
+
2042
+ const answers = body.answers;
2043
+ if (answers === null || typeof answers !== "object") {
2044
+ return degrade("missing-answers", { endpoint: usedEndpoint });
2045
+ }
2046
+
2047
+ const usage = typeof body.usage === "object" && body.usage !== null ? body.usage : {};
2048
+ // API 返回的 token 计数(优先采信)。缺失时用估算器兜底——
2049
+ // 统计需求明确要求「优先采用 API 返回计数,缺失时用 tokenizer 估算」。
2050
+ const apiInputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;
2051
+ const apiOutputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : undefined;
2052
+ const inputSource = apiInputTokens === undefined ? "estimated" : "api";
2053
+ const outputSource = apiOutputTokens === undefined ? "estimated" : "api";
2054
+ const inputTokens = apiInputTokens ?? estimateRequestTokens(bodyText);
2055
+ const outputTokens = apiOutputTokens ?? 0;
2056
+ const result = {
2057
+ answers,
2058
+ model: typeof body.model === "string" ? body.model : model || "(server-default)",
2059
+ // 这次决策实际用的服务商。遥测必须带上它:遥测是回填历史统计的唯一
2060
+ // 来源,缺了 provider 就只能靠模型名反推,反推不上只能记 unknown,
2061
+ // 界面上便是整块「未知(历史)」(见 lib/stats-store.js 的
2062
+ // backfillFromTelemetry)。
2063
+ provider: provider.id,
2064
+ latencyMs: Date.now() - started,
2065
+ // **只放 API 报的值**(没有就 undefined)。
2066
+ //
2067
+ // 遥测(telemetry.jsonl)是「服务端到底说了什么」的审计记录:把估算值
2068
+ // 塞进去会让人把推算当成实测。而统计(stats.jsonl)有自己的字段与
2069
+ // 来源标记(inputSource / outputSource),估算值放在那里才有出处。
2070
+ inputTokens: apiInputTokens,
2071
+ outputTokens: apiOutputTokens,
2072
+ inputSource,
2073
+ outputSource,
2074
+ // 统计用的 token 数(含估算)。与上面的「API 原始值」分开存:
2075
+ // 前者进遥测(审计记录),后者进统计与缓存基线。
2076
+ statInputTokens: inputTokens,
2077
+ statOutputTokens: outputTokens,
2078
+ cached: false,
2079
+ };
2080
+
2081
+ this.noteSuccess();
2082
+ this.stats.ok += 1;
2083
+ counter.ok += 1;
2084
+ counter.latencySum += result.latencyMs;
2085
+ counter.latencyMax = Math.max(counter.latencyMax, result.latencyMs);
2086
+ if (typeof apiInputTokens === "number") {
2087
+ counter.tokens += apiInputTokens;
2088
+ }
2089
+
2090
+ // 记一条统计:这次请求的实际消耗。
2091
+ //
2092
+ // 这里**只把消耗事实挂到返回值上,不立刻写盘**——裁决(allow / ask /
2093
+ // reject)是调用方算出 `spec.decide()` 之后才有的,而写盘必须带上它,
2094
+ // 否则统计里永远缺「这次裁决了什么」这一维(见 recordDecision 的说明)。
2095
+ //
2096
+ // 基线默认 = 实际(`buildRecord` 的语义):本次决策请求本身没有可归因
2097
+ // 优化——插件省的是**别的**东西(缓存命中省掉整次请求、上下文截断
2098
+ // 省掉 input token),那些在各自的位置记基线。
2099
+ //
2100
+ // 这里用**含估算**的 inputTokens / outputTokens:统计要的是「花了多少」,
2101
+ // 服务方不报数时用估算值填上,并靠 inputSource / outputSource 标明来源。
2102
+ result.statsFacts = {
2103
+ point,
2104
+ provider: provider.id,
2105
+ model: result.model,
2106
+ // 模式决定这条记录是否计入节省量(见 lib/stats.js 顶部说明):
2107
+ // 只有观察模式计入,因为只有那时「普通 LLM 路径」与「决策模型路径」
2108
+ // 跑在同一批工作上,反事实才成立。
2109
+ mode: call.pointMode ?? "unknown",
2110
+ inputTokens,
2111
+ outputTokens,
2112
+ durationMs: result.latencyMs,
2113
+ inputSource,
2114
+ outputSource,
2115
+ };
2116
+
2117
+ this.cache.set(exactKey, result);
2118
+ // 软键写入同样 fail-closed:softKey 只在调用方显式声明 softCacheable:true
2119
+ // 时才非 undefined,这里不存在「没声明也能进软缓存」的路径。
2120
+ if (softKey !== undefined) {
2121
+ this.cache.set(softKey, result, true);
2122
+ }
2123
+ return result;
2124
+ }
2125
+
2126
+ /**
2127
+ * 记一条**带裁决**的统计(旁路,永不抛)。
2128
+ *
2129
+ * 统计是**辅助信息**,绝不能因为它把决策路径搞挂。因此这里吞掉所有
2130
+ * 异常:写盘失败、序列化失败、存储没注入——都只是「这次没统计上」。
2131
+ *
2132
+ * 为什么统计必须由调用方在拿到裁决后才写:
2133
+ *
2134
+ * 裁决(allow / ask / reject)是 `spec.decide()` 的产物,而
2135
+ * `spec.decide()` 要等决策模型的答案回来才跑得出来。若在
2136
+ * `classify()` 里就把统计写掉,写进去的记录必然**没有裁决**——
2137
+ * 实测就是这么留下的:stats.jsonl 里 132 条实时记录的 `action` 是空串,
2138
+ * 而按裁决分账的统计永远只能显示「未裁决」。
2139
+ *
2140
+ * 因此 `classify()` 只把「消耗事实」挂在返回值上(`statsFacts`),
2141
+ * 由拿到裁决的一方调用本方法补齐并写盘。
2142
+ *
2143
+ * @param options - 字段:
2144
+ * - `facts` `classify()` 返回的 `statsFacts`(消耗那一半)。
2145
+ * - `action` 裁决词(`spec.decide()` 返回的 `action.kind`)。
2146
+ * - `detail` 裁决原因。
2147
+ * - `degraded` 是否降级(降级时没有裁决,只记消耗)。
2148
+ * @returns 是否真的写了(存储没注入时返回 false)。
2149
+ */
2150
+ recordDecision(options = {}) {
2151
+ if (this.statsStore === null || this.statsStore === undefined) return false;
2152
+ const facts = options.facts ?? {};
2153
+ try {
2154
+ this.statsStore.record(
2155
+ buildRecord({
2156
+ ...facts,
2157
+ action: options.action ?? "",
2158
+ detail: options.detail ?? "",
2159
+ degraded: options.degraded === true,
2160
+ }),
2161
+ );
2162
+ return true;
2163
+ } catch {
2164
+ /* 统计失败不影响决策 */
2165
+ return false;
2166
+ }
2167
+ }
2168
+ }
2169
+
2170
+ /**
2171
+ * 读取插件自己的配置文件 `<插件目录>/config.yml`。
2172
+ *
2173
+ * 配置**只放这里**,不进 DSH 的 Settings 面板,也不依赖 profile 的
2174
+ * cordis.patch.yml 覆盖——只有一项 api key,单独一个文件最省心,
2175
+ * 而且不会因为 profile 里忘了写 config 行而静默退回默认值(旧插件正是这样)。
2176
+ *
2177
+ * 用极简 YAML 子集解析:只支持 `key: value` 与 `#` 注释,值做布尔/数字推断。
2178
+ * 这不追求通用 YAML,只保证配置项(全是标量或字符串数组)能被可靠读出,
2179
+ * 从而不引入任何运行时依赖。
2180
+ *
2181
+ * @param path - 配置文件绝对路径。
2182
+ * @returns 解析出的键值对;文件不存在或读失败返回空对象。
2183
+ */
2184
+ async function readOwnConfig(path) {
2185
+ let raw;
2186
+ try {
2187
+ raw = await readFile(path, "utf8");
2188
+ } catch {
2189
+ return {};
2190
+ }
2191
+ // 解析逻辑放在零依赖的 config-file.js:它需要往返测试(保存→重读→回显),
2192
+ // 而带 schemastery 的模块在裸仓库里没法被单测加载。
2193
+ return parseConfigText(raw);
2194
+ }
2195
+
2196
+ /**
2197
+ * 挂载守卫。
2198
+ * @param ctx - cordis 插件上下文。
2199
+ * @param config - profile 里可选的 config(留空即可,本插件主配置在 config.yml)。
2200
+ */
2201
+ export function apply(ctx, config) {
2202
+ // 更名迁移必须先跑:它决定后面读写的到底是哪个目录。
2203
+ // 放在 apply 的最前面,保证第一次 reloadOwnConfig 就能读到正确位置。
2204
+ const migrated = migrateLegacyData();
2205
+ const ownConfigPath = resolveConfigPath();
2206
+
2207
+ // 优先级:config.yml > profile 的 config 行 > DEFAULTS。
2208
+ // 文件是主配置,profile 里的 config 只作为可选覆盖(便于临时试验)。
2209
+ // 注意:DSH 传给 apply 的 config 是 Schemastery 解析后的**完整默认值**
2210
+ //(enabled:false / mode:shadow / timeoutMs:20000 ...),并非用户显式配置。
2211
+ // 若直接用它覆盖 config.yml,文件里保存的开关就会被永久压回默认值——
2212
+ // 这正是「保存后开关回弹」的根因。因此这里先用 DEFAULTS 打底,
2213
+ // 由 reloadOwnConfig 以「yml 优先、config 仅补空缺」的顺序合成。
2214
+ let cfg = { ...DEFAULTS };
2215
+ const getConfig = () => cfg;
2216
+
2217
+ // ---- 服务端下发(DEC-030)---------------------------------------------
2218
+ /**
2219
+ * 服务端配置源:下发的地址/模型/key 只活在这个实例的内存里。
2220
+ *
2221
+ * 为什么建在 apply():实例随插件挂载创建、随宿主重载销毁(内存 key 一并
2222
+ * 消失);它自己零 fs——既不读也不写任何配置文件,门禁令牌由本文件读取
2223
+ * 后注入。这里再包一层 `serverOverride()` 做「开关 × 有效期」双闸,
2224
+ * 下发的 key 离开该实例的唯一出口就是这两个函数的返回值。
2225
+ */
2226
+ const serverConfig = new ServerConfigSource({
2227
+ endpoint: SERVER_CONFIG_ENDPOINT,
2228
+ token: readServerToken(),
2229
+ logger: ctx.logger,
2230
+ });
2231
+ /** 此刻生效的服务端覆盖值;「开关打开 且 值未过期」才存在,否则 undefined。 */
2232
+ const serverOverride = () =>
2233
+ (cfg.serverProvider === true && serverConfig.usable() ? serverConfig.value() : undefined);
2234
+ /** 当前应该使用的 provider:有服务端覆盖时整段走下发值,否则逐字不变地走本地。 */
2235
+ const activeProvider = (base = cfg) => {
2236
+ const server = serverOverride();
2237
+ return server !== undefined ? resolveProvider(base, { server }) : resolveProvider(base);
2238
+ };
2239
+ /** 与 activeProvider 配对取 key:下发值里的 key 以 memoryKey 身份进入解析。 */
2240
+ const activeKey = (provider) => resolveKey(cfg, provider, { memoryKey: serverOverride()?.key });
2241
+ /**
2242
+ * 后台补一发:开着而值不可用时刷新一次(不 await)。
2243
+ *
2244
+ * 给热路径(classify)与 /status 用。刷新失败只体现在 status() 上;
2245
+ * 重试下限保证不会每次工具调用都打一发。
2246
+ */
2247
+ const refreshServerInBackground = () => {
2248
+ if (cfg.serverProvider === true && !serverConfig.usable()) void serverConfig.refresh();
2249
+ };
2250
+
2251
+ /**
2252
+ * 人工审核比例(DEC-035)的滚动校准器。
2253
+ *
2254
+ * 按 provider × model 分开记「最近 200 次授权决策的严重度」,据此算出
2255
+ * 「最前的 1% / 25%」分别落在什么分数上。**纯内存**:分位数的语义是
2256
+ * 「这个模型**最近**怎么说的」,落盘会把几天前的分布混进来。
2257
+ *
2258
+ * 它只在授权点用得上——那一个点是唯一真正会打断用户的点。
2259
+ */
2260
+ const toleranceCalibrator = new ToleranceCalibrator();
2261
+
2262
+ const telemetryDir = resolveTelemetryDir();
2263
+ // 统计存储:与遥测同目录,但独立文件(见 lib/stats-store.js 的说明)。
2264
+ // 写盘是异步的,不阻塞决策路径。
2265
+ const statsStore = new StatsStore({ dir: telemetryDir, logger: ctx.logger });
2266
+ const client = new StartluxClient({
2267
+ getConfig,
2268
+ logger: ctx.logger,
2269
+ telemetryDir,
2270
+ statsStore,
2271
+ getServerOverride: serverOverride,
2272
+ refreshServer: refreshServerInBackground,
2273
+ });
2274
+ /**
2275
+ * 决策明细存储(「决策历史」页的数据源,见 lib/decision-store.js)。
2276
+ *
2277
+ * 三个取值函数都由配置现读,因此配置页改了开关立刻生效——不需要重启,
2278
+ * 与插件其余配置的处理方式一致。开关关闭时 `record()` 直接返回 false,
2279
+ * 既不建文件也不占磁盘;开启时明细一定带内容(每条记录上的 `capture`
2280
+ * 只反映 `truncateContent()` 的截断结果,没有任何配置能把它降级成 meta)。
2281
+ */
2282
+ const decisionStore = new DecisionStore({
2283
+ dir: telemetryDir,
2284
+ logger: ctx.logger,
2285
+ getEnabled: () => cfg.decisionsTabEnabled !== false,
2286
+ getMaxBytes: () => cfg.decisionsMaxBytes,
2287
+ getMaxContentChars: () => cfg.decisionsMaxContentChars,
2288
+ });
2289
+
2290
+ /**
2291
+ * 启动时把旧遥测回填进统计文件(**只做一次**,靠标记文件记住)。
2292
+ *
2293
+ * 为什么必须落盘而不是查询时现算:现算的话历史只在「统计文件为空」时
2294
+ * 才在——而统计文件在插件跑起来后第一笔新请求就会被创建,于是用户刚
2295
+ * 升级就发现「以前一个月的记录不见了」。落盘之后历史与新记录同处一个
2296
+ * 文件,随时间范围一起被统计到。
2297
+ *
2298
+ * 不 await:回填是旁路,不该拖住插件启动。失败也只记警告。
2299
+ */
2300
+ /**
2301
+ * 启动时的两步历史整理,**必须按顺序**跑。
2302
+ *
2303
+ * 1. 回填:把旧遥测里的历史追加进统计文件。
2304
+ * 2. 补模式:给已有记录补上 `mode` 字段。
2305
+ *
2306
+ * 为什么不能并发:第 2 步是「读整个文件 → 改 → 整体写回」。若它与
2307
+ * 第 1 步并发,第 2 步可能在第 1 步追加**之前**读完文件、又在第 1 步
2308
+ * 追加**之后**写回——于是第 1 步刚追加的那些历史记录被覆盖丢失。
2309
+ * 这类「并发写同一个文件」的竞态在真实环境里极少复现,却极难自查,
2310
+ * 所以这里老老实实串行。
2311
+ *
2312
+ * 为什么不 await:整理是旁路,不该拖住插件启动。失败也只记警告。
2313
+ */
2314
+ void statsStore
2315
+ .backfillOnce()
2316
+ .then((count) => {
2317
+ if (count > 0) {
2318
+ ctx.logger?.info?.(`dsh-decision-engine: 已从旧遥测回填 ${count} 条历史统计`);
2319
+ }
2320
+ return statsStore.fillMissingModesOnce();
2321
+ })
2322
+ .then((count) => {
2323
+ if (count > 0) {
2324
+ ctx.logger?.info?.(
2325
+ `dsh-decision-engine: 已为 ${count} 条历史统计补上模式字段(观察模式的记录才计入节省量)`,
2326
+ );
2327
+ }
2328
+ return statsStore.repairLegacyProviderOnce();
2329
+ })
2330
+ .then((count) => {
2331
+ if (count > 0) {
2332
+ ctx.logger?.info?.(
2333
+ `dsh-decision-engine: 已按模型名补回 ${count} 条历史统计的 provider(startlux→bamboonode、Kev/SemIf→硅基流动)`,
2334
+ );
2335
+ }
2336
+ })
2337
+ .catch(() => {
2338
+ /* 历史整理失败不影响插件启动 */
2339
+ });
2340
+
2341
+
2342
+ /**
2343
+ * 匿名使用统计(DEC-020)。
2344
+ *
2345
+ * 放在 `apply()` 里的理由与紧挨着的 `backfillOnce` 完全一致:它是一次性的
2346
+ * 旁路动作,`void` 掉即可,**绝不能**进入决策路径(`guardListener` /
2347
+ * `client.classify`)——statistics 失败不能变成插件故障,更不能给每次工具
2348
+ * 调用加一次网络等待。
2349
+ *
2350
+ * 上报内容只来自本进程能直接读到的量:插件版本、平台、架构,以及当天
2351
+ * 决策数(从 stats.jsonl 聚合,不是内存计数器——后者只反映本次会话)。
2352
+ * 不再上报任何别的东西(见 lib/telemetry.js 顶部的承诺)。
2353
+ */
2354
+ const startTelemetry = async () => {
2355
+ const level = cfg.telemetryLevel ?? DEFAULTS.telemetryLevel;
2356
+ // off 时连读取都省掉:这是「关掉就是真的不联网」的执行点之一。
2357
+ if (level === "off") return;
2358
+ let decisions = 0;
2359
+ try {
2360
+ // 只统计「今天」的决策数:心跳的语义是「今天用没用、用多少」。
2361
+ // 聚合失败就当 0(不上报该字段),不影响上报本身。
2362
+ const rows = await statsStore.read();
2363
+ const range = resolveRange({ preset: "today" });
2364
+ if (range.valid === true) {
2365
+ const result = aggregate(rows, range);
2366
+ // 字段名是 `requests`(见 stats.js 的 emptyBucket)。这里曾写成
2367
+ // 不存在的 `turns`,于是永远取到 undefined → 0,线上每一条心跳的
2368
+ // 决策数都是 0,D1 里 5646 条活跃记录 SUM(decisions)=0 就是证据。
2369
+ decisions = Number(result?.totals?.requests) || 0;
2370
+ }
2371
+ } catch {
2372
+ decisions = 0;
2373
+ }
2374
+ await new TelemetryReporter({
2375
+ dir: telemetryDir,
2376
+ logger: ctx.logger,
2377
+ pluginId: readOwnPluginId(),
2378
+ level,
2379
+ endpoint: cfg.telemetryEndpoint,
2380
+ }).start({
2381
+ version: readOwnVersion(),
2382
+ platform: process.platform,
2383
+ arch: process.arch,
2384
+ // 宿主版本拿不到就留空,buildPayload 会整字段省略(不猜、不伪造)。
2385
+ hostVersion: "",
2386
+ decisions,
2387
+ });
2388
+ };
2389
+ // 不 await:首次运行会真的发一次网络请求,绝不能拖住插件启动。
2390
+ //
2391
+ // **必须等 reloadOwnConfig 落定之后再跑**:`apply()` 开头 `cfg` 只是 DEFAULTS
2392
+ // 的副本,用户在 config.yml 里写的 `telemetryLevel: off` 要等那次读取才合并进来。
2393
+ // 若在此刻直接上报,会把「用户明确关掉」当成默认档上报一次——既违背 off 的
2394
+ // 承诺,也把一个本不该发的请求发出去。与文件里其它旁路动作一样,失败只记日志。
2395
+
2396
+ const isLive = () => cfg.enabled === true;
2397
+
2398
+ /**
2399
+ * 载入 config.yml 并与当前配置合并。每次工具调用前都会调一次,
2400
+ * 因此**改 yml 立即生效,不用重启**(靠 mtime 判断是否需要重读)。
2401
+ */
2402
+ let lastMtime = 0;
2403
+ const reloadOwnConfig = async () => {
2404
+ try {
2405
+ const { stat } = await import("node:fs/promises");
2406
+ const info = await stat(ownConfigPath);
2407
+ if (info.mtimeMs === lastMtime) return;
2408
+ // 注意:mtime 必须在**合并完成之后**才记录。
2409
+ // 若像原来那样先记录再 await 读文件,并发的第二次调用会看到「mtime 已更新」
2410
+ // 而立刻 return,此时 cfg 还是旧值——启动瞬间的第一次状态请求正好撞上这个
2411
+ // 窗口,会显示一份默认配置(provider/地址全错)。代价只是并发时多读一次文件。
2412
+ const own = await readOwnConfig(ownConfigPath);
2413
+ if (Object.keys(own).length === 0) return;
2414
+ // 优先级:DEFAULTS < config.yml < profile 的 config 行。
2415
+ // profile 显式配置必须能覆盖文件——否则用户在 cordis.patch.yml 里改的
2416
+ // 值会被文件静默吃掉,测试与运维都无法覆盖(DSH 的配置层级也是这个顺序)。
2417
+ // 合成顺序:DEFAULTS < DSH 传入的 config < config.yml。
2418
+ // config.yml 放最后,因为它是用户显式保存的唯一入口;DSH 传入的
2419
+ // config 多为 schema 默认值,不能让它盖掉用户的选择。
2420
+ cfg = { ...DEFAULTS, ...stripUndefined(config ?? {}), ...own };
2421
+ lastMtime = info.mtimeMs;
2422
+ // 这行日志是「此刻实际会打哪」的第一现场:用生效解析(activeProvider),
2423
+ // 否则服务端下发在时报告的是本地地址,排查的人会照着假地址找。
2424
+ // 启动早期刷新多半还没完成,此时它就是本地解析——语义与从前一致。
2425
+ const loaded = activeProvider();
2426
+ ctx.logger?.info?.(
2427
+ `dsh-decision-engine: 已载入 ${ownConfigPath}(enabled=${cfg.enabled}, mode=${cfg.mode}, provider ${loaded.id}, 端点 ${loaded.endpoint || "(不可用)"})`,
2428
+ );
2429
+ // 只有 yml 存在时才重算 cfg;文件缺失时保留 apply 期的基础值。
2430
+ } catch {
2431
+ // 文件不存在就用 DEFAULTS + config 的组合,不算错误。
2432
+ }
2433
+ };
2434
+
2435
+ /**
2436
+ * 把键值写回 config.yml —— 保留原有注释与顺序,只替换/追加目标键。
2437
+ *
2438
+ * 面板「保存」的落盘实现。刻意不做整文件重写:用户的注释是说明书,
2439
+ * 重写一次就全丢。做法是逐行扫描,命中 `key:` 就改值,没命中就在末尾追加。
2440
+ *
2441
+ * `model` 的特殊语义:空字符串表示「自动(服务端默认)」,必须写成
2442
+ * `model: ` 而不是删掉该行——否则键缺失会回落到内置硬编码模型名。
2443
+ *
2444
+ * @param updates - 要写入的键值。
2445
+ */
2446
+ const persistConfig = async (updates) => {
2447
+ const wasServerProvider = cfg.serverProvider === true;
2448
+ const { writeFile } = await import("node:fs/promises");
2449
+ // 先建目录:全新安装时数据目录还不存在,直接 writeFile 会 ENOENT。
2450
+ // 那会造成最糟的一种失败——凭据已经写进去了、配置却写不进去,
2451
+ // 用户只看到一条报错,而 key 其实「存了一半」。
2452
+ // 目录创建必须幂等(recursive),已有目录时是无操作。
2453
+ await mkdir(dirname(ownConfigPath), { recursive: true });
2454
+ let lines = [];
2455
+ try {
2456
+ lines = (await readFile(ownConfigPath, "utf8")).split("\n");
2457
+ } catch {
2458
+ // 首次写入:给一份最小骨架,并说明这个文件是什么(用户会直接编辑它)。
2459
+ lines = [
2460
+ "# dsh-decision-engine 配置(插件自有,唯一配置文件)",
2461
+ "#",
2462
+ "# 改这个文件即生效:每次工具调用前都会检查 mtime,改完不用重启 DSH。",
2463
+ "# 完整配置项说明见 config.example.yml。",
2464
+ "",
2465
+ ];
2466
+ }
2467
+ // 逐行替换而不是整文件重写:用户的注释是说明书,重写一次就全丢。
2468
+ // 具体实现与格式规则见 config-file.js(有往返测试)。
2469
+ const next = applyConfigUpdates(lines, updates);
2470
+ await writeFile(ownConfigPath, next.join("\n"), { encoding: "utf8", mode: 0o600 });
2471
+ lastMtime = 0;
2472
+ await reloadOwnConfig();
2473
+ // 开关刚被关掉(DEC-030):立刻把内存里的下发 key 扔掉。
2474
+ //
2475
+ // 位置刻意放在 reloadOwnConfig 之后:只有读到新配置才算「用户确实关了」,
2476
+ // 否则一次写盘失败(此时不抛、只是没改成)会把下发值误清——而用户看到的
2477
+ // 是「开关还开着、值却没了」。
2478
+ //
2479
+ // 关掉就清,而不是留到下次启动:这个开关的全部意义就是「别再拿服务端的
2480
+ // 账号了」。留一份内存副本,等于关掉之后进程里还攥着别人的 key。
2481
+ if (wasServerProvider === true && cfg.serverProvider !== true) serverConfig.clear();
2482
+ };
2483
+
2484
+ /**
2485
+ * 按 `keyStorage` 把密钥写到用户选定的位置。
2486
+ *
2487
+ * 三条规则:
2488
+ *
2489
+ * 1. `credentials`(默认):写进 `~/.dsh/.credentials.yaml` 的 `refs:` 段,
2490
+ * 键名就是该 provider 自己的变量名(TYPESAFE_API_KEY / BAMBOO_API_KEY /
2491
+ * custom 的变量名)。此时 yml 里**不保留**明文 key,也不会写显式 key 字段。
2492
+ * 2. `config`:写进插件自己的 `config.yml`,字段是该 provider 的显式 key
2493
+ * 字段(`systemoneApiKey` / `bamboonodeApiKey` / `customApiKey`)。
2494
+ * key 存进凭据文件的旧值**不清除**——换回 credentials 模式时还能用。
2495
+ * 3. 传空字符串:删除该位置的 key(用户清空即撤销)。
2496
+ *
2497
+ * 只有 body 里真的带了某个 key 字段才处理,避免「保存开关」时误删 key。
2498
+ *
2499
+ * @param patch - 前端提交的改动。
2500
+ * @param current - 当前生效配置。
2501
+ * @returns `{ ok, patch }`:patch 是还要写进 yml 的部分(已剔除已托管到凭据文件的 key)。
2502
+ */
2503
+ const persistKeys = async (patch, current) => {
2504
+ const storage = patch.keyStorage ?? current.keyStorage ?? "credentials";
2505
+ /** 本次要写进凭据文件的键值(ref 名 → key)。 */
2506
+ const credentialWrites = {};
2507
+ /** 本次要写进 yml 的键值。 */
2508
+ const ymlPatch = { ...patch };
2509
+
2510
+ // provider → 它的显式 key 字段名与 refs 键名。
2511
+ // 逐个预设生成而不是写死:漏掉一个 provider,它的 key 就会**绕过凭据文件
2512
+ // 直接以明文写进 config.yml**——那是「托管到凭据文件」这条承诺的破口。
2513
+ const slots = Object.values(PROVIDER_PRESETS).map((preset) => {
2514
+ const override = typeof current[preset.keyEnvField] === "string" ? current[preset.keyEnvField].trim() : "";
2515
+ return { keyField: preset.keyField, refName: override.length > 0 ? override : preset.apiKeyEnv };
2516
+ });
2517
+ // 历史共享字段:老前端只认 apiKey,按 bamboonode 处理
2518
+ slots.push({
2519
+ keyField: "apiKey",
2520
+ refName: current.bamboonodeApiKeyEnv || PROVIDER_PRESETS.bamboonode.apiKeyEnv,
2521
+ });
2522
+
2523
+ for (const slot of slots) {
2524
+ if (!Object.hasOwn(patch, slot.keyField)) continue;
2525
+ const raw = typeof patch[slot.keyField] === "string" ? patch[slot.keyField].trim() : "";
2526
+ if (storage === "credentials") {
2527
+ credentialWrites[slot.refName] = raw;
2528
+ // 明文不落 yml:这是「托管到凭据文件」的意义。
2529
+ delete ymlPatch[slot.keyField];
2530
+ } else {
2531
+ // 存到配置文件:yml 里直接写显式 key 字段。
2532
+ ymlPatch[slot.keyField] = raw;
2533
+ }
2534
+ }
2535
+
2536
+ const names = Object.keys(credentialWrites);
2537
+ if (names.length === 0) return { ok: true, patch: ymlPatch };
2538
+
2539
+ /** 已成功写入的项,带上写入前的值,供上层在后续步骤失败时回滚。 */
2540
+ const applied = [];
2541
+ for (const name of names) {
2542
+ // 先记下旧值:万一配置没写成功,要把凭据恢复原状,
2543
+ // 否则会出现「凭据存了、配置没存」的半成功状态。
2544
+ const previous = readCredentialFromDsh(name);
2545
+ const result = await writeCredentialToDsh(name, credentialWrites[name]);
2546
+ if (!result.ok) {
2547
+ // 本次之前已经写成功的,一并回滚,避免留下半套改动。
2548
+ for (const done of applied) {
2549
+ try {
2550
+ await writeCredentialToDsh(done.refName, done.previous ?? "");
2551
+ } catch {
2552
+ /* 回滚尽力而为,失败也不能再抛 */
2553
+ }
2554
+ }
2555
+ return {
2556
+ ok: false,
2557
+ patch: ymlPatch,
2558
+ error: `写入 ~/.dsh/.credentials.yaml 失败(${name}):${result.error}`,
2559
+ };
2560
+ }
2561
+ applied.push({ refName: name, previous });
2562
+ }
2563
+ return { ok: true, patch: ymlPatch, applied };
2564
+ };
2565
+
2566
+ /** 一次决策的完整记录,写入 telemetry.jsonl 便于事后核对。 */
2567
+ const logDecision = async (entry) => recordTelemetry(telemetryDir, entry);
2568
+
2569
+ /**
2570
+ * 写一条「工具授权」的决策明细。
2571
+ *
2572
+ * 为什么授权点要单独一个 helper,而不是复用 `engine.js` 的 `noteDetail`:
2573
+ * 授权是**手写**路径(它要处理 ask / deny 这类改变行为的返回,见
2574
+ * `guardListener` 头部注释),不经过 `createDecisionRunner`,因此拿不到
2575
+ * 引擎里那个闭包。两边写的字段形状必须一致,否则同一个页面上两种记录
2576
+ * 会长得不一样——所以这里按 `buildDecisionRecord` 的签名逐个对齐。
2577
+ *
2578
+ * 任何异常都吞掉:明细是旁路,绝不能让守卫从「降级放行」变成抛错。
2579
+ *
2580
+ * @param options.session - `exec.agent.session`(宿主的 Session 对象)。
2581
+ * @param options.pointMode - 该决策点此刻的三态(observe / enforce)。
2582
+ * @param options.exec - 工具执行现场,提供 `name` 与 `signal`。
2583
+ * @param options.state - 发给决策模型的现场数据。
2584
+ * @param options.questions - 发给决策模型的问题集。
2585
+ * @param options.result - `classify()` 的回包(降级路径可能没有)。
2586
+ * @param options.action / detail / degraded / scores - 裁决那一半。
2587
+ */
2588
+ const recordGuardDetail = (options) => {
2589
+ try {
2590
+ const sessionId = (() => {
2591
+ try {
2592
+ const id = options.session?.id;
2593
+ return typeof id === "string" ? id : "";
2594
+ } catch {
2595
+ return "";
2596
+ }
2597
+ })();
2598
+ const result = options.result;
2599
+ decisionStore.record(
2600
+ buildDecisionRecord({
2601
+ ts: new Date().toISOString(),
2602
+ sessionId,
2603
+ point: "authorize",
2604
+ tag: "guard",
2605
+ mode: options.pointMode,
2606
+ topMode: cfg.mode,
2607
+ provider: typeof result?.provider === "string" ? result.provider : "",
2608
+ model: typeof result?.model === "string" ? result.model : "",
2609
+ action: options.action,
2610
+ detail: options.detail,
2611
+ degraded: options.degraded === true,
2612
+ cached: result?.cached === true,
2613
+ latencyMs: result?.latencyMs,
2614
+ inputTokens: result?.inputTokens,
2615
+ outputTokens: result?.outputTokens,
2616
+ tool: options.exec?.name,
2617
+ ...(options.scores === undefined ? {} : { scores: options.scores }),
2618
+ ...(options.status === undefined ? {} : { status: options.status }),
2619
+ maxContentChars: cfg.decisionsMaxContentChars,
2620
+ content: {
2621
+ state: options.state,
2622
+ questions: options.questions,
2623
+ ...(result?.answers === undefined ? {} : { answers: result.answers }),
2624
+ },
2625
+ }),
2626
+ );
2627
+ } catch (error) {
2628
+ ctx.logger?.warn?.(
2629
+ `dsh-decision-engine: 授权明细写入失败(${error instanceof Error ? error.message : String(error)})`,
2630
+ );
2631
+ }
2632
+ };
2633
+
2634
+ /**
2635
+ * Layered authorization guard: mandatory policy precedes model advisory.
2636
+ */
2637
+ mountGuard(ctx, {
2638
+ getConfig,
2639
+ reloadOwnConfig,
2640
+ effectivePointModes,
2641
+ client,
2642
+ toleranceCalibrator,
2643
+ digestRecent,
2644
+ previewArguments,
2645
+ logDecision,
2646
+ recordGuardDetail,
2647
+ });
2648
+
2649
+ // ---- 其余八个决策点 ----------------------------------------------------
2650
+ //
2651
+ // 与工具授权不同,这八个点共用一条 execution 链路(engine.js):模式判断、
2652
+ // 冷却、答案校验、遥测都由 runner.ask() 统一处理,适配器只负责
2653
+ // 「给出 state」与「落地副作用」。这样任何一处降级逻辑都不会漏。
2654
+ const runner = createDecisionRunner({
2655
+ getConfig,
2656
+ client,
2657
+ logger: ctx.logger,
2658
+ modeOf: (current, id) => effectivePointModes(current)[id] ?? "off",
2659
+ log: logDecision,
2660
+ specOf: (id) => POINT_SPECS[id],
2661
+ // 裁决统计必须由引擎在拿到裁决后写盘(裁决此时才存在)。
2662
+ recordDecision: (options) => client.recordDecision(options),
2663
+ /**
2664
+ * 决策明细(「决策历史」页)。与 `log`(遥测)是两条独立旁路:
2665
+ * 遥测是「服务端说了什么」的结论口径,明细是本地的现场 + 会话归属。
2666
+ * 这里补上限指针——截断策略属于存储层,引擎不该知道配置项叫什么。
2667
+ */
2668
+ recordDetail: (record) =>
2669
+ decisionStore.record(
2670
+ buildDecisionRecord({ ...record, maxContentChars: cfg.decisionsMaxContentChars }),
2671
+ ),
2672
+ });
2673
+
2674
+ /** 遥测 + 适配器异常的统一出口(适配器出错必须只留痕、不改行为)。 */
2675
+ const telemetry = {
2676
+ log: logDecision,
2677
+ noteAdapterError(point, error, context) {
2678
+ const message = error instanceof Error ? error.message : String(error);
2679
+ context?.logger?.warn?.(`dsh-decision-engine: ${point} 适配器异常已忽略(${message})`);
2680
+ void logDecision({
2681
+ ts: new Date().toISOString(),
2682
+ point,
2683
+ tag: `adapter/${point}`,
2684
+ action: "degraded",
2685
+ detail: "adapter-error",
2686
+ extra: { message },
2687
+ });
2688
+ },
2689
+ };
2690
+
2691
+ /** 检索工具名集合:每次现读配置,改了立刻生效。 */
2692
+ const retrievalTools = () => new Set(cfg.retrievalTools ?? DEFAULT_RETRIEVAL_TOOLS);
2693
+
2694
+ /**
2695
+ * 构造一条插件注入的消息。
2696
+ *
2697
+ * `source.kind` 必须是「生产者自有」的 kind(v4 格式拒绝 `plugin` 这种
2698
+ * 已退役的写法),并且要用 `form: 'notice'` + `summary`,这样 UI 会把它
2699
+ * 渲染成一条上下文提示而不是一条用户发言。
2700
+ */
2701
+ const makeUserMessage = (text, kind) => ({
2702
+ id: `decision-engine:${kind}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`,
2703
+ role: "user",
2704
+ content: [{ type: "text", text }],
2705
+ source: { kind: `decision-engine-${kind}`, form: "notice", summary: text.slice(0, 120) },
2706
+ });
2707
+
2708
+ const adapterDeps = {
2709
+ runner,
2710
+ getConfig,
2711
+ telemetry,
2712
+ digestRecent,
2713
+ makeUserMessage,
2714
+ retrievalTools,
2715
+ };
2716
+
2717
+ // 模型路由:agent/request(热路径,内部有三道闸)
2718
+ mountRoutePoint(ctx, adapterDeps);
2719
+ // 分派 / 轮次 / 压缩:agent/pre-step 与 agent/turn-stopping
2720
+ mountAgentPoints(ctx, adapterDeps);
2721
+ // 检索必要性 / 技能选择:tools/pre-execute(按工具名过滤)
2722
+ mountToolPoints(ctx, adapterDeps);
2723
+ // 思维链翻译门槛:包住 translator.translate()
2724
+ const translateMounted = mountCotTranslatePoint(ctx, adapterDeps);
2725
+
2726
+ /**
2727
+ * 某个决策点的适配器是否真的挂上了。
2728
+ *
2729
+ * 挂在 hooks 上的点注册成功即算挂载;翻译点依赖宿主的 translator 服务,
2730
+ * 该服务不存在时挂载会静默失败——配置页必须能看出这一点,否则用户
2731
+ * 会对着一个永远不生效的开关。
2732
+ *
2733
+ * @param id - 决策点 id。
2734
+ * @returns 是否已挂载。
2735
+ */
2736
+ const pointMounted = (id) => {
2737
+ if (id === "cotTranslate") return translateMounted;
2738
+ return true;
2739
+ };
2740
+
2741
+ // ---- 命令:状态、开关、统计、自检 -------------------------------------
2742
+ /** 真正打一次端点并报告结果,用于不依赖工具调用链路的即时验证。 */
2743
+ const probe = async () => {
2744
+ await reloadOwnConfig();
2745
+ // 探测与实际决策必须打同一个目标:两边都走「生效解析」,否则会出现
2746
+ // 「测试通过但守卫不工作」——下发在时探测打服务端、决策也打服务端。
2747
+ const provider = activeProvider();
2748
+ if (!provider.valid) {
2749
+ return { ok: false, text: `配置不可用:${provider.error}\n请检查 provider 与 base URL` };
2750
+ }
2751
+ const endpoint = provider.endpoint;
2752
+ const key = activeKey(provider);
2753
+ if (key === undefined) {
2754
+ return {
2755
+ ok: false,
2756
+ text:
2757
+ `未找到 API key(provider: ${provider.id}):\n` +
2758
+ // 指名该 provider 自己的那一格(bamboonodeApiKey / siliconflowApiKey /
2759
+ // customApiKey …)。写死 apiKey 会让人把 key 填进 bamboonode 的字段,
2760
+ // 而其它 provider 根本不读它——「填了 key 还是说没 key」就是这么来的。
2761
+ `请在 ${CONFIG_FILE_NAME} 写 ${provider.preset?.keyField ?? "显式 key 字段"},` +
2762
+ (provider.apiKeyEnv.length > 0
2763
+ ? `或导出 $${provider.apiKeyEnv}(也可存入 .credentials.yaml 的 refs 段)`
2764
+ : `或设置 ${provider.preset?.keyEnvField ?? "密钥变量名"}`),
2765
+ };
2766
+ }
2767
+ const started = Date.now();
2768
+ /**
2769
+ * 探测的**独立预算**:20s 起,不再复用热路径的 `max(5000, timeoutMs)`。
2770
+ *
2771
+ * 为什么必须分开:用户点「测试」时永远是**闲置后的首发**,而冷启动首发
2772
+ * 实测要 7–16 秒——借热路径 8s 预算就等于每次都在最差的采样点上采样,
2773
+ * 于是「测试失败」但实际守卫工作正常。诊断工具不该被热路径预算绑住:
2774
+ * 它的目的就是把真实端点叫醒并报告真实延迟。(同一个文件里给 `/v1/models`
2775
+ * 早已单独开了 20s,probe 这里当初漏了。)
2776
+ *
2777
+ * 为什么是 20s 而不是更大:实测冷启动上界 15.9s,20s 已经覆盖并留了余量。
2778
+ * 探测是个**按钮**,用户盯着转圈等——把它设成 30s 会让最坏等待变成一分钟
2779
+ * (两发各 30s),那是「慢到以为卡死」,比报错更糟。诊断工具要准,但也要
2780
+ * 在人的耐心之内给出结论。
2781
+ */
2782
+ const timeoutMs = Math.max(20000, cfg.timeoutMs);
2783
+ // 与 classify 同一套重试:首发把服务端焐热、第二发通常 1–2 秒返回。
2784
+ const maxAttempts = 2;
2785
+ // 总预算封顶 35s:覆盖「首发 16s 超时 + 第二发 19s」这一最坏组合,
2786
+ // 又不会让按钮在最坏情况下等满两整轮 20s。
2787
+ const maxBudgetMs = Math.min(timeoutMs * 2, 35000);
2788
+ /**
2789
+ * 冷却期的说明**不改变行为**:用户点测试就是想验证端点,拦下来毫无意义。
2790
+ * 但必须说清「这次请求是一次试探」,否则用户会以为守卫此刻已经正常工作。
2791
+ */
2792
+ const cooldownLeftMs = client.cooldownUntil - Date.now();
2793
+ const cooldownText =
2794
+ cooldownLeftMs > 0
2795
+ ? `当前处于冷却中,距冷却结束还有 ${Math.ceil(cooldownLeftMs / 1000)} 秒;本次为试探请求\n`
2796
+ : "";
2797
+ // 探测与实际决策走**同一套**候选顺序与 404 回退:否则会出现
2798
+ // 「测试通过但守卫不工作」或反之。实测 SiliconFlow 只通 /systemone,
2799
+ // 而 /v1/decisions 回 404——只试一条路径必然误报。
2800
+ const bodyText = JSON.stringify(
2801
+ buildRequestBody(provider, {
2802
+ state: { probe: true, cwd: process.cwd() },
2803
+ questions: { alive: { type: "noul", instructions: "Is this request well formed?" } },
2804
+ thinkMode: cfg.thinkMode,
2805
+ }),
2806
+ );
2807
+ const candidates = client.endpointMemory.order(provider.baseUrl, provider.candidates ?? [endpoint]);
2808
+ let usedEndpoint = endpoint;
2809
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
2810
+ // 每次尝试各自一份 timer:上一份已经 abort,复用会让第二发立刻超时。
2811
+ const timer = AbortSignal.timeout(timeoutMs);
2812
+ try {
2813
+ const result = await sendWithEndpointFallback(candidates, (target) => {
2814
+ usedEndpoint = target;
2815
+ // 必须走 `client.fetchFn` 而不是裸 `fetch`:classify 用的就是它,
2816
+ // 注入的 fetch(测试桩 / 宿主定制)对裸 fetch 不可见——那会造出
2817
+ // 「测试通过但实跑不同」的假象,正是本函数要消灭的那类问题。
2818
+ return client.fetchFn(target, {
2819
+ method: "POST",
2820
+ headers: buildHeaders(provider, key),
2821
+ // 与 classify 保持一致:同一个 buildRequestBody,探测不可能与实际
2822
+ // 请求形状不同步(这正是「测试通过但守卫不工作」的经典来源)。
2823
+ body: bodyText,
2824
+ signal: timer,
2825
+ });
2826
+ });
2827
+ const response = result.response;
2828
+ const elapsed = Date.now() - started;
2829
+ // 追踪 id 各家名字不同:bamboonode 走 x-oneapi-request-id,SiliconFlow
2830
+ // 走 x-siliconcloud-trace-id(实测)。只读一家会让另一家的诊断永远显示
2831
+ //「(无)」,而排查问题时这个 id 是唯一的抓手。
2832
+ const requestId =
2833
+ response?.headers?.get?.("x-oneapi-request-id")
2834
+ ?? response?.headers?.get?.("x-siliconcloud-trace-id")
2835
+ ?? "(无)";
2836
+ let bodyTextOut = "";
2837
+ try {
2838
+ bodyTextOut = (await response.text()).slice(0, 300);
2839
+ } catch {
2840
+ /* 忽略 */
2841
+ }
2842
+ // 探测成功即记住胜出的路径:下次决策直接打它。
2843
+ if (response?.ok === true) {
2844
+ const style = styleOfEndpoint(result.endpoint);
2845
+ if (style !== undefined) client.endpointMemory.record(provider.baseUrl, result.endpoint, style);
2846
+ }
2847
+ // 换过路径就要说出来:用户需要知道「实际打的是哪条」。
2848
+ const triedText =
2849
+ result.attempts.length > 1
2850
+ ? `尝试过的路径: ${result.attempts.map((item) => `${item.endpoint} → ${item.status}`).join(",")}\n`
2851
+ : "";
2852
+ return {
2853
+ ok: response?.ok === true,
2854
+ text:
2855
+ `provider: ${provider.label}(${provider.id})\n` +
2856
+ `POST ${result.endpoint}\n` +
2857
+ cooldownText +
2858
+ `model: ${(provider.model ?? "").trim().length > 0 ? provider.model : "自动(服务端默认)"}\n` +
2859
+ `HTTP ${response?.status ?? "(无响应)"} 耗时 ${elapsed}ms\n` +
2860
+ `x-oneapi-request-id: ${requestId}\n` +
2861
+ (triedText.length > 0 ? `${triedText}` : "") +
2862
+ `响应: ${bodyTextOut}`,
2863
+ };
2864
+ } catch (error) {
2865
+ const reason = timer.aborted || error?.name === "AbortError" ? "timeout" : "network-error";
2866
+ if (
2867
+ shouldRetry({
2868
+ reason,
2869
+ attempt,
2870
+ elapsedMs: Date.now() - started,
2871
+ timeoutMs,
2872
+ maxAttempts,
2873
+ maxBudgetMs,
2874
+ })
2875
+ ) {
2876
+ continue;
2877
+ }
2878
+ // 失败原因翻译成人话:`error.message` 只有 "fetch failed" / "The operation
2879
+ // was aborted",用户据此既不知道该改什么,也分不清是本机链路还是服务端。
2880
+ // describeFailure 给出「标题 + 可执行下一步」,与 classify 侧同一套文案。
2881
+ const failure = describeFailure(reason, {
2882
+ timeoutMs,
2883
+ endpoint: usedEndpoint,
2884
+ message: error instanceof Error ? error.message : String(error),
2885
+ });
2886
+ return {
2887
+ ok: false,
2888
+ text:
2889
+ `provider: ${provider.label}(${provider.id})\n` +
2890
+ `POST ${usedEndpoint}\n` +
2891
+ cooldownText +
2892
+ `请求失败(${Date.now() - started}ms): ` +
2893
+ `${error instanceof Error ? error.message : String(error)}\n` +
2894
+ `失败原因: ${failure.title}\n` +
2895
+ `建议: ${failure.hint}`,
2896
+ };
2897
+ }
2898
+ }
2899
+ // 理论上到不了这里(循环每次都以 return 或 continue 结束),保持返回值完整。
2900
+ return { ok: false, text: `provider: ${provider.label}(${provider.id})\nPOST ${usedEndpoint}\n请求失败(${Date.now() - started}ms)` };
2901
+ };
2902
+
2903
+ // ---- 面板 API:给前端 UI 用(不经过 settings 服务)-------------------------
2904
+ ctx.inject?.(["connection"], () => {
2905
+ const connection = ctx.get?.("connection");
2906
+ if (connection === undefined || typeof connection.fetch?.register !== "function") return;
2907
+
2908
+ const json = (data, status = 200) =>
2909
+ Promise.resolve(
2910
+ new Response(JSON.stringify(data), {
2911
+ status,
2912
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
2913
+ }),
2914
+ );
2915
+
2916
+ /**
2917
+ * 把「密钥来源」翻成用户能核对的一句话。
2918
+ *
2919
+ * 为什么不让前端自己拼:界面只知道变量名(`BAMBOO_API_KEY`),
2920
+ * 不知道真正命中的是配置文件里的哪一格。只显示变量名会造成误判——
2921
+ * 用户看到 `BAMBOO_API_KEY` 会以为 key 在 `.credentials.yaml` 里,
2922
+ * 而实际生效的可能是 `config.yml` 的 `bamboonodeApiKey`,甚至以为
2923
+ * 那个变量「过期了」。来源判定与文案因此都放在服务端,只有一份真相。
2924
+ *
2925
+ * @param source - config / credentials / env / server / none。
2926
+ * @param provider - 已解析的 provider(提供 keyField 与 apiKeyEnv)。
2927
+ * @returns 可直接展示的中文说明。
2928
+ */
2929
+ const keySourceLabel = (source, provider) => {
2930
+ // 服务端下发(DEC-030):文案必须把「只在内存」说死——用户开这个开关
2931
+ // 前最想确认的就是「我的机器上会不会多出一份别人的明文 key」。
2932
+ if (source === "server") {
2933
+ return "服务端下发";
2934
+ }
2935
+ if (source === "config") {
2936
+ const field = provider.preset?.keyField ?? "显式 key 字段";
2937
+ return `配置文件 ${CONFIG_FILE_NAME} 的 ${field}`;
2938
+ }
2939
+ if (source === "credentials") {
2940
+ return `~/.dsh/.credentials.yaml 的 ${provider.apiKeyEnv}`;
2941
+ }
2942
+ if (source === "env") {
2943
+ return `环境变量 ${provider.apiKeyEnv}`;
2944
+ }
2945
+ return "未配置";
2946
+ };
2947
+
2948
+ /**
2949
+ * 汇总三个 provider 各自的 key 状态。
2950
+ *
2951
+ * 只回报「有没有」「从哪来的」「末四位」,绝不回传明文——
2952
+ * 这份 JSON 会经过浏览器,明文 key 不应出现在网络响应里。
2953
+ *
2954
+ * @returns id → `{ configured, hint, source, refName, sourceLabel }`。
2955
+ */
2956
+ const buildKeyStatus = () => {
2957
+ const out = {};
2958
+ for (const id of PROVIDER_IDS) {
2959
+ const probe = resolveProvider({ ...cfg, provider: id });
2960
+ const key = resolveKey({ ...cfg, provider: id }, probe);
2961
+ const inCredentials =
2962
+ probe.apiKeyEnv.length > 0 && readCredentialFromDsh(probe.apiKeyEnv) !== undefined;
2963
+ const source =
2964
+ probe.explicitKey.length > 0
2965
+ ? "config"
2966
+ : inCredentials
2967
+ ? "credentials"
2968
+ : key === undefined
2969
+ ? "none"
2970
+ : "env";
2971
+ out[id] = {
2972
+ configured: key !== undefined,
2973
+ hint: key === undefined ? "" : `…${key.slice(-4)}`,
2974
+ refName: probe.apiKeyEnv,
2975
+ // 该 provider 的显式 key 字段名:`keyStorage=config` 时保存的目标。
2976
+ // 与 refName(凭据文件/环境变量的变量名)是两个不同的位置,
2977
+ // 界面上必须分开说,否则「存到插件配置的 BAMBOO_API_KEY」这种
2978
+ // 自相矛盾的文案就会出现。
2979
+ keyField: probe.preset?.keyField ?? "",
2980
+ // explicit = 配置里写了明文;credentials = 凭据文件;env = 环境变量
2981
+ source,
2982
+ // 给界面直接用的可读来源。**必须由服务端算**:只有这里同时知道
2983
+ // provider 的显式字段名(bamboonodeApiKey)与真正命中的来源。
2984
+ //
2985
+ // 早先界面只显示 `apiKeyEnv`(如 BAMBOO_API_KEY),在
2986
+ // keyStorage=config 时会造成严重误解——那个变量根本没被读取,
2987
+ // 用户会以为 key 存在 .credentials.yaml 里,甚至以为它过期了。
2988
+ sourceLabel: keySourceLabel(source, probe),
2989
+ };
2990
+ }
2991
+ return out;
2992
+ };
2993
+
2994
+ /**
2995
+ * 人工审核比例(DEC-035)的完整回显——**两处界面共用这一份**。
2996
+ *
2997
+ * 配置页(走 /status 与 /decisions)与「决策历史」页(走 /session-decisions)
2998
+ * 都要放这个滑块。若各自拼一份,加一档或改一个文案就得改三处,漏一处就是
2999
+ * 「同一个设置在两个页面显示不一样」。
3000
+ *
3001
+ * 四档的元数据(label / summary / percentile)由服务端下发,前端不抄一份
3002
+ * ——与决策点清单一同理:加一档只改 lib/tolerance.js 一处。
3003
+ *
3004
+ * calibration 回答「这个模型最近攒了多少样本」:用户切到轻松档却还没攒够
3005
+ * 样本时,界面要能说明「现在用的是冷启动阈值,攒够后按该模型自己的分布算」,
3006
+ * 否则看起来就像设置没生效。
3007
+ *
3008
+ * @returns 冻结形状:{ level, levels, presets, calibration }。
3009
+ */
3010
+ const tolerancePayload = () => {
3011
+ const level = cfg.tolerance ?? DEFAULTS.tolerance;
3012
+ const active = activeProvider();
3013
+ const stats = toleranceCalibrator.stats(active.id, active.model);
3014
+ const preset = tolerancePreset(level);
3015
+ const threshold =
3016
+ preset.percentile === null
3017
+ ? null
3018
+ : toleranceCalibrator.threshold(active.id, active.model, preset.percentile);
3019
+ return {
3020
+ level,
3021
+ levels: TOLERANCE_LEVELS,
3022
+ presets: TOLERANCE_LEVELS.map((id) => {
3023
+ const item = TOLERANCE_PRESETS[id];
3024
+ return {
3025
+ id: item.id,
3026
+ label: item.label,
3027
+ labelEn: item.labelEn,
3028
+ summary: item.summary,
3029
+ summaryEn: item.summaryEn,
3030
+ percentile: item.percentile,
3031
+ };
3032
+ }),
3033
+ calibration: {
3034
+ provider: active.id,
3035
+ model: active.model,
3036
+ samples: stats.samples,
3037
+ ready: stats.ready,
3038
+ minSamples: toleranceCalibrator.minSamples,
3039
+ windowSize: toleranceCalibrator.windowSize,
3040
+ threshold: threshold === null ? null : Number(threshold.toFixed(4)),
3041
+ // 未校准好时实际生效的绝对阈值(界面要如实说明「现在用的是它」)。
3042
+ fallbackSeverity: preset.fallbackSeverity,
3043
+ },
3044
+ };
3045
+ };
3046
+
3047
+ /** 汇总当前状态;key 只回报“是否已配置”与末四位,绝不回传明文。 */
3048
+ const statusPayload = () => {
3049
+ // 两条解析路径在这里分叉(DEC-030):
3050
+ //
3051
+ // - **本地**(provider.id / providerLabel / providerSource / raw* /
3052
+ // custom*):前端把它们写回 config.yml。它们若被下发值污染上线
3053
+ // 就危险了——面板会把 `provider: server` 提交回来,而
3054
+ // `validateProviderPatch` 只认 PROVIDER_IDS,12 秒后就是一次 400
3055
+ // 「未知 provider」。所以本地三元组永远照实报本地选择。
3056
+ // - **生效值**(baseUrl / model / keyConfigured / keyHint / keySource):
3057
+ // 前端显示「实际打到哪、用哪把 key」。服务端生效时这些必须是下发值,
3058
+ // 否则界面会一边说「走 systemone」一边把请求打到别处。
3059
+ const local = resolveProvider(cfg);
3060
+ const server = serverOverride();
3061
+ const provider = server !== undefined ? resolveProvider(cfg, { server }) : local;
3062
+ const key = resolveKey(cfg, provider, { memoryKey: server?.key });
3063
+ return {
3064
+ enabled: cfg.enabled,
3065
+ mode: cfg.mode,
3066
+ guardEnabled: cfg.guardEnabled !== false,
3067
+ guardPolicyMode: cfg.guardPolicyMode,
3068
+ guardFallback: cfg.guardFallback,
3069
+ guardFallbackProvider: cfg.guardFallbackProvider,
3070
+ guardFallbackModel: cfg.guardFallbackModel,
3071
+ guardFallbackReasoningEffort: cfg.guardFallbackReasoningEffort,
3072
+ // provider 三元组:前端据此渲染选择器与 custom 表单,回显也靠它。
3073
+ provider: local.id,
3074
+ providerLabel: local.label,
3075
+ providerSource: local.source,
3076
+ providerValid: provider.valid,
3077
+ providerError: provider.error ?? "",
3078
+ providerCatalog: providerCatalog(),
3079
+ baseUrl: provider.endpoint || provider.baseUrl,
3080
+ rawBaseUrl: provider.baseUrl,
3081
+ // 只回报「实际会发什么」。预设的默认模型单独放 defaultModel:
3082
+ // 把预设默认值显示成当前模型,会让人以为插件固定了模型(其实没发字段)。
3083
+ model: provider.model || "(服务端默认)",
3084
+ defaultModel: provider.defaultModel,
3085
+ rawModel: provider.model,
3086
+ rawSystemoneModel: typeof cfg.systemoneModel === "string" ? cfg.systemoneModel : "",
3087
+ rawSiliconflowModel: typeof cfg.siliconflowModel === "string" ? cfg.siliconflowModel : "",
3088
+ rawSiliconflowIntlModel:
3089
+ typeof cfg.siliconflowIntlModel === "string" ? cfg.siliconflowIntlModel : "",
3090
+ /**
3091
+ * 全部 provider 的模型原值,键 = config.yml 里的字段名(model /
3092
+ * systemoneModel / siliconflowModel / …)。
3093
+ *
3094
+ * 上面几项是历史散字段(回显用),这张表是给「模型输入框写回配置」用的:
3095
+ * 界面必须知道当前 provider 的那一格叫什么名字,而名字只有预设表知道。
3096
+ * 由预设表生成并下发,前端就不必为每个 provider 抄一份分支——新增预设
3097
+ * 只改 providers.js 一处,界面的模型框自动接上。
3098
+ *
3099
+ * 值取 cfg 原文而非 provider.model:后者对 requiresModel 的 provider
3100
+ * 已经回落到预设默认(Kev-4B),把它回显成「当前值」会让用户以为自己
3101
+ * 填过模型,进而不知道留空本来就有默认。
3102
+ */
3103
+ rawModels: Object.fromEntries(
3104
+ Object.values(PROVIDER_PRESETS).map((preset) => [
3105
+ preset.modelField,
3106
+ typeof cfg[preset.modelField] === "string" ? cfg[preset.modelField] : "",
3107
+ ]),
3108
+ ),
3109
+ // custom 的字段原样回显(除 key 明文):用户保存后再打开必须看到自己填的值。
3110
+ customBaseUrl: cfg.customBaseUrl ?? "",
3111
+ customEndpointStyle: cfg.customEndpointStyle ?? "auto",
3112
+ // 实际用得通的那条路径(探测出来的)。路径后缀由服务商决定,
3113
+ // 实测 SiliconFlow 只通 /systemone;界面必须显示「实际打哪」,
3114
+ // 否则用户看到 404 完全猜不到是路径选错了。
3115
+ detectedEndpoint: client.endpointMemory.get(provider.baseUrl)?.endpoint ?? "",
3116
+ detectedEndpointStyle: client.endpointMemory.get(provider.baseUrl)?.style ?? "",
3117
+ customModel: cfg.customModel ?? "",
3118
+ customHeaders: cfg.customHeaders ?? "",
3119
+ customApiKeyEnv: cfg.customApiKeyEnv ?? "",
3120
+ customKeyConfigured:
3121
+ typeof cfg.customApiKey === "string" && cfg.customApiKey.trim().length > 0,
3122
+ // 密钥保存位置:界面据此显示「当前存到哪」以及切换后的实际行为。
3123
+ keyStorage: cfg.keyStorage ?? "credentials",
3124
+ // 各 provider 是否已配好 key(不泄露明文,只回报布尔与来源)。
3125
+ // 三个预设各自独立,切换时界面能准确显示"这一项有没有 key"。
3126
+ keyStatus: buildKeyStatus(),
3127
+ // 思考模式:回传归一化后的值,避免界面把 `thinkMode: sometimes`
3128
+ // 原样回显成「已生效」;可选清单由服务端下发,前端不硬编码。
3129
+ thinkMode: normalizeThinkMode(cfg.thinkMode),
3130
+ thinkModes: THINK_MODES,
3131
+ // 匿名统计档位:**必须回显**。
3132
+ //
3133
+ // 此前这里没有这一项,于是配置页的「匿名统计」下拉永远停在兜底值
3134
+ // `daily`,用户改成 `off` 既不落盘、重新打开也看不到自己的选择。
3135
+ // 隐私开关显示一个与实际不符的状态,比没有这个开关更糟。
3136
+ telemetryLevel: cfg.telemetryLevel ?? DEFAULTS.telemetryLevel,
3137
+ telemetryLevels: TELEMETRY_LEVELS,
3138
+ timeoutMs: cfg.timeoutMs,
3139
+ keyConfigured: key !== undefined,
3140
+ keyHint: key === undefined ? "" : `…${key.slice(-4)}`,
3141
+ // 来源要报**真正生效的那个**(DEC-030):服务端下发一旦生效,
3142
+ // 本地配置里那把 key 就没有参与本次解析。照旧报 "config" 会让
3143
+ // 界面说「密钥来自 config.yml 的 xxx」,而用户明明没往那儿填过。
3144
+ keySource:
3145
+ server !== undefined
3146
+ ? "server"
3147
+ : provider.explicitKey.length > 0
3148
+ ? "config"
3149
+ : provider.apiKeyEnv.length > 0 && readCredentialFromDsh(provider.apiKeyEnv) !== undefined
3150
+ ? "credentials"
3151
+ : "env",
3152
+ keyEnvName: provider.apiKeyEnv,
3153
+ configPath: ownConfigPath,
3154
+ // 决策历史(DEC-022):配置页的回显、以及客户端「是否挂载 tab」的
3155
+ // 唯一依据。**必须回传生效值而不是原值**——配置里可能根本没写这一项
3156
+ // (用 DEFAULTS 的 true),原样回 `undefined` 会让界面把开关画成关。
3157
+ decisionsTabEnabled: cfg.decisionsTabEnabled !== false,
3158
+ // 审批卡正文可读化(DEC-042 / DEC-043):客户端据此决定是否接管宿主的
3159
+ // detail slot。同样回传**生效值**——配置里没写这一项时,DEFAULTS 的 true
3160
+ // 才是真值;回 `undefined` 会让界面把开关画成关。
3161
+ approvalDetailReadable: cfg.approvalDetailReadable !== false,
3162
+ decisionsFile: decisionStore.path,
3163
+ // 人工审核比例(DEC-035):配置页滑块的唯一数据源。
3164
+ tolerance: tolerancePayload(),
3165
+ stats: client.stats,
3166
+ // 决策点与缓存:配置页靠这两项显示「哪个点在花多少钱」。
3167
+ points: effectivePointModes(cfg),
3168
+ cache: client.cache.report,
3169
+ /**
3170
+ * 服务端下发(DEC-030)的完整状态。
3171
+ *
3172
+ * 形状是**冻结契约**(前端 lib/client.js 按字段名消费),因此这里
3173
+ * 逐字段拼出来而不是把 `serverConfig.status()` 整个摊开:摊开会把
3174
+ * 将来新增的内部字段(例如重试计数)也一并送进浏览器,而这份 JSON
3175
+ * 的读者只有设置页。
3176
+ *
3177
+ * 注意 `enabled` 来自配置、`active` 来自「开关 × 有效期」两个闸:
3178
+ * 界面靠 enabled 画开关、靠 active 决定「生效端点」行要不要打角标。
3179
+ * 两者不一致正是回落发生的那一刻,这个区别必须能显示出来,
3180
+ * 否则用户看到一个开着的开关 + 本地地址,会以为功能坏了。
3181
+ */
3182
+ serverProvider: (() => {
3183
+ const state = serverConfig.status();
3184
+ const enabled = cfg.serverProvider === true;
3185
+ return {
3186
+ enabled,
3187
+ active: enabled && state.usable,
3188
+ endpoint: SERVER_CONFIG_ENDPOINT,
3189
+ label: SERVER_PROVIDER_LABEL,
3190
+ status: state.status,
3191
+ usable: state.usable,
3192
+ // 曾经取到过值之后又失败:错误文案要能说明「是回落了,不是从没开过」。
3193
+ error: enabled ? state.error : "",
3194
+ // 只有末四位:这是本插件对 key 暴露面的既有上限(见 buildKeyStatus)。
3195
+ keyHint: state.keyHint,
3196
+ baseUrl: state.baseUrl,
3197
+ model: state.model,
3198
+ endpointStyle: state.endpointStyle,
3199
+ fetchedAt: state.fetchedAt,
3200
+ expiresAt: state.expiresAt,
3201
+ expired: state.expired,
3202
+ };
3203
+ })(),
3204
+ };
3205
+ };
3206
+
3207
+ connection.fetch.register({
3208
+ path: "/api/decision-engine/status",
3209
+ methods: ["GET"],
3210
+ requestBody: "buffered",
3211
+ fetch: async () => {
3212
+ await reloadOwnConfig();
3213
+ // 打开着而手上没有可用值:顺带补一发(不 await,界面不等网络)。
3214
+ // 放在这里而不是只放启动时,是因为「刚打开面板」正是发现配置过期的
3215
+ // 最好时机——用户就在等这个页面。
3216
+ if (cfg.serverProvider === true && !serverConfig.usable()) void serverConfig.refresh();
3217
+ return json(statusPayload());
3218
+ },
3219
+ });
3220
+
3221
+ /**
3222
+ * 强制刷新下发配置(DEC-030)。
3223
+ *
3224
+ * 为什么要一条单独的路由:保存配置时服务端按 TTL 决定拉不拉(没过期就
3225
+ * 复用缓存),而用户点「重新获取」表达的是「现在就要一份新的」。
3226
+ * 返回形状与 `/status` 相同,前端直接走 `applyServerState`,草稿与服务端
3227
+ * 状态一次对齐,不会出现「界面显示旧值、服务端已是新值」。
3228
+ */
3229
+ connection.fetch.register({
3230
+ path: "/api/decision-engine/server-provider",
3231
+ methods: ["POST"],
3232
+ requestBody: "buffered",
3233
+ fetch: async () => {
3234
+ await reloadOwnConfig();
3235
+ // force:绕过 30s 重试下限——用户明确要求了,就真的去试一次。
3236
+ await serverConfig.refresh({ force: true });
3237
+ return json({ ok: true, ...statusPayload() });
3238
+ },
3239
+ });
3240
+
3241
+ connection.fetch.register({
3242
+ path: "/api/decision-engine/config",
3243
+ methods: ["PUT"],
3244
+ requestBody: "buffered",
3245
+ fetch: async (request) => {
3246
+ try {
3247
+ const body = JSON.parse(await request.text());
3248
+ // 只允许改这些键,避免前端误写任意字段。
3249
+ //
3250
+ // 各 provider 自己的三格(模型 / 显式 key / 密钥变量名)由预设表生成,
3251
+ // 不逐个写死:漏掉一个 provider,界面上「粘贴 key / 选模型」会 200
3252
+ // 成功但什么都没写——正是最难自查的「保存了没生效」。
3253
+ const allowed = [
3254
+ "enabled",
3255
+ "mode",
3256
+ "guardEnabled",
3257
+ "pointAuthorize",
3258
+ "guardPolicyMode",
3259
+ "guardFallback",
3260
+ "guardFallbackProvider",
3261
+ "guardFallbackModel",
3262
+ "guardFallbackReasoningEffort",
3263
+ "apiKey",
3264
+ "baseUrl",
3265
+ "thinkMode",
3266
+ "timeoutMs",
3267
+ // 匿名统计档位(DEC-020)。
3268
+ //
3269
+ // 它此前**不在这个白名单里**,于是配置页上那个「匿名统计」下拉
3270
+ // 能点、能显示、点完还显示「已保存」——但后端 400「no updatable
3271
+ // fields」,值从来没落盘。用户以为关掉了统计,实际每天照发。
3272
+ // 隐私开关静默失效是这类问题里最严重的一种,所以这里补上。
3273
+ "telemetryLevel",
3274
+ // 人工审核比例(DEC-035)。与 telemetryLevel 同一类风险:不在白名单里的
3275
+ // 后果是「面板能拨、显示已保存、值却从来没落盘」——用户以为改成了
3276
+ // 自治,实际还在被反复打扰。这类静默失效必须在白名单里堵死。
3277
+ "tolerance",
3278
+ // 决策历史(DEC-022):只有「决策历史」这一个开关。
3279
+ // `decisionsCapture` 已退役——开关开着就记明细且一定带内容,
3280
+ // 开关关掉就什么都不写,不存在「只记元数据」的第二档。
3281
+ // **上限项刻意不在白名单里**——那两个是运维旋钮(改体积上限、
3282
+ // 改截断长度),不是用户日常会拨的开关,走配置页会把界面变复杂;
3283
+ // 需要时手改 config.yml 即可。
3284
+ "decisionsTabEnabled",
3285
+ // 审批卡正文可读化(DEC-042):配置页要能拨,否则拨了也存不下去。
3286
+ "approvalDetailReadable",
3287
+ // 服务端下发开关(DEC-030):不在白名单里的后果与上面 telemetryLevel
3288
+ // 完全一样——面板能拨、能显示「已保存」,而服务端 400 什么都没写。
3289
+ "serverProvider",
3290
+ "provider",
3291
+ "customBaseUrl",
3292
+ "customEndpointStyle",
3293
+ "customHeaders",
3294
+ "keyStorage",
3295
+ ...Object.values(PROVIDER_PRESETS).flatMap((preset) => [
3296
+ preset.modelField,
3297
+ preset.keyField,
3298
+ preset.keyEnvField,
3299
+ ]),
3300
+ ];
3301
+ const patch = {};
3302
+ for (const key of allowed) {
3303
+ if (Object.hasOwn(body, key)) patch[key] = body[key];
3304
+ }
3305
+ if (Object.keys(patch).length === 0) return json({ error: "no updatable fields" }, 400);
3306
+
3307
+ // 校验在落盘之前:写进 yml 再报错等于让用户带着坏配置重启。
3308
+ const checked = validateProviderPatch(patch, cfg);
3309
+ if (!checked.ok) return json({ error: checked.error }, 400);
3310
+
3311
+ // key 按 provider 分开存,各自落到「用户选的位置」。
3312
+ // 先处理 key 再写 yml:写入凭据文件可能失败(权限/磁盘),
3313
+ // 失败时必须整体报错回滚,不能让"配置说存了、实际没存"。
3314
+ const keyResult = await persistKeys(patch, cfg);
3315
+ if (!keyResult.ok) return json({ error: keyResult.error }, 500);
3316
+ // key 已被写进指定位置的,不能同时再写进 yml(会变成两处明文)。
3317
+ const ymlPatch = { ...keyResult.patch };
3318
+
3319
+ try {
3320
+ await persistConfig(ymlPatch);
3321
+ } catch (error) {
3322
+ // 配置没写成功,就把已落地的凭据恢复原状:宁可整体失败,
3323
+ // 也不要留下「凭据存了、配置没存」的半套状态——那种状态在
3324
+ // 界面上看起来是保存失败,实际 key 已经悄悄换了。
3325
+ for (const done of keyResult.applied ?? []) {
3326
+ try {
3327
+ await writeCredentialToDsh(done.refName, done.previous ?? "");
3328
+ } catch {
3329
+ /* 回滚尽力而为 */
3330
+ }
3331
+ }
3332
+ return json(
3333
+ {
3334
+ error:
3335
+ `写入配置文件失败:${error instanceof Error ? error.message : String(error)}` +
3336
+ (keyResult.applied?.length > 0 ? "(已回滚本次写入的凭据)" : ""),
3337
+ },
3338
+ 500,
3339
+ );
3340
+ }
3341
+ return json({ ok: true, ...statusPayload() });
3342
+ } catch (error) {
3343
+ return json({ error: error instanceof Error ? error.message : String(error) }, 500);
3344
+ }
3345
+ },
3346
+ });
3347
+
3348
+ /**
3349
+ * 决策点总览与切换。
3350
+ *
3351
+ * GET 返回注册表 + 当前模式 + 分账实测数据。
3352
+ * PUT 改模式。
3353
+ *
3354
+ * 合成一个注册项按 method 分支,而不是同路径注册两次:
3355
+ * DSH 的 fetch 注册表以 path 为键,重复注册会互相覆盖(实测踩过)。
3356
+ *
3357
+ * 元数据由服务端下发而不是前端硬编码,是为了让「加了哪个决策点」
3358
+ * 只改一处(decision-points.js)。未实装的点显示为「即将支持」而不是
3359
+ * 消失,避免用户以为功能被删了。
3360
+ */
3361
+ const decisionsPayload = () => {
3362
+ const modes = effectivePointModes(cfg);
3363
+ return {
3364
+ points: DECISION_POINTS.map((point) => {
3365
+ const counter = client.pointCounter(point.id);
3366
+ return {
3367
+ id: point.id,
3368
+ key: point.key,
3369
+ hook: point.hook,
3370
+ hotPath: point.hotPath,
3371
+ available: point.available,
3372
+ trigger: point.trigger,
3373
+ label: point.label,
3374
+ labelEn: point.labelEn,
3375
+ summary: point.summary,
3376
+ saving: point.saving,
3377
+ gate: point.gate,
3378
+ mode: modes[point.id] ?? "off",
3379
+ configured: cfg[point.key],
3380
+ // 「这个点的适配器到底挂上了没有」必须能看见:装进一个没有
3381
+ // translator 服务的宿主时,开关能打开但永远不会生效,
3382
+ // 不显示真实挂载状态就会变成「配了没反应」。
3383
+ mounted: pointMounted(point.id),
3384
+ stats: {
3385
+ requests: counter.requests,
3386
+ cached: counter.cached,
3387
+ degraded: counter.degraded,
3388
+ ok: counter.ok,
3389
+ tokens: counter.tokens,
3390
+ avgLatencyMs: counter.ok === 0 ? 0 : Math.round(counter.latencySum / counter.ok),
3391
+ maxLatencyMs: counter.latencyMax,
3392
+ },
3393
+ };
3394
+ }),
3395
+ modes: POINT_MODES,
3396
+ cache: client.cache.report,
3397
+ // 人工审核比例(DEC-035):与配置页共用同一份元数据。
3398
+ tolerance: tolerancePayload(),
3399
+ };
3400
+ };
3401
+
3402
+ connection.fetch.register({
3403
+ path: "/api/decision-engine/decisions",
3404
+ methods: ["GET", "PUT"],
3405
+ requestBody: "buffered",
3406
+ fetch: async (request) => {
3407
+ try {
3408
+ if (request.method === "GET") {
3409
+ await reloadOwnConfig();
3410
+ return json(decisionsPayload());
3411
+ }
3412
+ const body = JSON.parse(await request.text());
3413
+ const patch = {};
3414
+ const requested = body.points;
3415
+ if (requested !== null && typeof requested === "object") {
3416
+ for (const [id, mode] of Object.entries(requested)) {
3417
+ const point = DECISION_POINTS.find((item) => item.id === id);
3418
+ if (point === undefined) continue;
3419
+ // off/observe/enforce/auto 之外的值一律拒绝,避免脏配置进 yml。
3420
+ if (![...POINT_MODES, "auto"].includes(mode)) {
3421
+ return json({ error: `invalid mode for ${id}: ${String(mode)}` }, 400);
3422
+ }
3423
+ if (!point.available && mode !== "off") {
3424
+ return json({ error: `decision point ${id} is not implemented yet` }, 400);
3425
+ }
3426
+ // 适配器没挂上时也拒绝开启:否则用户会配出一个「看起来生效、
3427
+ // 实际永不触发」的开关(例如宿主没有 translator 服务)。
3428
+ if (mode !== "off" && !pointMounted(point.id)) {
3429
+ return json(
3430
+ { error: `decision point ${id} is unavailable in this host: its service is not mounted` },
3431
+ 400,
3432
+ );
3433
+ }
3434
+ patch[point.key] = mode;
3435
+ }
3436
+ }
3437
+ if (Object.keys(patch).length === 0) return json({ error: "no updatable fields" }, 400);
3438
+ await persistConfig(patch);
3439
+ // 立即回一份新的总览,前端不必再发一次 GET。
3440
+ return json({ ok: true, ...decisionsPayload() });
3441
+ } catch (error) {
3442
+ return json({ error: error instanceof Error ? error.message : String(error) }, 500);
3443
+ }
3444
+ },
3445
+ });
3446
+
3447
+ /**
3448
+ * 列出可用于**决策端点**的模型。
3449
+ *
3450
+ * 不能把 `/v1/models` 原样交给用户选:bamboonode 的该端点返回全部 20 个
3451
+ * 模型,其中多数是 chat 模型,拿去打 `/v1/decisions` 会 400
3452
+ *「model ... is not served by this plugin」。因此按 `supported_endpoint_types`
3453
+ * 过滤——只有声明了 `StartLux Decision` / `StartLux SystemOne` 的才是决策端点
3454
+ * 真正接受的。bamboonode 作为预设**严格**适用这条:没声明类型的一律排除。
3455
+ *
3456
+ * 对 custom:指向的是**用户自己的服务**,类型声明不可信——很多网关只声明
3457
+ * `chat` / `openai` 之类,按 StartLux 类型过滤会把整份列表清空,下拉里只剩
3458
+ * 「自动」。因此 custom 不做类型过滤,模型清单交还给用户自己判断;
3459
+ * 请求本身仍有 404/400 回退与降级兜底。
3460
+ *
3461
+ * 对 systemone:自建服务的 `/v1/models` 未必带
3462
+ * `supported_endpoint_types`,此时若继续按类型过滤会得到空列表,用户会以为
3463
+ * 「服务没有可用模型」。因此改为「有类型就按类型过滤,没类型就全收」,
3464
+ * 并把过滤与否回报给前端说明。
3465
+ */
3466
+ connection.fetch.register({
3467
+ path: "/api/decision-engine/guard-models",
3468
+ methods: ["GET"],
3469
+ requestBody: "buffered",
3470
+ fetch: async (request) => {
3471
+ const url = new URL(request.url);
3472
+ return json(await guardModelCatalog(ctx.get?.("llm"), url.searchParams.get("provider"), url.searchParams.get("model")));
3473
+ },
3474
+ });
3475
+
3476
+ connection.fetch.register({
3477
+ path: "/api/decision-engine/models",
3478
+ methods: ["GET"],
3479
+ requestBody: "buffered",
3480
+ fetch: async () => {
3481
+ const provider = resolveProvider(cfg);
3482
+ if (!provider.valid) return json({ models: [], error: "bad-endpoint", hint: provider.error });
3483
+ const key = resolveKey(cfg, provider);
3484
+ if (key === undefined) return json({ models: [], error: "no-key" });
3485
+
3486
+ // /v1/models 是 OpenAI 兼容路径:由 base URL 反推 origin。
3487
+ // 刻意用 provider.baseUrl 而不是 endpoint:端点可能已经带
3488
+ // /decisions 或 /systemone,取 origin 才不会拼出 /decisions/v1/models。
3489
+ let origin;
3490
+ try {
3491
+ const url = new URL(provider.baseUrl);
3492
+ origin = `${url.protocol}//${url.host}`;
3493
+ } catch {
3494
+ return json({ models: [], error: "bad-endpoint", hint: provider.error });
3495
+ }
3496
+
3497
+ try {
3498
+ const response = await fetch(`${origin}/v1/models`, {
3499
+ headers: buildHeaders(provider, key),
3500
+ // 列表接口比决策接口慢(实测数秒),且只在用户打开配置页时调用一次,
3501
+ // 用独立超时,不占用决策路径的 timeoutMs。
3502
+ signal: AbortSignal.timeout(20000),
3503
+ });
3504
+ if (!response.ok) {
3505
+ const hint =
3506
+ response.status === 401 || response.status === 403
3507
+ ? `鉴权被拒(HTTP ${response.status}):检查 ${provider.id} 的 API key`
3508
+ : `模型列表请求失败(HTTP ${response.status})`;
3509
+ return json({ models: [], error: `http-${response.status}`, hint });
3510
+ }
3511
+ const body = await response.json();
3512
+ const rows = Array.isArray(body?.data) ? body.data : [];
3513
+ // 只认 StartLux 系端点类型。实测 `typesafe/jev-1.14` 虽声明
3514
+ // `jev-decisions`,打到本插件的 /v1/decisions 仍会 400
3515
+ //「model ... is not served by this plugin」——它对应的是另一个端点,
3516
+ // 放进下拉会让用户选了就失败,因此排除。
3517
+ const decisionTypes = ["StartLux Decision", "StartLux SystemOne"];
3518
+ const typedOnly = provider.id === "bamboonode";
3519
+ // custom 不做类型过滤(理由见上方路由说明)。
3520
+ const skipTypeFilter = provider.id === "custom";
3521
+ // 预设给了 knownModels 的 provider(SiliconFlow 实测):它的
3522
+ // /v1/models 会一并返回 98 个模型,其中决策模型只有 Kev-4B / SemIf,
3523
+ // 且**没有任何一条**带 supported_endpoint_types——按类型过滤会得到
3524
+ // 空列表,全收则把 96 个 chat 模型塞进下拉,选一个就是一个 400
3525
+ // `Model does not exist.`。这类端点只认预设清单里的名字。
3526
+ const knownModels = provider.preset?.knownModels ?? [];
3527
+ const models = [];
3528
+ for (const row of rows) {
3529
+ const id = typeof row?.id === "string" ? row.id : "";
3530
+ if (id.length === 0) continue;
3531
+ if (knownModels.length > 0) {
3532
+ if (!knownModels.some((name) => name.toLowerCase() === id.toLowerCase())) continue;
3533
+ models.push({ id, types: [] });
3534
+ continue;
3535
+ }
3536
+ const types = Array.isArray(row?.supported_endpoint_types) ? row.supported_endpoint_types : [];
3537
+ const isDecision = types.some((type) =>
3538
+ decisionTypes.some((needle) => String(type).toLowerCase() === needle.toLowerCase()),
3539
+ );
3540
+ // bamboonode:没声明类型的一律排除(它会 400)。
3541
+ // custom:一律全收(见上方说明)。
3542
+ // 其它 provider:只在「有人声明类型」时才按类型过滤,否则全收。
3543
+ if (typedOnly) {
3544
+ if (!isDecision) continue;
3545
+ } else if (!skipTypeFilter && types.length > 0 && !isDecision && provider.endpointStyle !== "systemone") {
3546
+ continue;
3547
+ }
3548
+ models.push({ id, types });
3549
+ }
3550
+ return json({
3551
+ models,
3552
+ current: provider.model,
3553
+ defaultHint: provider.defaultModel || DEFAULT_MODEL,
3554
+ provider: provider.id,
3555
+ filtered: typedOnly || knownModels.length > 0,
3556
+ });
3557
+ } catch (error) {
3558
+ return json({
3559
+ models: [],
3560
+ error: error instanceof Error ? error.message : String(error),
3561
+ });
3562
+ }
3563
+ },
3564
+ });
3565
+
3566
+ connection.fetch.register({
3567
+ path: "/api/decision-engine/test",
3568
+ methods: ["POST"],
3569
+ requestBody: "buffered",
3570
+ fetch: async () => {
3571
+ const result = await probe();
3572
+ return json({ ok: result.ok, text: result.text });
3573
+ },
3574
+ });
3575
+
3576
+ /**
3577
+ * 统计与对比。
3578
+ *
3579
+ * 查询参数:
3580
+ * - `preset` today | week | month | year | custom(默认 today)
3581
+ * - `start` 自定义起始(`YYYY-MM-DDTHH:mm`)
3582
+ * - `end` 自定义结束(`YYYY-MM-DDTHH:mm`)
3583
+ * - `sort` 排序字段(默认 inputTokens)
3584
+ * - `dir` asc | desc(默认 desc)
3585
+ * - `includeLegacy` 是否把旧遥测回填成历史统计(默认 1)
3586
+ *
3587
+ * 回填只在**统计文件为空**时做一次(首次访问):回填结果不落盘,
3588
+ * 每次请求都重新算一遍历史遥测会拖慢响应,而且回填记录本身也没法
3589
+ * 与真实记录区分。
3590
+ */
3591
+ connection.fetch.register({
3592
+ path: "/api/decision-engine/stats",
3593
+ methods: ["GET"],
3594
+ requestBody: "buffered",
3595
+ fetch: async (request) => {
3596
+ await reloadOwnConfig();
3597
+ const params = new URL(request.url).searchParams;
3598
+ const range = resolveRange({
3599
+ preset: params.get("preset") ?? "today",
3600
+ start: params.get("start") ?? undefined,
3601
+ end: params.get("end") ?? undefined,
3602
+ });
3603
+ if (range.valid !== true) {
3604
+ return json({ error: range.error, range: { preset: range.preset, valid: false } }, 400);
3605
+ }
3606
+ let records = [];
3607
+ try {
3608
+ records = await statsStore.read();
3609
+ } catch {
3610
+ records = [];
3611
+ }
3612
+ // 历史回填在**启动时**做完了(并落盘),这里直接读统计文件即可。
3613
+ // 若它还没跑完(插件刚启动、用户立刻打开统计页),补一次即可。
3614
+ if (records.length === 0) {
3615
+ try {
3616
+ await statsStore.backfillOnce();
3617
+ records = await statsStore.read();
3618
+ } catch {
3619
+ records = [];
3620
+ }
3621
+ }
3622
+ // 推算出来的历史条数(老遥测缺 provider 与 output token)。
3623
+ const legacyCount = records.filter((row) => row.estimated === true).length;
3624
+ const result = aggregate(records, range);
3625
+ const sortKey = params.get("sort") ?? "inputTokens";
3626
+ const direction = params.get("dir") === "asc" ? "asc" : "desc";
3627
+ return json({
3628
+ ...result,
3629
+ rows: sortRows(result.rows, sortKey, direction),
3630
+ byProvider: sortRows(result.byProvider, sortKey, direction),
3631
+ // 范围预设与标签由服务端下发:前端不抄一份清单,避免两边漂移。
3632
+ presets: RANGE_PRESETS,
3633
+ labels: RANGE_LABELS,
3634
+ // 历史数据说明:回填的记录 provider 由模型名反推(可能不准),
3635
+ // 仍缺 output token 与耗时基线,界面必须让用户知道「这一段数字是
3636
+ // 推算的」,否则会被当成实测。
3637
+ legacy: {
3638
+ included: legacyCount > 0,
3639
+ count: legacyCount,
3640
+ note: legacyCount > 0
3641
+ ? "含由旧遥测推算的历史数据(provider 按模型名推断,output token 与耗时基线为推算值)"
3642
+ : "",
3643
+ },
3644
+ store: {
3645
+ path: statsStore.path,
3646
+ written: statsStore.written,
3647
+ failed: statsStore.failed,
3648
+ dropped: statsStore.dropped,
3649
+ },
3650
+ });
3651
+ },
3652
+ });
3653
+
3654
+ connection.fetch.register({
3655
+ path: "/api/decision-engine/log",
3656
+ methods: ["GET"],
3657
+ requestBody: "buffered",
3658
+ fetch: async (request) => {
3659
+ const limit = Number(new URL(request.url).searchParams.get("limit") ?? "50");
3660
+ const count = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 200) : 50;
3661
+ let raw = "";
3662
+ try {
3663
+ raw = await readFile(join(telemetryDir, "telemetry.jsonl"), "utf8");
3664
+ } catch {
3665
+ return json([]);
3666
+ }
3667
+ const entries = [];
3668
+ for (const line of raw.split("\n").slice(-400)) {
3669
+ const trimmed = line.trim();
3670
+ if (trimmed.length === 0) continue;
3671
+ try {
3672
+ entries.push(JSON.parse(trimmed));
3673
+ } catch {
3674
+ /* 半行跳过 */
3675
+ }
3676
+ }
3677
+ return json(entries.slice(-count).reverse());
3678
+ },
3679
+ });
3680
+
3681
+ /**
3682
+ * 某个会话的决策明细(「决策历史」标签页的数据源)。
3683
+ *
3684
+ * 与 `/log` 的区别不是路径而是**口径**:
3685
+ *
3686
+ * - `/log` 读的是 telemetry.jsonl(全机、无会话维度、只有结论);
3687
+ * - 这里读的是 decisions.jsonl(有会话归属、有决策内容、按体积轮转)。
3688
+ *
3689
+ * `sessionId` 是**必填**的:没有会话参数时返回 400 而不是「返回全部」。
3690
+ * 返回全部会让人以为「这个会话里居然有别的会话的记录」,而正确的回答是
3691
+ * 「不知道你要问哪个会话」。这一条与列表页「跨会话聚合是统计页的事」
3692
+ * 的边界一致。
3693
+ */
3694
+ connection.fetch.register({
3695
+ path: "/api/decision-engine/session-decisions",
3696
+ methods: ["GET"],
3697
+ requestBody: "buffered",
3698
+ fetch: async (request) => {
3699
+ const params = new URL(request.url).searchParams;
3700
+ const sessionId = params.get("sessionId") ?? "";
3701
+ if (sessionId.trim().length === 0) {
3702
+ return json({ error: "sessionId is required" }, 400);
3703
+ }
3704
+ const limit = Number(params.get("limit") ?? "200");
3705
+ try {
3706
+ const page = await decisionStore.readForSession(sessionId, {
3707
+ limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, MAX_ENTRIES_PER_QUERY) : 200,
3708
+ });
3709
+ return json({
3710
+ sessionId,
3711
+ enabled: page.enabled,
3712
+ entries: page.entries,
3713
+ total: page.total,
3714
+ scanned: page.scanned,
3715
+ truncated: page.truncated,
3716
+ // 文件路径给排查用:用户常问「记录到底写到哪了」。
3717
+ file: page.file,
3718
+ // 单条内容长度上限:界面据此说明内容可能被截断。截断是内容丢失的
3719
+ // **唯一**来源——没有任何配置键能把整条内容剥成只留元数据。
3720
+ contentLimit: cfg.decisionsMaxContentChars,
3721
+ // 人工审核比例(DEC-035):**决策历史页也要放那个滑块**。
3722
+ // 用户是在这个页面上看到「刚才又问我了」的,于是「改一下打扰频率」的
3723
+ // 动作最自然的位置就在这里。随本页轮询一起回传,滑块才不会在别处
3724
+ // 改了之后显示一个过期档位。
3725
+ tolerance: tolerancePayload(),
3726
+ });
3727
+ } catch (error) {
3728
+ return json({ error: error instanceof Error ? error.message : String(error) }, 500);
3729
+ }
3730
+ },
3731
+ });
3732
+ });
3733
+
3734
+ const emitBootLog = () => {
3735
+ // 迁移结果必须可见:用户升级后如果发现「配置跑到了新目录」,
3736
+ // 至少日志里能查到这是插件做的,而不是以为文件被谁动了。
3737
+ if (migrated === true) {
3738
+ ctx.logger?.info?.(
3739
+ `dsh-decision-engine: 已把旧数据目录的配置迁移到 ${ownConfigPath}(原文件保留)`,
3740
+ );
3741
+ }
3742
+ const provider = resolveProvider(cfg);
3743
+ const keyState = resolveKey(cfg, provider) === undefined ? "未配置 ❌" : "已配置 ✅";
3744
+ ctx.logger?.info?.(
3745
+ `dsh-decision-engine:${cfg.enabled ? `运行中(${cfg.mode} 模式)` : "已注册但未启用(在插件配置页启用)"};` +
3746
+ `provider ${provider.id};端点 ${provider.endpoint || `(不可用:${provider.error})`};` +
3747
+ `模型 ${provider.model || "自动(服务端默认)"};密钥 ${keyState};超时 ${cfg.timeoutMs}ms`,
3748
+ );
3749
+ };
3750
+ // reloadOwnConfig 已在上面 void 调用过一次;这里等它落定后再打日志。
3751
+ void reloadOwnConfig().then(emitBootLog, emitBootLog);
3752
+ void reloadOwnConfig()
3753
+ .catch(() => {
3754
+ /* 读配置失败就用 DEFAULTS,上报仍按默认档走 */
3755
+ })
3756
+ .then(() => startTelemetry())
3757
+ .catch((err) => {
3758
+ ctx.logger?.warn?.(`dsh-decision-engine: 匿名统计跳过(不影响插件):${String(err?.message ?? err)}`);
3759
+ });
3760
+
3761
+ }
3762
+
3763
+ /** 丢掉 undefined,避免覆盖 DEFAULTS。 */
3764
+ function stripUndefined(source) {
3765
+ const out = {};
3766
+ for (const [key, value] of Object.entries(source)) {
3767
+ if (value !== undefined) out[key] = value;
3768
+ }
3769
+ return out;
3770
+ }
3771
+
3772
+ /**
3773
+ * 从完整端点里取出它的路径风格(`decisions` / `systemone`)。
3774
+ *
3775
+ * 端点一定来自 buildEndpoint,理论上必然是合法 URL;但这里仍然做保护——
3776
+ * 它在**请求失败后的降级路径**上被调用,一个解析异常不该把整次决策变成
3777
+ * 未捕获异常(那会让守卫从「降级放行」变成「抛错」,行为倒退)。
3778
+ *
3779
+ * @param endpoint - 完整端点。
3780
+ * @returns 路径风格;无法识别时返回 undefined。
3781
+ */
3782
+ function styleOfEndpoint(endpoint) {
3783
+ try {
3784
+ return endpointStyleOfPath(new URL(endpoint).pathname);
3785
+ } catch {
3786
+ return undefined;
3787
+ }
3788
+ }
3789
+
3790
+ /**
3791
+ * 供测试与运维脚本使用的导出。
3792
+ *
3793
+ * `DECISION_POINTS` 必须在这里导出:`scripts/verify-wiring.mjs` 靠它逐个
3794
+ * 校验「每个点的 pointXxx 键都在 schema 里」。早先漏掉这一项时,脚本里的
3795
+ * `?? []` 让它静默跳过了一整轮检查——检查脚本自己静默失败,比没有检查更危险。
3796
+ */
3797
+ export {
3798
+ StartluxClient,
3799
+ DEFAULTS,
3800
+ DEFAULT_BASE_URL,
3801
+ DEFAULT_MODEL,
3802
+ DEFAULT_PROVIDER,
3803
+ DEFAULT_TOLERANCE,
3804
+ DECISION_POINTS,
3805
+ ENDPOINT_STYLES,
3806
+ POINT_MODES,
3807
+ PROVIDER_IDS,
3808
+ PROVIDER_PRESETS,
3809
+ TOLERANCE_LEVELS,
3810
+ TOLERANCE_PRESETS,
3811
+ ToleranceCalibrator,
3812
+ buildEndpoint,
3813
+ extractGuardAnswers,
3814
+ normalizeBaseUrl,
3815
+ providerCatalog,
3816
+ resolveKeyFor,
3817
+ resolveProvider,
3818
+ resolveTolerance,
3819
+ severityOf,
3820
+ toleranceCutoff,
3821
+ tolerancePreset,
3822
+ validateBaseUrl,
3823
+ };