@leonhardthust/dsh-decision-engine 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -413,16 +413,19 @@ guardFallbackReasoningEffort: ""
413
413
  # ============================================================================
414
414
  # 匿名使用统计(可选)
415
415
  # ============================================================================
416
- # 只上报「装了多少台、每天有多少人在用」,用来改进插件。字段只有七个:
417
- # 匿名安装 id(首次运行随机生成的 UUIDv4,与账号无关)、插件版本、平台、
418
- # 架构、宿主版本、当日决策数、事件类型。
416
+ # 用途只有一个:让作者知道还有多少人在用、哪些版本/平台在用、每天判了多少
417
+ # 次,用来判断这个插件值不值得继续维护(以及优先修哪个平台的问题)。
419
418
  #
420
- # 绝不上报:API key、prompt / 工具参数、文件路径、provider 地址、会话内容、
421
- # 用户名;服务端也不记录 IP。删掉 analytics.json 即换新身份。
419
+ # 上报字段(全部):随机安装 id(首次运行生成的 UUIDv4,与账号无关)、插件版本、
420
+ # 平台、架构、宿主版本、当天决策数、事件类型(install / daily heartbeat)。
422
421
  #
423
- # off 完全联网都不做(连版本检查都没有)
424
- # install 仅首次运行上报一次「装了」
425
- # daily 每天上报一次,附带当日决策数(默认)
422
+ # 绝不上报:API key、prompt / 工具参数、文件路径、provider 地址、模型名、会话
423
+ # 内容、用户名;服务端也不记录 IP。删掉 analytics.json 即换新身份。
424
+ #
425
+ # off 完全关闭:一个请求都不发
426
+ # install 仅首次运行上报一次「装了」,之后不再联网
427
+ # daily 每天上报一次**上一完整自然日**的决策数(默认;跨日由 6 小时一次
428
+ # 的检查补报,长驻进程不会漏掉)
426
429
  #
427
430
  # telemetryLevel: daily
428
431
  # telemetryEndpoint: "" # 留空用内置端点
package/docs/releasing.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # npm 发布
2
2
 
3
- 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.12.0`,公开包。
3
+ 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.14.0`,公开包。
4
4
 
5
5
  本分支基于 `zuosus/dsh-decision-engine` 的 MIT 代码,保留原作者与许可证;npm scope、源码仓库、bundle 引用和浏览器主模块 / charts chunk 标识已同步为本分支身份。内部 API、样式标识和用户数据目录保留旧名称以兼容已有配置。不要同时启用原包与本 scoped fork,它们提供相同的服务与页面。
6
6
 
@@ -9,7 +9,7 @@
9
9
  - `.github/workflows/ci.yml`:main push、PR、手动运行或发布工作流复用;Ubuntu 24.04 + Node.js 24 + Vite+ 1.6.0。执行 frozen install、完整测试与接线、隔离情景、实际打包和 tarball 检查。失败不会被忽略。
10
10
  - `.github/workflows/publish.yml`:每次 `main` push 自动检测版本。先比较整个 push 的 `before` 与当前 manifest,拒绝错误包名、非法或倒退版本;再只读查询 npmjs。当前版本已上传则跳过;当前版本高于 npmjs latest 且未上传则进入完整验证及发布。多提交 push 比较整个 push 前后,不仅比较 HEAD 的父提交。
11
11
  - 后续发版只需修改 `package.json` 版本并提交到 main;无需创建 tag 或手动点运行。发布依赖检测和本次提交的完整 CI 均成功,不跳过测试。稳定版本发布到 `latest`,预发布版本自动进入 `next`。
12
- - 当前 `1.12.0` 包含 DEC-072 / DEC-073:新增阿里云百炼(workspace + 地域拼端点)与 TokenHub(tokenhub.com;腾讯云 TokenHub 无决策端点)预设,设置页暴露百炼的 Workspace ID / 地域;「免费模型」从独立开关合并为 provider 下拉第一项。`1.11.2` 的 never 回退、`1.11.1` 的 DEC-070、`1.11.0` 的审计修复、`1.10.0` 的五项改动依次见 DEC-062 ~ DEC-071。
12
+ - 当前 `1.14.0` 包含 DEC-075:统计账本修复——output token 未知值单独计数(不再当 0)、attempts/requests/degraded 分桶与汇总对账、匿名心跳改报上一完整自然日并按 `day` 归档、删除缓存基线的区间均值插补、节省量明确为「决策请求层估算」、安全拦截只按 authorize 统计。`1.13.0` 的 DEC-074、`1.12.0` 的 DEC-072/073、`1.11.2` 的 never 回退、`1.11.1` 的 DEC-070、`1.11.0` 的审计修复、`1.10.0` 的五项改动依次见 DEC-062 ~ DEC-074。
13
13
  - checkout 与 setup-vp Action 固定到已核对的 commit SHA;不通过 `pull_request_target` 运行不可信 PR。
14
14
  - 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。发布 step 只使用 OIDC(trusted publishing),不再读取任何 npm token secret。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
15
15
 
@@ -58,7 +58,7 @@ Windows 全量测试最近仍有 3 个既有 POSIX 文件权限断言失败;
58
58
  授权及完整验证均满足后:
59
59
 
60
60
  ```powershell
61
- npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.12.0.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
61
+ npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.14.0.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
62
62
  npm view @leonhardthust/dsh-decision-engine version --registry "https://registry.npmjs.org/" --json
63
63
  ```
64
64
 
@@ -66,4 +66,4 @@ npm view @leonhardthust/dsh-decision-engine version --registry "https://registry
66
66
 
67
67
  ## 安装
68
68
 
69
- DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.12.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
69
+ DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.14.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
package/lib/client.js CHANGED
@@ -144,11 +144,15 @@ window.__ModuleLoader__.load({
144
144
  "auto|由服务端决定(实测约 7.2s,比 never 慢)": "auto | decided by the server (measured ~7.2s, slower than never)",
145
145
  "always|总是思考(约 +5s,质量收益未实测)": "always | always think (about +5s; quality gain not measured)",
146
146
  // —— 匿名使用统计(DEC-020)——
147
- "匿名统计": "Anonymous stats",
147
+ "匿名使用统计": "Anonymous usage stats",
148
148
  "每天上报一次:匿名安装 id、版本、平台与当日决策数": "Reports once a day: anonymous install id, version, platform, and today's decision count",
149
+ "每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、上一完整自然日的决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。": "Once a day, used only so the author can tell how many people still use the plugin and whether it is worth maintaining: a random install ID, plugin version, platform/arch, host version, and the previous complete day's decision count. No conversation content, file paths, keys, model names, or identity information.",
149
150
  "已关闭:任何情况下都不联网": "Off: never touches the network",
151
+ "完全关闭:不发送任何请求。": "Fully off: no request is ever sent.",
150
152
  "仅首次运行上报一次「安装了」,之后不再联网": "Reports \"installed\" once on first run, then never again",
153
+ "仅在第一次运行时上报一次「安装了」(字段同上),之后不再联网。": "Reports \"installed\" once on the first run (same fields as above), then never touches the network again.",
151
154
  "off(不联网)": "off (no network)",
155
+ "off(完全关闭)": "off (fully disabled)",
152
156
  "install(仅首次)": "install (first run only)",
153
157
  "daily(每天一次,默认)": "daily (once a day, default)",
154
158
  "auto(先 /decisions)": "auto (/decisions first)",
@@ -188,6 +192,10 @@ window.__ModuleLoader__.load({
188
192
  "重试": "Retry",
189
193
  "测试中…": "Testing…",
190
194
  "测试端点": "Test endpoint",
195
+ "连通性测试": "Connectivity test",
196
+ "分别向决策端点与回退语言模型各发一次最小请求;仅在你点击时发生,会消耗少量 token": "Sends one minimal request to the decision endpoint and to the fallback language model. Only when you click; uses a few tokens.",
197
+ "已跳过": "Skipped",
198
+ "详情": "Details",
191
199
  "未保存:密钥太短(至少 10 个字符)": "Not saved: the key is too short (at least 10 characters)",
192
200
  "测试端点失败": "Endpoint test failed",
193
201
  // —— 使用免费模型(DEC-030,原名「跟随服务端下发」,DEC-037 改名) ——
@@ -274,6 +282,11 @@ window.__ModuleLoader__.load({
274
282
  "节省比例(观察)": "Savings ratio (observe)",
275
283
  // —— 页面标题与说明 ——
276
284
  "用量统计与对比": "Usage & comparison",
285
+ "估算节省率": "Estimated savings rate",
286
+ "尝试 / 回答 / 降级": "Attempts / answered / degraded",
287
+ "决策请求估算节省率(观察)": "Estimated decision-request savings rate (observe)",
288
+ "口径:决策请求层面的估算(缓存去重 + 输入处理),不含主 Agent、子代理、工具调用与上下文压缩的成本变化;完整 Agent 净节省需要同任务配对 A/B,本页不提供。": "Scope: an estimate at the decision-request layer (cache dedupe + input processing). It excludes cost changes in the main agent, subagents, tool calls and context compaction; full agent net savings require a paired A/B and are not shown here.",
289
+ "安全口径:拦截率只按 authorize 点统计(本区间拒绝 {n} 次);其它点的 reject {m} 次属于流程优化,未计入。": "Safety scope: the block rate counts only the authorize point ({n} rejections here). The {m} rejections from other points are workflow optimizations and are not counted.",
277
290
  "重新读取": "Reload",
278
291
  "按 provider × model 独立统计;区间为左闭右开,相邻范围不会重复计入。": "Counted separately per provider × model; ranges are half-open, so adjacent ranges never double-count.",
279
292
  "节省量只统计「观察」模式。": "Savings count only \"observe\" mode.",
@@ -5498,7 +5511,7 @@ window.__ModuleLoader__.load({
5498
5511
  { key: "savedOutputTokens", label: "省 output" },
5499
5512
  { key: "observeDurationMs", label: "实际耗时(观察)", actual: true },
5500
5513
  { key: "savedDurationMs", label: "省耗时" },
5501
- { key: "savedRatio", label: "节省比例" },
5514
+ { key: "savedRatio", label: "估算节省率" },
5502
5515
  ];
5503
5516
 
5504
5517
  /**
@@ -5521,10 +5534,19 @@ window.__ModuleLoader__.load({
5521
5534
  totals === null
5522
5535
  ? []
5523
5536
  : [
5524
- { label: "请求次数", value: groupDigits(totals.requests) },
5537
+ { label: "尝试 / 回答 / 降级", value: `${groupDigits(totals.attempts ?? totals.requests)} / ${groupDigits(totals.requests)} / ${groupDigits(totals.degraded)}` },
5525
5538
  { label: "命中缓存", value: groupDigits(totals.cached) },
5526
5539
  { label: "input token", value: groupDigits(totals.inputTokens) },
5527
- { label: "output token", value: groupDigits(totals.outputTokens) },
5540
+ {
5541
+ label: "output token",
5542
+ // 未知值不进入总量(详见 lib/stats.js):这里必须把未计量条数
5543
+ // 说出来,否则读者会把「已知部分」当成全部。
5544
+ value:
5545
+ groupDigits(totals.outputTokens) +
5546
+ ((totals.outputTokensUnmeasured ?? 0) > 0
5547
+ ? `(${groupDigits(totals.outputTokensUnmeasured)} 条未计量)`
5548
+ : ""),
5549
+ },
5528
5550
  { label: "总耗时", value: formatMs(totals.durationMs) },
5529
5551
  // 写清「真实请求」:缓存命中的耗时是 0,混进分母会让这个
5530
5552
  // 数字随命中率变好看,读的人以为请求变快了。
@@ -5544,7 +5566,7 @@ window.__ModuleLoader__.load({
5544
5566
  saved: true,
5545
5567
  },
5546
5568
  { label: "省时间(观察)", value: formatMs(totals.savedDurationMs), saved: true },
5547
- { label: "节省比例(观察)", value: formatRatio(totals.savedRatio), saved: true },
5569
+ { label: "决策请求估算节省率(观察)", value: formatRatio(totals.savedRatio), saved: true },
5548
5570
  ];
5549
5571
 
5550
5572
  return h(
@@ -5602,6 +5624,20 @@ window.__ModuleLoader__.load({
5602
5624
  h("strong", null, "节省量只统计「观察」模式。"),
5603
5625
  " 观察模式下决策模型只记录、不改变行为,于是同一批工作既有普通 LLM 的实际消耗,也有决策模型「本该怎么跑」的结果——两者比的是同一批活,这个对比才成立。「生效」模式下普通 LLM 路径并没有真的发生,「省了多少」只能是事后估算,因此不计入。",
5604
5626
  ),
5627
+ // 口径边界(审计 P1):这是「决策请求层面」的估算,不是 Agent
5628
+ // 端到端净节省。不写清楚,读者会把 80% 当成「整个任务省了 80%」。
5629
+ h(
5630
+ "div",
5631
+ { style: { marginTop: "4px" } },
5632
+ "口径:决策请求层面的估算(缓存去重 + 输入处理),不含主 Agent、子代理、工具调用与上下文压缩的成本变化;完整 Agent 净节省需要同任务配对 A/B,本页不提供。",
5633
+ ),
5634
+ Number(stats?.estimatedBaselineMissing ?? 0) > 0
5635
+ ? h(
5636
+ "div",
5637
+ { style: { marginTop: "4px" } },
5638
+ `另有 ${groupDigits(stats.estimatedBaselineMissing)} 条历史缓存命中缺少基线,未计入节省(不再用同区间均值插补,避免时间泄漏与结果不可加)。`,
5639
+ )
5640
+ : null,
5605
5641
  savingsUnavailable
5606
5642
  ? h(
5607
5643
  "div",
@@ -6265,6 +6301,16 @@ window.__ModuleLoader__.load({
6265
6301
  { style: { marginTop: "6px" } },
6266
6302
  "基线 = 不启用本插件的缓存与上下文压缩时,同样的工作本应消耗的 token 与时间。节省量相对基线计算(比例的分母是基线);没有可归因的优化时节省量为 0。平均耗时按**真实请求**计算,不含缓存命中(那一次根本没发请求)。思考模式(think_mode)是用户配置而非插件优化,刻意不计入节省。",
6267
6303
  ),
6304
+ stats.safety !== undefined
6305
+ ? h(
6306
+ "div",
6307
+ { style: { marginTop: "6px" } },
6308
+ t("安全口径:拦截率只按 authorize 点统计(本区间拒绝 {n} 次);其它点的 reject {m} 次属于流程优化,未计入。", {
6309
+ n: groupDigits(stats.safety.reject ?? 0),
6310
+ m: groupDigits(stats.safety.otherPointRejects ?? 0),
6311
+ }),
6312
+ )
6313
+ : null,
6268
6314
  /**
6269
6315
  * 模式分账:让用户一眼看出「节省为 0」的原因。
6270
6316
  *
@@ -7273,10 +7319,11 @@ window.__ModuleLoader__.load({
7273
7319
  setProviderError("");
7274
7320
  try {
7275
7321
  const result = await call("/test", { method: "POST" });
7276
- // 服务端返回的整段诊断(provider / 端点 / HTTP 码 / 请求 id / 响应体)
7277
- // 原样展示:只报「失败」而不给细节,用户无法判断该改地址还是改 key。
7322
+ // DEC-074:服务端现在返回分链路的 `results`(决策端点 / 回退模型),
7323
+ // 界面优先渲染成两张卡片;`text` 只作为旧服务端的兜底与纯文本记录。
7278
7324
  setMsg({
7279
7325
  ok: result.ok === true,
7326
+ results: Array.isArray(result.results) ? result.results : null,
7280
7327
  text: result.ok === true
7281
7328
  ? t("端点连通 ✅\n{text}", { text: result.text ?? "" })
7282
7329
  : tr(result.text ?? "") || t("端点返回失败"),
@@ -7865,6 +7912,109 @@ window.__ModuleLoader__.load({
7865
7912
  ),
7866
7913
  "endpoint",
7867
7914
  ),
7915
+ // 4b2) 连通性测试:从页脚前移到它要验证的对象旁边(provider / 模型 /
7916
+ // 端点 / key 都在上面几行)。一次点击同时测两条链路:决策端点
7917
+ // (自拼 System One)与回退语言模型(宿主 llm.stream)。结果就
7918
+ // 显示在下面,不再需要滚到页脚找「测试」的结论。
7919
+ row(
7920
+ "连通性测试",
7921
+ t("分别向决策端点与回退语言模型各发一次最小请求;仅在你点击时发生,会消耗少量 token"),
7922
+ h(
7923
+ "button",
7924
+ {
7925
+ type: "button",
7926
+ onClick: runTest,
7927
+ disabled: busy !== "",
7928
+ style: { ...S.saveBtn, flexShrink: 0 },
7929
+ },
7930
+ busy === "test" ? "测试中…" : "测试端点",
7931
+ ),
7932
+ "connectivity",
7933
+ ),
7934
+ msg !== null
7935
+ ? h(
7936
+ "div",
7937
+ {
7938
+ style: { display: "flex", flexDirection: "column", gap: "8px", marginTop: "10px" },
7939
+ key: "test-msg",
7940
+ },
7941
+ // 结构化结果(DEC-074):每条链路一张小卡片——先给结论(通/不通/
7942
+ // 跳过)+ 一行摘要,原始诊断折叠进「详情」。旧服务端只回 `text`
7943
+ // 时退回整段文本,不至于空白。
7944
+ ...(Array.isArray(msg.results)
7945
+ ? msg.results.map((item) => {
7946
+ const tone = item.skipped === true ? "#fbbf24" : item.ok === true ? "#4ade80" : "#f87171";
7947
+ const icon = item.skipped === true ? "–" : item.ok === true ? "✓" : "✕";
7948
+ return h(
7949
+ "div",
7950
+ {
7951
+ key: `test-${item.key ?? item.label}`,
7952
+ style: {
7953
+ border: `1px solid ${tone}55`,
7954
+ borderRadius: "8px",
7955
+ padding: "8px 10px",
7956
+ background: `${tone}0d`,
7957
+ },
7958
+ },
7959
+ h(
7960
+ "div",
7961
+ { style: { display: "flex", alignItems: "center", gap: "8px" } },
7962
+ h("span", { style: { color: tone, fontWeight: 600 } }, icon),
7963
+ h("span", { style: { fontWeight: 600 } }, item.label),
7964
+ item.skipped === true
7965
+ ? h("span", { style: { ...S.meta, marginTop: 0, color: tone } }, t("已跳过"))
7966
+ : null,
7967
+ ),
7968
+ h(
7969
+ "div",
7970
+ { style: { ...S.meta, marginTop: "4px", whiteSpace: "pre-wrap", wordBreak: "break-all" } },
7971
+ item.summary ?? "",
7972
+ ),
7973
+ typeof item.detail === "string" && item.detail.length > 0 && item.detail !== item.summary
7974
+ ? h(
7975
+ "details",
7976
+ { style: { marginTop: "4px" } },
7977
+ h(
7978
+ "summary",
7979
+ { style: { cursor: "pointer", ...S.meta, marginTop: 0, opacity: 0.7 } },
7980
+ t("详情"),
7981
+ ),
7982
+ h(
7983
+ "pre",
7984
+ {
7985
+ style: {
7986
+ margin: "6px 0 0",
7987
+ whiteSpace: "pre-wrap",
7988
+ wordBreak: "break-all",
7989
+ fontSize: "12px",
7990
+ opacity: 0.8,
7991
+ fontFamily: "inherit",
7992
+ },
7993
+ },
7994
+ item.detail,
7995
+ ),
7996
+ )
7997
+ : null,
7998
+ );
7999
+ })
8000
+ : [
8001
+ h(
8002
+ "div",
8003
+ {
8004
+ style: {
8005
+ ...S.meta,
8006
+ marginTop: 0,
8007
+ color: msg.ok ? "#4ade80" : "#f87171",
8008
+ whiteSpace: "pre-wrap",
8009
+ fontFamily: "inherit",
8010
+ fontSize: "13px",
8011
+ },
8012
+ },
8013
+ msg.text,
8014
+ ),
8015
+ ]),
8016
+ )
8017
+ : null,
7868
8018
  // 4c) 密钥保存位置:默认托管到 .credentials.yaml(DEC-037 从 API Key 紧邻处
7869
8019
  // 下移到这里:它讲的是「这把 key 存到哪个文件」,不是「怎么连上服务」,
7870
8020
  // 夹在连接组中间会打断 provider → 模型 → key → 端点这条阅读线;
@@ -8663,12 +8813,14 @@ window.__ModuleLoader__.load({
8663
8813
  // 放在「思考模式」之后:它同样是「一次性选择、改完即存」的下拉,
8664
8814
  // 与上面的开关/下拉一组,不与 provider 表格混在一起。
8665
8815
  row(
8666
- "匿名统计",
8667
- draft.telemetryLevel === "daily"
8668
- ? "每天上报一次:匿名安装 id、版本、平台与当日决策数"
8816
+ "匿名使用统计",
8817
+ // 与下拉的兜底保持一致:undefined 时下拉显示 daily,说明也必须说 daily,
8818
+ // 否则首帧会出现「选中的是 daily、解释却写仅首次」的自相矛盾。
8819
+ (draft.telemetryLevel ?? "daily") === "daily"
8820
+ ? t("每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、上一完整自然日的决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。")
8669
8821
  : draft.telemetryLevel === "off"
8670
- ? "已关闭:任何情况下都不联网"
8671
- : "仅首次运行上报一次「安装了」,之后不再联网",
8822
+ ? t("完全关闭:不发送任何请求。")
8823
+ : t("仅在第一次运行时上报一次「安装了」(字段同上),之后不再联网。"),
8672
8824
  h(
8673
8825
  "select",
8674
8826
  {
@@ -8689,10 +8841,10 @@ window.__ModuleLoader__.load({
8689
8841
  "option",
8690
8842
  { key: value, value },
8691
8843
  value === "off"
8692
- ? "off(不联网)"
8844
+ ? t("off(完全关闭)")
8693
8845
  : value === "install"
8694
- ? "install(仅首次)"
8695
- : "daily(每天一次,默认)",
8846
+ ? t("install(仅首次)")
8847
+ : t("daily(每天一次,默认)"),
8696
8848
  ),
8697
8849
  ),
8698
8850
  ),
@@ -8701,8 +8853,10 @@ window.__ModuleLoader__.load({
8701
8853
  ];
8702
8854
 
8703
8855
  const footerItems = [
8704
- // 页脚常驻在所有分组之下:保存状态、测试端点、状态信息与诊断结果。
8856
+ // 页脚常驻在所有分组之下:保存状态与状态信息。
8705
8857
  // 它回答的是「刚才那一下存没存」,与页面哪一组展开无关。
8858
+ // 连通性测试已前移到「连接方式」组(DEC-074)——它要验证的对象都在
8859
+ // 那里,结论也该贴着对象显示。
8706
8860
  // 4g) 自动保存状态行:**没有「保存」按钮**,改动即落盘。
8707
8861
  // 这一行回答的是「刚才那一下到底存没存」:进行中 / 已存 /
8708
8862
  // 失败原因(附重试)。原来那个按钮的「保存中…」搬到这里。
@@ -8755,16 +8909,6 @@ window.__ModuleLoader__.load({
8755
8909
  "重试",
8756
8910
  )
8757
8911
  : null,
8758
- h(
8759
- "button",
8760
- {
8761
- type: "button",
8762
- disabled: busy !== "",
8763
- onClick: runTest,
8764
- style: { ...S.saveBtn, background: "transparent", color: "inherit" },
8765
- },
8766
- busy === "test" ? "测试中…" : "测试端点",
8767
- ),
8768
8912
  ),
8769
8913
  // 4h) 状态信息(紧凑)
8770
8914
  h(
@@ -8795,20 +8939,6 @@ window.__ModuleLoader__.load({
8795
8939
  }) + (activeKeyStatus.configured === true ? "" : t("(未读到)"))
8796
8940
  : null,
8797
8941
  ),
8798
- msg !== null
8799
- ? h(
8800
- "div",
8801
- {
8802
- style: {
8803
- ...S.meta,
8804
- marginTop: "10px",
8805
- color: msg.ok ? "#4ade80" : "#f87171",
8806
- },
8807
- key: "msg",
8808
- },
8809
- msg.text,
8810
- )
8811
- : null,
8812
8942
  ];
8813
8943
 
8814
8944
  // 配置 tab 的全部内容。
package/lib/index.js CHANGED
@@ -654,9 +654,10 @@ export const Config = z.object({
654
654
  .description("缓存存活时间;改策略或配置后旧裁决不应继续被复用"),
655
655
  telemetryLevel: z.union([...TELEMETRY_LEVELS]).default(DEFAULTS.telemetryLevel)
656
656
  .description(
657
- "匿名使用统计:off 完全不联网 / install 仅首次运行报一次(默认)/ "
658
- + "daily 每天报一次(含当日决策数)。只上报插件版本、平台、架构与计数,"
659
- + "永不上报 prompt、密钥、路径或会话内容",
657
+ "匿名使用统计:off 完全不联网 / install 仅首次运行报一次 / daily 每天一次(默认,含当天决策数)。"
658
+ + "用途只有「判断还有多少人在用、哪些版本与平台在用、值不值得继续维护」;"
659
+ + "上报随机安装 ID、插件版本、平台/架构、宿主版本与决策计数,"
660
+ + "永不上报 prompt、密钥、路径、模型名或会话内容",
660
661
  ),
661
662
  telemetryEndpoint: z.string().default(DEFAULTS.telemetryEndpoint)
662
663
  .description("统计上报地址;留空用内置端点(https://dsh.zuos.us/api/e)"),
@@ -2220,9 +2221,11 @@ class StartluxClient {
2220
2221
  const apiInputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;
2221
2222
  const apiOutputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : undefined;
2222
2223
  const inputSource = apiInputTokens === undefined ? "estimated" : "api";
2223
- const outputSource = apiOutputTokens === undefined ? "estimated" : "api";
2224
+ // output token 没有估算器:缺了就是**未知**,不能写成 0 还标 estimated。
2225
+ // 写成 0 会把「不知道」伪装成「测得 0」,总量被系统性低估(审计 P0-1)。
2226
+ const outputSource = apiOutputTokens === undefined ? "missing" : "api";
2224
2227
  const inputTokens = apiInputTokens ?? estimateRequestTokens(bodyText);
2225
- const outputTokens = apiOutputTokens ?? 0;
2228
+ const outputTokens = apiOutputTokens ?? null;
2226
2229
  const result = {
2227
2230
  answers,
2228
2231
  model: typeof body.model === "string" ? body.model : model || "(server-default)",
@@ -2241,10 +2244,11 @@ class StartluxClient {
2241
2244
  outputTokens: apiOutputTokens,
2242
2245
  inputSource,
2243
2246
  outputSource,
2244
- // 统计用的 token 数(含估算)。与上面的「API 原始值」分开存:
2245
- // 前者进遥测(审计记录),后者进统计与缓存基线。
2247
+ // 统计用的 token 数(含估算)。input 有估算器;output 没有——缺了
2248
+ // 就是 null + missing,聚合时不进入总量,只计「未计量条数」。
2246
2249
  statInputTokens: inputTokens,
2247
2250
  statOutputTokens: outputTokens,
2251
+ outputTokensMissing: apiOutputTokens === undefined,
2248
2252
  cached: false,
2249
2253
  };
2250
2254
 
@@ -2279,6 +2283,7 @@ class StartluxClient {
2279
2283
  mode: call.pointMode ?? "unknown",
2280
2284
  inputTokens,
2281
2285
  outputTokens,
2286
+ outputTokensMissing: apiOutputTokens === undefined,
2282
2287
  durationMs: result.latencyMs,
2283
2288
  inputSource,
2284
2289
  outputSource,
@@ -2599,12 +2604,27 @@ export function apply(ctx, config) {
2599
2604
  const level = cfg.telemetryLevel ?? DEFAULTS.telemetryLevel;
2600
2605
  // off 时连读取都省掉:这是「关掉就是真的不联网」的执行点之一。
2601
2606
  if (level === "off") return;
2607
+ /**
2608
+ * 统计**上一完整自然日**,而不是「今天到目前为止」。
2609
+ *
2610
+ * 审计 P0-3:旧实现取启动时的「今天」计数,上午启动时只看到当天前几次
2611
+ * 决策,随后全天几百次都不会再更新;服务端按收到日期归档,于是这一天
2612
+ * 的 decisions 被系统性低估。改为日终口径:本次上报的是昨天 00:00 到
2613
+ * 今天 00:00 的完整计数,并用 `day` 明确告诉服务端它属于哪一天。
2614
+ */
2615
+ const now = new Date();
2616
+ const end = new Date(now);
2617
+ end.setHours(0, 0, 0, 0);
2618
+ const start = new Date(end);
2619
+ start.setDate(start.getDate() - 1);
2620
+ const pad = (value) => String(value).padStart(2, "0");
2621
+ const localStamp = (date) =>
2622
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
2623
+ const day = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`;
2602
2624
  let decisions = 0;
2603
2625
  try {
2604
- // 只统计「今天」的决策数:心跳的语义是「今天用没用、用多少」。
2605
- // 聚合失败就当 0(不上报该字段),不影响上报本身。
2606
2626
  const rows = await statsStore.read();
2607
- const range = resolveRange({ preset: "today" });
2627
+ const range = resolveRange({ preset: "custom", start: localStamp(start), end: localStamp(end) });
2608
2628
  if (range.valid === true) {
2609
2629
  const result = aggregate(rows, range);
2610
2630
  // 字段名是 `requests`(见 stats.js 的 emptyBucket)。这里曾写成
@@ -2628,6 +2648,7 @@ export function apply(ctx, config) {
2628
2648
  // 宿主版本拿不到就留空,buildPayload 会整字段省略(不猜、不伪造)。
2629
2649
  hostVersion: "",
2630
2650
  decisions,
2651
+ day,
2631
2652
  });
2632
2653
  };
2633
2654
  // 不 await:首次运行会真的发一次网络请求,绝不能拖住插件启动。
@@ -3150,6 +3171,108 @@ export function apply(ctx, config) {
3150
3171
  return { ok: false, text: `provider: ${provider.label}(${provider.id})\nPOST ${usedEndpoint}\n请求失败(${Date.now() - started}ms)` };
3151
3172
  };
3152
3173
 
3174
+ /**
3175
+ * 探测回退语言模型(guardFallback = model 时)。
3176
+ *
3177
+ * 为什么要单独测:决策端点与回退模型是两条完全不同的链路——前者是自己拼的
3178
+ * System One 请求,后者走宿主的 `llm.stream`。决策端点通、回退模型 401 的
3179
+ * 情况完全可能发生;只测前者会让用户以为整套守卫都在工作,而真正需要兜底
3180
+ * 时才发现回退模型不可用。
3181
+ *
3182
+ * 刻意只测「显式指定 provider/model」这一种策略:
3183
+ * - session 用的是当前会话模型,设置页没有会话上下文,测不了(也不该猜);
3184
+ * - allow / deny 根本不调模型,没有可测的链路。
3185
+ *
3186
+ * 这是一次最小的真实模型调用(maxTokens 16),只在用户点「测试」时发生;
3187
+ * 失败/超时都折叠成一段可读诊断,绝不让测试按钮本身抛错。
3188
+ *
3189
+ * @returns `{ ok: boolean|null, skipped?: boolean, text }`;null = 本策略不适用。
3190
+ */
3191
+ const probeFallbackModel = async () => {
3192
+ const policy = cfg.guardFallback;
3193
+ if (policy !== "model") {
3194
+ return {
3195
+ ok: null,
3196
+ skipped: true,
3197
+ text: `回退语言模型: 当前策略为 ${policy},不调用独立模型(${policy === "session" ? "使用会话当前模型,设置页无法离线测试" : "没有可测的模型链路"})`,
3198
+ };
3199
+ }
3200
+ const provider = typeof cfg.guardFallbackProvider === "string" ? cfg.guardFallbackProvider.trim() : "";
3201
+ const model = typeof cfg.guardFallbackModel === "string" ? cfg.guardFallbackModel.trim() : "";
3202
+ if (provider.length === 0 || model.length === 0) {
3203
+ return { ok: false, text: "回退语言模型: 策略为 model,但 provider / model 未填全" };
3204
+ }
3205
+ const llm = ctx.get?.("llm");
3206
+ if (typeof llm?.stream !== "function") {
3207
+ return { ok: false, text: "回退语言模型: 宿主未提供 llm.stream,无法测试(请确认 DSH 版本支持该服务)" };
3208
+ }
3209
+ const configuredTimeout = Number.isFinite(cfg.timeoutMs) ? cfg.timeoutMs : 20000;
3210
+ const timeoutMs = Math.max(8000, Math.min(20000, configuredTimeout));
3211
+ const effort = typeof cfg.guardFallbackReasoningEffort === "string" ? cfg.guardFallbackReasoningEffort.trim() : "";
3212
+ const started = Date.now();
3213
+ try {
3214
+ const stream = llm.stream({
3215
+ provider,
3216
+ model,
3217
+ ...(effort.length > 0 ? { reasoningEffort: effort } : {}),
3218
+ // 只要求一个词:这是连通性测试,不是质量评测;输出越短越省钱。
3219
+ messages: [{ role: "user", content: [{ type: "text", text: "Reply with the single word: pong" }] }],
3220
+ tools: [],
3221
+ maxTokens: 16,
3222
+ signal: AbortSignal.timeout(timeoutMs),
3223
+ purpose: "decision-engine-connectivity",
3224
+ });
3225
+ let finish = null;
3226
+ let text = "";
3227
+ for await (const chunk of stream) {
3228
+ if (chunk?.type === "text-delta") text += chunk.text;
3229
+ if (chunk?.type === "finish") finish = chunk.reason?.kind ?? "unknown";
3230
+ }
3231
+ // length 也算连通:模型确实回应了,只是 16 token 被截断(例如它先输出了
3232
+ // 一段思考)。只有「没有 finish 或 finish 不是正常结束」才算失败。
3233
+ const ok = finish === "stop" || finish === "length";
3234
+ return {
3235
+ ok,
3236
+ text:
3237
+ `回退语言模型: ${provider} / ${model}${effort.length > 0 ? `(effort ${effort})` : ""}\n` +
3238
+ `耗时 ${Date.now() - started}ms finish: ${finish ?? "(无)"}\n` +
3239
+ `响应: ${text.trim().slice(0, 120) || "(空)"}`,
3240
+ };
3241
+ } catch (error) {
3242
+ const message = error instanceof Error ? error.message : String(error);
3243
+ return {
3244
+ ok: false,
3245
+ text: `回退语言模型: ${provider} / ${model}\n请求失败(${Date.now() - started}ms): ${message}`,
3246
+ };
3247
+ }
3248
+ };
3249
+
3250
+ /**
3251
+ * 把 probe() 的多行诊断压成一句「人先看这个」的结论。
3252
+ *
3253
+ * 原始输出是给排查用的(request id、尝试过的路径、响应体),但测试按钮的
3254
+ * 第一眼需要一个能判断「通没通、多快」的短句;详情仍然保留,折叠起来。
3255
+ *
3256
+ * @param text - probe() 返回的完整文本。
3257
+ * @returns 一行摘要(不抛错,解析不出来就取前两行)。
3258
+ */
3259
+ const summarizeProbe = (text) => {
3260
+ const lines = String(text ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
3261
+ const provider = lines.find((line) => line.startsWith("provider: "));
3262
+ const post = lines.find((line) => line.startsWith("POST "));
3263
+ const http = lines.find((line) => line.startsWith("HTTP "));
3264
+ const failed = lines.find((line) => line.startsWith("请求失败"));
3265
+ const reason = lines.find((line) => line.startsWith("失败原因: "));
3266
+ const parts = [];
3267
+ if (provider) parts.push(provider.slice("provider: ".length));
3268
+ if (post) parts.push(post);
3269
+ if (http) parts.push(http.replace(/\s+/g, " "));
3270
+ else if (failed) parts.push(failed);
3271
+ if (reason) parts.push(reason.slice("失败原因: ".length));
3272
+ if (parts.length === 0) return lines.slice(0, 2).join(" · ") || "(无结果)";
3273
+ return parts.join(" · ");
3274
+ };
3275
+
3153
3276
  // ---- 面板 API:给前端 UI 用(不经过 settings 服务)-------------------------
3154
3277
  ctx.inject?.(["connection"], () => {
3155
3278
  const connection = ctx.get?.("connection");
@@ -3847,8 +3970,42 @@ export function apply(ctx, config) {
3847
3970
  methods: ["POST"],
3848
3971
  requestBody: "buffered",
3849
3972
  fetch: async () => {
3850
- const result = await probe();
3851
- return json({ ok: result.ok, text: result.text });
3973
+ // 两条链路各测一次:决策端点是自拼的 System One 请求,回退模型走宿主
3974
+ // 的 llm.stream。只测前者会让「决策端点通、回退模型不可用」被误报成
3975
+ // 整体健康——而回退模型恰好是审批通道不可用时的兜底裁判。
3976
+ const decision = await probe();
3977
+ await reloadOwnConfig();
3978
+ const fallback = await probeFallbackModel();
3979
+ // fallback.ok 为 null 表示该策略不适用(session / allow / deny),
3980
+ // 不能把「没有这条链路」算成失败;但明确失败(false)必须让整体变红。
3981
+ const ok = decision.ok === true && fallback.ok !== false;
3982
+ // 给界面一份结构化结果:每条链路一个短摘要 + 可折叠的原始诊断。
3983
+ // 摘要与详情分开,是因为用户第一眼只需要「通没通、多快」,排查时才
3984
+ // 展开 request id / 响应体。
3985
+ const results = [
3986
+ {
3987
+ key: "decision",
3988
+ label: "决策端点",
3989
+ ok: decision.ok === true,
3990
+ skipped: false,
3991
+ summary: summarizeProbe(decision.text),
3992
+ detail: decision.text,
3993
+ },
3994
+ {
3995
+ key: "fallback",
3996
+ label: "回退语言模型",
3997
+ ok: fallback.ok,
3998
+ skipped: fallback.skipped === true,
3999
+ summary: fallback.text,
4000
+ detail: fallback.detail ?? null,
4001
+ },
4002
+ ];
4003
+ return json({
4004
+ ok,
4005
+ // `text` 保留给旧前端/日志:纯文本仍然可读。
4006
+ text: [decision.text, fallback.text].filter((line) => typeof line === "string" && line.length > 0).join("\n\n"),
4007
+ results,
4008
+ });
3852
4009
  },
3853
4010
  });
3854
4011
 
@@ -4041,6 +4198,16 @@ export function apply(ctx, config) {
4041
4198
  .catch((err) => {
4042
4199
  ctx.logger?.warn?.(`dsh-decision-engine: 匿名统计跳过(不影响插件):${String(err?.message ?? err)}`);
4043
4200
  });
4201
+ // 跨日补报(审计 P0-3):长驻进程不会重新启动,只在启动时上报就永远
4202
+ // 只有第一天的心跳。每 6 小时检查一次;`decideKind` 用「今天是否已上报」
4203
+ // 去重,所以同一天不会重复发,跨日后会补报刚结束的那一天。
4204
+ const telemetryTimer = setInterval(() => {
4205
+ void reloadOwnConfig()
4206
+ .catch(() => {})
4207
+ .then(() => startTelemetry())
4208
+ .catch(() => {});
4209
+ }, 6 * 60 * 60 * 1000);
4210
+ telemetryTimer.unref?.();
4044
4211
 
4045
4212
  }
4046
4213
 
package/lib/stats.js CHANGED
@@ -74,6 +74,14 @@ export const TOKEN_SOURCES = {
74
74
  estimated: "estimated",
75
75
  /** 拿不到任何值(0)。 */
76
76
  unknown: "unknown",
77
+ /**
78
+ * 服务端明确没有返回这个计量值。
79
+ *
80
+ * 与 `unknown` 的区别:`unknown` 是「老记录没带来源字段」,`missing` 是
81
+ * 「这次请求确实没有 output token 可用」。两者都不能当成 0 参与总量,
82
+ * 但 `missing` 需要单独计数以便报告覆盖率。
83
+ */
84
+ missing: "missing",
77
85
  };
78
86
 
79
87
  /**
@@ -149,7 +157,8 @@ export function buildRecord(options = {}) {
149
157
  const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
150
158
  const source = (value) =>
151
159
  TOKEN_SOURCES[value] === TOKEN_SOURCES.api ||
152
- TOKEN_SOURCES[value] === TOKEN_SOURCES.estimated
160
+ TOKEN_SOURCES[value] === TOKEN_SOURCES.estimated ||
161
+ TOKEN_SOURCES[value] === TOKEN_SOURCES.missing
153
162
  ? value
154
163
  : TOKEN_SOURCES.unknown;
155
164
  const inputTokens = num(options.inputTokens);
@@ -187,6 +196,15 @@ export function buildRecord(options = {}) {
187
196
  baseDurationMs,
188
197
  inputSource: source(options.inputSource),
189
198
  outputSource: source(options.outputSource),
199
+ /**
200
+ * output token 未知。
201
+ *
202
+ * 此前 `outputTokens` 缺失时被写成 0、来源却标 estimated——等于把一个
203
+ * 「不知道」伪装成「测得 0」,总量被系统性低估。现在显式带一个标记,
204
+ * 聚合时**不把未知值加进已知总量**,只累计未计量条数,界面再报覆盖率。
205
+ */
206
+ outputTokensMissing:
207
+ options.outputTokensMissing === true || options.outputSource === "missing",
190
208
  };
191
209
  }
192
210
 
@@ -414,6 +432,10 @@ function normalizeRecord(raw) {
414
432
  baseDurationMs: num(raw?.baseDurationMs),
415
433
  inputSource: typeof raw?.inputSource === "string" ? raw.inputSource : "unknown",
416
434
  outputSource: typeof raw?.outputSource === "string" ? raw.outputSource : "unknown",
435
+ outputTokensMissing:
436
+ raw?.outputTokensMissing === true || raw?.outputSource === "missing",
437
+ /** 缓存命中的基线是否来自历史插补(审计 P1:不再插补,只标记)。 */
438
+ baselineEstimated: raw?.baselineEstimated === true,
417
439
  // 预拼的「provider\0model」组合键:aggregate 过去每条记录拼一次、
418
440
  // 基线补齐再拼一次,两次结果必然相同。放在归一化时算一次即可。
419
441
  // 前缀 `\u0000` 保证它不会与被当作记录字段读取的键撞名。
@@ -454,6 +476,10 @@ export function isInRange(record, range) {
454
476
  * @param record - 已规范化的记录。
455
477
  */
456
478
  function accumulate(bucket, record) {
479
+ // 「尝试」= 发出去(或准备发出去)的每一次,含降级;「请求」= 拿到回答的
480
+ // 那部分。两者分开后,趋势图与汇总表可以用同一个分母对账:
481
+ // attempts = requests + degraded
482
+ bucket.attempts += 1;
457
483
  if (record.degraded === true) {
458
484
  bucket.degraded += 1;
459
485
  bucket.degradedDurationMs += record.durationMs;
@@ -462,7 +488,12 @@ function accumulate(bucket, record) {
462
488
  bucket.requests += 1;
463
489
  if (record.cached === true) bucket.cached += 1;
464
490
  bucket.inputTokens += record.inputTokens;
465
- bucket.outputTokens += record.outputTokens;
491
+ if (record.outputTokensMissing === true) {
492
+ // 未知值不进入已知总量,只计数;界面据此报「N 条未计量」。
493
+ bucket.outputTokensUnmeasured += 1;
494
+ } else {
495
+ bucket.outputTokens += record.outputTokens;
496
+ }
466
497
  bucket.durationMs += record.durationMs;
467
498
  /**
468
499
  * 节省量只认**观察模式**的记录。
@@ -483,10 +514,13 @@ function accumulate(bucket, record) {
483
514
  if (!contributesSavings(record.mode)) return;
484
515
  bucket.observeRequests += 1;
485
516
  bucket.observeInputTokens += record.inputTokens;
486
- bucket.observeOutputTokens += record.outputTokens;
517
+ // output 未知时基线也无从复核:只跳过 output 这一对,不把 0 当实测。
518
+ if (record.outputTokensMissing !== true) {
519
+ bucket.observeOutputTokens += record.outputTokens;
520
+ bucket.observeBaseOutputTokens += record.baseOutputTokens;
521
+ }
487
522
  bucket.observeDurationMs += record.durationMs;
488
523
  bucket.observeBaseInputTokens += record.baseInputTokens;
489
- bucket.observeBaseOutputTokens += record.baseOutputTokens;
490
524
  bucket.observeBaseDurationMs += record.baseDurationMs;
491
525
  }
492
526
 
@@ -510,6 +544,8 @@ function resetBucket(bucket) {
510
544
  */
511
545
  function emptyBucket() {
512
546
  return {
547
+ /** 尝试次数 = requests + degraded;对账用。 */
548
+ attempts: 0,
513
549
  requests: 0,
514
550
  cached: 0,
515
551
  degraded: 0,
@@ -517,7 +553,11 @@ function emptyBucket() {
517
553
  degradedDurationMs: 0,
518
554
  inputTokens: 0,
519
555
  outputTokens: 0,
556
+ /** output token 未知的记录数(不进入 outputTokens,见 accumulate)。 */
557
+ outputTokensUnmeasured: 0,
520
558
  durationMs: 0,
559
+ /** 缓存命中却缺基线、无法估计节省的记录数(不插补,只计数)。 */
560
+ estimatedBaselineMissing: 0,
521
561
  /**
522
562
  * 观察模式专属的累加桶。
523
563
  *
@@ -602,7 +642,10 @@ function finalizeBucket(bucket) {
602
642
  * 这段过程,所以它不该出现在分母里。
603
643
  */
604
644
  const realRequests = Math.max(0, bucket.requests - bucket.cached);
645
+ /** 有 output 计量的真实请求:平均 output 的分母不能把未知值当 0。 */
646
+ const measuredRequests = Math.max(0, realRequests - bucket.outputTokensUnmeasured);
605
647
  return {
648
+ attempts: bucket.attempts,
606
649
  requests: bucket.requests,
607
650
  realRequests,
608
651
  cached: bucket.cached,
@@ -611,6 +654,12 @@ function finalizeBucket(bucket) {
611
654
  degradedDurationMs: bucket.degradedDurationMs,
612
655
  inputTokens: bucket.inputTokens,
613
656
  outputTokens: bucket.outputTokens,
657
+ /** output token 未能计量的记录数;>0 时总量是「已知部分」。 */
658
+ outputTokensUnmeasured: bucket.outputTokensUnmeasured,
659
+ /** 有 output 计量的真实请求数(= realRequests - 未计量)。 */
660
+ measuredRequests,
661
+ /** 缓存命中的历史记录里缺基线、无法估计节省的条数。 */
662
+ estimatedBaselineMissing: bucket.estimatedBaselineMissing,
614
663
  totalTokens: bucket.inputTokens + bucket.outputTokens,
615
664
  durationMs: bucket.durationMs,
616
665
  /**
@@ -635,7 +684,14 @@ function finalizeBucket(bucket) {
635
684
  savedTimeRatio,
636
685
  avgDurationMs: realRequests === 0 ? 0 : Math.round(bucket.durationMs / realRequests),
637
686
  avgInputTokens: realRequests === 0 ? 0 : Math.round(bucket.inputTokens / realRequests),
638
- avgOutputTokens: realRequests === 0 ? 0 : Math.round(bucket.outputTokens / realRequests),
687
+ avgOutputTokens: measuredRequests === 0 ? 0 : Math.round(bucket.outputTokens / measuredRequests),
688
+ /**
689
+ * 节省量的**口径**。`decision_request` = 只看决策请求这一层:
690
+ * 缓存去重 + 输入处理,不含主 Agent / 子代理 / 工具开销。
691
+ * 完整 Agent 净节省只能靠配对 A/B(尚未实现),界面文案必须按这个
692
+ * 口径解释,不能把它说成「总体省了多少」。
693
+ */
694
+ savedRatioScope: "decision_request",
639
695
  };
640
696
  }
641
697
 
@@ -948,10 +1004,20 @@ export function buildSeries(records, range, now = new Date()) {
948
1004
  buckets.forEach((key, index) => indexOf.set(key, index));
949
1005
 
950
1006
  const series = {
1007
+ /** 尝试次数(含降级);与 totals.attempts 同口径。 */
1008
+ attempts: new Array(buckets.length).fill(0),
1009
+ /** 拿到回答的请求数(不含降级);与 totals.requests 同口径。 */
951
1010
  requests: new Array(buckets.length).fill(0),
1011
+ /** 降级次数;与 totals.degraded 同口径。 */
1012
+ degraded: new Array(buckets.length).fill(0),
952
1013
  inputTokens: new Array(buckets.length).fill(0),
953
1014
  outputTokens: new Array(buckets.length).fill(0),
1015
+ /** 该桶里 output token 未知的记录数(不进入 outputTokens)。 */
1016
+ outputTokensUnmeasured: new Array(buckets.length).fill(0),
1017
+ /** 已回答请求的耗时(不含降级);与 totals.durationMs 同口径。 */
954
1018
  durationMs: new Array(buckets.length).fill(0),
1019
+ /** 降级累计等待耗时;与 totals.degradedDurationMs 同口径。 */
1020
+ degradedDurationMs: new Array(buckets.length).fill(0),
955
1021
  allow: new Array(buckets.length).fill(0),
956
1022
  ask: new Array(buckets.length).fill(0),
957
1023
  reject: new Array(buckets.length).fill(0),
@@ -964,11 +1030,21 @@ export function buildSeries(records, range, now = new Date()) {
964
1030
  // 落在区间外的记录(理论上已被筛掉)直接跳过:宁少一个点,
965
1031
  // 也不要把它的值塞进错误的桶里——那会让图与表对不上。
966
1032
  if (index === undefined) continue;
1033
+ series.attempts[index] += 1;
1034
+ if (record.degraded === true) {
1035
+ // 与 accumulate 完全同口径:降级只进 attempts / degraded,
1036
+ // 不进 requests / token / 回答耗时,否则「分桶之和」又对不上汇总。
1037
+ series.degraded[index] += 1;
1038
+ series.degradedDurationMs[index] += record.durationMs;
1039
+ series.none[index] += 1;
1040
+ continue;
1041
+ }
967
1042
  series.requests[index] += 1;
968
1043
  series.inputTokens[index] += record.inputTokens;
969
- series.outputTokens[index] += record.outputTokens;
1044
+ if (record.outputTokensMissing === true) series.outputTokensUnmeasured[index] += 1;
1045
+ else series.outputTokens[index] += record.outputTokens;
970
1046
  series.durationMs[index] += record.durationMs;
971
- const hasVerdict = record.action.length > 0 && record.degraded !== true;
1047
+ const hasVerdict = record.action.length > 0;
972
1048
  const outcome = hasVerdict ? outcomeOf(record.point, record.action) : "none";
973
1049
  series[outcome === "none" ? "none" : outcome][index] += 1;
974
1050
  }
@@ -1115,59 +1191,27 @@ export function aggregate(records, range) {
1115
1191
  * 而历史记录里缓存命中的占比往往不低(实测 telemetry.jsonl 里 678/2029
1116
1192
  * 条是缓存命中),只补耗时会让节省量看起来偏小。
1117
1193
  */
1118
- const realSamples = new Map();
1119
- for (const record of scoped) {
1120
- if (record.cached === true || record.degraded === true) continue;
1121
- if (record.durationMs <= 0) continue;
1122
- const comboKey = `${record.provider}\u0000${record.model}`;
1123
- const sample = realSamples.get(comboKey) ?? { duration: 0, input: 0, output: 0, count: 0 };
1124
- sample.duration += record.durationMs;
1125
- sample.input += record.inputTokens;
1126
- sample.output += record.outputTokens;
1127
- sample.count += 1;
1128
- realSamples.set(comboKey, sample);
1129
- }
1130
- let needsRecount = false;
1131
- for (const record of scoped) {
1132
- if (record.cached !== true) continue;
1133
- const sample = realSamples.get(record.comboKey);
1134
- if (sample === undefined || sample.count === 0) continue;
1135
- /**
1136
- * 缺哪个补哪个,而不是「有一个就不补」。
1137
- *
1138
- * 回填出来的历史命中记录是有 input 基线的(老遥测记了被复用的那份
1139
- * token 数),但**耗时**基线老遥测确实没有。若按「任一基线存在就跳过」
1140
- * 判断,这批记录的耗时节省会永远是 0——而缓存命中省下的时间恰恰是
1141
- * 用户最想看到的数字。
1142
- */
1143
- if (record.baseInputTokens === 0) {
1144
- record.baseInputTokens = Math.round(sample.input / sample.count);
1145
- needsRecount = true;
1146
- }
1147
- if (record.baseOutputTokens === 0) {
1148
- record.baseOutputTokens = Math.round(sample.output / sample.count);
1149
- needsRecount = true;
1150
- }
1151
- if (record.baseDurationMs === 0) {
1152
- record.baseDurationMs = Math.round(sample.duration / sample.count);
1153
- needsRecount = true;
1154
- }
1155
- }
1156
-
1157
- // 基线补齐后要重新累加一遍:上面的 accumulate 发生在补齐之前,
1158
- // 补出来的基线不会自动进桶。重新累加的代价是一次 O(n) 遍历,
1159
- // 与统计本身相比可忽略。
1160
- if (needsRecount) {
1161
- for (const bucket of [totals, ...byCombo.values(), ...byProvider.values(), ...byPoint.values()]) {
1162
- resetBucket(bucket);
1163
- }
1164
- for (const record of scoped) {
1165
- accumulate(totals, record);
1166
- accumulate(byCombo.get(record.comboKey), record);
1167
- accumulate(byProvider.get(record.provider), record);
1168
- accumulate(byPoint.get(record.point), record);
1169
- }
1170
- }
1194
+ /**
1195
+ * 历史缓存命中的基线缺失——**不再用查询区间的均值插补**。
1196
+ *
1197
+ * 均值插补有三个确认的问题:
1198
+ * 1. 同一模型同时处理很短与很长的请求,按模型分组的均值不保证
1199
+ * 与被插补请求同分布;
1200
+ * 2. 较早的命中可以借用同窗口内较晚发生的请求作基线(时间泄漏);
1201
+ * 3. 同一记录在不同查询窗口会得到不同基线,节省量不可加
1202
+ * (S(A∪B) ≠ S(A)+S(B))。
1203
+ *
1204
+ * 现在只计数并上报:节省量只用「写入时已持久化」的精确基线(新记录
1205
+ * 携带 baseInput/baseDuration),缺基线的历史命中不计入节省,但
1206
+ * `estimatedBaselineMissing` 让界面明确告诉用户有多少条无法估计。
1207
+ */
1208
+ const estimatedBaselineMissing = scoped.filter(
1209
+ (record) =>
1210
+ record.cached === true
1211
+ && record.baseInputTokens === 0
1212
+ && record.baseOutputTokens === 0
1213
+ && record.baseDurationMs === 0,
1214
+ ).length;
1171
1215
 
1172
1216
  const rows = [...byCombo.entries()].map(([comboKey, bucket]) => {
1173
1217
  const [provider, model] = comboKey.split("\u0000");
@@ -1185,6 +1229,26 @@ export function aggregate(records, range) {
1185
1229
  point,
1186
1230
  ...bucket,
1187
1231
  }));
1232
+ /**
1233
+ * 安全口径(审计 P1):`authorize` 的 reject 才是「安全拒绝」。
1234
+ *
1235
+ * retrieval.skip / skillPick.redirect / route.switch / compact.prune 都是
1236
+ * 流程优化,它们也会产生 reject/allow,但与「有没有放行一次危险调用」
1237
+ * 不是同一件事。把所有点的 reject 合成一个「拦截率」,启用越多优化
1238
+ * 拦截率越高,而安全策略可能一点没变。这里单独给出 authorize 的分账,
1239
+ * 其它点的 reject 只计数、不进入安全拦截率。
1240
+ */
1241
+ const authorizeOutcome = outcomeByPoint.get("authorize") ?? emptyOutcomeBucket();
1242
+ const safety = {
1243
+ point: "authorize",
1244
+ allow: authorizeOutcome.allow,
1245
+ ask: authorizeOutcome.ask,
1246
+ reject: authorizeOutcome.reject,
1247
+ /** 其它决策点产生的 reject(流程优化,不计入安全拦截率)。 */
1248
+ otherPointRejects: Math.max(0, outcomeTotals.reject - authorizeOutcome.reject),
1249
+ note:
1250
+ "安全拦截率只统计 authorize 点;retrieval / skillPick / route / compact 的 reject 属于流程优化,不计入。",
1251
+ };
1188
1252
  /**
1189
1253
  * 裁决总览:三格 + 未裁决 + 未分类明细。
1190
1254
  *
@@ -1244,6 +1308,10 @@ export function aggregate(records, range) {
1244
1308
  */
1245
1309
  series,
1246
1310
  recordCount: scoped.length,
1311
+ /** 缺基线、无法估计节省的历史缓存命中条数(不插补,见上)。 */
1312
+ estimatedBaselineMissing,
1313
+ /** 安全分账:只有 authorize 点算安全拦截(见上)。 */
1314
+ safety,
1247
1315
  /**
1248
1316
  * 节省量的**来源说明**。
1249
1317
  *
package/lib/telemetry.js CHANGED
@@ -172,6 +172,17 @@ function shortField(value, max = 24) {
172
172
  return String(value ?? "").trim().replace(/[^0-9A-Za-z._+-]/g, "").slice(0, max);
173
173
  }
174
174
 
175
+ /**
176
+ * 规范化上报的日期(`YYYY-MM-DD`)。
177
+ *
178
+ * @param value - 原始值。
179
+ * @returns 合法日期串;非法返回空串(字段省略,服务端按收到时间归档)。
180
+ */
181
+ export function normalizeDay(value) {
182
+ const text = String(value ?? "").trim();
183
+ return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : "";
184
+ }
185
+
175
186
  /**
176
187
  * 插件标识(npm 包名的合法形态):小写,字母数字与 `-` / `.` / `_`,最长 48。
177
188
  *
@@ -201,7 +212,8 @@ export function normalizePluginId(value) {
201
212
  * @param input.platform - `process.platform`。
202
213
  * @param input.arch - `process.arch`。
203
214
  * @param input.hostVersion - 宿主版本(拿不到时为空串)。
204
- * @param input.decisions - 当天决策数(0 表示没有,不上报该字段)。
215
+ * @param input.decisions - 上一完整自然日的决策数(0 表示没有,不上报该字段)。
216
+ * @param input.day - 这组计数属于哪一天(`YYYY-MM-DD`,可选);服务端据此归档。
205
217
  * @returns 可直接 `JSON.stringify` 的对象。
206
218
  */
207
219
  export function buildPayload(input = {}) {
@@ -218,6 +230,11 @@ export function buildPayload(input = {}) {
218
230
  };
219
231
  const host = normalizeVersion(input.hostVersion);
220
232
  if (host.length > 0) payload.host = host;
233
+ // 客户端声明的「这组决策数属于哪一天」。日终/次日上报上一完整自然日时,
234
+ // 服务端必须按这个日期落库,而不是按收到请求的日期——否则昨天的计数会
235
+ // 被记到今天(审计 P0-3)。非法值不带,服务端退回收到时间。
236
+ const day = normalizeDay(input.day);
237
+ if (day.length > 0) payload.day = day;
221
238
  const decisions = Number(input.decisions);
222
239
  if (Number.isFinite(decisions) && decisions > 0) payload.d = Math.min(Math.trunc(decisions), 100000);
223
240
  return payload;
@@ -386,6 +403,7 @@ export class TelemetryReporter {
386
403
  arch: input.arch,
387
404
  hostVersion: input.hostVersion,
388
405
  decisions: input.decisions,
406
+ day: input.day,
389
407
  });
390
408
  return { state: next, payload, today, kind };
391
409
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leonhardthust/dsh-decision-engine",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — a small decision model answers structured questions before the main model acts, so calls can be allowed, escalated, rerouted to a cheaper model, or skipped. All eight decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, COT translation) are implemented and individually switchable. Works against any service exposing a decision endpoint: built-in presets (system one, bamboonode, SiliconFlow, Silicon Flow) or a custom base URL.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",