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 +31 -1
- package/CHANGELOG.md +15 -0
- package/README.md +6 -5
- package/client.js +177 -95
- package/docs/REFERENCE.en.md +5 -3
- package/docs/REFERENCE.md +6 -4
- package/index.js +9 -12
- package/package.json +1 -1
- package/src/config.js +3 -3
- package/src/metrics.js +13 -23
- package/src/state.js +79 -19
- package/src/usage.js +124 -0
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
|
|
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
|
-
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
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
|
|
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.
|
|
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': '
|
|
121
|
+
'panel.budget': 'Max steps',
|
|
119
122
|
'panel.budgetOff': 'No cap',
|
|
120
123
|
'panel.budgetSteps': '{n} steps',
|
|
121
|
-
'panel.
|
|
124
|
+
'panel.scope': 'This conversation only',
|
|
122
125
|
'panel.budgetTitle': 'Reports at the cap; never truncates the task',
|
|
123
|
-
'panel.map': '
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
187
|
+
/** The workspace inventory switch for this conversation, off by default. */
|
|
182
188
|
inventory: false,
|
|
183
|
-
/**
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
248
|
-
* `src/state.js`. The bundle cannot import
|
|
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
|
|
269
|
-
* (`src/state.js` `sanitise`): this half writes entries the
|
|
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
|
|
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,
|
|
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 {
|
|
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 (
|
|
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
|
|
336
|
-
*
|
|
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
|
-
*
|
|
339
|
-
*
|
|
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}
|
|
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,
|
|
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 {
|
|
355
|
-
const entry = { ...current,
|
|
356
|
-
if (typeof
|
|
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
|
|
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
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
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 =
|
|
498
|
-
store.stepBudget =
|
|
499
|
-
store.inventory =
|
|
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
|
|
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
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
const
|
|
552
|
-
const
|
|
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 (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
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
|
-
//
|
|
606
|
-
|
|
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)
|
|
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, {
|
|
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
|
|
773
|
-
*
|
|
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, {
|
|
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(
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
|
799
|
-
*
|
|
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, {
|
|
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
|
|
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
|
|
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 }
|
package/docs/REFERENCE.en.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Reference: principles, configuration and limits
|
|
2
2
|
|
|
3
|
-
This manual describes what **dsh-fast-mode 1.5.
|
|
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
|
|
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.
|
|
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
|
-
- **右键是面板。**
|
|
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 {
|
|
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.
|
|
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
|
|
320
|
-
|
|
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,
|
|
366
|
-
// "inherit"
|
|
367
|
-
//
|
|
368
|
-
const own =
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - The `sessions` field carries
|
|
17
|
-
* gear, ts } }` as a compact JSON
|
|
18
|
-
* so a write lands in the running plugin without a
|
|
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,
|
|
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
|
|
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,
|
|
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 {
|
|
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 (
|
|
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 {
|
|
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 {
|
|
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 {
|
|
197
|
+
* @param {SessionEntry} choice
|
|
143
198
|
* @param {number} [now]
|
|
144
|
-
* @returns {{sessions: string, map: Record<string,
|
|
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 {
|
|
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,
|
|
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
|
+
}
|