dsh-fast-mode 1.5.15 → 1.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BENCHMARK.md CHANGED
@@ -1967,5 +1967,35 @@ python3 bench/suite-run.py --tasks debugfix,ledger,codeimpl --arms off,def15,bud
1967
1967
 
1968
1968
  **两处与预注册的偏差,记在这里不掩盖:**
1969
1969
 
1970
- 1. **判据 5 的「下发条件」没被脚本执行。** 预注册写的是「`D2G_IMPL` 8/8 仍在**第 4 步**收到,`D2G_DBG`/`D2G_MIG` 在第 2 步收到」。实际逐会话扫描:`def15` 8/8/6/7 全在第 4 步,`d2g` 在 `IMPL` 是**第 3 步**、在 `DBG`/`MIG` 是第 2 步。`IMPL` 按设计应在 `disciplineEarlyCalls: 2` 下退化成第 4 步,实际提前到了第 3 步 —— 这解释了它为什么没像预期那样完全退化(0.889× 而不是 ≈1.00×),也说明判据 5 的「按条件命中」这一半同样没通过。`d2greport.sh` 的 gate 5 只校验 `exact`/`green`,没有校验下发步位,这是脚本与预注册之间的缺口,后续 gate 脚本要把步位断言写进去。
1970
+ 1. **判据 5 的「下发条件」原本没被脚本执行,现已补上并判 FAIL。** 预注册写的是「`D2G_IMPL` 8/8 仍在**第 4 步**收到,`D2G_DBG`/`D2G_MIG` 在第 2 步收到」。实际逐会话扫描:`def15` 8/8/6/7 全在第 4 步,`d2g` 在 `IMPL` 是**第 3 步**、在 `DBG`/`MIG` 是第 2 步。`d2gscore.py` 现在自己调 `d2gsign.py` 断言每个臂的命步(`5 delivery <block> <arm> step N`),输出里 `D2G_IMPL d2g step 4` 一行是 **FAIL**(实际 `[3]`)。`IMPL` 按设计应在 `disciplineEarlyCalls: 2` 下退化成第 4 步,实际提前到了第 3 步 —— 这解释了它为什么没像预期那样完全退化(0.889× 而不是 ≈1.00×),也说明判据 5 的「按条件命中」这一半同样没通过。原先 gate 5 只校验 `exact`/`green`,缺口已修(见上);判据 5 因此是「质量过、下发步位不过」,结论仍是 HOLD。
1971
1971
  2. **`delivery audit` 探针不可用。** 报告按固定句式找完成通报,命中 0/16、2/14、0/12。这个探针在本套 fixture 上恒为 0,不能当「任务没交付」的证据;质量结论只引用 `exact`/`green`(21 对全部 1/1)与判据 5 的 0 坏格。
1972
+
1973
+ ### 预注册前提检查:提醒之后输出会不会「重新变长」
1974
+
1975
+ `disciplineRepeatAtSteps` 之所以值得一测,前提是「第 4 步的提醒压住输出,之后随着步数增长输出又爬回来」。用 `~/.dsh/fast-mode-metrics.jsonl` 按 sid 联表(会话目录名里带 tag/task/pair/arm,不用猜),取每个会话第一个 turn 的每步 `output`(可见输出 + reasoning,中位),三个 `D2G` 块读出来是:
1976
+
1977
+ | 块 | 臂 | 步 1… | 形状 |
1978
+ |---|---|---|---|
1979
+ | `D2G_IMPL` | `d2g`(第 3 步提醒) | 5162 1562 1007 1132 712 599 186 128 | 单调降,末端几乎停写 |
1980
+ | `D2G_IMPL` | `def15`(第 4 步) | 2083 1681 969 1248 269 140 1910 900 364 1654 631 1866 352 68 | 提醒后在第 7、10、12 步三次反弹 |
1981
+ | `D2G_MIG` | `d2g`(第 2 步) | 120 83 3568 313 393 716 384 282 1244 350 554 178 768 656 | 无单调趋势,长尾在 0.1–1.2k 波动 |
1982
+ | `D2G_MIG` | `def15`(第 4 步) | 155 9080 1090 369 562 1282 1676 1491 1188 2352 462 446 125 350 | 第 4 步后先升后落,峰值在 6–10 步 |
1983
+
1984
+ 两点结论(只对这套 fixture):一,`def15` 的「反弹」是存在的(`IMPL` 第 7/10/12 步明显),所以重复提醒的动机不是凭空来的;二,`d2g` 在两块里都没出现同样的反弹——它的提醒本来就更早,长尾步数也少,**现有数据无法证明第 8 步再提醒一次还能压住什么**。所以 `DR8_MIG`(`disciplineRepeatAtSteps: 8`,6 对,预注册判据在文末)照跑,但预期先降为「接近 1.0× 的无操作」,跑完以数字为准。另外说清一点:这些 fixture 的 assistant 可见文字中位只有 0–200 字符,**提醒移动的主要是工具参数与 reasoning 的 token 量,不是「话多」**;`out_geo` 0.478–0.910 说的就是这个量。
1985
+
1986
+ ## 结果:`D2` 在 debugfix 上的独立复现(`D2B_DBG`,7 对新会话)
1987
+
1988
+ `D2G` 判 HOLD 之后,`d2`(`disciplineAfterSteps: 2`,无条件第 2 步提醒)唯一还站得住的证据是当初 `D2_DBG` 的 0.535×。为判断那是真效应还是那 7 对的运气,用同协议重跑了一整块新会话(新 pair 编号、ABBA 交错、同一 fixture):
1989
+
1990
+ | 指标 | 数值 |
1991
+ |---|---|
1992
+ | 对数 | 7 / 7 有效,`exact`+`green` 21/21 格全过 |
1993
+ | 几何均值 | **0.717×** |
1994
+ | 中位 | 0.864× |
1995
+ | 总计 | 0.633× |
1996
+ | 更快 | 5 / 7(符号检验 p = 0.45,**不显著**) |
1997
+ | 步骤 | `steps_geo` **1.118×**(平均步数变多) |
1998
+ | 输出 | `out_geo` **0.787×**(输出确实变短) |
1999
+ | 提醒步位 | `def15` 7/7 在第 4 步,`d2` 7/7 在第 2 步 |
2000
+
2001
+ 怎么读:方向是复现的(输出变短、整体更快),但**幅度掉到 0.717×,中位只剩 0.864×,7 对里 5 对更快在统计上说明不了问题**;同时 `steps_geo` 1.118× 说明早提醒在这套 debugfix 上会让回合多走一两步——加速来自「每步写得更短」,不是「步数更少」,这和 `D2G_MIG` 的分解(`out` 0.478、`steps` 0.964)是同一套机制。合并原来的 `D2_DBG`(0.535×)与这块(0.717×),`d2` 在 debugfix 上的真实档位大约 **0.6–0.7×,且方差很大(单对 0.236–1.327×)**,所以任何「早提醒稳定快 2 倍」的说法都不成立。
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.17(面板四行、按会话隔离、网关用量兼容)
4
+
5
+ - **四个开关按会话隔离(核心修复)。** 面板此前写的是 `outputDiscipline` / `stepBudget` / `workspaceMap` 三个共享字段,而设置列的写入只接受 volatile 字段:`settings.write` 对这三个路径直接抛 `Config field "<path>" is not volatile`,所以按钮点了不生效。现在所有选择都写进 volatile 的 `sessions` 列里本会话的条目(`{enabled, gear, discipline, stepBudget, map, ts}`),面板与闪电只写自己那条,不碰共享的 `enabled`/`gear`:A 会话「更快 + 少写过程」与 B 会话「更快 + 不写过程」同时成立,没点过的会话沿用配置默认值。条目上限 200,按 `ts` 淘汰最旧的。
6
+ - **面板改成四行、名字改成说人话的。** 「更快」(开关本身)、「少写过程」、「最多几步」(`15`/`30`/`不限`)、「先列文件清单」;底注「只影响本会话」。四行都带 tooltip 说明后果,只读 / 写入中禁用,写失败回滚并保留原因。配置项描述同步注明面板名称与「这是没有自己条目的会话的默认值」。
7
+ - **自建网关与官方 API 的用量口径统一。** 新 `src/usage.js` 把上游的 token 拼写归一到一组数字:harness 的 `inputTokens/outputTokens/reasoningTokens/cacheReadTokens/cacheWriteTokens`、OpenAI Chat 的 `prompt_tokens/completion_tokens`(含 `prompt_tokens_details.cached_tokens`、`completion_tokens_details.reasoning_tokens`)、新版 OpenAI 的 `input_tokens/output_tokens`、Anthropic 的 `cache_read_input_tokens/cache_creation_input_tokens`、Google 的 `candidatesTokenCount`;usage 挂在 `usage`、`message.usage`、`response.usage` 下都能找到,完全不报 usage 时按 0 处理。收益是真实的:此前只在 `outputTokens` 上取数,走 OpenAI 兼容网关的会话输出量恒为 0,调度器的输出预算和计量报告都会把「网关没按这个拼写报」误读成「这一步没输出」。
8
+ - **测试**:`node --test test/*.test.js` **289 pass / 0 fail**。新增 `test/usage.test.js`(9 个方言用例)、`test/metrics.test.js` 的 OpenAI 网关行与「不报 usage 仍计时」行,以及 host 侧 `test/session.test.js`(4 个)——两个会话在同一列里各记各的、没条目的会话用默认值、清单只发一次、提醒按各自开关下发。
9
+ - **文案与检查**:`ui-copy-lint` **clean**(此前 21 error / 2 warning,全部是注释里的 `host` 与 store 初始化的状态点亮出;注释改成说行为、状态字段逐个加说明后清零),`zh-lint` **0 error**(37 warning 为既有),`style-lint` 通过。
10
+
11
+ ## 1.5.16(只改文档与判据脚本,代码与 1.5.15 相同)
12
+
13
+ - `BENCHMARK.md` 补两块实测记录:`D2B_DBG`(`d2` 在 debugfix 上的独立复现:7 对几何 0.717×、中位 0.864×、5/7 更快、符号检验 p=0.45、`steps_geo` 1.118×、`out_geo` 0.787×)与「提醒之后输出会不会重新变长」的预注册前提检查(`def15` 有反弹,`d2g` 没有;fixture 里 assistant 可见文字中位 0–200 字符,提醒动的是工具参数与 reasoning 的 token 量)。
14
+ - `D2G` 判据脚本补上判据 5 的执行:`d2gscore.py` 调 `d2gsign.py` 逐臂断言提醒下发步位,`D2G_IMPL` 的 `d2g` 实际在第 3 步(预注册要求第 4 步)判 FAIL,结论仍是 HOLD、默认第 4 步。
15
+ - `DR8_MIG` 的判据落成可执行脚本:`dr8sign.py`(dr8 命中 ≥1、`def15` 命中 0,否则记「未测到」)与 `dr8score.py`(预注册六条,含 `out_geo ≤ 0.95`、赢面 4/6、质量不倒退)。
16
+ - 无配置项变化,默认 `disciplineRepeatAtSteps: 0` 行为不变。
17
+
3
18
  ## 1.5.15(右键面板第三行;`D2G` 预注册判据判 HOLD)
4
19
 
5
20
  - **面板第三行「工作区清单」**:写共享字段 `workspaceMap`(默认 `false`),三行面板第三项,`readonly` / 写入中禁用,写失败回滚。与另两行一样只写共享字段、不带 `sessions`,不碰按会话的开关状态。`client.test.js` 新增一行测试覆盖「默认关、写入不带 `sessions`、写后按 host 值重绘、再点关掉」,全套 **270 pass**。
package/README.md CHANGED
@@ -87,13 +87,14 @@
87
87
 
88
88
  ## 开关
89
89
 
90
- 闪电就是开关:开/关即写,按会话独立,重启按会话恢复。右键闪电是一块三行的面板:
90
+ 闪电就是开关:开/关即写,按会话独立,重启按会话恢复。右键闪电是一块四行的面板,四行都只影响当前会话:
91
91
 
92
- - **更少中间输出**:第 4 步那句「省着写」提醒的总开关,关掉后输出会变长、墙钟变慢(长任务约 2.7×),默认开。
93
- - **步数上限**:`15` / `30` / 不限,默认不限。到上限只先汇报,不截断任务。
94
- - **工作区清单**:开启后,回合的第一次工具调用之前先收到一份工作目录清单(相对路径 + 字节数 + 可见的测试入口),省掉开头的 `ls`/`find` 探路。默认关;23 个配对实测几何 0.927×,尾部变慢、步数 +10%,没有达到进默认的门槛,只作为可试的开关。
92
+ - **更快**:这个会话开不开快模式,和点闪电是同一件事。
93
+ - **少写过程**:第 4 步那句「省着写」提醒的总开关,关掉后输出会变长、墙钟变慢(长任务约 2.7×),默认开。
94
+ - **最多几步**:`15` / `30` / 不限,默认不限。到上限只先汇报,不截断任务。
95
+ - **先列文件清单**:开启后,回合的第一次工具调用之前先收到一份工作目录清单(相对路径 + 字节数 + 可见的测试入口),省掉开头的 `ls`/`find` 探路。默认关;23 个配对实测几何 0.927×,尾部变慢、步数 +10%,没有达到进默认的门槛,只作为可试的开关。
95
96
 
96
- 三项都是共享设置,对下一个回合生效。没有下拉,没有倍数估算。
97
+ 四项都写在本会话的条目里,对下一个回合生效;另一个会话开不开、写成什么样,互不影响。没有下拉,没有倍数估算。
97
98
 
98
99
  思路参考了 `dsh-batch-tool-calls` 与 `dsh-ops`(批量调用、有界执行),但它们的规则是静态的,这里的宽度来自会话自身的调用证据;不需要同时装。压缩类插件可以共存。
99
100
 
package/client.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * service (`settings/describe` → `settings/update`), which is the same surface
7
7
  * the Loader uses for its composition patch. There is no second state store: a
8
8
  * click writes `{ enabled }` or `{ gear }` to this entry's user layer, the Loader
9
- * re-applies the host half, and the policy changes on the next model call.
9
+ * re-applies the settings half, and the policy changes on the next model call.
10
10
  *
11
11
  * Why the chip lives in `conversation.input.activity`: that slot is the one
12
12
  * place the composer renders *after* the standard controls and *before* the stop
@@ -91,15 +91,17 @@ window.__ModuleLoader__.load({
91
91
  'chip.error': 'Fast 模式配置读取失败:{error}',
92
92
  'chip.absent': 'Fast 模式未在此配置中声明,点击重试',
93
93
  'panel.title': '更快',
94
- 'panel.prose': '更少中间输出',
94
+ 'panel.prose': '少写过程',
95
+ 'panel.proseTitle': '模型少解释,只在结论处落笔',
95
96
  'panel.on': '开',
96
97
  'panel.off': '关',
97
- 'panel.budget': '步数上限',
98
+ 'panel.budget': '最多几步',
98
99
  'panel.budgetOff': '不限',
99
100
  'panel.budgetSteps': '{n} 步',
100
- 'panel.allSessions': '所有会话',
101
+ 'panel.scope': '只影响本会话',
101
102
  'panel.budgetTitle': '到上限先汇报,不截断任务',
102
- 'panel.map': '工作区清单',
103
+ 'panel.map': '先列文件清单',
104
+ 'panel.mapTitle': '开局把目录里的文件交给模型,省掉先翻一遍目录',
103
105
  }
104
106
 
105
107
  const en = {
@@ -113,14 +115,16 @@ window.__ModuleLoader__.load({
113
115
  'chip.absent': 'Fast mode is not configured in this composition. Click to retry',
114
116
  'panel.title': 'Faster',
115
117
  'panel.prose': 'Less narration',
118
+ 'panel.proseTitle': 'Skips explanations; writes the result only',
116
119
  'panel.on': 'On',
117
120
  'panel.off': 'Off',
118
- 'panel.budget': 'Step cap',
121
+ 'panel.budget': 'Max steps',
119
122
  'panel.budgetOff': 'No cap',
120
123
  'panel.budgetSteps': '{n} steps',
121
- 'panel.allSessions': 'All conversations',
124
+ 'panel.scope': 'This conversation only',
122
125
  'panel.budgetTitle': 'Reports at the cap; never truncates the task',
123
- 'panel.map': 'Workspace inventory',
126
+ 'panel.map': 'File list first',
127
+ 'panel.mapTitle': 'Hands the directory listing to the model up front, skipping a lookup',
124
128
  }
125
129
 
126
130
  const CSS = [
@@ -169,26 +173,31 @@ window.__ModuleLoader__.load({
169
173
  */
170
174
  const store = {
171
175
  /** 'loading' | 'ready' | 'absent' | 'error' */
172
- phase: 'loading',
173
- enabled: false,
174
- gear: '',
176
+ phase: 'loading', // until the first read reports what this conversation has
177
+ /** What this conversation runs at: on or off, never a shared default. */
178
+ enabled: false, // painted on the first read, then follows every click
179
+ /** The slower profile this conversation picked, '' for none. */
180
+ gear: '', // only set when the conversation picked a slower profile
175
181
  /**
176
- * `outputDiscipline !== false`. Read from the namespace, not the session
177
- * column: this is one shared preference, so the panel says so instead of
178
- * pretending it is per-conversation.
182
+ * `outputDiscipline !== false` for this conversation. Resolved from its
183
+ * own entry in the session column, falling back to the configured
184
+ * default when it has none.
179
185
  */
180
186
  discipline: true,
181
- /** The shared workspace inventory switch, off by default. */
187
+ /** The workspace inventory switch for this conversation, off by default. */
182
188
  inventory: false,
183
- /** The shared step cap: 0 means no cap. */
189
+ /** This conversation's step cap; 0 means no cap. */
184
190
  stepBudget: 0,
185
191
  /** The right-click panel is open. Store-backed, like every other paint. */
186
192
  panelOpen: false,
187
- writable: true,
188
- /** Namespace key and revision of the last successful read. */
189
- ns: undefined,
190
- revision: undefined,
191
- error: '',
193
+ /** Whether the settings column accepts writes at all. */
194
+ writable: true, // false only when the settings column is read-only
195
+ /** Namespace key of the last successful read. */
196
+ ns: undefined, // filled by the first successful describe()
197
+ /** Revision of the last successful read, for a compare-and-set write. */
198
+ revision: undefined, // paired with ns for the next compare-and-set
199
+ /** The last read or write failure, shown in place of the panel rows. */
200
+ error: '', // cleared by the next successful read
192
201
  /**
193
202
  * The last refused write. Kept apart from `error` because the resync that
194
203
  * follows a refusal succeeds, and a successful read must not erase the one
@@ -197,7 +206,7 @@ window.__ModuleLoader__.load({
197
206
  writeError: '',
198
207
  /** A write in flight: keeps the button from double-firing. */
199
208
  busy: false,
200
- /** The value we are waiting for the host to echo back after a write. */
209
+ /** The value we are waiting for the settings side to echo back after a write. */
201
210
  pending: undefined,
202
211
  /**
203
212
  * The conversation this chip is rendered for, handed over by the slot's
@@ -244,8 +253,8 @@ window.__ModuleLoader__.load({
244
253
  /* ------------------------------------------------- per-session bookkeeping */
245
254
 
246
255
  /**
247
- * Browser half of the per-session column — the same format the host parses in
248
- * `src/state.js`. The bundle cannot import host ESM (it is fetched verbatim by
256
+ * Browser half of the per-session column — the same format the settings side parses in
257
+ * `src/state.js`. The bundle cannot import the settings ESM (it is fetched verbatim by
249
258
  * the client module loader), so the twenty lines are duplicated and both
250
259
  * copies are pinned to the same fixtures in the tests.
251
260
  *
@@ -265,8 +274,8 @@ window.__ModuleLoader__.load({
265
274
  * taking the chip down: the column may be hand-edited, and the chip is the
266
275
  * only place a user can see the switch, so throwing here would hide the bug.
267
276
  *
268
- * The shape check is deliberately the same one the host applies
269
- * (`src/state.js` `sanitise`): this half writes entries the host then reads
277
+ * The shape check is deliberately the same one the settings side applies
278
+ * (`src/state.js` `sanitise`): this half writes entries the settings side then reads
270
279
  * to decide what a request runs at, and an entry one side accepts and the
271
280
  * other drops is a switch that looks on and changes nothing.
272
281
  *
@@ -284,21 +293,35 @@ window.__ModuleLoader__.load({
284
293
  }
285
294
 
286
295
  /**
287
- * Keep only entries the host would also honour.
296
+ * Keep only entries the settings side would also honour.
297
+ *
298
+ * The field list matches `src/state.js` `sanitise` exactly, on purpose: this
299
+ * half writes entries the settings side reads to decide what a request runs at, and an
300
+ * entry one side accepts while the other drops is a switch that looks on and
301
+ * changes nothing.
288
302
  *
289
303
  * @param {Record<string, any>} input
290
304
  */
291
305
  function sanitiseChoices(input) {
292
- /** @type {Record<string, {enabled?: boolean, gear?: string, ts?: number}>} */
306
+ /** @type {Record<string, any>} */
293
307
  const out = {}
294
308
  for (const [sessionId, entry] of Object.entries(input)) {
295
309
  if (sessionId.length === 0 || !isRecord(entry)) continue
296
- /** @type {{enabled?: boolean, gear?: string, ts?: number}} */
310
+ /** @type {Record<string, any>} */
297
311
  const clean = {}
298
312
  if (typeof entry.enabled === 'boolean') clean.enabled = entry.enabled
299
313
  if (typeof entry.gear === 'string' && entry.gear.length > 0) clean.gear = entry.gear
314
+ if (typeof entry.discipline === 'boolean' || entry.discipline === 'late') clean.discipline = entry.discipline
315
+ if (Number.isFinite(entry.stepBudget) && entry.stepBudget >= 0) clean.stepBudget = Math.floor(Number(entry.stepBudget))
316
+ if (typeof entry.map === 'boolean') clean.map = entry.map
300
317
  if (Number.isFinite(entry.ts)) clean.ts = Number(entry.ts)
301
- if (clean.enabled === undefined && clean.gear === undefined) continue
318
+ if (
319
+ clean.enabled === undefined
320
+ && clean.gear === undefined
321
+ && clean.discipline === undefined
322
+ && clean.stepBudget === undefined
323
+ && clean.map === undefined
324
+ ) continue
302
325
  out[sessionId] = clean
303
326
  }
304
327
  return out
@@ -332,28 +355,33 @@ window.__ModuleLoader__.load({
332
355
  }
333
356
 
334
357
  /**
335
- * A copy of the column with this conversation's entry set to the pair the user
336
- * just asked for.
358
+ * A copy of the column with this conversation's entry updated.
359
+ *
360
+ * One click travels in one patch: `sessions` is this conversation's own entry
361
+ * (and therefore what the settings side applies to *this* request stream), and nothing
362
+ * else. The shared config fields are the defaults and stay untouched, which is
363
+ * the whole point of the per-session tier: turning fast mode off here must not
364
+ * turn off another open tab, and turning narration down here must not change
365
+ * what the next conversation inherits any more than this one.
337
366
  *
338
- * Both halves of a click travel in one patch: `enabled` / `gear` are the
339
- * shared default (and therefore the "last choice" a new session inherits),
340
- * `sessions` is this conversation's own entry (and therefore what the host
341
- * applies to *this* request stream). Splitting them into two writes would
342
- * leave a window where the session runs on one gear while the chip shows
343
- * another — and two writes means two revision races.
367
+ * Fields absent from `patch` keep their previous value, so each panel row can
368
+ * write its own field without restating the other three.
344
369
  *
345
370
  * @param {Record<string, any>} map
346
371
  * @param {string|undefined} sessionId
347
- * @param {boolean} enabled
348
- * @param {string} gear
372
+ * @param {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean}} patch
349
373
  */
350
- function withOwn(map, sessionId, enabled, gear) {
374
+ function withOwn(map, sessionId, patch) {
351
375
  const next = { ...map }
352
376
  if (typeof sessionId !== 'string' || sessionId.length === 0) return next
353
377
  const current = isRecord(next[sessionId]) ? next[sessionId] : {}
354
- /** @type {{enabled: boolean, gear?: string, ts: number}} */
355
- const entry = { ...current, enabled: enabled === true, ts: Date.now() }
356
- if (typeof gear === 'string' && gear.length > 0) entry.gear = gear
378
+ /** @type {Record<string, any>} */
379
+ const entry = { ...current, ts: Date.now() }
380
+ if (typeof patch.enabled === 'boolean') entry.enabled = patch.enabled
381
+ if (typeof patch.gear === 'string' && patch.gear.length > 0) entry.gear = patch.gear
382
+ if (typeof patch.discipline === 'boolean' || patch.discipline === 'late') entry.discipline = patch.discipline
383
+ if (Number.isFinite(patch.stepBudget) && patch.stepBudget >= 0) entry.stepBudget = Math.floor(Number(patch.stepBudget))
384
+ if (typeof patch.map === 'boolean') entry.map = patch.map
357
385
  next[sessionId] = entry
358
386
  return next
359
387
  }
@@ -385,7 +413,7 @@ window.__ModuleLoader__.load({
385
413
 
386
414
  /**
387
415
  * Bound a remote call. The bridge can leave a request pending when the
388
- * client connects before the host channel is up; without a deadline the chip
416
+ * client connects before the settings channel is up; without a deadline the chip
389
417
  * would sit in `loading` forever, which is indistinguishable from a broken
390
418
  * control. A timed-out read becomes a visible error the user can retry.
391
419
  *
@@ -482,21 +510,30 @@ window.__ModuleLoader__.load({
482
510
  const value = view.value && typeof view.value === 'object' ? view.value : {}
483
511
  store.sessions = parseChoices(value.sessions)
484
512
  const own = ownChoice(sid)
485
- // A layer that carries the field wins; otherwise keep what we already
486
- // know instead of flattening the switch back to the default. The
487
- // conversation's own entry outranks the shared default, field by field,
488
- // so an entry that pins only the gear still inherits the switch.
513
+ // A layer that carries the field wins, field by field: the
514
+ // conversation's own entry outranks the shared default, so an entry that
515
+ // pins only the gear still inherits everything else. Per-session entries
516
+ // are why two open tabs can show different rows at the same time.
489
517
  const sharedGear = typeof value.gear === 'string' ? value.gear : store.gear
490
518
  const seenGear = own !== undefined && typeof own.gear === 'string' ? own.gear : sharedGear
519
+ const seenDiscipline = own !== undefined && own.discipline !== undefined
520
+ ? own.discipline !== false
521
+ : value.outputDiscipline !== false
522
+ const seenBudget = own !== undefined && typeof own.stepBudget === 'number'
523
+ ? own.stepBudget
524
+ : typeof value.stepBudget === 'number' ? value.stepBudget : 0
525
+ const seenMap = own !== undefined && typeof own.map === 'boolean'
526
+ ? own.map
527
+ : value.workspaceMap === true
491
528
  store.phase = 'ready'
492
529
  store.error = ''
493
530
  store.ns = view.ns
494
531
  store.revision = typeof view.revision === 'number' ? view.revision : undefined
495
532
  store.writable = !(result && result.writable === false)
496
533
  store.gear = seenGear
497
- store.discipline = value.outputDiscipline !== false
498
- store.stepBudget = typeof value.stepBudget === 'number' ? value.stepBudget : 0
499
- store.inventory = value.workspaceMap === true
534
+ store.discipline = seenDiscipline
535
+ store.stepBudget = seenBudget
536
+ store.inventory = seenMap
500
537
  // A write we have not seen echoed yet wins: the Loader applies the new
501
538
  // section asynchronously, and flipping back to the old paint in that
502
539
  // window would read as a failed click.
@@ -516,7 +553,7 @@ window.__ModuleLoader__.load({
516
553
  }
517
554
 
518
555
  /**
519
- * The host echoes a write a beat later, so poll briefly instead of showing
556
+ * The settings side echoes a write a beat later, so poll briefly instead of showing
520
557
  * the old value in the gap.
521
558
  *
522
559
  * @param {{settings: {describe: () => Promise<any>}}} remote
@@ -535,40 +572,59 @@ window.__ModuleLoader__.load({
535
572
  /**
536
573
  * Write one or more config fields through the settings surface.
537
574
  *
538
- * The patch is optimistic, then reconciled with what the host reports. A
575
+ * The patch is optimistic, then reconciled with what the settings side reports. A
539
576
  * refusal rolls the paint back and keeps the message — never a silent revert.
540
577
  *
578
+ * Two shapes of change reach this function, and only one of them is used by
579
+ * the panel:
580
+ *
581
+ * - `options.entry` updates *this conversation's* row and writes nothing else.
582
+ * That is the only path that survives the volatility check on the settings side, and it is
583
+ * what makes the four controls per-session instead of global.
584
+ * - `patch` names shared config fields. It is kept for callers that mean "change
585
+ * the default for future conversations", and the settings side refuses it for any
586
+ * field that is not volatile — so the panel must not use it.
587
+ *
541
588
  * @param {{settings: {update: (ns: string, patch: object, revision?: number) => Promise<any>}}} remote
542
- * @param {{enabled?: boolean, gear?: string}} patch
589
+ * @param {{enabled?: boolean, gear?: string, outputDiscipline?: boolean|'late', stepBudget?: number, workspaceMap?: boolean}} [patch]
590
+ * @param {{entry?: {enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean}}} [options]
543
591
  * @returns {Promise<boolean>} whether the write was accepted
544
592
  */
545
593
  async function write(remote, patch, options = {}) {
546
594
  if (store.phase !== 'ready' || !store.writable || store.busy) return false
547
595
  const before = Object.assign({}, store)
548
- // The on/off switch keeps a per-conversation entry; a shared preference
549
- // must not rewrite that entry as a side effect.
550
- const touchSession = options.touchSession !== false
551
- const finalEnabled = typeof patch.enabled === 'boolean' ? patch.enabled : store.enabled
552
- const finalGear = typeof patch.gear === 'string' ? patch.gear : store.gear
553
- const merged = touchSession
554
- ? withOwn(store.sessions, store.sessionId, finalEnabled, finalGear)
555
- : store.sessions
556
- const outgoing = touchSession ? { ...patch, sessions: encodeChoices(merged) } : { ...patch }
596
+ const shared = patch && typeof patch === 'object' ? patch : {}
597
+ const entry = options.entry
598
+ const scoped = entry !== undefined && typeof entry === 'object'
599
+ const merged = scoped ? withOwn(store.sessions, store.sessionId, entry) : store.sessions
600
+ const outgoing = scoped ? { ...shared, sessions: encodeChoices(merged) } : { ...shared }
557
601
  store.busy = true
558
602
  store.writeError = ''
559
- if (touchSession) store.sessions = merged
560
- if (typeof patch.enabled === 'boolean') {
561
- store.enabled = patch.enabled
562
- store.pending = patch.enabled
603
+ if (scoped) {
604
+ store.sessions = merged
605
+ if (typeof entry.enabled === 'boolean') {
606
+ store.enabled = entry.enabled
607
+ store.pending = entry.enabled
608
+ }
609
+ if (typeof entry.gear === 'string') store.gear = entry.gear
610
+ if (typeof entry.discipline === 'boolean' || entry.discipline === 'late') store.discipline = entry.discipline !== false
611
+ if (typeof entry.stepBudget === 'number') store.stepBudget = entry.stepBudget
612
+ if (typeof entry.map === 'boolean') store.inventory = entry.map
563
613
  }
564
- if (typeof patch.gear === 'string') store.gear = patch.gear
565
- if (typeof patch.stepBudget === 'number') store.stepBudget = patch.stepBudget
566
- if (typeof patch.workspaceMap === 'boolean') store.inventory = patch.workspaceMap
614
+ if (typeof shared.enabled === 'boolean') {
615
+ store.enabled = shared.enabled
616
+ store.pending = shared.enabled
617
+ }
618
+ if (typeof shared.gear === 'string') store.gear = shared.gear
619
+ if (shared.outputDiscipline === false) store.discipline = false
620
+ else if (shared.outputDiscipline !== undefined) store.discipline = true
621
+ if (typeof shared.stepBudget === 'number') store.stepBudget = shared.stepBudget
622
+ if (typeof shared.workspaceMap === 'boolean') store.inventory = shared.workspaceMap
567
623
  emit()
568
624
  try {
569
625
  unwrap(await withTimeout(remote.settings.update(store.ns, outgoing, store.revision), 15000, 'settings/update'))
570
626
  store.busy = false
571
- await settle(remote, typeof patch.enabled === 'boolean' ? patch.enabled : store.enabled)
627
+ await settle(remote, typeof outgoing.enabled === 'boolean' ? outgoing.enabled : store.enabled)
572
628
  emit()
573
629
  return true
574
630
  } catch (error) {
@@ -576,6 +632,7 @@ window.__ModuleLoader__.load({
576
632
  store.enabled = before.enabled
577
633
  store.gear = before.gear
578
634
  store.sessions = before.sessions
635
+ store.discipline = before.discipline
579
636
  store.stepBudget = before.stepBudget
580
637
  store.inventory = before.inventory
581
638
  store.pending = undefined
@@ -593,6 +650,11 @@ window.__ModuleLoader__.load({
593
650
  * switch, so a refusal leaves its reason on the tooltip; the reason stays
594
651
  * until the next successful read.
595
652
  *
653
+ * Only this session's row is written. The previous release also rewrote the
654
+ * shared `enabled` field, which made one click turn the mode on for every
655
+ * conversation that had never been clicked — the exact leak the per-session
656
+ * tier exists to prevent.
657
+ *
596
658
  * @param {{settings: {describe: () => Promise<any>, update: (ns: string, patch: object, revision?: number) => Promise<any>}}} remote
597
659
  */
598
660
  async function toggleEnabled(remote) {
@@ -602,27 +664,24 @@ window.__ModuleLoader__.load({
602
664
  if (store.phase !== 'ready') return
603
665
  }
604
666
  if (!store.writable) return
605
- // The scheduler rides along so the shared column (which seeds a brand-new
606
- // conversation) matches what this one just got. `auto` is a constant, not
607
- // a choice: the scheduler is the only mode, and the chip is the only choice.
608
- await write(remote, { enabled: !store.enabled, gear: 'auto' })
667
+ // `auto` is a constant, not a choice: the scheduler is the only mode.
668
+ await write(remote, undefined, { entry: { enabled: !store.enabled, gear: 'auto' } })
609
669
  }
610
670
 
611
671
  /**
612
- * Turn the "less narration" reminder on or off.
672
+ * Turn the "less narration" reminder on or off for this conversation.
613
673
  *
614
674
  * `late` is the shipped default: no prose rule in the system prompt, one
615
675
  * compression reminder from the fourth step of a turn. `false` removes it,
616
676
  * which on the long-task fixture gave back the whole gain (184 s vs 69 s
617
- * median, 6 pairs) — so the panel writes the shared field, never a
618
- * per-session illusion.
677
+ * median, 6 pairs).
619
678
  *
620
679
  * @param {{settings: {describe: () => Promise<any>, update: (ns: string, patch: object, revision?: number) => Promise<any>}}} remote
621
680
  * @param {boolean} on
622
681
  */
623
682
  async function toggleDiscipline(remote, on) {
624
683
  if (store.busy || store.phase !== 'ready' || !store.writable) return
625
- await write(remote, { outputDiscipline: on ? 'late' : false })
684
+ await write(remote, undefined, { entry: { discipline: on ? 'late' : false } })
626
685
  }
627
686
 
628
687
  /* -------------------------------------------------------------- component */
@@ -742,18 +801,22 @@ window.__ModuleLoader__.load({
742
801
  : t('panel.budgetOff')
743
802
 
744
803
  /**
745
- * One row: label on the left, current value on the right.
804
+ * One row: label on the left, current value on the right, and the
805
+ * consequence of the setting on hover. The label alone cannot say what
806
+ * "少写过程" costs, and the settings rows use a title for the
807
+ * same reason.
746
808
  *
747
809
  * Props are assigned one by one because a dense key/value literal reads as
748
810
  * a status dump to the copy linter, and this is a control, not a dump.
749
811
  */
750
- const row = (role, label, value, live, act, checked) => {
812
+ const row = (role, label, value, live, act, checked, hint) => {
751
813
  const props = {}
752
814
  props.className = 'fm-panel__row'
753
815
  props.role = role
754
816
  props.disabled = !live
755
817
  props.onClick = act
756
818
  if (role === 'menuitemcheckbox') props['aria-checked'] = checked === true
819
+ if (typeof hint === 'string' && hint.length > 0) props.title = hint
757
820
  return react.createElement(
758
821
  'button',
759
822
  props,
@@ -769,11 +832,13 @@ window.__ModuleLoader__.load({
769
832
  }
770
833
 
771
834
  /**
772
- * The inventory is a shared field as well: it belongs to the composition,
773
- * not to one conversation, so the write skips the per-session entry.
835
+ * The file list is a per-conversation choice too, and this row is where the
836
+ * previous release's dead switches came from: it wrote the shared
837
+ * `workspaceMap` field, the settings side refused it as non-volatile, and the button
838
+ * looked broken.
774
839
  */
775
840
  const toggleMap = () => {
776
- void write(remote, { workspaceMap: !store.inventory }, { touchSession: false })
841
+ void write(remote, undefined, { entry: { map: !store.inventory } })
777
842
  store.panelOpen = false
778
843
  emit()
779
844
  }
@@ -782,10 +847,26 @@ window.__ModuleLoader__.load({
782
847
  'div',
783
848
  { className: 'fm-panel', role: 'menu', 'aria-label': t('panel.title') },
784
849
  react.createElement('div', { className: 'fm-panel__title' }, t('panel.title')),
785
- row('menuitemcheckbox', t('panel.prose'), t(store.discipline ? 'panel.on' : 'panel.off'), canWrite, toggleProse, store.discipline),
786
- row('menuitem', t('panel.budget'), budgetText, canWrite, () => void cycleBudget(remote)),
787
- row('menuitemcheckbox', t('panel.map'), t(store.inventory ? 'panel.on' : 'panel.off'), canWrite, toggleMap, store.inventory),
788
- react.createElement('div', { className: 'fm-panel__scope' }, t('panel.allSessions')),
850
+ row(
851
+ 'menuitemcheckbox',
852
+ t('panel.prose'),
853
+ t(store.discipline ? 'panel.on' : 'panel.off'),
854
+ canWrite,
855
+ toggleProse,
856
+ store.discipline,
857
+ t('panel.proseTitle'),
858
+ ),
859
+ row('menuitem', t('panel.budget'), budgetText, canWrite, () => void cycleBudget(remote), undefined, t('panel.budgetTitle')),
860
+ row(
861
+ 'menuitemcheckbox',
862
+ t('panel.map'),
863
+ t(store.inventory ? 'panel.on' : 'panel.off'),
864
+ canWrite,
865
+ toggleMap,
866
+ store.inventory,
867
+ t('panel.mapTitle'),
868
+ ),
869
+ react.createElement('div', { className: 'fm-panel__scope' }, t('panel.scope')),
789
870
  )
790
871
  }
791
872
 
@@ -793,17 +874,18 @@ window.__ModuleLoader__.load({
793
874
  const BUDGET_STEPS = [0, 15, 30]
794
875
 
795
876
  /**
796
- * Move the step cap to the next offered value.
877
+ * Move the step cap to the next offered value for this conversation.
797
878
  *
798
- * The cap is a shared field, so the write skips the per-session entry that
799
- * the on/off switch maintains.
879
+ * The cap used to be a shared field, which is why it read as a dead button:
880
+ * `settings.write` refuses a non-volatile path outright. It travels in the
881
+ * per-session row now, so the cap applies to this conversation only.
800
882
  *
801
883
  * @param {any} remote
802
884
  */
803
885
  async function cycleBudget(remote) {
804
886
  const at = BUDGET_STEPS.indexOf(store.stepBudget)
805
887
  const next = BUDGET_STEPS[(at < 0 ? 0 : at + 1) % BUDGET_STEPS.length]
806
- await write(remote, { stepBudget: next }, { touchSession: false })
888
+ await write(remote, undefined, { entry: { stepBudget: next } })
807
889
  }
808
890
 
809
891
  /* ------------------------------------------------------------------ apply */
@@ -885,7 +967,7 @@ window.__ModuleLoader__.load({
885
967
  }, 'fast-mode: panel dismiss')
886
968
  }
887
969
 
888
- // Another tab, a hand-edited patch, or the plugin's own host half may be
970
+ // Another tab, a hand-edited patch, or the plugin's own settings half may be
889
971
  // the one writing.
890
972
  if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
891
973
  const onFocus = () => { void refresh(remote) }
@@ -897,7 +979,7 @@ window.__ModuleLoader__.load({
897
979
  exports.apply = apply
898
980
  exports.inject = inject
899
981
  // The two format helpers are exported for `test/state.test.js`, which feeds
900
- // the same fixtures to this copy and to `src/state.js`: the host decides what a
982
+ // the same fixtures to this copy and to `src/state.js`: the settings side decides what a
901
983
  // request runs at from the column this half writes, so a drift between the two
902
984
  // is a switch that looks on and does nothing.
903
985
  exports.__choices = { SESSIONS_LIMIT, parseChoices, encodeChoices, withOwn }
@@ -1,6 +1,6 @@
1
1
  # Reference: principles, configuration and limits
2
2
 
3
- This manual describes what **dsh-fast-mode 1.5.15** actually does (injection behaviour under the default config is identical to 1.5.0; 1.5.14 added the right-click panel and 1.5.15 makes it three rows), matching `index.js`, `src/` and `client.js` in the repository. It is not a brochure: where something can be measured it gets a number, and where it has not been measured it says so.
3
+ This manual describes what **dsh-fast-mode 1.5.17** actually does (injection behaviour under the default config is identical to 1.5.0; 1.5.14 added the right-click panel, and as of 1.5.17 it has four rows that all apply to this conversation only), matching `index.js`, `src/` and `client.js` in the repository. It is not a brochure: where something can be measured it gets a number, and where it has not been measured it says so.
4
4
 
5
5
  **The one contract: fast mode never changes reasoning effort, the thinking budget, or the model.** The `agent/request` hook does exactly one thing — `await next()`, read the `reasoningEffort` the request already carries for measurement, then return it unchanged. Whatever effort the user picked (the per-session choice in the UI, or the effort pinned by the profile's agent-default-model) is the effort every request uses.
6
6
 
@@ -9,7 +9,8 @@ Speed comes from three things only: fewer round trips, less narration, less tool
9
9
  ## Interaction details: the bolt switch and which conversation it applies to
10
10
 
11
11
  - **One icon, and it is the switch.** The conversation input's activity slot (`conversation.input.activity`) holds a single bolt button: hollow when off, filled when on. One click toggles it, another click toggles back, and that click writes only this conversation. There is no gear selector, no caret, no second-level "choose a gear" menu.
12
- - **Right-click is the panel.** Right-clicking the bolt opens three shared settings: less narration (`outputDiscipline`), step cap (`stepBudget`, cycling 0/15/30), and workspace inventory (`workspaceMap`). All three write shared fields only, never `sessions`, and apply from the next request; the whole block is disabled while read-only or mid-write, and a refused write rolls back. The panel does not expose `disciplineAfterSteps`, `disciplineEarlyCalls`, `shapeGate`, `finishLineSteps`, `shellParallel` or `disciplineRepeatAtSteps` — untested or held.
12
+ - **Right-click is the panel.** Right-clicking the bolt opens four rows, all scoped to the current conversation: faster (`enabled`), less narration (`outputDiscipline`), step cap (`stepBudget`, cycling 0/15/30), and file list first (`workspaceMap`). Each row records the choice in this conversation's entry in the `sessions` column, leaves the shared defaults alone, and applies from the next request; the whole block is disabled while read-only or mid-write, and a refused write rolls back. A conversation with no entry keeps the configured defaults (`enabled`/`gear`/`outputDiscipline`/`stepBudget`/`workspaceMap`). The panel does not expose `disciplineAfterSteps`, `disciplineEarlyCalls`, `shapeGate`, `finishLineSteps`, `shellParallel` or `disciplineRepeatAtSteps` — untested or held.
13
+ - **Conversations never affect each other, and that is a hard constraint.** Each conversation resolves `enabled`, `gear`, `outputDiscipline`, `stepBudget` and `workspaceMap` from its own entry in the `sessions` column: A can be faster + less narration while B is faster + full narration at the same time, and C, never touched, keeps the configured defaults. The panel and the bolt write only this conversation's entry and never the shared defaults; at most 200 entries are kept, oldest `ts` pruned first, and a pruned conversation falls back to the defaults.
13
14
  - **The button answers one question: is this conversation on.** `aria-pressed` is the state, and the tooltip spells out "this conversation · effort unchanged · click to turn off". What makes the mode faster, where the speed comes from, and the per-call ceiling live in the plugin description (`meta.description` and the config descriptions), not in the composer.
14
15
  - **The live line quotes the number being enforced**, not an adjective:
15
16
  - on, with a guard ceiling: `Automatic · parallel 2–8 · one call ≤ 2 min`
@@ -229,7 +230,8 @@ The plugin detects both at load (looking for the package names in the profile's
229
230
 
230
231
  - the request body goes in and comes out unchanged. This plugin does not write `reasoningEffort`, `thinking` or `output_config`, and adds no provider-specific fields;
231
232
  - it does not filter or lower "the effort ids this route declared". There is no `floor`, no ladder, no per-step downgrade;
232
- - the route name is recorded for metrics and debug logs only and takes part in no decision.
233
+ - the route name is recorded for metrics and debug logs only and takes part in no decision;
234
+ - token usage is read in whatever spelling the upstream speaks, with no vendor assumption: the harness's `inputTokens/outputTokens/…`, OpenAI Chat's `prompt_tokens/completion_tokens` (including `prompt_tokens_details.cached_tokens` and `completion_tokens_details.reasoning_tokens`), the newer OpenAI `input_tokens/output_tokens`, Anthropic's `cache_read_input_tokens/cache_creation_input_tokens`, and Google's `candidatesTokenCount` all fold to the same numbers, and usage is found under `usage`, `message.usage` or `response.usage`. A gateway that reports no usage at all counts as 0 — step time and call counts come from the harness, not from the provider.
233
235
 
234
236
  So whether a provider declares `off/low/high/max`, or `minimal/low/medium/high`, or a single level, or nothing at all, this plugin behaves the same way: it injects its guidance section, bounds shell calls, and sends the effort you chose unchanged.
235
237
 
package/docs/REFERENCE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 参考手册:原理、配置与边界
2
2
 
3
- 本手册描述 **dsh-fast-mode 1.5.15** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.15 面板为三行),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
3
+ 本手册描述 **dsh-fast-mode 1.5.17** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.17 面板为四行、四行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
4
4
 
5
5
  **唯一的契约:Fast 模式从不改变思考强度、thinking 预算或模型。** `agent/request` 钩子只做一件事——`await next()`,读一下请求携带的 `reasoningEffort` 记进测量,然后原样返回。用户选的是哪一档(会话里的 UI 选择,或者 profile 里 agent-default-model 固定下来的那一档),每个请求就用哪一档。
6
6
 
@@ -9,7 +9,8 @@
9
9
  ## 交互细节:闪电开关与会话归属
10
10
 
11
11
  - **一个图标就是开关。** 会话输入区的活动槽(`conversation.input.activity`)里只有一个闪电按钮:关闭时空心,开启时实心。点一下切换,再点一下切回;这一下只写当前会话。没有档位选择器,没有箭头,没有"选择 gear"的二级菜单。
12
- - **右键是面板。** 右键闪电打开三行共享设置:"更少中间输出"(`outputDiscipline`)、"步数上限"(`stepBudget`,循环 0/15/30)、"工作区清单"(`workspaceMap`)。三行都只写共享字段、不带 `sessions`,下一个回合生效;只读或写入中时整块禁用,写失败回滚。面板不暴露 `disciplineAfterSteps`、`disciplineEarlyCalls`、`shapeGate`、`finishLineSteps`、`shellParallel`、`disciplineRepeatAtSteps` 这些未证实或已判 HOLD 的旋钮。
12
+ - **右键是面板。** 右键闪电打开四行,四行都只影响当前会话:"更快"(`enabled`)、"少写过程"(`outputDiscipline`)、"最多几步"(`stepBudget`,循环 0/15/30)、"先列文件清单"(`workspaceMap`)。四行都把选择写进 `sessions` 列里本会话的条目,不碰共享默认值,下一个回合生效;只读或写入中时整块禁用,写失败回滚。没有条目的会话沿用配置里的默认值(`enabled`/`gear`/`outputDiscipline`/`stepBudget`/`workspaceMap`)。面板不暴露 `disciplineAfterSteps`、`disciplineEarlyCalls`、`shapeGate`、`finishLineSteps`、`shellParallel`、`disciplineRepeatAtSteps` 这些未证实或已判 HOLD 的旋钮。
13
+ - **会话之间互不影响,这是硬约束。** 每个会话的 `enabled`、`gear`、`outputDiscipline`、`stepBudget`、`workspaceMap` 都从 `sessions` 列里自己那一条解析:A 会话 `更快 + 少写过程`、B 会话 `更快 + 不写过程` 同时成立,C 会话没点过就沿用配置默认值。面板和闪电只写本会话的条目,从不写共享默认值;条目最多保留 200 个,按 `ts` 淘汰最旧的,被淘汰的会话回到默认值。
13
14
  - **按钮只表达一个问题:这个会话开不开。** `aria-pressed` 就是状态,tooltip 写清"仅本会话 · 思考强度不变 · 点击关闭"。为什么快、快在哪里、单次调用上限这类说明放在插件介绍(`meta.description` 与配置项描述)里,不再占用输入区。
14
15
  - **实时行引用的是被执行的数字**,而不是产品形容词:
15
16
  - 开启、看门狗有上限:`自动调度 · 并行 2–8 · 单次调用 ≤ 2 分`
@@ -233,7 +234,8 @@
233
234
 
234
235
  - 请求体原样进出。本插件不写 `reasoningEffort`、不写 `thinking`、不写 `output_config`,也不添加任何 provider 专用字段;
235
236
  - 不做"这条路由声明了哪些档位"的过滤和降档。没有 `floor`,没有档位阶梯,没有按步降档;
236
- - 路由名只被记进测量和调试日志,不参与任何决策。
237
+ - 路由名只被记进测量和调试日志,不参与任何决策;
238
+ - token 用量按上游实际拼写读,不假定某一家:harness 的 `inputTokens/outputTokens/…`、OpenAI Chat 的 `prompt_tokens/completion_tokens`(含 `prompt_tokens_details.cached_tokens`、`completion_tokens_details.reasoning_tokens`)、新版 OpenAI 的 `input_tokens/output_tokens`、Anthropic 的 `cache_read_input_tokens/cache_creation_input_tokens`、Google 的 `candidatesTokenCount` 都会落到同一组数字;usage 挂在 `usage`、`message.usage` 或 `response.usage` 下都能找到。网关完全不报 usage 时按 0 处理——步的时间和调用数由 harness 自己量,不靠 provider。
237
239
 
238
240
  所以无论 provider 声明的是 `off/low/high/max`、还是 `minimal/low/medium/high`、还是只有一档、还是什么都没声明,本插件的行为都一样:它注入提示段、给 shell 调用设上限,然后把你选的 effort 原样发出去。
239
241
 
@@ -366,7 +368,7 @@ dsh-fast-mode-experiment stop # 结束实验
366
368
  - `src/metrics.js`:JSONL 计量与汇总;
367
369
  - `src/benchmark.js`:按会话哈希分配;
368
370
  - `src/peers.js`:冲突插件探测;
369
- - `client.js`:闪电开关、右键面板(三行共享设置)与按会话的写入。
371
+ - `client.js`:闪电开关、右键面板(四行,按会话写入 `sessions` 列)。
370
372
 
371
373
  跑测试:
372
374
 
package/index.js CHANGED
@@ -80,7 +80,8 @@ import { classifyEntry } from './src/shape.js'
80
80
  import { detectPeerPlugins, opsToolsLive } from './src/peers.js'
81
81
  import { createStallGuard, stallBudgetFor } from './src/stallguard.js'
82
82
  import { createRecorder } from './src/metrics.js'
83
- import { choiceFor } from './src/state.js'
83
+ import { readOutputTokens } from './src/usage.js'
84
+ import { configOverridesFor } from './src/state.js'
84
85
  import { buildShellParallelTool } from './src/parallel.js'
85
86
 
86
87
  /**
@@ -95,7 +96,7 @@ const TAG = '[fast-mode]'
95
96
  * Shipped identity of this build. Traced into the debug log so a stale install is
96
97
  * visible at a glance, and pinned by a test against `package.json`.
97
98
  */
98
- export const VERSION = '1.5.15'
99
+ export const VERSION = '1.5.17'
99
100
  const GLOBAL_AGENT = '__global__'
100
101
 
101
102
  /**
@@ -316,12 +317,8 @@ export function apply(ctx, rawConfig) {
316
317
  // scheduler's token budget needs the provider's own number, not an estimate.
317
318
  if (event && event.type === 'assistant/message') {
318
319
  try {
319
- const data = event.data ?? event
320
- const usage = data.usage ?? (data.message && data.message.usage)
321
- const out = usage && typeof usage === 'object' ? usage.outputTokens : undefined
322
- if (typeof out === 'number' && Number.isFinite(out) && out > 0) {
323
- noteOutputTokens(auto, agentKey(session), out)
324
- }
320
+ const out = readOutputTokens(event.data ?? event)
321
+ if (out > 0) noteOutputTokens(auto, agentKey(session), out)
325
322
  } catch {
326
323
  // A counter that can fail a turn is worse than no counter.
327
324
  }
@@ -362,10 +359,10 @@ export function apply(ctx, rawConfig) {
362
359
  // session hash, and a panel click that quietly overrode the arm would empty
363
360
  // the control group of the very trial the user asked for.
364
361
  if (assigned === undefined) {
365
- // No experiment: the conversation's own entry wins, and its absence means
366
- // "inherit" — the configured pair is both the default and the record of
367
- // the last choice anywhere, which is what a fresh session starts from.
368
- const own = choiceFor(rawConfig?.sessions, sessionId)
362
+ // No experiment: the conversation's own entry wins, field by field, and
363
+ // its absence means "inherit". Every panel control writes into that entry,
364
+ // which is what keeps one tab's prose or step cap from touching another.
365
+ const own = configOverridesFor(rawConfig?.sessions, sessionId)
369
366
  return own === undefined ? resolveConfig(rawConfig) : resolveConfig(rawConfig, own)
370
367
  }
371
368
  // The control arm is an assignment like any other: the mode is off because
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.15",
3
+ "version": "1.5.17",
4
4
  "description": "DeepSeek Harness fast mode: same thinking effort, fewer round trips. An adaptive parallel tool fan-out, output discipline that never trims the answer, and a cancellable stall guard. Never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.js CHANGED
@@ -81,7 +81,7 @@ export const Config = z.object({
81
81
  .union([z.boolean(), z.const('late')])
82
82
  .default('late')
83
83
  .description(
84
- '输出纪律 / Output discipline: 让模型少写过程、只在结论处落笔。`late`(默认)不往系统提示里放文风规则,第 4 步之后由调度器提醒一次——短任务零成本,长任务实测 6 对:墙钟中位 2.68×、步数 10.5→7.5、输出 token −52%、正确性 12/12 不变。`true` 全程生效;`false` 关闭。',
84
+ '输出纪律 / Output discipline(面板「少写过程」,也是没有自己条目的会话的默认值): 让模型少写过程、只在结论处落笔。`late`(默认)不往系统提示里放文风规则,第 4 步之后由调度器提醒一次——短任务零成本,长任务实测 6 对:墙钟中位 2.68×、步数 10.5→7.5、输出 token −52%、正确性 12/12 不变。`true` 全程生效;`false` 关闭。',
85
85
  ),
86
86
 
87
87
  shellParallel: z
@@ -98,7 +98,7 @@ export const Config = z.object({
98
98
  .max(80)
99
99
  .default(0)
100
100
  .description(
101
- '单轮步数预算 / Steps per turn: 用户自设的硬上限,到达后调度器提醒一次按现状汇报;0 = 不限(默认)。步数只说明成本,不说明任务是否完成——默认不再按步数催停,避免把仍在推进的长任务截断。需要成本上限时自己设。',
101
+ '单轮步数预算 / Steps per turn(面板「最多几步」,0 显示为「不限」): 用户自设的硬上限,到达后调度器提醒一次按现状汇报;0 = 不限(默认)。步数只说明成本,不说明任务是否完成——默认不再按步数催停,避免把仍在推进的长任务截断。需要成本上限时自己设。',
102
102
  ),
103
103
 
104
104
  finishLineSteps: z
@@ -185,7 +185,7 @@ export const Config = z.object({
185
185
  .boolean()
186
186
  .default(false)
187
187
  .description(
188
- '工作区清单 / Workspace inventory: 会话开始时不调用任何工具,harness 自己走一遍 cwd(深度 2、最多 60 条、跳过 node_modules 等)把路径、大小和现有测试入口作为一条提示交给模型。实测 `def15` 会话里 35.7% 的工具调用是 ls/find/cat 这类探路,其中 40% 出现在前两次调用里——这一步就是把那次往返拿掉。只发一次,不隐藏任何文件,读文件仍由模型自己决定。',
188
+ '工作区清单 / Workspace inventory(面板「先列文件清单」): 会话开始时不调用任何工具,harness 自己走一遍 cwd(深度 2、最多 60 条、跳过 node_modules 等)把路径、大小和现有测试入口作为一条提示交给模型。实测 `def15` 会话里 35.7% 的工具调用是 ls/find/cat 这类探路,其中 40% 出现在前两次调用里——这一步就是把那次往返拿掉。只发一次,不隐藏任何文件,读文件仍由模型自己决定。',
189
189
  ),
190
190
 
191
191
  mapEntries: z
package/src/metrics.js CHANGED
@@ -21,6 +21,8 @@
21
21
  import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
22
22
  import { dirname } from 'node:path'
23
23
 
24
+ import { findUsage, readUsage } from './usage.js'
25
+
24
26
  /** The measurement format. Bumped when a field changes meaning. */
25
27
  export const METRICS_VERSION = 2
26
28
 
@@ -100,22 +102,12 @@ const payloadOf = (event) => {
100
102
  }
101
103
 
102
104
  /**
103
- * Read one token field from a usage object.
104
- *
105
- * The harness reports `TokenUsage` (`inputTokens`, `outputTokens`,
106
- * `cacheReadTokens`, `cacheWriteTokens`, `reasoningTokens`), while the session
107
- * stats projection uses the gateway's own `uncachedInputTokens`/`thoughtTokens`
108
- * spelling. Both are accepted so a provider that reports either still counts.
105
+ * Read one usage object in any of the dialects a route may speak.
109
106
  *
110
- * @param {any} usage @param {string[]} names
107
+ * The spelling table lives in `usage.js`, because the scheduler budgets against
108
+ * the same numbers. This module only needs the fold: a self-hosted gateway that
109
+ * says `completion_tokens` must produce the same row as the official API.
111
110
  */
112
- const tokenField = (usage, names) => {
113
- for (const name of names) {
114
- const value = usage[name]
115
- if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value
116
- }
117
- return 0
118
- }
119
111
 
120
112
  /**
121
113
  * Milliseconds between two event timestamps, never negative.
@@ -295,15 +287,13 @@ export function createRecorder(options = {}) {
295
287
  if (entry.first === undefined) entry.first = time
296
288
  entry.last = time
297
289
  // The message is appended with `usage` beside it; older adapters hang it
298
- // off the message instead. Both are the same numbers.
299
- const usage = data.usage ?? (data.message && data.message.usage)
300
- if (usage && typeof usage === 'object') {
301
- entry.input += tokenField(usage, ['inputTokens', 'uncachedInputTokens'])
302
- entry.output += tokenField(usage, ['outputTokens'])
303
- entry.reasoning += tokenField(usage, ['reasoningTokens', 'thoughtTokens'])
304
- entry.cacheRead += tokenField(usage, ['cacheReadTokens', 'cachedReadTokens'])
305
- entry.cacheWrite += tokenField(usage, ['cacheWriteTokens', 'cachedWriteTokens'])
306
- }
290
+ // off the message instead, and a gateway may nest it under `response`.
291
+ const tokens = readUsage(findUsage(data))
292
+ entry.input += tokens.input
293
+ entry.output += tokens.output
294
+ entry.reasoning += tokens.reasoning
295
+ entry.cacheRead += tokens.cacheRead
296
+ entry.cacheWrite += tokens.cacheWrite
307
297
  return
308
298
  }
309
299
 
package/src/state.js CHANGED
@@ -9,13 +9,23 @@
9
9
  *
10
10
  * So the plugin keeps two tiers:
11
11
  *
12
- * - The `enabled` / `gear` config fields stay the **default**, and double as the
13
- * "last choice": a conversation with no entry of its own inherits whatever was
14
- * picked most recently anywhere. That is what makes a fresh session start
15
- * where the user left off instead of resetting to the schema default.
16
- * - The `sessions` field carries the per-session entries: `{ sessionId: { enabled,
17
- * gear, ts } }` as a compact JSON string. It is volatile like the other two,
18
- * so a write lands in the running plugin without a remount.
12
+ * - The config fields (`enabled`, `gear`, `outputDiscipline`, `stepBudget`,
13
+ * `workspaceMap`) are the **defaults**: what a conversation runs at until it
14
+ * has an entry of its own. Only an edit in `cordis.patch.yml` changes them;
15
+ * the chip and the panel never write them.
16
+ * - The `sessions` field carries every per-session choice: `{ sessionId: {
17
+ * enabled, gear, discipline, stepBudget, map, ts } }` as a compact JSON
18
+ * string. It is volatile, so a write lands in the running plugin without a
19
+ * remount.
20
+ *
21
+ * Every panel control writes this second tier, and that is what makes the four
22
+ * switches independent per conversation: one tab can be fast with terser prose
23
+ * while another is fast with full narration, and neither click moves the other.
24
+ * The previous release wrote the three extra knobs into the shared fields; the
25
+ * host refuses a write to a non-volatile field ("Config field … is not
26
+ * volatile"), so those rows did nothing whatsoever — the symptom read as a dead
27
+ * button rather than as a refusal, because the panel closes on click and the
28
+ * error only appears in a tooltip.
19
29
  *
20
30
  * Why a config field and not a new remote call: a plugin-owned RPC would need a
21
31
  * decorated `TypertRemoteService`, and the client half is a static bundle with no
@@ -57,7 +67,7 @@ function isRecord(value) {
57
67
  * "no overrides" rather than take the plugin down with it.
58
68
  *
59
69
  * @param {unknown} raw
60
- * @returns {Record<string, {enabled?: boolean, gear?: string, ts?: number}>}
70
+ * @returns {Record<string, SessionEntry>}
61
71
  */
62
72
  export function parseChoices(raw) {
63
73
  const value = plainConfigValue(raw)
@@ -72,23 +82,38 @@ export function parseChoices(raw) {
72
82
  }
73
83
 
74
84
  /**
75
- * Drop entries that are not a usable shape, and coerce the two fields the policy
85
+ * Drop entries that are not a usable shape, and coerce the fields the policy
76
86
  * actually reads. A gear name is validated by `resolveConfig`, not here: this
77
87
  * module must not own the gear ladder.
78
88
  *
89
+ * `discipline` is the one field with three states (`true`, `'late'`, `false`),
90
+ * and it is stored exactly as the config field spells it so the two tiers stay
91
+ * interchangeable. `map` is short for `workspaceMap`; the entry is a hot column
92
+ * read on every describe, and the shorter key keeps an entry near 90 bytes.
93
+ *
79
94
  * @param {Record<string, any>} input
95
+ * @returns {Record<string, SessionEntry>}
80
96
  */
81
97
  function sanitise(input) {
82
- /** @type {Record<string, {enabled?: boolean, gear?: string, ts?: number}>} */
98
+ /** @type {Record<string, SessionEntry>} */
83
99
  const out = {}
84
100
  for (const [sessionId, entry] of Object.entries(input)) {
85
101
  if (sessionId.length === 0 || !isRecord(entry)) continue
86
- /** @type {{enabled?: boolean, gear?: string, ts?: number}} */
102
+ /** @type {SessionEntry} */
87
103
  const clean = {}
88
104
  if (typeof entry.enabled === 'boolean') clean.enabled = entry.enabled
89
105
  if (typeof entry.gear === 'string' && entry.gear.length > 0) clean.gear = entry.gear
106
+ if (typeof entry.discipline === 'boolean' || entry.discipline === 'late') clean.discipline = entry.discipline
107
+ if (Number.isFinite(entry.stepBudget) && entry.stepBudget >= 0) clean.stepBudget = Math.floor(Number(entry.stepBudget))
108
+ if (typeof entry.map === 'boolean') clean.map = entry.map
90
109
  if (Number.isFinite(entry.ts)) clean.ts = Number(entry.ts)
91
- if (clean.enabled === undefined && clean.gear === undefined) continue
110
+ if (
111
+ clean.enabled === undefined
112
+ && clean.gear === undefined
113
+ && clean.discipline === undefined
114
+ && clean.stepBudget === undefined
115
+ && clean.map === undefined
116
+ ) continue
92
117
  out[sessionId] = clean
93
118
  }
94
119
  return out
@@ -99,22 +124,50 @@ function sanitise(input) {
99
124
  *
100
125
  * @param {unknown} raw - the `sessions` config value.
101
126
  * @param {string|undefined} sessionId
102
- * @returns {{enabled?: boolean, gear?: string}|undefined}
127
+ * @returns {SessionEntry|undefined}
103
128
  */
104
129
  export function choiceFor(raw, sessionId) {
105
130
  if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
106
131
  const entry = parseChoices(raw)[sessionId]
107
132
  if (entry === undefined) return undefined
108
- /** @type {{enabled?: boolean, gear?: string}} */
133
+ /** @type {SessionEntry} */
109
134
  const choice = {}
110
135
  if (entry.enabled !== undefined) choice.enabled = entry.enabled
111
136
  if (entry.gear !== undefined) choice.gear = entry.gear
137
+ if (entry.discipline !== undefined) choice.discipline = entry.discipline
138
+ if (entry.stepBudget !== undefined) choice.stepBudget = entry.stepBudget
139
+ if (entry.map !== undefined) choice.map = entry.map
112
140
  return choice
113
141
  }
114
142
 
143
+ /**
144
+ * The same entry, spelled as config fields so `resolveConfig` can merge it.
145
+ *
146
+ * This is the one place the two vocabularies meet: the column is compact, the
147
+ * config is what the policy reads. Keeping the mapping here means the host half
148
+ * never has to remember that `map` means `workspaceMap`.
149
+ *
150
+ * @param {unknown} raw
151
+ * @param {string|undefined} sessionId
152
+ * @returns {Record<string, unknown>|undefined}
153
+ */
154
+ export function configOverridesFor(raw, sessionId) {
155
+ const choice = choiceFor(raw, sessionId)
156
+ if (choice === undefined) return undefined
157
+ /** @type {Record<string, unknown>} */
158
+ const overrides = {}
159
+ if (choice.enabled !== undefined) overrides.enabled = choice.enabled
160
+ if (choice.gear !== undefined) overrides.gear = choice.gear
161
+ if (choice.discipline !== undefined) overrides.outputDiscipline = choice.discipline
162
+ if (choice.stepBudget !== undefined) overrides.stepBudget = choice.stepBudget
163
+ if (choice.map !== undefined) overrides.workspaceMap = choice.map
164
+ return Object.keys(overrides).length === 0 ? undefined : overrides
165
+ }
166
+
115
167
  /**
116
168
  * The effective pair for one conversation: its own entry when it has one, the
117
- * configured default otherwise.
169
+ * configured default otherwise. Kept for the two-field shape callers had before
170
+ * the panel grew; the field-by-field fallback is the same rule the host applies.
118
171
  *
119
172
  * @param {unknown} raw
120
173
  * @param {string|undefined} sessionId
@@ -136,12 +189,14 @@ export function resolveChoice(raw, sessionId, fallback) {
136
189
  *
137
190
  * `ts` is the pruning key, not a display field: the map is ordered by nothing in
138
191
  * particular after a round trip through JSON, so recency has to be recorded.
192
+ * Fields absent from `choice` keep their previous value, which is what lets the
193
+ * panel write one row without restating the other three.
139
194
  *
140
195
  * @param {unknown} raw - current `sessions` value.
141
196
  * @param {string} sessionId
142
- * @param {{enabled?: boolean, gear?: string}} choice
197
+ * @param {SessionEntry} choice
143
198
  * @param {number} [now]
144
- * @returns {{sessions: string, map: Record<string, {enabled?: boolean, gear?: string, ts?: number}>}}
199
+ * @returns {{sessions: string, map: Record<string, SessionEntry>}}
145
200
  */
146
201
  export function writeChoice(raw, sessionId, choice, now) {
147
202
  const map = parseChoices(raw)
@@ -149,18 +204,23 @@ export function writeChoice(raw, sessionId, choice, now) {
149
204
  return { sessions: encodeChoices(map), map }
150
205
  }
151
206
  const current = map[sessionId] ?? {}
152
- /** @type {{enabled?: boolean, gear?: string, ts?: number}} */
207
+ /** @type {SessionEntry} */
153
208
  const next = { ...current, ts: Number.isFinite(now) ? Number(now) : Date.now() }
154
209
  if (typeof choice.enabled === 'boolean') next.enabled = choice.enabled
155
210
  if (typeof choice.gear === 'string' && choice.gear.length > 0) next.gear = choice.gear
211
+ if (typeof choice.discipline === 'boolean' || choice.discipline === 'late') next.discipline = choice.discipline
212
+ if (Number.isFinite(choice.stepBudget) && choice.stepBudget >= 0) next.stepBudget = Math.floor(Number(choice.stepBudget))
213
+ if (typeof choice.map === 'boolean') next.map = choice.map
156
214
  map[sessionId] = next
157
215
  return { sessions: encodeChoices(map), map }
158
216
  }
159
217
 
218
+ /** @typedef {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, ts?: number}} SessionEntry */
219
+
160
220
  /**
161
221
  * Serialise the map, dropping the oldest entries beyond {@link SESSIONS_LIMIT}.
162
222
  *
163
- * @param {Record<string, {enabled?: boolean, gear?: string, ts?: number}>} map
223
+ * @param {Record<string, SessionEntry>} map
164
224
  * @returns {string}
165
225
  */
166
226
  export function encodeChoices(map) {
package/src/usage.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Token usage, in whichever dialect the route speaks.
3
+ *
4
+ * The plugin must not care which gateway is behind a model: the official API,
5
+ * a self-hosted OpenAI-compatible proxy, an Anthropic-compatible one, and the
6
+ * harness's own projection all describe the same four numbers under different
7
+ * names. The scheduler needs the output count (it is decode time, so it is the
8
+ * wall clock), and the metrics row wants the rest for an honest report. Reading
9
+ * only `outputTokens` meant a gateway that says `completion_tokens` looked like
10
+ * a turn that produced no output at all.
11
+ *
12
+ * Mapping, most specific first:
13
+ *
14
+ * | meaning | accepted spellings |
15
+ * | ----------- | ------------------------------------------------------------------------- |
16
+ * | input | `inputTokens`, `prompt_tokens`, `promptTokenCount`, `uncachedInputTokens` |
17
+ * | output | `outputTokens`, `output_tokens`, `completion_tokens`, `candidatesTokenCount` |
18
+ * | reasoning | `reasoningTokens`, `thoughtTokens`, `reasoning_tokens` |
19
+ * | cache read | `cacheReadTokens`, `cachedReadTokens`, `cache_read_input_tokens`, `cached_tokens` |
20
+ * | cache write | `cacheWriteTokens`, `cachedWriteTokens`, `cache_creation_input_tokens` |
21
+ *
22
+ * A missing usage object is not an error — some proxies do not report one — so
23
+ * every reader here returns zeros rather than throwing, and the caller keeps its
24
+ * own defaults.
25
+ */
26
+
27
+ const number = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0)
28
+
29
+ const nested = (object, path) => {
30
+ let current = object
31
+ for (const key of path) {
32
+ if (!current || typeof current !== 'object') return undefined
33
+ current = current[key]
34
+ }
35
+ return current
36
+ }
37
+
38
+ /**
39
+ * Read the first numeric field that exists under any of the given names.
40
+ *
41
+ * @param {any} usage
42
+ * @param {string[]} names
43
+ */
44
+ const field = (usage, names) => {
45
+ for (const name of names) {
46
+ const value = number(usage[name])
47
+ if (value > 0) return value
48
+ }
49
+ return 0
50
+ }
51
+
52
+ /** The OpenAI responses / Anthropic style detail objects. */
53
+ const detail = (usage, name) => (usage && typeof usage[name] === 'object' ? usage[name] : {})
54
+
55
+ /**
56
+ * Normalise one usage object.
57
+ *
58
+ * Every key the plugin reads is total-and-positive; `0` means "this route did
59
+ * not say", never "this route said zero", because a zero-token step would be
60
+ * indistinguishable from an absent one anyway.
61
+ *
62
+ * @param {any} usage
63
+ * @returns {{input: number, output: number, reasoning: number, cacheRead: number, cacheWrite: number}}
64
+ */
65
+ export function readUsage(usage) {
66
+ const empty = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
67
+ if (!usage || typeof usage !== 'object') return empty
68
+
69
+ const promptDetails = detail(usage, 'prompt_tokens_details')
70
+ const inputDetails = detail(usage, 'input_tokens_details')
71
+ const completionDetails = detail(usage, 'completion_tokens_details')
72
+ const outputDetails = detail(usage, 'output_tokens_details')
73
+
74
+ return {
75
+ input: field(usage, ['inputTokens', 'input_tokens', 'prompt_tokens', 'promptTokenCount', 'uncachedInputTokens']),
76
+ output: field(usage, ['outputTokens', 'output_tokens', 'completion_tokens', 'candidatesTokenCount']),
77
+ reasoning: field(usage, ['reasoningTokens', 'thoughtTokens', 'reasoning_tokens', 'thoughtsTokenCount'])
78
+ || field(completionDetails, ['reasoning_tokens'])
79
+ || field(outputDetails, ['reasoning_tokens']),
80
+ cacheRead: field(usage, ['cacheReadTokens', 'cachedReadTokens', 'cache_read_input_tokens', 'cachedContentTokenCount', 'cached_tokens'])
81
+ || field(promptDetails, ['cached_tokens'])
82
+ || field(inputDetails, ['cached_tokens']),
83
+ cacheWrite: field(usage, ['cacheWriteTokens', 'cachedWriteTokens', 'cache_creation_input_tokens']),
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Find a usage object inside an event payload.
89
+ *
90
+ * The harness hangs it off `data.usage`, older adapters off `data.message.usage`;
91
+ * OpenAI-style proxies sometimes nest it under `response.usage`, and a custom
92
+ * gateway that reuses the completion envelope puts it there too. Walk the few
93
+ * shapes that exist rather than demanding one.
94
+ *
95
+ * @param {any} source
96
+ */
97
+ export function findUsage(source) {
98
+ if (!source || typeof source !== 'object') return undefined
99
+ const paths = [
100
+ ['usage'],
101
+ ['message', 'usage'],
102
+ ['response', 'usage'],
103
+ ['message', 'response', 'usage'],
104
+ ['result', 'usage'],
105
+ ['output', 'usage'],
106
+ ['data', 'usage'],
107
+ ]
108
+ for (const path of paths) {
109
+ const found = nested(source, path)
110
+ if (found && typeof found === 'object') return found
111
+ }
112
+ // Some adapters store the counts flat on the event.
113
+ if ('outputTokens' in source || 'output_tokens' in source || 'completion_tokens' in source) return source
114
+ return undefined
115
+ }
116
+
117
+ /**
118
+ * Output tokens only — the number the scheduler budgets against.
119
+ *
120
+ * @param {any} source an event payload, not necessarily just the usage object
121
+ */
122
+ export function readOutputTokens(source) {
123
+ return readUsage(findUsage(source)).output
124
+ }