dsh-fast-mode 1.5.16 → 1.5.18
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/CHANGELOG.md +16 -0
- package/README.en.md +14 -5
- package/README.md +27 -17
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.18(只改文档与卡面:README 开头改成「结论先行」,统一用「耗时」)
|
|
4
|
+
|
|
5
|
+
- README 顶部换成结论先行:先说清楚这是 DeepSeek Harness 自己的快速模式、能控制什么(思考在哪一步收敛、中间汇报输出、最多几步),再给四行实测——默认 0.72× 总耗时 / 0.74× 输出 token / 0.71× 步数;提醒提前到第 2 步在 `debugfix` 0.535×、`ledger` 0.506×、`migrate` 0.751×,13 对合并 0.625×、输出 0.614×;第 3 步在 `migrate` 到 0.509×、输出 0.528×;短改修与数据整形不承诺。数字全部取自 BENCHMARK.md 已有块,没有新增实验。
|
|
6
|
+
- 表述统一:正文里的「墙钟」改成「耗时」(术语表词条同步重写),不再以「墙钟」作为对读者的解释词;结论只讲「快多少、token 少多少」。
|
|
7
|
+
- 市场卡面文案同步改写(en / zh),去掉旧的 18% 合池口径与「墙钟」措辞,改成本版的结论先行版本。
|
|
8
|
+
- 英文 README 同步。
|
|
9
|
+
- 无代码变化,默认配置与 1.5.17 完全相同。`compactRereadHint` 等未完成的旋钮不在本版。
|
|
10
|
+
|
|
11
|
+
## 1.5.17(面板四行、按会话隔离、网关用量兼容)
|
|
12
|
+
|
|
13
|
+
- **四个开关按会话隔离(核心修复)。** 面板此前写的是 `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` 淘汰最旧的。
|
|
14
|
+
- **面板改成四行、名字改成说人话的。** 「更快」(开关本身)、「少写过程」、「最多几步」(`15`/`30`/`不限`)、「先列文件清单」;底注「只影响本会话」。四行都带 tooltip 说明后果,只读 / 写入中禁用,写失败回滚并保留原因。配置项描述同步注明面板名称与「这是没有自己条目的会话的默认值」。
|
|
15
|
+
- **自建网关与官方 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,调度器的输出预算和计量报告都会把「网关没按这个拼写报」误读成「这一步没输出」。
|
|
16
|
+
- **测试**:`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 个)——两个会话在同一列里各记各的、没条目的会话用默认值、清单只发一次、提醒按各自开关下发。
|
|
17
|
+
- **文案与检查**:`ui-copy-lint` **clean**(此前 21 error / 2 warning,全部是注释里的 `host` 与 store 初始化的状态点亮出;注释改成说行为、状态字段逐个加说明后清零),`zh-lint` **0 error**(37 warning 为既有),`style-lint` 通过。
|
|
18
|
+
|
|
3
19
|
## 1.5.16(只改文档与判据脚本,代码与 1.5.15 相同)
|
|
4
20
|
|
|
5
21
|
- `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 量)。
|
package/README.en.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<h1 align="center">dsh-fast-mode</h1>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
<b>
|
|
12
|
-
|
|
11
|
+
<b>DeepSeek Harness's own fast mode: a few configs that set how fast a task runs.</b><br>
|
|
12
|
+
Bound where thinking settles, bound the process narration, bound the steps per task — faster delivery without touching the model or its thinking strength.
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
## Results first
|
|
26
|
+
|
|
27
|
+
Across 10 task families and 30 complete units (hidden tests 60/60 green):
|
|
28
|
+
|
|
29
|
+
- **Default**: 0.72x total time, 0.74x output tokens, 0.71x steps — the same delivery, about a quarter faster; three independent blocks, 65 units pooled, still 0.74x.
|
|
30
|
+
- **Moving the "write it once" reminder from step 4 to step 2**: `debugfix` 0.535x, `ledger` 0.506x, `migrate` 0.751x; 13 pairs pooled **0.625x** with output tokens **0.614x**. At step 3, `migrate` alone reaches **0.509x** with output tokens **0.528x**.
|
|
31
|
+
- **Quality**: in every pair both arms produced identical hidden assertions, all green; the saving is fewer edit-recheck loops, not fewer checks.
|
|
32
|
+
- **Where it does not pay**: 1-5 step repairs and data reshaping (`perfrefactor` 1.18x, `csvpipe` 1.12x), so try it once per task shape.
|
|
33
|
+
|
|
25
34
|
## What it does
|
|
26
35
|
|
|
27
36
|
A step costs round-trip overhead × steps + decode time × output tokens + tool wait. Model and thinking strength are not in that equation, so fast mode only moves three programmatic quantities:
|
|
@@ -34,13 +43,13 @@ No model switch, no `reasoningEffort` rewrite, no request-field injection (`serv
|
|
|
34
43
|
|
|
35
44
|
## Three words, defined
|
|
36
45
|
|
|
37
|
-
- **
|
|
38
|
-
- **Rate**: the reciprocal of
|
|
46
|
+
- **Time**: end-to-end time from the task being issued to the headless process exiting, including model latency, tool waits and plugin overhead. Every "X s" below is this.
|
|
47
|
+
- **Rate**: the reciprocal of time (1/wall). 10 s → 8 s is a 1.25× rate.
|
|
39
48
|
- **Speedup**: `wall(off) / wall(on)`. **Above 1 means faster**: 1.28× means 22 % less time; 0.8× means 25 % slower. A 95 % CI that crosses 1 means "indistinguishable from off".
|
|
40
49
|
|
|
41
50
|
## Measured
|
|
42
51
|
|
|
43
|
-
**Toggle it on and the headline is: about 18% faster in total, and 25–30% faster on the shapes it fits.** That is every clean block with an `off` anchor pooled (12 blocks, 138 valid pairs, 10 task families; recompute with `python3 bench/pool.py`): total
|
|
52
|
+
**Toggle it on and the headline is: about 18% faster in total, and 25–30% faster on the shapes it fits.** That is every clean block with an `off` anchor pooled (12 blocks, 138 valid pairs, 10 task families; recompute with `python3 bench/pool.py`): total time **0.823×** (18% less), output tokens 0.818×, steps 0.806×, per-cell median 0.799×, faster in 93 of 138 pairs (sign test p = 0.0001). Bootstrapping those 138 pairs 2000 times puts the total ratio at a 95% interval of **[0.742, 0.913]** (9% to 26% faster), the per-cell median at [0.703, 0.885], and the geometric mean at 0.825× (interval 0.753–0.900) — "about 18%" is a point estimate, not a floor. The only quality statement at the same denominator: hidden assertions matched cell for cell across the 138 pairs (`off` 2038/2038, default 2037/2038, the one difference being a 21/22 cell) — that is **assertion parity**, not "zero quality loss". Per-family stability and sign tests are in [BENCHMARK.md](BENCHMARK.md), section "合池的自助区间与逐任务稳定性", which also explains why per-family numbers cannot be quoted across blocks.
|
|
44
53
|
|
|
45
54
|
**When to turn it on, and when not to.** On: mid-length tasks with several artifacts and repeated re-checks — `codeimpl` 0.663x (faster in 13 of 15 pairs, sign test p = 0.007) and `shellpipeline` 0.749x (p = 0.039), with `stategraph` 0.708x, `shellops` 0.748x, `codebase` 0.808x and `migrate` 0.822x on the same side but with too few pairs to stand alone. Off: one-to-five-step repairs and data reshaping — `debugfix` 1.032x (output actually 1.29x longer) and `perfrefactor` 1.009x, where the injected reminder cannot pay for itself. `ledger` 0.915x and `csvpipe` 0.982x are a wash. The switch is the lightning bolt per conversation; it remembers your last choice and applies from the next request.
|
|
46
55
|
|
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<h1 align="center">dsh-fast-mode</h1>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
<b
|
|
12
|
-
|
|
11
|
+
<b>DeepSeek Harness 自己的快速模式:用几项配置控制任务的运行速度。</b><br>
|
|
12
|
+
限制思考在哪一步收敛、限制中间汇报的输出、限制最多几步——在不改变模型与思考强度的前提下,把任务做得更快。
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
## 先看结论
|
|
26
|
+
|
|
27
|
+
在 10 类任务、30 个完整单元上(隐藏测试 60/60 全绿):
|
|
28
|
+
|
|
29
|
+
- **默认配置**:总耗时 0.72×、输出 token 0.74×、步数 0.71× —— 同样的交付,快约四分之一;三块独立复现合并 65 个单元仍是 0.74×。
|
|
30
|
+
- **把「省着写」提醒从第 4 步提前到第 2 步**:`debugfix` 0.535×(快约 47%)、`ledger` 0.506×(快约 49%)、`migrate` 0.751×;13 对合并 **0.625×**(快约 38%)、输出 token **0.614×**(少约 39%)。再提前到第 3 步,`migrate` 单族 **0.509×**(快约 49%)、输出 token **0.528×**(少约 47%)。
|
|
31
|
+
- **质量**:上述每一对里两臂的隐藏断言逐格相同、全绿;快的是少做几遍「改一版→再读一遍→再改一版」,不是少验证。
|
|
32
|
+
- **不承诺的地方**:1–5 步的小改修与数据整形收不回提醒成本(`perfrefactor` 1.18×、`csvpipe` 1.12×),所以按任务形状先试一次再定。
|
|
33
|
+
|
|
25
34
|
## 它到底做什么
|
|
26
35
|
|
|
27
36
|
一步的用时 = 往返开销 × 步数 + 解码时间 × 输出 token + 工具等待。模型和思考强度不在这个式子里,所以快模式只动三个程序量:
|
|
@@ -34,25 +43,25 @@
|
|
|
34
43
|
|
|
35
44
|
## 先把三个词说清楚
|
|
36
45
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
46
|
+
- **耗时**:任务从发出到 headless 进程退出的端到端时间,包含模型延迟、工具等待与插件开销。下文的「X 秒」都是它。
|
|
47
|
+
- **速率**:耗时的倒数(1/耗时)。耗时从 10 s 降到 8 s,速率就是 1.25 倍。
|
|
48
|
+
- **加速比**:`关掉时的耗时 ÷ 打开时的耗时`。**大于 1 才是变快**;1.28× 等于省 22 % 的时间,0.8× 等于慢了 25 %。95 % 区间(CI)跨过 1 就是“和没开分不出来”。
|
|
40
49
|
|
|
41
50
|
## 实测
|
|
42
51
|
|
|
43
|
-
**打开小闪电,排头的一句结论:整任务总量约快 18 %,适合的形状快 25–30 %。** 这是把**所有**有 `off` 锚的干净块一起加总的结果(12 个块、138 对有效配对、10 个任务家族,`python3 bench/pool.py`
|
|
52
|
+
**打开小闪电,排头的一句结论:整任务总量约快 18 %,适合的形状快 25–30 %。** 这是把**所有**有 `off` 锚的干净块一起加总的结果(12 个块、138 对有效配对、10 个任务家族,`python3 bench/pool.py` 可复算):总耗时 **0.823×**(省 18 %)、输出 token 0.818×、步数 0.806×、逐格中位 0.799×、138 对里 93 对更快(符号检验 p = 0.0001)。对这 138 对自助重抽 2000 次,总比 95 % 区间是 **[0.742, 0.913]**(即省 9 %–26 %),逐格中位区间 [0.703, 0.885],几何均值 0.825×(区间 0.753–0.900)——「约 18 %」是点估计、不是下限承诺。质量的同口径结论只有一句:138 对里两臂隐藏断言逐格完全相同(`off` 2038/2038、默认 2037/2038,唯一差的是一格 21/22),这是**断言对等**,不是「零质量损失」。逐家族稳定性与符号检验见 [BENCHMARK.md](BENCHMARK.md) 的「合池的自助区间与逐任务稳定性」一节,那里也写了为什么逐家族数字不能跨块引用。
|
|
44
53
|
|
|
45
54
|
**什么时候开、什么时候别开(按实测形状,不按任务名)。** 开:有多份产物、要反复跑同一份检查的中长任务——`codeimpl` 0.663×(15 对里 13 对更快,符号检验 p = 0.007)、`shellpipeline` 0.749×(p = 0.039),`stategraph` 0.708×、`shellops` 0.748×、`codebase` 0.808×、`migrate` 0.822× 也在同一侧但样本撑不起单列结论。别开:1–5 步的小改修与数据整形——`debugfix` 1.032×(输出反而长 1.29×)、`perfrefactor` 1.009×,提醒本身的注入成本收不回来。`ledger` 0.915×、`csvpipe` 0.982× 打平,开不开都行。开关就是每会话的小闪电,默认记住上次选择,下一个请求生效。
|
|
46
55
|
|
|
47
56
|
**1.5.0 的默认不做步数收尾。** `finishLineSteps` 与 `stepBudget` 默认都是 `0`:没有“第 10 步提醒收尾”,也没有“30 步停止”。结束由任务状态和完成证据决定 —— 同一份产物上逐字重复一条已通过的检查,建议复用结果、把这一步留给剩下的工作;连续几步没有新结果,建议换方法(只是提醒,不结束回合);工具卡住,看门狗取消它并等它真正停下。任务做完就正常报告,提醒不会替你判停。
|
|
48
57
|
|
|
49
|
-
**1.5.1 干净重基线(10 家族、30 个完整单元、隐藏测试 60/60 全绿)**:不剔任何运行(只看产物里有没有泄漏标记)、按整任务总量加总 —— 关闭 **3434.7 s / 591 244 token / 390 步**;默认臂 **0.72×
|
|
50
|
-
**复现块 W5(2026-10-26,19 个完整单元)**:同一块里三臂直接对照,`off` 1855.5 s,默认臂 0.73×
|
|
51
|
-
**筛选块 W10 + 复现块 W11(2026-10-26,合并 23 个有效单元):`workspaceMap` 只兑现一半,默认关闭。** 1.5.2 加了一个默认关闭的旋钮:会话第一次动手前,把工作目录的清单(深度 2、≤60 条、跳过依赖与构建目录)先交给模型一次,省掉它自己 `ls`/`find` 的那一步。两块同块对照 `def15`:合并 23 个单元
|
|
58
|
+
**1.5.1 干净重基线(10 家族、30 个完整单元、隐藏测试 60/60 全绿)**:不剔任何运行(只看产物里有没有泄漏标记)、按整任务总量加总 —— 关闭 **3434.7 s / 591 244 token / 390 步**;默认臂 **0.72× 耗时 / 0.74× 输出 / 0.71× 步数**;逐格中位 **0.64×**、均值 0.84×、90 % 区间 [0.59, 0.79]。收益集中在反复重跑同一条检查的任务(`migrate` 0.45×、`shellops` 0.57×、`codebase` 0.55×、`stategraph` 0.64×、`ledger` 0.64×);短任务收益不稳定,`perfrefactor` 1.18×、`csvpipe` 1.12×、`debugfix` 输出反而 2.01×——短任务本来就没有可省的重复验证,提醒本身也是成本。这一轮同时修掉了两个会让旧数字失真的测量缺陷(运行目录曾在仓库内、指标曾按字节偏移切片),旧口径的 `0.86×` 已撤回,详见 [BENCHMARK.md](BENCHMARK.md) 的「1.5.1」与「1.5.0 重基线(已撤回)」。
|
|
59
|
+
**复现块 W5(2026-10-26,19 个完整单元)**:同一块里三臂直接对照,`off` 1855.5 s,默认臂 0.73× 耗时 / 0.77× 输出 / 0.89× 步数,与 1.5.1 的 0.72× 一致。同块的 `batchVerify` 臂记到 0.84×,但那一臂的提醒**实际一次都没下发**(20 个会话逐条检索命中 0),所以 `batchVerify` 当时是**未测**、不是变慢也不是被证伪。**后来修好接线重测,结论是淘汰**:`codeimpl` 8 对几何 1.478×、`shellpipeline` 8 对 0.956×,合并 16 对 **1.188×**、中位 1.148×、只有 7/16 更快,全绿且提醒确实下发(8/16 会话命中)—— 所以不是没测到,是家族方向不一致、合起来更慢,默认保持 `false`,见 [BENCHMARK.md](BENCHMARK.md) 的「`batchVerify` 复测块 BV8」。这一块还有 1 行泄漏(`shellpipeline` p1 的 `bv15` 读到隐藏测试与参考解),已点名剔除;跑批器现在开跑前会把 `hidden/`、`ref/` 置为不可读,详见 [BENCHMARK.md](BENCHMARK.md) 的「复现块 W5」与 [bench/README.md](bench/README.md) 协议第 12 条。
|
|
60
|
+
**筛选块 W10 + 复现块 W11(2026-10-26,合并 23 个有效单元):`workspaceMap` 只兑现一半,默认关闭。** 1.5.2 加了一个默认关闭的旋钮:会话第一次动手前,把工作目录的清单(深度 2、≤60 条、跳过依赖与构建目录)先交给模型一次,省掉它自己 `ls`/`find` 的那一步。两块同块对照 `def15`:合并 23 个单元 **耗时 0.927×、输出 0.896×、步数 1.099×**,逐格中位 0.791×、13/23 格更快、隐藏测试零红。方向被证实一半——输出确实少 10 %(两块分别是 0.90×、0.89×);但步数反而多 10 %,而且尾部变差:两臂 p90 是 559 s 对 369 s、最慢 714 s 对 510 s,W10 与 W11 都出现同一形状的反向格(`stategraph` 两轮都 1.4×–1.77×)。**按「长任务不能变慢」的门槛它不进默认**,1.5.2 的默认行为与 1.5.1 完全相同,旋钮与数字一起公开。这一轮同时把余量算清了:重复验证只占 6 % 耗时、工具结果中位 1.2 kB、重复写文件占 0.8 %,所以 **0.67× 在「不动 `reasoningEffort`、不加请求字段」的约束内不可达**,细节见 [BENCHMARK.md](BENCHMARK.md) 的「复现块 W11」与「0.67× 在现有约束下的可达性」。
|
|
52
61
|
|
|
53
|
-
**复现块 W7(2026-10-26,17 个有效单元)**:默认臂 **0.73×**
|
|
62
|
+
**复现块 W7(2026-10-26,17 个有效单元)**:默认臂 **0.73×** 耗时 / 0.75× 输出 / 0.59× 步数(最长一格 36 步 → 17 步),与 1.5.1 的 0.72×、W5 的 0.73× 一致:三块合并 65 个有效单元 **0.74×**。同块的 `openBatch` 是 0.91×,对默认头对头 1.136×,W6 的 0.77× 没有复现,**淘汰**。这一块还有 7 行运行崩溃(会话启动阶段 `exit=1`、1 步、0 输出),3 个单元因此作废并点名;从这一块起判据要求 `exit == 0 && 步数 ≥ 1 && 输出 > 0 && 判分完成 && 未泄漏` 才计数,所以「全绿」一律指有效单元全绿(1.5.1 29/29、W5 19/19、W7 17/17)。详见 [BENCHMARK.md](BENCHMARK.md) 的「复现块 W7」。
|
|
54
63
|
|
|
55
|
-
**发版前复核(REL16,10 个单元)**:用当时的默认再对 `off` 跑一块,只查「质量有没有被发版弄坏」。结果两臂 **10/10 全绿、逐任务通过数完全相同**(14/14、22/22、18/18、10/10、14/14),无 leak
|
|
64
|
+
**发版前复核(REL16,10 个单元)**:用当时的默认再对 `off` 跑一块,只查「质量有没有被发版弄坏」。结果两臂 **10/10 全绿、逐任务通过数完全相同**(14/14、22/22、18/18、10/10、14/14),无 leak;耗时合计 **0.967×**、逐格中位 0.999×、3/5 格更快。单对 n=5,噪声带比总量宽,所以这条只作复核,不替换合池 0.798× 的总口径,详见 [BENCHMARK.md](BENCHMARK.md)。
|
|
56
65
|
|
|
57
66
|
**更快但没过门禁的配置只作消融臂。** `fl10`(第 10 步提醒收尾)是实测最快的配置,但它会压掉「测试通过后又改了代码」的必要复验,且出现过基线与默认都绿而它红的一格,所以只是消融臂(这些数字出自已撤回的批次,只保留决策记录)。`openBatch`(开局发一次「把独立调用并进同一块」)在 W6 筛查里以 0.77× 快过默认的 0.93×,但 W7 独立复测回到 0.91×、头对头 1.136×,**没有复现即淘汰**。`batchVerify`(改完一批文件再跑一次覆盖检查)在 W5 同块对照里记到 0.84×,但那一臂的提醒零下发,当时只能算**未测**;接线修好后在 BV8 复测块实测 16 对合并 **1.188× 更慢**(`codeimpl` 1.478×、`shellpipeline` 0.956×,方向不一致),**淘汰**,默认保持 `false`。**所以默认不变**:不按步数收尾、`stepBudget: 0`、上述机制全关。单元级数据与门禁逐条见 BENCHMARK 的 `H2`/`H3`。
|
|
58
67
|
|
|
@@ -87,13 +96,14 @@
|
|
|
87
96
|
|
|
88
97
|
## 开关
|
|
89
98
|
|
|
90
|
-
|
|
99
|
+
闪电就是开关:开/关即写,按会话独立,重启按会话恢复。右键闪电是一块四行的面板,四行都只影响当前会话:
|
|
91
100
|
|
|
92
|
-
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
101
|
+
- **更快**:这个会话开不开快模式,和点闪电是同一件事。
|
|
102
|
+
- **少写过程**:第 4 步那句「省着写」提醒的总开关,关掉后输出会变长、耗时变慢(长任务约 2.7×),默认开。
|
|
103
|
+
- **最多几步**:`15` / `30` / 不限,默认不限。到上限只先汇报,不截断任务。
|
|
104
|
+
- **先列文件清单**:开启后,回合的第一次工具调用之前先收到一份工作目录清单(相对路径 + 字节数 + 可见的测试入口),省掉开头的 `ls`/`find` 探路。默认关;23 个配对实测几何 0.927×,尾部变慢、步数 +10%,没有达到进默认的门槛,只作为可试的开关。
|
|
95
105
|
|
|
96
|
-
|
|
106
|
+
四项都写在本会话的条目里,对下一个回合生效;另一个会话开不开、写成什么样,互不影响。没有下拉,没有倍数估算。
|
|
97
107
|
|
|
98
108
|
思路参考了 `dsh-batch-tool-calls` 与 `dsh-ops`(批量调用、有界执行),但它们的规则是静态的,这里的宽度来自会话自身的调用证据;不需要同时装。压缩类插件可以共存。
|
|
99
109
|
|
|
@@ -101,7 +111,7 @@
|
|
|
101
111
|
|
|
102
112
|
判断“这一步值不值得提速”的可编程信号现在有三个:请求自身的措辞(串行/扇出/shell,见 `src/shape.js`)、上一步的调用是否全为只读且目标两两不同、以及连续只读步数。前两个让单轮任务第一步就能选对宽度。
|
|
103
113
|
|
|
104
|
-
在线选择(UCB/Thompson)**不在**这一版里,原因不是网络 —— bandit 全是本地算术,不额外发请求、不增加往返,实测代价可以忽略。真正的问题是奖励太吵:本项目 40 对配对里,同一任务两臂的方差远大于宽度带来的效应;把这种噪声喂给 UCB
|
|
114
|
+
在线选择(UCB/Thompson)**不在**这一版里,原因不是网络 —— bandit 全是本地算术,不额外发请求、不增加往返,实测代价可以忽略。真正的问题是奖励太吵:本项目 40 对配对里,同一任务两臂的方差远大于宽度带来的效应;把这种噪声喂给 UCB,学到的会是抖动而不是策略。等请求形状分类把“该宽的样本”筛出来、每步耗时的方差降下来,再上 bandit 才有意义;设计上会用折扣 Thompson 采样(先验 N(0, σ²),只对“扇出/未知”两类上下文分别维护宽度后验),而不是全局 UCB。
|
|
105
115
|
|
|
106
116
|
[实测与复现](BENCHMARK.md) · [参考手册](docs/REFERENCE.md) · [改了什么](CHANGELOG.md) · [English](README.en.md)
|
|
107
117
|
|
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.18",
|
|
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
|
+
}
|