dsh-llm-verifier 0.2.0 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +15 -11
  2. package/lib/client.js +137 -13
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +377 -115
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/auto.d.ts +32 -12
  7. package/lib/types/auto.d.ts.map +1 -1
  8. package/lib/types/auto.js +50 -38
  9. package/lib/types/auto.js.map +1 -1
  10. package/lib/types/client-i18n.d.ts +50 -1
  11. package/lib/types/client-i18n.d.ts.map +1 -1
  12. package/lib/types/client-i18n.js +83 -4
  13. package/lib/types/client-i18n.js.map +1 -1
  14. package/lib/types/client.d.ts +2 -2
  15. package/lib/types/client.d.ts.map +1 -1
  16. package/lib/types/client.js +81 -32
  17. package/lib/types/client.js.map +1 -1
  18. package/lib/types/engine.d.ts +19 -0
  19. package/lib/types/engine.d.ts.map +1 -1
  20. package/lib/types/engine.js +54 -4
  21. package/lib/types/engine.js.map +1 -1
  22. package/lib/types/index.d.ts.map +1 -1
  23. package/lib/types/index.js +32 -43
  24. package/lib/types/index.js.map +1 -1
  25. package/lib/types/router.d.ts +31 -0
  26. package/lib/types/router.d.ts.map +1 -1
  27. package/lib/types/router.js +181 -17
  28. package/lib/types/router.js.map +1 -1
  29. package/lib/types/statistics.d.ts +28 -0
  30. package/lib/types/statistics.d.ts.map +1 -1
  31. package/lib/types/statistics.js +101 -0
  32. package/lib/types/statistics.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/auto.test.ts +42 -23
  35. package/src/auto.ts +55 -46
  36. package/src/client-i18n.ts +93 -4
  37. package/src/client.test.ts +134 -3
  38. package/src/client.tsx +61 -4
  39. package/src/config.test.ts +6 -3
  40. package/src/engine.test.ts +40 -1
  41. package/src/engine.ts +56 -4
  42. package/src/index.ts +34 -44
  43. package/src/router.test.ts +191 -3
  44. package/src/router.ts +171 -14
  45. package/src/statistics.test.ts +116 -0
  46. package/src/statistics.ts +112 -0
package/README.md CHANGED
@@ -239,13 +239,13 @@ flowchart LR
239
239
  | **混合语义路由** | 结构化证据不足时,是否允许裁判模型保守分类 `compare/select/track/none`;不会生成新候选或编造检查点 |
240
240
  | **语义路由置信度** | 语义识别达到该值才执行对应工具,默认 `0.9` |
241
241
  | **最多候选数** | 自动 `select` 一次最多纳入的候选数,默认 `8` |
242
- | **每任务/每会话最多路由** | 限制 `compare/select/track` 及语义分类的自动次数。它与最终验收**共享同一个尝试计数器**(实际上限 = 本项 + 对应的「每任务/每会话最多验收」),预算按真实对局数估算(8 候选约需 54 次调用);被拒绝时写入宿主日志并区分「预算不足 / 已有 verifier 在跑 / 同一证据已路由」,strict 下额外注入一次提示 |
242
+ | **每任务/每会话最多路由** | 限制 `compare/select/track` 及语义分类的自动次数。它与最终验收**共享同一个尝试计数器**(实际上限 = 本项 + 对应的「每任务/每会话最多验收」),预算按真实对局数估算(8 候选约需 108 次调用);被拒绝时写入宿主日志并区分「预算不足 / 已有 verifier 在跑 / 同一证据已路由」,strict 下额外注入一次提示 |
243
243
  | **同时验收子 Agent** | 默认关闭;开启后 `subagent` / fork 子会话也会执行自动路由与最终验收 |
244
- | **进度完成阈值** | `track` 任一检查点低于该值时 steering 要求继续工作,默认 `0.8` |
244
+ | **进度完成阈值** | `track` **最新**检查点低于该值时 steering 要求继续工作,默认 `0.8`。更早的检查点只随消息展示:每个任务的第一个检查点都是"刚写完计划",用"任一检查点低于阈值"判定会让继续分支恒真(实测 5/5) |
245
245
  | **单项/总证据字符上限** | 自动候选与检查点脱敏后的单项、整次路由输入硬上限,默认 `20000 / 60000`;同时约束语义路由提示词的总长度(只保留预算内最新证据)。截断标记本身也计入上限,因此截断后的条目不会被判定为超限。候选与检查点都按**数量分摊**总字符预算(单项仍受 2 万字符上限),自动 `track` 还最多渲染**最近 6 个**检查点(更早的在首个检查点中注明已省略),因此 8 个长候选或几十个检查点都不会让整条决策被上限整条丢弃 |
246
246
  | **任务/会话模型调用预算** | 分类、compare/select/track 与最终验收共享的估算请求预算,默认 `96 / 240`(按真实对局数估算:8 候选的完整锦标赛约 18 场对决 × 3 标准 = 54 次调用,再加最终验收 3 标准 × 2 轮 = 6 次;默认值因此给最终验收留出余量) |
247
- | **通过阈值** | 自动会话验收要求证据分数达到阈值且胜过“未执行有效工作”基线 |
248
- | **路由评估轮次** | 自动路由(compare/select/track)每个标准的重复轮次;默认 1,作为低成本初筛 |
247
+ | **通过阈值** | 自动会话验收要求证据分数达到阈值、胜过“未执行有效工作”基线,**且每一项标准各自也达到阈值**(均值会把某一项彻底失败平均掉:3 项中 1 项 0 分、2 项满分时均分 0.67 仍会越过 0.65) |
248
+ | **路由评估轮次** | 自动路由每个标准的重复轮次;默认 1。`compare` 只判一对,会被**向上取整到偶数**(引擎只在奇数轮交换 A/B 位置,奇数轮等于让第一个候选固定坐在 A 位);`select` 的 ring 天然对称、pivot 轮的 A/B 槽位由引擎逐对平衡,`track` 没有位置可换,两者都保留配置值——不对症的偏差不付双倍调用 |
249
249
  | **最终验收轮次** | 最终会话验收每个标准的重复轮次,默认 `2`:奇数轮不交换、偶数轮交换 A/B 位置,因此默认配置下位置偏好被抵消。调低到 1 会省一次调用,但会失去这层保护 |
250
250
  | **智能模式最少工具调用** | `smart` 策略需要的最少非 Verifier 工具调用数 |
251
251
  | **最大证据字符** | 自动发送给裁判的最近会话轨迹字符上限 |
@@ -278,7 +278,7 @@ flowchart LR
278
278
  ```text
279
279
  3 个以上同组真实候选 → verifier_select → steering 实施胜出候选
280
280
  恰好 2 个同组真实候选 → verifier_compare → steering 实施胜出候选
281
- 已有多个进度快照/检查点 → verifier_track → 未达阈值则 steering 继续
281
+ 已有多个进度快照/检查点 → verifier_track → 最新检查点未达阈值则 steering 继续
282
282
  候选决策和进度阶段完成 → verifier_current_session → 最终交付验收
283
283
  ```
284
284
 
@@ -303,21 +303,25 @@ flowchart LR
303
303
 
304
304
  - 同一任务内至少两个内容或状态发生真实变化的 `todo/write` 快照形成进度检查点;完全相同的重复快照会被折叠;
305
305
  - 检查点数量本身没有硬上限,但一次路由最多渲染**最近 6 个**检查点(`MAX_ROUTED_CHECKPOINTS`),被略过的数量会写在首个检查点里;「路由证据总字符上限」按检查点数分摊,所以长任务不会因为检查点太多而被整条丢弃;
306
- - 每个进度检查点都会附带**该时刻之前最近一次成功工具输出**(`tool/result` / `tool/ptc-dispatch` 原文,脱敏并限长)。只有 todo 文本的检查点无法证明"已完成",裁判提示词明确拒绝给无实测输出的状态打高分,因此证据随检查点一同送入——否则一个真正完成的任务也会稳定被打回继续工作。
306
+ - 每个进度检查点都会附带**该时刻之前最近一次成功工具输出**(`tool/result` / `tool/ptc-dispatch` 原文,脱敏并限长)。只有 todo 文本的检查点无法证明"已完成",裁判提示词明确拒绝给无实测输出的状态打高分,因此证据随检查点一同送入——否则一个真正完成的任务也会稳定被打回继续工作;
307
+ - 记账类工具(`todo_write` / `create_goal` / `get_goal` / `update_goal` / `interrupt_agent` / `list_agents` / `exit_plan_mode` / `skill`)的输出**不作为证据**:检查点已经渲染了它们写的快照,再附一次只会顶掉此前真正干活的工具输出(实测检查点曾附上 `create_goal`、`update_goal`、`interrupt_agent` 的结果);
308
+ - **最新检查点**额外附带 Agent 最近一段正文,并明确标注为"自述、不是实测证据":交付物本身就是散文的任务(评审、分析)此前每个检查点都只能拿 0%,因为交付物从不经过工具;
309
+ - **最新检查点按"路由时刻的当前状态"渲染**:它的证据是最新的实测输出(标注 `at routing time`),而不是最后一个 todo 快照之前的输出。此前判定依据的快照与路由时刻之间往往隔着多次工具调用,实测路由把"校验构建产物"当成了证据,而真正的测试运行更早、根本没进提示词;
310
+ - **PTC 包装调用也按实际派发内容归类**:`run_code` 只派发了记账类工具时,它自身的结果(通常就是 todo 列表原文)同样不作为证据;派发过真实工具的包装仍保留。
307
311
 
308
- 显式调用过对应的 `verifier_compare`、`verifier_select` 或 `verifier_track` 后,自动 Router 不会再对同类结构化对象重复执行。每个输入还会计算稳定指纹,同一证据不会重复消费预算。
312
+ 显式调用过对应的 `verifier_compare`、`verifier_select` 或 `verifier_track` 后,自动 Router 不会再对同类结构化对象重复执行。每个输入还会计算稳定指纹,同一证据不会重复消费预算;`track` 的指纹覆盖**渲染后的 steps**(检查点证据 + 最新叙述),因此"todo 没变但证据变了"不会被误判成"同一证据已路由",而完全没变化的日志仍然只消费一次预算。
309
313
 
310
314
  ### 第二阶段:混合语义识别
311
315
 
312
- 当结构化对象不足时,启用“混合语义路由”可让配置的裁判模型只做严格 JSON 分类:`none / compare / select / track`。分类器不能返回自由文本证据,只能返回会话中已经存在且成功配对的 `tool/call` callId 或真实 `todo/write` seq;宿主再从不可变快照重新提取、脱敏和限长。额外 prose、Markdown fence、未知字段、重复/不存在的引用、非递增检查点都会 fail closed。
316
+ 当结构化对象不足时,启用“混合语义路由”可让配置的裁判模型只做严格 JSON 分类:`none / compare / select / track`。分类器不能返回自由文本证据,只能返回会话中已经存在且成功配对的 `tool/call` callId 或真实 `todo/write` seq;宿主再从不可变快照重新提取、脱敏和限长。额外 prose、Markdown fence、未知字段、重复/不存在的引用、非递增检查点都会 fail closed。**记账类调用既不会出现在候选列表里,也不接受被引用**:`create_goal` / `skill` / `todo_write` 之类的结果不是"互相竞争的方案",引用它们会让整条决策按无效引用丢弃。
313
317
 
314
- - `smart`:仅在会话出现 Subagent、Workflow、Goal、Todo 或“候选/方案/检查点”等明确线索时分类;
318
+ - `smart`:仅在会话出现 **Subagent / Workflow / 计划模式产物**时分类。Todo 与 Team 快照不在其列:它们是结构化 track 路由自己的输入,且结构化路由先跑——把它们算作线索只会在"结构化已经产出决策"或"已显式 track 过"的形状里多付一次分类调用;
315
319
  - `strict`:每个准备结束边界都分类,无法确认时返回 `none`;路由调用失败时注入 steering,阻止静默跳过;
316
320
  - `manual`:结构化与语义自动路由、最终自动验收都关闭,四个工具仍可显式调用。
317
321
 
318
322
  候选选择产生 steering 后,Agent 必须实施胜出候选;进度未达阈值也会 steering 继续。任何自动 `compare/select/track` 成功都会设置 `finalVerificationRequired`,下一停止边界即使没有传统写入类工具也必须执行 `verifier_current_session`;只有针对最新快照且达到阈值的最终验收才能清除要求(计划预审通过**不会**设置该标记:批准计划不等于完成工作)。路由采用 reservation/commit/fail 状态,取消、过期或失败不会被误记为成功;strict 会维持阻断并 fail closed。
319
323
 
320
- 显式调用 `verifier_current_session` **不会**无条件关闭自动门控:只有该次复核真的达到通过阈值(`winner === 'A'` 且分数不低于阈值),并且之后没有再产生实质工作(写文件、执行命令等)时,才会被当作"已完成验收"而放行。判决失败、低于阈值、结果无法解析、或通过之后又继续改动的复核,都不会解除门控——下一停止边界仍会照常验收。此外,Agent Teams 的队友会话以 `team-message` 作为任务边界,因此队友会话同样可以触发(并遵守)任务级门控。
324
+ 显式调用 `verifier_current_session` **不会**无条件关闭自动门控:只有该次复核真的达到通过阈值(`winner === 'A'`、分数不低于阈值,**且每一项标准各自不低于阈值**——与自动门控同一条 `sessionAccepted` 规则,否则显式调用会变成绕过单项失败的漏洞),并且之后没有再产生实质工作(写文件、执行命令等)时,才会被当作"已完成验收"而放行。判决失败、低于阈值、结果无法解析、或通过之后又继续改动的复核,都不会解除门控——下一停止边界仍会照常验收。此外,Agent Teams 的队友会话以 `team-message` 作为任务边界,因此队友会话同样可以触发(并遵守)任务级门控;Team 任务验收与计划预审都要求裁判给出显式的 `Verdict: <A-T>` 行,解析不出来时:计划预审放行(预算耗尽时 deny 会死锁 plan mode,只能放行),**Team 任务验收在 strict 下照常阻断**,与其它自动阶段一致。
321
325
 
322
326
  所有自动阶段共享任务/会话级模型调用预算;分类请求以 `verifier_route_classify` 单独记录到统计。若某条决策因为超过证据上限、或语义路由引用了不存在/已失效的证据而没能执行,插件会写入宿主日志,并在统计里记下一条带 `dropped-over-budget` / `invalid-references` 判决的记录(没有模型调用,因此 `calls` 为 0),看板可以直接回答“这次为什么没有路由”。候选、步骤和标签在发送前统一执行默认脱敏、单项字符上限和总字符上限,图片证据按与显式工具相同的路径传入。
323
327
 
@@ -326,7 +330,7 @@ flowchart LR
326
330
  - **按话题持久化**:成功评分缓存与调用统计写入当前 DSH 话题的持久化目录(`~/.dsh/sessions/<workspace>/<session-id>/verifier/`)。不同话题相互隔离;永久删除话题时,该目录会随会话日志一并删除,不再在桌面或项目工作目录生成 `.dsh-verifier-cache`。
327
331
  - **持久化缓存**:成功的评分结果将基于 SHA-256 哈希值进行持久化缓存。Key 的计算维度包含:完整渲染后的裁判提示词(涵盖任务描述、候选内容、评审标准与提示词模板本身)、评分通道(概率期望 / 显式标签)、Provider、Model、推理强度、输出上限、轮次编号及图片摘要。缓存按调用前预测的通道查询,但**始终按响应的实际评分通道写入**:首次调用发生中途降级(无可用路由或供应商拒绝 logprobs)时,结果会落在显式标签键下供后续调用复用,而不会被误当作概率期望评分;提示词模板变更或 logprobs 能力状态翻转都会自然失效。失败或被取消的请求不会写入缓存。
328
332
  - **并发请求合并**:相同的并发比对请求会自动共用在途 Promise(In-flight Deduplication),避免重复调用。
329
- - **运行统计与监控**:每次调用返回完整的统计信息,涵盖实际请求次数、重试次数(Retries)、各类 Token 消耗(输入/缓存命中/输出/推理 Token)、缓存命中与未命中次数(Cache Hits/Misses)以及费用估算;统计记录还会保存一条 `verdict` 摘要(阶段、结果、分数、基线、胜负、阈值),看板的「最近调用」因此能直接显示每次判决与阈值的关系,回答"这次为什么没通过",而不是只显示"调用成功/失败"(注意:`success` 表示**模型调用没有抛错**,不等于任务通过验收);其中:
333
+ - **运行统计与监控**:每次调用返回完整的统计信息,涵盖实际请求次数、重试次数(Retries)、各类 Token 消耗(输入/缓存命中/输出/推理 Token)、缓存命中与未命中次数(Cache Hits/Misses)以及费用估算;统计记录还会保存一条 `verdict` 摘要(阶段、结果、分数、基线、胜负、阈值,会话验收额外带 `criteria` 逐标准分数;`track` 额外带 `scores` 检查点曲线,`score` 取**最新**检查点而不是历史最低分;`compare` 的 `score` 是**胜方**分数并额外记录 `scoreB`,结果记为 `compared` 而不是拿一个它从未使用的阈值判"未达标";旧记录缺少这些字段时按旧样式渲染),看板的「最近调用」因此能直接显示每次判决与阈值的关系,回答"这次为什么没通过",而不是只显示"调用成功/失败"(注意:`success` 表示**模型调用没有抛错**,不等于任务通过验收);其中:
330
334
  - `topLogprobScores`:采用概率分布期望计算的评分次数;
331
335
  - `explicitTagScores`:降级采用模型显式输出标签的评分次数。
332
336
  - **能力记忆**:当某个 Provider/Model 明确拒绝 `logprobs` 参数后,插件会记住该能力探测结果,避免后续每次调用重复报错。探测结果按话题持久化到 verifier 目录的 `capabilities-v1.json`(24 小时 TTL 后自动重新探测),随话题一并删除;跨进程与会话恢复不再重复探测,首次降级并发窗口内的相同请求也会合并在途调用、不重复计费。
package/lib/client.js CHANGED
@@ -36,7 +36,7 @@ window.__ModuleLoader__.load({
36
36
  "field.autoRouteMaxPerSession.title": "每会话最多路由",
37
37
  "field.autoRouteMaxPerSession.help": "同一会话中自动路由的尝试上限;与「每会话最多验收」共享计数器,实际可用次数同样是两者之和。",
38
38
  "field.autoTrackCompletionThreshold.title": "进度完成阈值",
39
- "field.autoTrackCompletionThreshold.help": "track 任一检查点低于该值时 steering 要求 Agent 继续执行;范围 0–1。",
39
+ "field.autoTrackCompletionThreshold.help": "track 最新检查点低于该值时 steering 要求 Agent 继续执行;更早的检查点只作展示,范围 0–1。",
40
40
  "field.autoRouteMaxItemChars.title": "单项证据字符上限",
41
41
  "field.autoRouteMaxItemChars.help": "每个候选或检查点在脱敏后允许发送给裁判的最大字符数。",
42
42
  "field.autoRouteMaxInputChars.title": "路由证据总字符上限",
@@ -111,6 +111,7 @@ window.__ModuleLoader__.load({
111
111
  "settings.catalogFailures": "部分模型目录读取失败",
112
112
  "settings.saved": "设置已保存。",
113
113
  "settings.reload": "重新载入",
114
+ "settings.unsaved": "当前页面有未保存的修改:点击「保存」后才会写入设置;「重新载入」会丢弃这些修改。",
114
115
  "settings.save": "保存设置",
115
116
  "settings.saving": "保存中…",
116
117
  "stats.pageTitle": "Verifier 工具统计",
@@ -138,6 +139,7 @@ window.__ModuleLoader__.load({
138
139
  "stats.outcome.passed": "通过",
139
140
  "stats.outcome.below-threshold": "未达标",
140
141
  "stats.outcome.tie": "平局",
142
+ "stats.outcome.compared": "已比较",
141
143
  "stats.outcome.error": "异常",
142
144
  "stats.outcome.dropped-over-budget": "超预算丢弃",
143
145
  "stats.outcome.invalid-references": "引用无效",
@@ -145,6 +147,8 @@ window.__ModuleLoader__.load({
145
147
  "stats.verdict.winner": "胜方 {winner}",
146
148
  "stats.verdict.scoreThreshold": "得分 {score}(阈值 {threshold})",
147
149
  "stats.verdict.scoreOnly": "得分 {score}",
150
+ "stats.verdict.checkpoints": "检查点 {scores}",
151
+ "stats.verdict.criteria": "标准 {criteria}",
148
152
  "metric.cacheHitRate": "缓存命中率",
149
153
  "metric.cacheHitNote": "{hits} 命中 / {total} 评分",
150
154
  "metric.tokens": "Token",
@@ -216,7 +220,7 @@ window.__ModuleLoader__.load({
216
220
  "field.autoRouteMaxPerSession.title": "Max Routes per Session",
217
221
  "field.autoRouteMaxPerSession.help": "Session attempt cap for automatic routing; shares its counter with \"Max Verifications per Session\" (effective limit is the sum of both).",
218
222
  "field.autoTrackCompletionThreshold.title": "Progress Completion Threshold",
219
- "field.autoTrackCompletionThreshold.help": "When any track checkpoint falls below this score, steering instructs the Agent to continue; range 0–1.",
223
+ "field.autoTrackCompletionThreshold.help": "When the newest track checkpoint falls below this score, steering instructs the Agent to continue; earlier checkpoints are shown only; range 0–1.",
220
224
  "field.autoRouteMaxItemChars.title": "Max Chars per Route Item",
221
225
  "field.autoRouteMaxItemChars.help": "Maximum sanitized character count allowed per candidate or checkpoint when sent to the judge.",
222
226
  "field.autoRouteMaxInputChars.title": "Total Route Input Chars Cap",
@@ -291,6 +295,7 @@ window.__ModuleLoader__.load({
291
295
  "settings.catalogFailures": "Some model catalogs failed to load",
292
296
  "settings.saved": "Settings saved.",
293
297
  "settings.reload": "Reload",
298
+ "settings.unsaved": "This page has unsaved changes: click Save to write them; Reload discards them.",
294
299
  "settings.save": "Save Settings",
295
300
  "settings.saving": "Saving…",
296
301
  "stats.pageTitle": "Verifier Tool Statistics",
@@ -318,6 +323,7 @@ window.__ModuleLoader__.load({
318
323
  "stats.outcome.passed": "Passed",
319
324
  "stats.outcome.below-threshold": "Below Threshold",
320
325
  "stats.outcome.tie": "Tie",
326
+ "stats.outcome.compared": "Compared",
321
327
  "stats.outcome.error": "Error",
322
328
  "stats.outcome.dropped-over-budget": "Dropped (over budget)",
323
329
  "stats.outcome.invalid-references": "Invalid references",
@@ -325,6 +331,8 @@ window.__ModuleLoader__.load({
325
331
  "stats.verdict.winner": "Winner {winner}",
326
332
  "stats.verdict.scoreThreshold": "Score {score} (threshold {threshold})",
327
333
  "stats.verdict.scoreOnly": "Score {score}",
334
+ "stats.verdict.checkpoints": "Checkpoints {scores}",
335
+ "stats.verdict.criteria": "Criteria {criteria}",
328
336
  "metric.cacheHitRate": "Cache Hit Rate",
329
337
  "metric.cacheHitNote": "{hits} hits / {total} scored",
330
338
  "metric.tokens": "Tokens",
@@ -447,7 +455,56 @@ window.__ModuleLoader__.load({
447
455
  if (typeof draft === "string" && draft.trim()) return draft.trim();
448
456
  if (typeof previous === "string" && previous.trim()) return previous.trim();
449
457
  }
450
- /** An eight-candidate select: ring + pivot rounds (18 pairs) x three criteria, one repeat. */
458
+ /**
459
+ * Deep equality for JSON-shaped settings values, insensitive to object key
460
+ * order and treating a missing key as `undefined`. The settings page compares a
461
+ * draft against a resolved view that was built from a different object shape,
462
+ * so identity comparison is never enough.
463
+ */
464
+ function sameSettingValue(left, right) {
465
+ if (left === right) return true;
466
+ if (Array.isArray(left) && Array.isArray(right)) {
467
+ if (left.length !== right.length) return false;
468
+ for (let index = 0; index < left.length; index += 1) if (!sameSettingValue(left[index], right[index])) return false;
469
+ return true;
470
+ }
471
+ if (Array.isArray(left) || Array.isArray(right)) return false;
472
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
473
+ const a = left;
474
+ const b = right;
475
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) if (!sameSettingValue(a[key], b[key])) return false;
476
+ return true;
477
+ }
478
+ /**
479
+ * Build the user layer for one settings save.
480
+ *
481
+ * Keys this client does not own survive untouched and every draft field is
482
+ * written, but a field that only repeats the base composition is *dropped*
483
+ * rather than pinned. Pinning is what makes a shipped default change
484
+ * unreachable: the budget fields once defaulted to 48/160, and a section that
485
+ * stored those numbers kept overriding 64/240 and then 96/240 forever. `base`
486
+ * is the descriptor's composition base (the resolved plugin config), so the
487
+ * stored section keeps exactly what the user really overrode.
488
+ *
489
+ * An explicit `undefined` draft value means "clear this override": it deletes
490
+ * the stored key instead of pinning a valueless one, which is how the form
491
+ * removes a judge label, a reasoning effort or a cache directory.
492
+ *
493
+ * A host that reports no `base` cannot be pruned against and keeps the old
494
+ * write-everything behavior.
495
+ */
496
+ function sectionForSave(user, draft, base) {
497
+ const section = { ...user };
498
+ for (const [key, value] of Object.entries(draft)) {
499
+ if (value === void 0 || base !== void 0 && sameSettingValue(value, base[key])) {
500
+ delete section[key];
501
+ continue;
502
+ }
503
+ section[key] = value;
504
+ }
505
+ return section;
506
+ }
507
+ /** An eight-candidate select: ring + pivot rounds (18 pairs) x three criteria, one round (the per-pair orientation removes the slot bias). */
451
508
  const WORST_CASE_ROUTE_CALLS_PER_JUDGE = 54;
452
509
  /** Final acceptance: three criteria x the default two repeats (one per A/B position). */
453
510
  const WORST_CASE_FINAL_CALLS_PER_JUDGE = 6;
@@ -504,6 +561,10 @@ window.__ModuleLoader__.load({
504
561
  });
505
562
  } else scoreText = tFormat(t["stats.verdict.scoreOnly"], { score: scorePct });
506
563
  }
564
+ let checkpointsText;
565
+ if (Array.isArray(verdict.scores) && verdict.scores.length > 1) checkpointsText = tFormat(t["stats.verdict.checkpoints"], { scores: verdict.scores.map(formatPercentage).join(" → ") });
566
+ let criteriaText;
567
+ if (Array.isArray(verdict.criteria) && verdict.criteria.length > 0) criteriaText = tFormat(t["stats.verdict.criteria"], { criteria: verdict.criteria.map((criterion) => criterion.id + " " + formatPercentage(criterion.score)).join(" · ") });
507
568
  let winnerText;
508
569
  if (verdict.winner) {
509
570
  const winnerValue = verdict.winner === "tie" ? t["stats.winner.tie"] : verdict.winner;
@@ -513,6 +574,8 @@ window.__ModuleLoader__.load({
513
574
  outcomeText,
514
575
  phaseText,
515
576
  scoreText,
577
+ checkpointsText,
578
+ criteriaText,
516
579
  winnerText,
517
580
  isFailed
518
581
  };
@@ -836,6 +899,50 @@ window.__ModuleLoader__.load({
836
899
  (0, react.useEffect)(() => {
837
900
  load();
838
901
  }, []);
902
+ const dirty = (0, react.useMemo)(() => loaded !== null && draft !== null && !sameSettingValue(draft, values(loaded.settings)), [loaded, draft]);
903
+ const latest = (0, react.useRef)({
904
+ loaded,
905
+ draft
906
+ });
907
+ latest.current = {
908
+ loaded,
909
+ draft
910
+ };
911
+ (0, react.useEffect)(() => {
912
+ const sync = () => {
913
+ if (document.visibilityState === "hidden") return;
914
+ const { loaded: at, draft: local } = latest.current;
915
+ if (!at || !local) return;
916
+ if (!sameSettingValue(local, values(at.settings))) return;
917
+ (async () => {
918
+ try {
919
+ const s = await remote.settings.describe();
920
+ if (!s.ok) return;
921
+ const view = s.value.namespaces.find((x) => x.ns === NS);
922
+ if (!view || view.revision === at.settings.revision) return;
923
+ const next = {
924
+ ...at,
925
+ settings: view,
926
+ writable: s.value.writable
927
+ };
928
+ latest.current = {
929
+ loaded: next,
930
+ draft: values(view)
931
+ };
932
+ setLoaded(next);
933
+ setDraft(values(view));
934
+ setEditing({});
935
+ setSaved(false);
936
+ } catch {}
937
+ })();
938
+ };
939
+ window.addEventListener("focus", sync);
940
+ document.addEventListener("visibilitychange", sync);
941
+ return () => {
942
+ window.removeEventListener("focus", sync);
943
+ document.removeEventListener("visibilitychange", sync);
944
+ };
945
+ }, [remote]);
839
946
  const models = (0, react.useMemo)(() => loaded?.groups.find((g) => g.id === draft?.provider)?.models ?? [], [loaded, draft?.provider]);
840
947
  const efforts = models.find((m) => m.id === draft?.model)?.reasoning?.efforts ?? [];
841
948
  const patch = (key, value) => {
@@ -894,17 +1001,15 @@ window.__ModuleLoader__.load({
894
1001
  setSaved(false);
895
1002
  setError(null);
896
1003
  try {
897
- const section = {
898
- ...record(loaded.settings.user),
1004
+ const editable = {
899
1005
  ...draft,
900
- extraJudges: serializeExtraJudges(draft.extraJudges)
1006
+ extraJudges: serializeExtraJudges(draft.extraJudges),
1007
+ reasoningEffort: draft.reasoningEffort || void 0,
1008
+ label: draft.label?.trim() || void 0,
1009
+ cacheDir: resolveCacheDirOnSave(draft.cacheDir, values(loaded.settings).cacheDir)
901
1010
  };
902
- if (!draft.reasoningEffort) delete section.reasoningEffort;
903
- if (!draft.label || !draft.label.trim()) delete section.label;
904
- else section.label = draft.label.trim();
905
- const resolvedCacheDir = resolveCacheDirOnSave(draft.cacheDir, values(loaded.settings).cacheDir);
906
- if (!resolvedCacheDir) delete section.cacheDir;
907
- else section.cacheDir = resolvedCacheDir;
1011
+ const base = loaded.settings.base === void 0 ? void 0 : record(loaded.settings.base);
1012
+ const section = sectionForSave(record(loaded.settings.user), editable, base);
908
1013
  const res = await remote.settings.update(NS, section, loaded.settings.revision);
909
1014
  if (!res.ok) throw new Error(res.error.message);
910
1015
  setLoaded((v) => v ? {
@@ -1629,7 +1734,7 @@ window.__ModuleLoader__.load({
1629
1734
  },
1630
1735
  children: error
1631
1736
  }),
1632
- saved && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1737
+ saved && !dirty && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1633
1738
  style: {
1634
1739
  margin: 0,
1635
1740
  fontSize: 12,
@@ -1638,6 +1743,15 @@ window.__ModuleLoader__.load({
1638
1743
  },
1639
1744
  children: t["settings.saved"]
1640
1745
  }),
1746
+ dirty && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1747
+ style: {
1748
+ margin: 0,
1749
+ fontSize: 12,
1750
+ lineHeight: "18px",
1751
+ color: "var(--dsw-alias-state-warn-label)"
1752
+ },
1753
+ children: t["settings.unsaved"]
1754
+ }),
1641
1755
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1642
1756
  style: {
1643
1757
  display: "flex",
@@ -2407,6 +2521,14 @@ window.__ModuleLoader__.load({
2407
2521
  style: { color: typeof item.verdict.threshold === "number" && typeof item.verdict.score === "number" && item.verdict.score < item.verdict.threshold ? "#e76565" : "var(--dsw-text-primary)" },
2408
2522
  children: verdictInfo.scoreText
2409
2523
  }),
2524
+ verdictInfo.checkpointsText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2525
+ style: { color: "var(--dsw-text-secondary)" },
2526
+ children: verdictInfo.checkpointsText
2527
+ }),
2528
+ verdictInfo.criteriaText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2529
+ style: { color: "var(--dsw-text-secondary)" },
2530
+ children: verdictInfo.criteriaText
2531
+ }),
2410
2532
  verdictInfo.winnerText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2411
2533
  style: { color: "var(--dsw-text-secondary)" },
2412
2534
  children: verdictInfo.winnerText
@@ -2633,6 +2755,8 @@ window.__ModuleLoader__.load({
2633
2755
  exports.isVerdictFailed = isVerdictFailed;
2634
2756
  exports.money = money;
2635
2757
  exports.resolveCacheDirOnSave = resolveCacheDirOnSave;
2758
+ exports.sameSettingValue = sameSettingValue;
2759
+ exports.sectionForSave = sectionForSave;
2636
2760
  exports.tFormat = tFormat;
2637
2761
  exports.toolLabels = toolLabels;
2638
2762
  exports.useLanguage = useLanguage;