dsh-fast-mode 1.5.34 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BENCHMARK.md CHANGED
@@ -2533,3 +2533,33 @@ metrics 242400 steps written, caches trimmed
2533
2533
  | 逐对更快 | 5/6 | — |
2534
2534
 
2535
2535
  两族的逐对几何分别是 `debugfix` 0.50×、`migrate` 0.50×,与历史块同量级(这两个族本来就是插件收益最大的地方),说明封顶没有动到任何行为路径。
2536
+
2537
+ ## 结果:`LZ1`(`lazy` 想太久就少想一点,1.6.0 新开关,三臂 36 次运行)——墙钟 **0.39× / 0.43×**,12 对里 11 对更快,判分 **216/216 全绿**
2538
+
2539
+ `lazy` 是 1.6.0 唯一的默认关闭开关:它不改插件其余部分,只在「这一步已经想太久」时做两件事——给对话里放一段「先按已有信息定下去」的提示,以及(`lazyEffort: false` 可关)把下一次请求的 `reasoningEffort` 降一档。三臂设计就是为了把这两半分开:
2540
+
2541
+ - `def15`:完全关闭(历史基准臂);
2542
+ - `lazt16`:`lazy: true, lazyEffort: false`,**只发提示文字**,一个请求字段都不写;
2543
+ - `laz16`:`lazy: true`,提示文字 + 请求字段降档。
2544
+
2545
+ `codeimpl`(14 项判分)与 `ledger`(22 项判分)两族各 6 对,ABBA 交替,共 **36 次运行、12 对**,同一台机器连续跑完(`bench/results/suite-LZ1.jsonl`)。
2546
+
2547
+ | 口径 | `def15` | `lazt16`(只发文字) | `laz16`(文字 + 降档) |
2548
+ | --- | --- | --- | --- |
2549
+ | 12 次运行合计墙钟 | **2 795.9 s** | 1 040.9 s(**0.372×**) | 1 010.9 s(**0.362×**) |
2550
+ | 逐对几何墙钟比(对 `def15`) | — | **0.389×**,95 % CI [0.252, 0.600] | **0.430×**,95 % CI [0.273, 0.677] |
2551
+ | 逐对更快 | — | **11/12**,符号检验 p = 0.0032 | **11/12**,p = 0.0032 |
2552
+ | 单次墙钟中位 | 174.9 s | 57.4 s | 80.0 s |
2553
+ | 记录到的推理字符中位(独立于 usage 的第二通道) | 65 342 | 19 156(0.372× 几何) | 24 404(0.418× 几何) |
2554
+ | 可见输出字符中位 | 21 594 | 8 310 | 9 468 |
2555
+ | 步数合计 / 中位 | 116 / 8.0 | 70 / 5.5 | 58 / 4.5 |
2556
+ | 判分(隐藏测试) | **216/216**,12/12 绿 | **216/216**,12/12 绿 | **216/216**,12/12 绿 |
2557
+
2558
+ 分族看,`codeimpl` 上 `lazt16` 中位 51.7 s 对 `def15` 106.1 s、`laz16` 78.1 s;`ledger` 上 125.1 s / 239.3 s,`laz16` 85.0 s。两族方向一致,不是单族异常值撑起来的。
2559
+
2560
+ **这一块能说什么、不能说什么:**
2561
+
2562
+ - 能说:在 12 对同题同配的对照里,打开 `lazy` 后 11 对更快,几何 0.39–0.43×,判分与输出正确性没有任何一格变红;机制是「步数变少 + 每步推理字符变少」,两侧同时发生,不是纯粹少说话。
2563
+ - 能说:**收益主要来自提示文字那一半**。把两臂直接对打:`laz16 / lazt16` 的逐对几何是 **1.106×**(95 % CI [0.682, 1.792],12 对里 5 对更快)——也就是说「降 `reasoningEffort`」这一半在这条路由上**没有测出额外收益,也没测出明显代价**。这与本文件早先的记录一致:这条路由的 usage 里 `reasoning` token 一直是 0,`reasoning_effort` 是否被上游当真从未被证明;`lazy` 的第二通道(流式推理字符)就是为这件事加的,读数显示两臂推理字符同量级(0.372× 对 0.418×)。
2564
+ - 不能说:这不是「所有题都变快 2.6 倍」。样本是这两族短任务、单机、单路由,`def15` 本块的中位偏慢侧(历史上 `codeimpl` 中位 161 s、`ledger` 649 s,本块 174.9 s 跨两族),逐对设计吸收了基线漂移,但换模型/换路由后倍数会变。也不要说 `lazy` 提高质量:它只做到「没把质量弄坏」——216/216 与 `def15` 持平,不是更好。
2565
+ - 一块**退役 0.9.x 教训**的验证:降档不跨轮、不跨会话、每步最多一次、每轮最多两次、工具报错后两步内不降、地板 `low`、从不升档,且每个请求都以「用户此刻选的档位」为基准重新计算(`agent/request` 读的是刚解好的 `reasoningEffort`)。所以用户中途换档,下一次请求就是用户的档,不会像 0.9.x 那样把会话钉在低档上。
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0(一个新开关 `lazy`「想太久就少想一点」:动态收思考强度 + 压过度思考,默认关闭;12 对 36 次运行实测 0.39–0.43×,判分 216/216 全绿)
4
+
5
+ 按这一轮的要求,只开**一个**新开关,不动任何默认值。它的理念照抄 [ponytail](https://github.com/dietrichgebert/ponytail) 的核心:先读懂再动手,然后从「最小能work的那一级」开始做,不为想象中的未来写代码;但**读完之前不许偷懒**,也不许省掉信任边界校验、安全、数据丢失处理、可访问性。`lazy` 就是把这条用在「思考」上:不是让它不读、不想,而是想够了就停。
6
+
7
+ - **新开关 `lazy`,出现在面板第六行「想太久就少想一点」。** 默认 `false`,逐会话写入 `sessions` 列(`src/state.js` 的 `configOverridesFor` 映射),只影响本会话。它是本插件**唯一**一个会改变答案而不只是时延的开关,所以默认关、tooltip 也只说后果(「想太久时先按已有信息定下去,安全校验照旧」),不说「更快」。
8
+ - **两半机制,可分开验证。** 提示侧:入场步在对话里放一段 `src/lazy.js` 的梯子文本(先缩小范围 → 复用现成实现 → 标准库/平台能力 → 已装依赖 → 一行 → 最小实现;并把「绝不省略」的四类写清楚),每轮一次;同一轮里推理字符超过 `HOT_CHARS`(1600)就补一条 nudge(最多 2 条,间隔按轮内累计 4000/8000 字符)。字段侧:那一步确实想太久时,把**下一次**请求的 `reasoningEffort` 降一档(`EFFORT_RUNGS` 里往 `low` 方向走,地板 `low`,从不升档),每步最多一次、每轮最多两次,工具报错后两步内不降。`lazyEffort: false` 只发文字、一个请求字段都不写,是这一块的对照臂。
9
+ - **不会重演 0.9.x 把会话钉在低档的事故。** 0.9.x 的问题是改写值活过了它被授权的那个回合:值进了请求头,面板从请求头投影「上次用的强度」,于是一个本来跑在中档的会话会漂到低档并跨重启保留。这一版的基准值是**每个请求现读**的 `resolved.reasoningEffort`(用户此刻的选择),降档只写进这一个请求的返回值,没有任何跨请求/跨会话状态;用户中途换档,下一次就是用户的档。测试里有专门的源码扫描断言:全仓只允许这一处 `reasoningEffort:`,形状是 `planned.reduced ? { ...resolved, reasoningEffort: applied } : resolved`,且不允许出现 `thinking: {` 或任何 `decideEffort`。
10
+ - **测量补齐两个此前拿不到的通道。** `src/metrics.js` 每行新增 `reasoningChars` / `textChars`(流式 chunk 的字符数,`text`、`delta`、`delta.text` 三种拼法都认,认不出就算 0,只做下界)。加这个是因为这条路由的 usage 里 `reasoning` token **一直是 0**(750 行 fit 数据、1888 步),`reasoning_effort` 是否被上游当真从未被证明——所以 `lazy` 的收益必须能从「实际流出了多少推理字符」独立读到,而不是相信一个恒为 0 的字段。降档发生时,行里同时有 `effort`(实际带出去的)与 `effortBase`(用户原本选的),两列同现就是降档的机器证据;下一个未降档的请求会显式清掉 `effortBase`,不会把它归因到后来每一步。
11
+ - **实测(`LZ1`,12 对、36 次运行、两族任务、ABBA 交替):** `lazt16`(只发文字)逐对几何墙钟 **0.389×**,95 % CI [0.252, 0.600],**11/12 更快**,符号检验 p = 0.0032;`laz16`(文字 + 降档)**0.430×**,CI [0.273, 0.677],同样 11/12。推理字符几何 0.372× / 0.418×,步数中位 8.0 → 5.5 / 4.5,判分 **216/216 全绿**(两臂与 `def15` 完全相同)。把两臂直接对打:`laz16 / lazt16` = **1.106×**(CI [0.682, 1.792],5/12 更快),即**收益来自提示文字那一半,降 `reasoningEffort` 这一半没有测出额外收益**——这一条如实写进 `BENCHMARK.md`,不做成「双保险都有效」。
12
+ - **没有动的东西:** 默认值一个没改(`enabled: false` 仍是出厂态)、`disciplineAfterSteps` 仍是 4、`d2`/`d2g`/`dr8` 仍然 HOLD、没有新增任何提醒时机或预算旋钮。外部评审建议「先做稳定性、别加旋钮」,这一轮只在用户明确要求下加这一个开关,其余仍是稳定性与测量。
13
+ - 测试 313 → **336 全绿**(新增 `test/lazy.test.js` 15 条、`test/plugin.test.js` 6 条集成、`test/metrics.test.js` 2 条 `effortBase` 断言;源码扫描测试按「恰好一处、且是这一种形状」放宽)。动 `index.js`、`src/lazy.js`(新)、`src/config.js`、`src/effort.js`、`src/state.js`、`src/metrics.js`、`client.js`、`bench/suite-run.py`、`bench/arm-config.mjs`、`bench/arms/laz16.yml`(新)、`bench/arms/lazt16.yml`(新)、两份 README、`docs/REFERENCE.md`、`BENCHMARK.md`、`CHANGELOG.md`。
14
+
15
+ ## 1.5.35(1.5.34 的补包:把 `bench/soak.mjs` 打进 npm 包)
16
+
17
+ - 1.5.34 的 `BENCHMARK.md` 让人「自己跑一遍浸泡」,但 `bench/soak.mjs` 没在 `package.json` 的 `files` 里——装了包的人照着跑会发现文件不存在。这一版只补这一行 `files`(自包含:只 import `../index.js`),行为零改动,测试仍 313 全绿。
18
+
3
19
  ## 1.5.34(稳定性版:插件里最后 6 个随宿主任期增长的缓存全部封顶,附一个可重跑的浸泡测试)
4
20
 
5
21
  按外部评审这一轮的建议,不新增任何提醒时机、重复提醒或预算旋钮,默认值与全部开关一律不动。只做一件事:**让插件在「几周不重启的宿主」里不再积累任何东西**,并把这件事变成能重跑的数字。
package/README.en.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <p align="center">
11
11
  <b>DeepSeek Harness's own fast mode: a few configs that set how fast a task runs.</b><br>
12
- Never changes the model or its reasoning effort; it cuts redundant round trips and output through execution-strategy hints — the gain is fewer steps and fewer output tokens, not deleted narration.
12
+ Never changes the model, and by default not its reasoning effort either; it cuts redundant round trips and output through execution-strategy hints — the gain is fewer steps and fewer output tokens, not deleted narration. The one exception is the off-by-default `lazy` switch: one rung down when a turn keeps over-thinking, and the switch is yours.
13
13
  </p>
14
14
 
15
15
  <p align="center">
@@ -40,11 +40,11 @@ A step costs round-trip overhead × steps + decode time × output tokens + tool
40
40
  - **Output discipline.** Asks the model to skip call previews, restatements and progress reports, never to shorten the final answer. Honest scope: visible assistant prose is only **1.18 %** of the characters in (prose + tool arguments) (def15, 341 sessions / 3,327 messages / 3,569 calls, 51,366 prose vs 4,315,096 argument characters; excludes reasoning and tool results; recompute with `python3 bench/textshare.py --limit 0`), so the speedup cannot be attributed to deleted narration — it comes from fewer round trips and fewer output tokens (fewer repeat edits, re-reads and verification passes). That is not the same claim as "nothing about the work changes".
41
41
  - **Stall watchdog.** Shell/script calls get a 120–300 s adaptive cap; a stuck call is cancelled and "change the approach" is written back into the context.
42
42
 
43
- No model switch, no `reasoningEffort` rewrite, no request-field injection (`service_tier`, `max_tokens`, anything like them). While the mode is on, the model's input carries this plugin's own scheduling guidance and reminders — that is the mechanism; switch the mode off and they are gone. DeepSeek's official API, an OpenAI-compatible relay, a self-hosted gateway, Anthropic routes — all work the same.
43
+ No model switch, no request-field injection, and no `reasoningEffort` byte written by default (`lazy` is the one off-by-default exception) (`service_tier`, `max_tokens`, anything like them). While the mode is on, the model's input carries this plugin's own scheduling guidance and reminders — that is the mechanism; switch the mode off and they are gone. DeepSeek's official API, an OpenAI-compatible relay, a self-hosted gateway, Anthropic routes — all work the same.
44
44
 
45
45
  ## Three boundaries
46
46
 
47
- - **The explicit promise**: the plugin does not change the model, does not rewrite `reasoningEffort`, and does not inject request fields. That is where quality degradation would most likely come from, and the plugin stays out of it.
47
+ - **The explicit promise**: the plugin does not change the model, does not inject request fields, and by default does not rewrite `reasoningEffort`. With `lazy` on it moves at most one rung down, never up, never below `low`, at most twice per turn, and every request re-reads your own choice first. That is where quality degradation would most likely come from, and by default the plugin stays out of it.
48
48
  - **Honest mechanism**: execution-strategy hints cut redundant round trips and output. The evidence is falling steps and output tokens (whole-pool default arm: 0.806× steps, 0.818× output); no claim that the gain comes from deleting narration.
49
49
  - **Quality boundary**: quality is judged by hidden tests and real evaluations, and behaviour is not claimed to be fully equivalent. When the quality gate fails, quality wins: experimental knobs ship off, and the default is only adopted from batches where both arms' hidden assertions match cell by cell.
50
50
 
@@ -103,17 +103,20 @@ Legacy `fast` / `turbo` / `max` / `ludicrous` values are read as `auto` with a d
103
103
 
104
104
  ## The switch
105
105
 
106
- The bolt is the switch: one click writes on/off, per conversation, restored on reload. Right-click it for a five-row panel — the switch itself and four settings:
106
+ The bolt is the switch: one click writes on/off, per conversation, restored on reload. Right-click it for a six-row panel — the switch itself and five settings:
107
107
 
108
108
  - **Less narration** — master switch for the step-4 reminder; with it off, long tasks take about 2.7x longer and write about twice as much. On by default.
109
109
  - **Step cap** — `15` / `30` / off, off by default. At the cap it reports instead of truncating the task.
110
110
  - **Workspace inventory** — before the turn's first tool call, one inventory notice (relative paths + byte sizes + visible test entry points) so the turn does not open with `ls`/`find`. Off by default; over 23 pairs it measured 0.927x geometric with a slower tail and 10% more steps, so it stays a switch to try rather than a default.
111
111
  - **Read several files at once** — batches independent reads into one call. Off by default; the measured gain shows up when it is stacked with the inventory switch (below), not alone.
112
+ - **Think less when it stalls** (`lazy`) — when a turn keeps pouring out reasoning, one notice asks for the cheapest rung that works and one rung less thinking on the next request. Off by default; it can change the answer, not only the timing.
112
113
 
113
- All five rows take effect from the next request, per conversation. No dropdown, no multiplier estimate.
114
+ All six rows take effect from the next request, per conversation. No dropdown, no multiplier estimate.
114
115
 
115
116
  **Turn several on at once?** Yes, but only the measured pair. Defaults plus inventory plus batched reads is **0.742x** over 11 pairs; adding the step-2 reminder timing (not a panel row, still on HOLD) reaches **0.669x** over 22 pairs, CI95 [0.554, 0.799], 18/22 faster, hidden tests green. Each switch alone lands at 0.80–0.88 with a confidence interval that crosses 1 — the gain comes from each one cutting a different share of the same cost, not from any single switch. The other knobs (`batchVerify`, `preferPatch`, step budget, wider fan-out) all land at 0.96–1.10; do not stack them.
116
117
 
118
+ `lazy` is not on that scale: it can change the answer, not only the timing, so even though the controlled block says it is faster (`LZ1`: 11 of 12 pairs faster, geometric 0.39–0.43×, 216/216 graded assertions green) it stays off by default and joins no default combination. The text-only arm's readings (`lazyEffort: false`) are in the same `LZ1` block of [BENCHMARK.md](BENCHMARK.md).
119
+
117
120
  The idea draws on `dsh-batch-tool-calls` and `dsh-ops` (batched calls, bounded execution), but their widths are static while this width comes from the conversation's own call evidence; do not install all three. Context-compaction plugins coexist.
118
121
 
119
122
  ## Limits and next step
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <p align="center">
11
11
  <b>DeepSeek Harness 自己的快速模式:用几项配置控制任务的运行速度。</b><br>
12
- 不改模型、不改 reasoningEffort;用执行策略提示减少冗余回合与输出——收益来自步数与输出 token 下降,不是删旁白。
12
+ 默认不改模型、不改 reasoningEffort;用执行策略提示减少冗余回合与输出——收益来自步数与输出 token 下降,不是删旁白。唯一的例外是默认关闭的 `lazy`:想太久时收一档,开关在你手里。
13
13
  </p>
14
14
 
15
15
  <p align="center">
@@ -40,11 +40,11 @@
40
40
  - **输出纪律**:提示模型不预告调用、不复述结果、不写进度报告,但**不缩短最终答案**。口径要说清:实测里可见助手正文只占「正文+工具参数」字符的 **1.19%**(def15 全量 385 会话 / 3,659 消息 / 4,007 调用,正文 57,101 / 参数 4,757,921 字符;不含 reasoning 与工具返回;`python3 bench/textshare.py --limit 0` 可复算),所以提速不能归因于删旁白——它来自回合数与输出 token 变少(少重复编辑、少回头读、少验证往返),这和「内容不受影响」不是一回事。
41
41
  - **卡顿看门狗**:给 shell/脚本类调用设 120–300 秒自适应上限,卡住的调用被取消并把“换做法”写回上下文。
42
42
 
43
- 没有模型切换、没有 `reasoningEffort` 改写、没有请求字段注入;开着时模型输入里会多出本插件自己的调度提示与提醒(这是机制本身),关掉后连这些也不存在。DeepSeek 官方 API、OpenAI 兼容中转、自建网关、Anthropic 协议 —— 走哪条路由都成立。
43
+ 没有模型切换、没有请求字段注入;`reasoningEffort` 默认一个字节都不写(`lazy` 是唯一、默认关闭的例外);开着时模型输入里会多出本插件自己的调度提示与提醒(这是机制本身),关掉后连这些也不存在。DeepSeek 官方 API、OpenAI 兼容中转、自建网关、Anthropic 协议 —— 走哪条路由都成立。
44
44
 
45
45
  ## 三条边界
46
46
 
47
- - **明确承诺**:不改模型、不改 `reasoningEffort`、不注入请求字段。降智风险最容易发生的地方,插件不碰。
47
+ - **明确承诺**:不改模型、不注入请求字段、默认不改 `reasoningEffort`。打开 `lazy` 时它只降一档、从不升档、最低 `low`、每轮最多两次、下一个请求就从你的选择重新读起;降智风险最容易发生的地方,默认一个字段都不碰。
48
48
  - **机制诚实**:靠执行策略提示减少冗余回合与输出。证据是步数与输出 token 下降(全池默认臂 0.806× 步数、0.818× 输出);不宣称收益来自「删旁白」。
49
49
  - **质量边界**:任务质量以隐藏测试判分与真实评测为准,不宣称行为完全等价。质量门槛不过就优先保质量:实验性旋钮默认关闭,默认配置只在两臂隐藏断言逐格相同的批次里采用。
50
50
 
@@ -103,18 +103,21 @@
103
103
 
104
104
  ## 开关
105
105
 
106
- 闪电就是开关:开/关即写,按会话独立,重启按会话恢复。右键闪电是一块五行的面板,五行都只影响当前会话:
106
+ 闪电就是开关:开/关即写,按会话独立,重启按会话恢复。右键闪电是一块六行的面板,六行都只影响当前会话:
107
107
 
108
108
  - **更快**:这个会话开不开快模式,和点闪电是同一件事。
109
109
  - **少写过程**:第 4 步那句「省着写」提醒的总开关,关掉后输出会变长、耗时变慢(长任务约 2.7×),默认开。
110
110
  - **最多几步**:`15` / `30` / 不限,默认不限。到上限只先汇报,不截断任务。
111
111
  - **先列文件清单**:开启后,回合的第一次工具调用之前先收到一份工作目录清单(相对路径 + 字节数 + 可见的测试入口),省掉开头的 `ls`/`find` 探路。默认关;23 个配对实测几何 0.927×,尾部变慢、步数 +10%,没有达到进默认的门槛,只作为可试的开关。
112
112
  - **一次读多个文件**:把互不依赖的读取合成一批发出,少几个来回。默认关;单独开测不到显著,和上一项一起开才有(见下)。
113
+ - **想太久就少想一点**(`lazy`):一轮里思考文字明显超量时,先给一次"先找最省的一级、不扩范围、不砍校验"的提示,再把下一个请求的思考强度降一档。默认关;它动的是答案而不只是时延。
113
114
 
114
- 五项都写在本会话的条目里,对下一个回合生效;另一个会话开不开、写成什么样,互不影响。没有下拉,没有倍数估算。
115
+ 六项都写在本会话的条目里,对下一个回合生效;另一个会话开不开、写成什么样,互不影响。没有下拉,没有倍数估算。
115
116
 
116
117
  **几个一起开会不会更快?** 会,但只有实测过的那几个。默认 + `workspaceMap` + `openBatch` 两开:11 对 **0.742×**;再把 `disciplineAfterSteps` 提前到 2(面板里没有这一项,属于 HOLD 的时点实验)三开:22 对 **0.669×**、CI95 [0.554, 0.799]、18/22 更快、隐藏测试全绿。单独开其中任何一个都测不到显著(0.80–0.88,置信区间全跨 1)——所以增益来自它们各自省掉的是同一类成本的不同份,不是哪个开关本身。其余开关(`batchVerify`、`preferPatch`、步数预算、更宽并行)叠上去都是 0.96–1.10,没有理由跟着一起开。细节见 [BENCHMARK.md](BENCHMARK.md) 的「直接跑一次多个开关一起开」。
117
118
 
119
+ `lazy` 不与上面这些同口径:它动的是答案而不只是时延,所以哪怕测出来更快(`LZ1`:12 对里 11 对更快,逐对几何 0.39–0.43×,判分 216/216 全绿)也仍然默认关闭,不进任何默认组合。纯提示臂(`lazyEffort: false`)的读数同样在 [BENCHMARK.md](BENCHMARK.md) 的 `LZ1` 块。
120
+
118
121
  思路参考了 `dsh-batch-tool-calls` 与 `dsh-ops`(批量调用、有界执行),但它们的规则是静态的,这里的宽度来自会话自身的调用证据;不需要同时装。压缩类插件可以共存。
119
122
 
120
123
  ## 边界与下一步
package/bench/soak.mjs ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Soak the plugin in a long-lived host process and watch the heap.
4
+ *
5
+ * 1.5.34 bounded every long-lived cache the plugin owns (`compacted`,
6
+ * `mapSent`, `autoBudgets`, `startedAt`, and the metrics sink's labels/steps/
7
+ * open/pending sets). A unit test can check the mapping logic but not the
8
+ * growth, because the caches are private to the closure. This script mounts the
9
+ * real plugin exactly like the harness does and then plays a host that never
10
+ * restarts: thousands of sessions, each with its own turns and tool calls,
11
+ * interleaved so every cache sees churn.
12
+ *
13
+ * It reports heap after warm-up and at the end. Growth stays flat when the caps
14
+ * work; without them the heap climbs with the session count and a real host
15
+ * eventually dies.
16
+ *
17
+ * node --expose-gc bench/soak.mjs # 4000 sessions, 4 turns each
18
+ * node --expose-gc bench/soak.mjs --sessions 8000 # longer
19
+ * node --expose-gc bench/soak.mjs --metrics # exercise the metrics sink
20
+ * # (temp file, then removed)
21
+ *
22
+ * Two notes from building this. The event feed has to be *awaited*: firing
23
+ * `agent/request` without awaiting left 16 000 promises pending and reported
24
+ * +49 MB that belonged to the test, not the plugin. And the heap baseline is
25
+ * taken after a warm-up batch, because the first sessions pay for lazily built
26
+ * module state that is not growth.
27
+ */
28
+ import { rmSync } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { join } from 'node:path'
31
+
32
+ import { apply } from '../index.js'
33
+
34
+ /** Same fake-Cordis shape as `test/plugin.test.js`, trimmed to what the soak drives. */
35
+ function fakeContext(services = {}) {
36
+ const handlers = new Map()
37
+ return {
38
+ handlers,
39
+ on(event, handler) {
40
+ handlers.set(event, handler)
41
+ return () => handlers.delete(event)
42
+ },
43
+ inject(names, callback) {
44
+ if (!names.every(name => services[name])) return undefined
45
+ const child = {
46
+ ...services,
47
+ get: name => services[name],
48
+ effect(setup) {
49
+ const disposer = setup()
50
+ return typeof disposer === 'function' ? disposer : () => {}
51
+ },
52
+ }
53
+ return callback(child)
54
+ },
55
+ get: name => services[name],
56
+ logger: { info() {}, warn() {}, debug() {} },
57
+ fire(event, ...args) {
58
+ const handler = handlers.get(event)
59
+ return handler ? handler(...args) : undefined
60
+ },
61
+ }
62
+ }
63
+
64
+ const llm = {
65
+ resolveModelInfo: () => ({
66
+ reasoning: { efforts: ['none', 'minimal', 'low', 'medium', 'high'].map(id => ({ id })), defaultEffort: 'high' },
67
+ }),
68
+ }
69
+
70
+ const argOf = (flag, fallback) => {
71
+ const hit = process.argv.indexOf(flag)
72
+ return hit > -1 ? Number(process.argv[hit + 1]) : fallback
73
+ }
74
+
75
+ const SESSIONS = argOf('--sessions', 4000)
76
+ const TURNS = argOf('--turns', 4)
77
+ const STEPS = 3
78
+
79
+ const mb = () => process.memoryUsage().heapUsed / 1024 / 1024
80
+
81
+ /**
82
+ * Measure retained heap, not allocated heap. Without a collection the number is
83
+ * mostly garbage from the steps just driven, which is what makes a healthy run
84
+ * look like it leaks. `--expose-gc` is required for a meaningful number; without
85
+ * it the script says so instead of reporting a false failure.
86
+ */
87
+ const settle = () => {
88
+ if (typeof global.gc === 'function') {
89
+ global.gc()
90
+ global.gc()
91
+ return true
92
+ }
93
+ return false
94
+ }
95
+
96
+ /** One turn: the pre-step notice path plus a request, as the harness orders them. */
97
+ const SKIP_EVENT = process.argv.includes('--no-event')
98
+ const SKIP_REQUEST = process.argv.includes('--no-request')
99
+
100
+ async function drive(ctx, session, turn, bump) {
101
+ const agent = { id: session.id, session }
102
+ const emit = (event) => {
103
+ if (!SKIP_EVENT) ctx.fire('session/event', session, event)
104
+ }
105
+ emit({ type: 'turn/start', turn })
106
+ for (let step = 1; step <= STEPS; step += 1) {
107
+ emit({ type: 'step/start', turn, step, time: Date.now() })
108
+ if (!SKIP_REQUEST) {
109
+ await ctx.fire(
110
+ 'agent/request',
111
+ { agent, turn, step, signal: new AbortController().signal },
112
+ async () => ({ provider: 'company', model: 'deepseek-v4.1-flash', reasoningEffort: 'medium' }),
113
+ )
114
+ }
115
+ emit({ type: 'assistant/message', turn, step, time: Date.now(), usage: { inputTokens: 200, outputTokens: 30 } })
116
+ emit({ type: 'step/end', turn, step, time: Date.now() })
117
+ bump()
118
+ }
119
+ // Compaction happens on a turn boundary in the real host; emitting it here keeps
120
+ // the post-compaction reminder cache under churn.
121
+ if (turn % 2 === 1) emit({ type: 'turn/compacted', turn })
122
+ }
123
+
124
+ async function main() {
125
+ const ctx = fakeContext({ llm })
126
+ // Metrics and the experiment file off: a soak must not touch the developer's
127
+ // home directory, and it must not be steered by a live experiment file.
128
+ const OFF = process.argv.includes('--off')
129
+ // `--metrics` writes the same JSONL sink a real session writes, to a temp file
130
+ // outside the home directory, so the label/step caches see the same churn.
131
+ const METRICS = process.argv.includes('--metrics')
132
+ const metricsFile = METRICS ? join(tmpdir(), `dsh-fast-mode-soak-${process.pid}.jsonl`) : ''
133
+ apply(ctx, { enabled: !OFF, metrics: METRICS, metricsFile, benchmarkFile: '' })
134
+
135
+ // `--one-id` reuses a single session id, which separates per-session caches
136
+ // from anything retained per call.
137
+ const ONE = process.argv.includes('--one-id')
138
+ const sessions = Array.from({ length: SESSIONS }, (_, i) => ({ id: ONE ? 'soak-shared' : `soak-${i}` }))
139
+ const started = Date.now()
140
+ let steps = 0
141
+ const bump = () => { steps += 1 }
142
+
143
+ // Warm-up: the first sessions pay for module-level lazily built things
144
+ // (regexes, the gear table), which would otherwise look like growth.
145
+ for (const session of sessions.slice(0, Math.min(200, SESSIONS))) {
146
+ for (let turn = 0; turn < TURNS; turn += 1) await drive(ctx, session, turn, bump)
147
+ }
148
+ const collected = settle()
149
+ const warm = mb()
150
+
151
+ for (const session of sessions) {
152
+ for (let turn = 0; turn < TURNS; turn += 1) await drive(ctx, session, turn, bump)
153
+ }
154
+ settle()
155
+ const end = mb()
156
+
157
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
158
+ const growth = end - warm
159
+ const perSession = growth / SESSIONS
160
+ console.log(`sessions ${SESSIONS} × ${TURNS} turns, ${steps} steps`)
161
+ console.log(`heap warm ${warm.toFixed(2)} MB`)
162
+ console.log(`heap end ${end.toFixed(2)} MB`)
163
+ console.log(`growth ${growth >= 0 ? '+' : ''}${growth.toFixed(2)} MB over ${SESSIONS} sessions`)
164
+ console.log(`per session ${(perSession * 1024).toFixed(1)} KB`)
165
+ console.log(`elapsed ${elapsed} s`)
166
+ if (!collected) console.log('note run with --expose-gc for a retained-heap number')
167
+ // Without the caps this adds a Map entry and a Set entry per session; V8's own
168
+ // noise is a few MB, so the threshold is generous on purpose — this checks
169
+ // "bounded", not "exact".
170
+ if (METRICS) {
171
+ // The sink is a diagnostic file, not a result: leave nothing behind.
172
+ try {
173
+ rmSync(metricsFile, { force: true })
174
+ rmSync(`${metricsFile}.1`, { force: true })
175
+ } catch {
176
+ // Temp file; a failed cleanup must not fail the soak.
177
+ }
178
+ console.log(`metrics ${steps} steps written, caches trimmed`)
179
+ }
180
+ if (perSession > 0.004) {
181
+ console.error('FAIL: heap grows with the session count — a cache is unbounded')
182
+ process.exitCode = 1
183
+ }
184
+ }
185
+
186
+ main().catch((error) => {
187
+ console.error(error)
188
+ process.exitCode = 1
189
+ })
package/client.js CHANGED
@@ -84,7 +84,7 @@ window.__ModuleLoader__.load({
84
84
  const zh = {
85
85
  'chip.on': 'Fast 模式已开启',
86
86
  'chip.off': 'Fast 模式已关闭',
87
- 'chip.titleOn': 'Fast 模式:已开启 · 仅本会话 · 思考强度不变,点击关闭 · 右键设置',
87
+ 'chip.titleOn': 'Fast 模式:已开启 · 仅本会话 · 点击关闭 · 右键设置',
88
88
  'chip.titleOff': 'Fast 模式:已关闭 · 点击开启 · 右键设置',
89
89
  'chip.titleLoading': 'Fast 模式:正在读取配置,点击重试',
90
90
  'chip.readonly': 'Fast 模式配置为只读,无法从这里切换',
@@ -104,6 +104,8 @@ window.__ModuleLoader__.load({
104
104
  'panel.mapTitle': '开局把目录里的文件交给模型,省掉先翻一遍目录',
105
105
  'panel.batch': '一次读多个文件',
106
106
  'panel.batchTitle': '把互不依赖的读取合成一批发出,少几个来回',
107
+ 'panel.lazy': '想太久就少想一点',
108
+ 'panel.lazyTitle': '想太久时先按已有信息定下去,安全校验照旧',
107
109
  }
108
110
 
109
111
  const en = {
@@ -129,6 +131,8 @@ window.__ModuleLoader__.load({
129
131
  'panel.mapTitle': 'Hands the directory listing to the model up front, skipping a lookup',
130
132
  'panel.batch': 'Read several files at once',
131
133
  'panel.batchTitle': 'Batches independent reads into one call, fewer round trips',
134
+ 'panel.lazy': 'Think less when it stalls',
135
+ 'panel.lazyTitle': 'Cuts a turn short when it keeps reasoning instead of deciding; safety checks stay',
132
136
  }
133
137
 
134
138
  const CSS = [
@@ -192,6 +196,8 @@ window.__ModuleLoader__.load({
192
196
  inventory: false,
193
197
  /** Batches independent reads into one call for this conversation. */
194
198
  batch: false,
199
+ /** The lazy switch: this conversation may be reasoned at a lower effort when it over-thinks. */
200
+ lazy: false,
195
201
  /** This conversation's step cap; 0 means no cap. */
196
202
  stepBudget: 0,
197
203
  /** The right-click panel is open. Store-backed, like every other paint. */
@@ -537,6 +543,9 @@ window.__ModuleLoader__.load({
537
543
  const seenBatch = own !== undefined && typeof own.batch === 'boolean'
538
544
  ? own.batch
539
545
  : value.openBatch === true
546
+ const seenLazy = own !== undefined && typeof own.lazy === 'boolean'
547
+ ? own.lazy
548
+ : value.lazy === true
540
549
  store.phase = 'ready'
541
550
  store.error = ''
542
551
  store.ns = view.ns
@@ -547,6 +556,7 @@ window.__ModuleLoader__.load({
547
556
  store.stepBudget = seenBudget
548
557
  store.inventory = seenMap
549
558
  store.batch = seenBatch
559
+ store.lazy = seenLazy
550
560
  // A write we have not seen echoed yet wins: the Loader applies the new
551
561
  // section asynchronously, and flipping back to the old paint in that
552
562
  // window would read as a failed click.
@@ -624,6 +634,7 @@ window.__ModuleLoader__.load({
624
634
  if (typeof entry.stepBudget === 'number') store.stepBudget = entry.stepBudget
625
635
  if (typeof entry.map === 'boolean') store.inventory = entry.map
626
636
  if (typeof entry.batch === 'boolean') store.batch = entry.batch
637
+ if (typeof entry.lazy === 'boolean') store.lazy = entry.lazy
627
638
  }
628
639
  if (typeof shared.enabled === 'boolean') {
629
640
  store.enabled = shared.enabled
@@ -635,6 +646,7 @@ window.__ModuleLoader__.load({
635
646
  if (typeof shared.stepBudget === 'number') store.stepBudget = shared.stepBudget
636
647
  if (typeof shared.workspaceMap === 'boolean') store.inventory = shared.workspaceMap
637
648
  if (typeof shared.openBatch === 'boolean') store.batch = shared.openBatch
649
+ if (typeof shared.lazy === 'boolean') store.lazy = shared.lazy
638
650
  emit()
639
651
  try {
640
652
  unwrap(await withTimeout(remote.settings.update(store.ns, outgoing, store.revision), 15000, 'settings/update'))
@@ -651,6 +663,7 @@ window.__ModuleLoader__.load({
651
663
  store.stepBudget = before.stepBudget
652
664
  store.inventory = before.inventory
653
665
  store.batch = before.batch
666
+ store.lazy = before.lazy
654
667
  store.pending = undefined
655
668
  store.writeError = describeError(error)
656
669
  emit()
@@ -866,6 +879,16 @@ window.__ModuleLoader__.load({
866
879
  emit()
867
880
  }
868
881
 
882
+ /**
883
+ * The one switch that changes how much this conversation thinks. It can
884
+ * change the answer, not only its timing, so the row names that first.
885
+ */
886
+ const toggleLazy = () => {
887
+ void write(remote, undefined, { entry: { lazy: !store.lazy } })
888
+ store.panelOpen = false
889
+ emit()
890
+ }
891
+
869
892
  return react.createElement(
870
893
  'div',
871
894
  { className: 'fm-panel', role: 'menu', 'aria-label': t('panel.title') },
@@ -898,6 +921,15 @@ window.__ModuleLoader__.load({
898
921
  store.batch,
899
922
  t('panel.batchTitle'),
900
923
  ),
924
+ row(
925
+ 'menuitemcheckbox',
926
+ t('panel.lazy'),
927
+ t(store.lazy ? 'panel.on' : 'panel.off'),
928
+ canWrite,
929
+ toggleLazy,
930
+ store.lazy,
931
+ t('panel.lazyTitle'),
932
+ ),
901
933
  react.createElement('div', { className: 'fm-panel__scope' }, t('panel.scope')),
902
934
  )
903
935
  }
package/docs/REFERENCE.md CHANGED
@@ -1,17 +1,19 @@
1
1
  # 参考手册:原理、配置与边界
2
2
 
3
- 本手册描述 **dsh-fast-mode 1.5.34** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.33 面板为五行、五行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
3
+ 本手册描述 **dsh-fast-mode 1.6.0** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.33 面板为五行,1.6.0 起为六行,六行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
4
4
 
5
- **唯一的契约:Fast 模式从不改变思考强度、thinking 预算或模型。** `agent/request` 钩子只做一件事——`await next()`,读一下请求携带的 `reasoningEffort` 记进测量,然后原样返回。用户选的是哪一档(会话里的 UI 选择,或者 profile 里 agent-default-model 固定下来的那一档),每个请求就用哪一档。
5
+ **默认契约:Fast 模式不改变思考强度、thinking 预算或模型。** `agent/request` 钩子默认只做一件事——`await next()`,读一下请求携带的 `reasoningEffort` 记进测量,然后原样返回。用户选的是哪一档(会话里的 UI 选择,或者 profile 里 agent-default-model 固定下来的那一档),每个请求就用哪一档。
6
+
7
+ **唯一的例外是 1.6.0 起逐会话可点的 `lazy`,默认关闭。** 它只在「上一请求流出的思考字符明显超量」时把**下一个请求**降一档(最低 `low`,从不升档,每轮最多两次,工具报错后两步内不降),而且每个请求都从用户当次的选择重新读起,所以下一次请求就恢复;它不改模型、不写 thinking 预算、不加任何 provider 字段,`lazyEffort: false` 时一个字段都不写。见下面"六、`lazy`:想太久就少想一点"。
6
8
 
7
9
  快的部分只来自三件事:更少的往返、更少的冗余输出、更少的卡顿等待。下面逐条说明。
8
10
 
9
11
  ## 交互细节:闪电开关与会话归属
10
12
 
11
13
  - **一个图标就是开关。** 会话输入区的活动槽(`conversation.input.activity`)里只有一个闪电按钮:关闭时空心,开启时实心。点一下切换,再点一下切回;这一下只写当前会话。没有档位选择器,没有箭头,没有"选择 gear"的二级菜单。
12
- - **右键是面板。** 右键闪电打开五行,五行都只影响当前会话:"更快"(`enabled`)、"少写过程"(`outputDiscipline`)、"最多几步"(`stepBudget`,循环 0/15/30)、"先列文件清单"(`workspaceMap`)、"一次读多个文件"(`openBatch`)。五行都把选择写进 `sessions` 列里本会话的条目,不碰共享默认值,下一个回合生效;只读或写入中时整块禁用,写失败回滚。没有条目的会话沿用配置里的默认值(`enabled`/`gear`/`outputDiscipline`/`stepBudget`/`workspaceMap`/`openBatch`)。面板不暴露 `disciplineAfterSteps`、`disciplineEarlyCalls`、`shapeGate`、`finishLineSteps`、`shellParallel`、`disciplineRepeatAtSteps` 这些未证实或已判 HOLD 的旋钮。
13
- - **会话之间互不影响,这是硬约束。** 每个会话的 `enabled`、`gear`、`outputDiscipline`、`stepBudget`、`workspaceMap` 都从 `sessions` 列里自己那一条解析:A 会话 `更快 + 少写过程`、B 会话 `更快 + 不写过程` 同时成立,C 会话没点过就沿用配置默认值。面板和闪电只写本会话的条目,从不写共享默认值;条目最多保留 200 个,按 `ts` 淘汰最旧的,被淘汰的会话回到默认值。
14
- - **按钮只表达一个问题:这个会话开不开。** `aria-pressed` 就是状态,tooltip 写清"仅本会话 · 思考强度不变 · 点击关闭"。为什么快、快在哪里、单次调用上限这类说明放在插件介绍(`meta.description` 与配置项描述)里,不再占用输入区。
14
+ - **右键是面板。** 右键闪电打开六行,六行都只影响当前会话:"更快"(`enabled`)、"少写过程"(`outputDiscipline`)、"最多几步"(`stepBudget`,循环 0/15/30)、"先列文件清单"(`workspaceMap`)、"一次读多个文件"(`openBatch`)、"想太久就少想一点"(`lazy`)。六行都把选择写进 `sessions` 列里本会话的条目,不碰共享默认值,下一个回合生效;只读或写入中时整块禁用,写失败回滚。没有条目的会话沿用配置里的默认值(`enabled`/`gear`/`outputDiscipline`/`stepBudget`/`workspaceMap`/`openBatch`/`lazy`)。面板不暴露 `disciplineAfterSteps`、`disciplineEarlyCalls`、`shapeGate`、`finishLineSteps`、`shellParallel`、`disciplineRepeatAtSteps` 这些未证实或已判 HOLD 的旋钮。
15
+ - **会话之间互不影响,这是硬约束。** 每个会话的 `enabled`、`gear`、`outputDiscipline`、`stepBudget`、`workspaceMap`、`lazy` 都从 `sessions` 列里自己那一条解析:A 会话 `更快 + 少写过程`、B 会话 `更快 + 不写过程` 同时成立,C 会话没点过就沿用配置默认值。面板和闪电只写本会话的条目,从不写共享默认值;条目最多保留 200 个,按 `ts` 淘汰最旧的,被淘汰的会话回到默认值。
16
+ - **按钮只表达一个问题:这个会话开不开。** `aria-pressed` 就是状态,tooltip 写清"仅本会话 · 点击关闭 · 右键设置"。为什么快、快在哪里、单次调用上限这类说明放在插件介绍(`meta.description` 与配置项描述)里,不再占用输入区。
15
17
  - **实时行引用的是被执行的数字**,而不是产品形容词:
16
18
  - 开启、看门狗有上限:`自动调度 · 并行 2–8 · 单次调用 ≤ 2 分`
17
19
  - 开启、看门狗关闭(`stallGuard: false`,或 `toolTimeoutMs` 为负数):`自动调度 · 并行 2–8 · 单次调用不限时`
@@ -29,9 +31,9 @@
29
31
 
30
32
  1. `const resolved = await next()`;
31
33
  2. 读 `resolved.reasoningEffort`、路由名,写进测量标签;
32
- 3. `return resolved`。
34
+ 3. `lazy` 关闭时 `return resolved`,一个字段都不动;`lazy` 打开且 `lazyEffort !== false` 时,只有 `src/lazy.js` 的账本里有证据(上一请求流出的思考字符超过阈值、没有报错冷却、本轮降档次数未用完、当前档在 `low` 之上)才 `return { ...resolved, reasoningEffort: 下一档 }`,否则仍然原样返回。
33
35
 
34
- 它不写 `reasoningEffort`,不写 thinking 预算,不换模型,也不添加任何 provider 专用字段。因此"更快"只剩下三个真实杠杆:并行扇出(减少往返)、输出纪律(减少过程文字)、卡顿看门狗(减少工具等待)。思考深度和解码速度不在其中——前者是用户的契约,后者任何提示词都做不到。
36
+ 除这一个可点的例外,它不写 thinking 预算,不换模型,也不添加任何 provider 专用字段。因此常备的"更快"杠杆仍是三个:并行扇出(减少往返)、输出纪律(减少过程文字)、卡顿看门狗(减少工具等待);`lazy` 是第四个,默认关闭,因为它动的是答案而不只是时延。思考深度和解码速度不在常备杠杆里——前者是用户的契约,后者任何提示词都做不到。
35
37
 
36
38
  ### 二、自适应调度器:`gear: auto`
37
39
 
@@ -136,6 +138,19 @@
136
138
 
137
139
  上限是 200 个会话,超出时淘汰 `ts` 最旧的条目——这是内存与配置文件体积的边界,不是权限模型。host 端的实现(`src/state.js`)和浏览器里的副本(`client.js`)是两份代码,测试用同一组 fixture 锁住它们的读写一致。
138
140
 
141
+ ### 六、`lazy`:想太久就少想一点(1.6.0,默认关闭)
142
+
143
+ Ponytail 那句"最好的代码是没写的代码"在这里只借一半:**先读懂,再省**。这一节是插件里唯一会改动 `reasoningEffort` 的路径,所以它默认关闭、逐会话可点、并且每个请求都从用户当次的选择重新读起。
144
+
145
+ 两个动作,都只在有证据时发生:
146
+
147
+ - **提示侧(`lazy: true` 时始终生效)。** 每轮第一步发一条 notice:先找最省的一级(能不能不做 → 有没有现成 helper → 标准库/平台 → 已装的依赖 → 一行 → 最小实现),确认必须写才写;不许扩范围;不许砍信任边界校验、数据丢失处理、安全与可访问性;结尾用一行说清跳过了什么、风险在哪。文本在 `src/lazy.js` 的 `DIRECTIVE`,`lazyEffort: false` 也照样发。
148
+ - **effort 侧(`lazy: true` 且 `lazyEffort !== false`)。** 账本记两件事:上一步流出的思考字符(`agent/assistant-stream` 的 `reasoning-delta`)与本轮累计。上一步 ≥ 1600 字符,或本轮累计越过 4000 / 8000 字符,就把**下一个请求**降一档(`src/lazy.js` 的 `EFFORT_RUNGS` 顺序,最低到 `low`,从不升档);每轮最多降两次、每步最多降一次(重试不会连降),工具结果报错后两步内不降——报错通常说明该想得更多,不是更少。
149
+
150
+ 为什么不会像 0.9.x 那样把会话"卡"在低档:`agent/request` 每次都从 `resolved.reasoningEffort`(用户当下的选择)重新读基准,降档只作用于它自己那一次返回值,所以用户切回 High 之后的第一个请求就是 High;面板里关掉 `lazy` 同样立刻恢复。测量口径上,`lazyEffort: false` 是"只发文字、一个字段都不写"的对照臂,用来分辨收益来自提示词还是 provider 真的更低档;`agent/assistant-stream` 记的 `reasoningChars` 是第二条独立通道(不依赖 provider 报 `reasoning_tokens`)。两块数字见 `BENCHMARK.md` 的 `LZ1` 块(12 对、36 次运行):「只发文字」臂逐对几何墙钟 **0.389×**(95 % CI [0.252, 0.600],11/12 更快,p = 0.0032),「文字 + 降档」臂 **0.430×**(CI [0.273, 0.677],11/12),推理字符 0.372× / 0.418×,两臂判分 **216/216 全绿**。两臂直接对打 `laz16 / lazt16` = 1.106×(CI 跨 1),所以诚实的结论是:**收益主要来自提示文字那一半;降 `reasoningEffort` 这一半在这条路由上没有测出额外收益,也没测出代价。** 样本是两个短任务族、单机、单路由,别把它读成「所有题都快 2.5 倍」。
151
+
152
+ 不改的东西:模型、`thinking` 预算、`output_config`、provider 专用字段、以及档位词汇本身(路由声明的档位名不认识时原样放过,不猜)。
153
+
139
154
  ## 配置
140
155
 
141
156
  放在 profile 的 `cordis.patch.yml` 里:
@@ -166,6 +181,8 @@
166
181
  | `sessions` | `''` | 插件自动维护的会话级覆盖,见上一节。**不要手改。** |
167
182
  | `outputDiscipline` | `'late'` | 输出纪律。`late`(默认)前 4 步不写任何输出约束,第 4 步起由调度器提醒一次;`true` 全程生效;`false` 关闭输出纪律,仍注入并行与有界调用规则。 |
168
183
  | `openBatch` | `false` | 开局批量:每轮第一步发一次「把已知要做的独立调用放进同一块、shell 命令用 `&&` 串起来」。短任务的钱几乎全是每步固定成本(首字延迟 ~0.5 s/次),少一次往返就省一次;只影响第一轮第一步,长任务可忽略。旧批次里它不优于默认,但那些数字已因测量缺陷撤回(运行目录在仓库内、指标按字节偏移切片),尚无干净重测。保持在 `false`,但 1.5.33 起进面板(「一次读多个文件」),逐会话可开;和 `workspaceMap` 一起开 11 对 0.742×,再加 `disciplineAfterSteps: 2` 三开 22 对 0.669×〔CI 0.554–0.799〕,单独开仍测不到显著。 |
184
+ | `lazy` | `false` | 想太久就少想一点:每轮第一步给一次"先找最省的一级、不扩范围、不砍校验"的提示,并在思考字符明显超量时把下一个请求降一档思考强度(最低 `low`,从不升档,每轮最多两次,报错后两步内不降)。默认关闭:它是唯一会改变答案而不只是时延的开关。1.6.0 起进面板("想太久就少想一点"),逐会话可点。 |
185
+ | `lazyEffort` | `true` | `lazy` 的 effort 半边。设为 `false` 时只发提示文字,一个请求字段都不写——这是分辨"提示词有效"与"provider 真降档"的对照臂。 |
169
186
  | `shapeGate` | `false` | 形状闸门:本轮累计调用 ≥ 4 且其中写入类(不在只读名单里的工具)占比 ≥ 60 % 时,**整轮不再注入任何提醒**,行为退回原生。判断只用已有的工具名,不发请求、不加请求体字段。它给的是「不适合就别管我」的退路:写密集型的任务没有可并行的调用,每条提醒都只是一次它用不上的往返。显式设了 `stepBudget` 时该收尾线优先于闸门——「别加速」不等于「无视我叫停」。默认关,因为它会同时关掉本可以救回长尾的收尾提醒。 |
170
187
  | `preferPatch` | `false` | 整文件写入之后提醒一次「改已有文件时只发改动的行(edit hunk),不要重发整个文件」。本步 `write` 的正文 ≥ 2000 字符才武装,之后一步发一次,每轮最多一次;文本明确要求结果与原文件逐字节相同,不限制写什么、也不影响何时结束。默认关闭:它只作用于模型的输出形式(工具调用参数),正确性由文件内容决定、与发送形式无关;实测是否真省墙钟见 `BENCHMARK.md` 的 `pp16` 臂。 |
171
188
  | `compactRereadHint` | false | 压缩提醒里多一句「本回合写过的文件不要为确认整份重读」;不新增消息。实验旋钮,默认关 |
@@ -193,7 +210,7 @@
193
210
 
194
211
  三条规则让它们真的能一起用:
195
212
 
196
- 1. **不抢同一个字段。** 本插件不碰 `reasoningEffort`、`thinking`、`output_config`,所以没有任何"两个组件同时钳制 effort"的问题。
213
+ 1. **不抢同一个字段。** 本插件默认不碰 `reasoningEffort`、`thinking`、`output_config`;`lazy` 打开时也只在 `agent/request` 的返回值上降一档,不写配置、不写 thinking,压缩器与它没有共享字段——所以没有"两个组件同时钳制 effort"的问题。
197
214
  2. **压缩步骤不需要特殊照顾。** 1.0.0 没有"按住某一步"的概念;历史折叠发生在请求发出之前,本插件看到的只是一个普通请求。
198
215
  3. **摘要调用碰不到。** 压缩器自己的摘要请求往往跑在它自己的进程或 HTTP 层里,不经过 `agent/request`,本插件既不影响它,也不被它影响。
199
216
 
@@ -233,12 +250,12 @@
233
250
 
234
251
  **完全支持,而且不需要任何配置。** 官方 DeepSeek API(`deepseek-official`)、官方 API 前面挂的代理和中转、自建网关(如 `litellm` + Anthropic Messages)、OpenAI 兼容端点,行为完全一样,原因很简单:
235
252
 
236
- - 请求体原样进出。本插件不写 `reasoningEffort`、不写 `thinking`、不写 `output_config`,也不添加任何 provider 专用字段;
253
+ - 请求体原样进出。本插件默认不写 `reasoningEffort`、不写 `thinking`、不写 `output_config`,也不添加任何 provider 专用字段;`lazy` 打开时 `reasoningEffort` 只会被换成一个该路由已经在用的更省档位(不认识当前档位就原样放过),路由不报 reasoning token 也不影响提示侧照常工作;
237
254
  - 不做"这条路由声明了哪些档位"的过滤和降档。没有 `floor`,没有档位阶梯,没有按步降档;
238
255
  - 路由名只被记进测量和调试日志,不参与任何决策;
239
256
  - 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。
240
257
 
241
- 所以无论 provider 声明的是 `off/low/high/max`、还是 `minimal/low/medium/high`、还是只有一档、还是什么都没声明,本插件的行为都一样:它注入提示段、给 shell 调用设上限,然后把你选的 effort 原样发出去。
258
+ 所以无论 provider 声明的是 `off/low/high/max`、还是 `minimal/low/medium/high`、还是只有一档、还是什么都没声明,本插件的行为都一样:它注入提示段、给 shell 调用设上限,然后把你选的 effort 原样发出去(`lazy` 关闭时;打开时只在此前确实想太久的那一步之后降一档,见「六、`lazy`」)。
242
259
 
243
260
  ## 测量、实验与报告
244
261
 
@@ -254,7 +271,8 @@
254
271
  | `ts` | 这一步结束的 ISO 时间 |
255
272
  | `sid` / `turn` / `step` | 会话 id、轮次、步号 |
256
273
  | `mode` / `gear` | `on` 或 `off`;关闭时 gear 记为 `off` |
257
- | `effort` | **请求实际携带的思考强度,原样记录,不做任何改写**——这是"插件没有动 effort"的证据 |
274
+ | `effort` | **请求实际携带的思考强度**。默认就是你在 UI 里选的那档,一字不改;`lazy` 降档时这里是降后的值 |
275
+ | `effortBase` | 仅在 `lazy` 降档时出现:用户原本选的那档。和 `effort` 的差就是降档证据 |
258
276
  | `route` | 路由/模型的标签,仅用于分组 |
259
277
  | `ttftMs` | 首字延迟:步开始到第一条 assistant 消息 |
260
278
  | `llmMs` | 模型耗时:步开始到最后一条 assistant 消息 |
@@ -264,7 +282,7 @@
264
282
  | `cacheRead` / `cacheWrite` | 上游 usage 的缓存读写 token |
265
283
  | `assistantMessages` | 本步的 assistant 消息数 |
266
284
 
267
- `effort` 这一列是理解 1.0.0 的关键:它记录的是请求**实际带出去的**值。如果你在 UI 里选了 `high`,每一行就是 `high`;插件不会把它改成别的。
285
+ `effort` 这一列是理解请求路径的关键:它记录的是请求**实际带出去的**值。默认情况下它就是你在 UI 里选的那档(选了 `high`,每一行就是 `high`);只有开了 `lazy` 且那一步确实想太久时,它才会变成降后的值,同时 `effortBase` 记下你原本的选择。两列同时出现就是「降档发生过」的机器证据。
268
286
 
269
287
  ### 每个会话自己的开关
270
288
 
@@ -331,7 +349,7 @@ dsh-fast-mode-experiment stop # 结束实验
331
349
  ## 安全边界
332
350
 
333
351
  - **只观察,不改写。** `tools/pre-execute` / `tools/post-execute` 是纯观察者(记录开始时间和调用结果),`agent/request` 原样返回请求。除了注入提示段和两条 producer-owned notice(压缩后提醒、调度指令),插件不往会话里写任何东西。
334
- - **模型与 effort 不可写。** 源码里没有对 `reasoningEffort` 的赋值;测试会扫描源码,防止将来有人把它加回来。
352
+ - **模型不可写,effort 只有一处可写。** 源码里只有 `src/lazy.js` 的降档路径会写 `reasoningEffort`(`agent/request` 返回值上的一个字段,`lazy` 默认关闭);`test/effort.test.js` 扫描源码,要求这个赋值恰好一处、形状固定,并且仍然禁止 `thinking:` 与 0.9.x 的 `decideEffort`。
335
353
  - **设置只能由这次点击改,且带 revision。** 没有后台静默改配置的路径;冲突写入会失败并回滚,原因是 tooltip。
336
354
  - **计量是本地的。** 只写 `metricsFile`;`--lan` 是唯一的出网路径,只读,需要显式 token。
337
355
  - **看门狗只改 signal。** 调用方原来的 signal 在 `finally` 里恢复;取消后调用体的 rejection 被吞掉,不会变成未处理的拒绝。
@@ -364,13 +382,14 @@ dsh-fast-mode-experiment stop # 结束实验
364
382
  - `src/guidance.js`:模型可见的提示文本;
365
383
  - `src/stallguard.js`:看门狗与预算优先级;
366
384
  - `src/effort.js`:档位表与旧名字归一化(不再有 effort 阶梯);
385
+ - `src/lazy.js`:`lazy` 的提示文本、思考字符账本与降一档的判定;
367
386
  - `src/config.js`:schema 与表单字段;
368
387
  - `src/state.js`:`sessions` 列的解析与编码;
369
388
  - `src/notices.js`:producer-owned notice 的构造与来源校验;
370
389
  - `src/metrics.js`:JSONL 计量与汇总;
371
390
  - `src/benchmark.js`:按会话哈希分配;
372
391
  - `src/peers.js`:冲突插件探测;
373
- - `client.js`:闪电开关、右键面板(五行,按会话写入 `sessions` 列)。
392
+ - `client.js`:闪电开关、右键面板(六行,按会话写入 `sessions` 列)。
374
393
 
375
394
  跑测试:
376
395
 
@@ -378,6 +397,6 @@ dsh-fast-mode-experiment stop # 结束实验
378
397
  node --test test/*.test.js
379
398
  ```
380
399
 
381
- 当前 223 个测试在 Node 20.20.2、22.23.4 与 24 上全部通过(每个版本 223/223,0 失败)。测试覆盖:调度器宽度与指令、预算优先级、提示段的开关条件、`sessions` 列在 host 与浏览器两份实现间的一致性、notice 来源校验、计量字段、分配哈希、报告与实验 CLI 的参数解析,以及一条扫描源码、确认没有改写 `reasoningEffort` 的回归测试。
400
+ 当前 336 个测试在 Node 24.16.0 上全部通过(336/336,0 失败)。测试覆盖:调度器宽度与指令、预算优先级、提示段的开关条件、`sessions` 列在 host 与浏览器两份实现间的一致性、notice 来源校验、计量字段、分配哈希、报告与实验 CLI 的参数解析,`lazy` 的账本与降档边界(15 条单元测试)、以及一条扫描源码、确认 `reasoningEffort` 只有那一处受控赋值的回归测试。
382
401
 
383
402
  文档入口:`README.md`(中文)、`README.en.md`(English)、`BENCHMARK.md`(测量方法与历史数据)、`CHANGELOG.md`(每个版本删了什么、加了什么)。