dsh-fast-mode 1.5.35 → 1.6.1

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.1(1.6.0 的补包:npm 页面上那句「从不改 reasoningEffort」在 1.6.0 之后不准确了)
4
+
5
+ - 1.6.0 加了 `lazy`(默认关闭,唯一会改 `reasoningEffort` 的路径),但 `package.json` 的 `description` 还是旧话「Never changes reasoning effort, the model, or the request body」,npm 首页会照抄这句。这一版只改这一句:**默认**不改推理强度、模型与请求体,例外是默认关闭的 `lazy`(想太久时降一档)。行为、默认值、测试零改动(336 全绿)。
6
+
7
+ ## 1.6.0(一个新开关 `lazy`「想太久就少想一点」:动态收思考强度 + 压过度思考,默认关闭;12 对 36 次运行实测 0.39–0.43×,判分 216/216 全绿)
8
+
9
+ 按这一轮的要求,只开**一个**新开关,不动任何默认值。它的理念照抄 [ponytail](https://github.com/dietrichgebert/ponytail) 的核心:先读懂再动手,然后从「最小能work的那一级」开始做,不为想象中的未来写代码;但**读完之前不许偷懒**,也不许省掉信任边界校验、安全、数据丢失处理、可访问性。`lazy` 就是把这条用在「思考」上:不是让它不读、不想,而是想够了就停。
10
+
11
+ - **新开关 `lazy`,出现在面板第六行「想太久就少想一点」。** 默认 `false`,逐会话写入 `sessions` 列(`src/state.js` 的 `configOverridesFor` 映射),只影响本会话。它是本插件**唯一**一个会改变答案而不只是时延的开关,所以默认关、tooltip 也只说后果(「想太久时先按已有信息定下去,安全校验照旧」),不说「更快」。
12
+ - **两半机制,可分开验证。** 提示侧:入场步在对话里放一段 `src/lazy.js` 的梯子文本(先缩小范围 → 复用现成实现 → 标准库/平台能力 → 已装依赖 → 一行 → 最小实现;并把「绝不省略」的四类写清楚),每轮一次;同一轮里推理字符超过 `HOT_CHARS`(1600)就补一条 nudge(最多 2 条,间隔按轮内累计 4000/8000 字符)。字段侧:那一步确实想太久时,把**下一次**请求的 `reasoningEffort` 降一档(`EFFORT_RUNGS` 里往 `low` 方向走,地板 `low`,从不升档),每步最多一次、每轮最多两次,工具报错后两步内不降。`lazyEffort: false` 只发文字、一个请求字段都不写,是这一块的对照臂。
13
+ - **不会重演 0.9.x 把会话钉在低档的事故。** 0.9.x 的问题是改写值活过了它被授权的那个回合:值进了请求头,面板从请求头投影「上次用的强度」,于是一个本来跑在中档的会话会漂到低档并跨重启保留。这一版的基准值是**每个请求现读**的 `resolved.reasoningEffort`(用户此刻的选择),降档只写进这一个请求的返回值,没有任何跨请求/跨会话状态;用户中途换档,下一次就是用户的档。测试里有专门的源码扫描断言:全仓只允许这一处 `reasoningEffort:`,形状是 `planned.reduced ? { ...resolved, reasoningEffort: applied } : resolved`,且不允许出现 `thinking: {` 或任何 `decideEffort`。
14
+ - **测量补齐两个此前拿不到的通道。** `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`,不会把它归因到后来每一步。
15
+ - **实测(`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`,不做成「双保险都有效」。
16
+ - **没有动的东西:** 默认值一个没改(`enabled: false` 仍是出厂态)、`disciplineAfterSteps` 仍是 4、`d2`/`d2g`/`dr8` 仍然 HOLD、没有新增任何提醒时机或预算旋钮。外部评审建议「先做稳定性、别加旋钮」,这一轮只在用户明确要求下加这一个开关,其余仍是稳定性与测量。
17
+ - 测试 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`。
18
+
3
19
  ## 1.5.35(1.5.34 的补包:把 `bench/soak.mjs` 打进 npm 包)
4
20
 
5
21
  - 1.5.34 的 `BENCHMARK.md` 让人「自己跑一遍浸泡」,但 `bench/soak.mjs` 没在 `package.json` 的 `files` 里——装了包的人照着跑会发现文件不存在。这一版只补这一行 `files`(自包含:只 import `../index.js`),行为零改动,测试仍 313 全绿。
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/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.35** 的实际行为(默认配置下的注入行为与 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`(每个版本删了什么、加了什么)。
package/index.js CHANGED
@@ -85,6 +85,7 @@ import { createRecorder } from './src/metrics.js'
85
85
  import { readOutputTokens } from './src/usage.js'
86
86
  import { configOverridesFor } from './src/state.js'
87
87
  import { buildShellParallelTool } from './src/parallel.js'
88
+ import { beginStep as lazyBeginStep, createLazyWatch, DIRECTIVE_SUMMARY as LAZY_SUMMARY, NUDGE_SUMMARY as LAZY_NUDGE_SUMMARY, noteReasoning as lazyNoteReasoning, noteToolResult as lazyNoteToolResult, planEffort as lazyPlanEffort } from './src/lazy.js'
88
89
 
89
90
  /**
90
91
  * The settings-page schema. Re-exported from here because the Loader reads
@@ -98,7 +99,7 @@ const TAG = '[fast-mode]'
98
99
  * Shipped identity of this build. Traced into the debug log so a stale install is
99
100
  * visible at a glance, and pinned by a test against `package.json`.
100
101
  */
101
- export const VERSION = '1.5.35'
102
+ export const VERSION = '1.6.1'
102
103
  const GLOBAL_AGENT = '__global__'
103
104
 
104
105
  /**
@@ -140,6 +141,26 @@ function latestUserText(messages) {
140
141
  return ''
141
142
  }
142
143
 
144
+ /**
145
+ * How many messages in this turn came from the human. Plugin notices are also
146
+ * `role: 'user'`, so a bare role count would move every time this plugin spoke
147
+ * and every turn would look new.
148
+ *
149
+ * @param {unknown} messages
150
+ * @returns {number}
151
+ */
152
+ function claimedUserCount(messages) {
153
+ if (!Array.isArray(messages)) return 0
154
+ let count = 0
155
+ for (const m of messages) {
156
+ if (!m || m.role !== 'user') continue
157
+ const source = m.source
158
+ if (source && typeof source === 'object' && source.form === 'notice') continue
159
+ count += 1
160
+ }
161
+ return count
162
+ }
163
+
143
164
  function insertAfterClaimed(messages, claimed, notices) {
144
165
  const list = Array.isArray(messages) ? messages : []
145
166
  if (notices.length === 0) return list
@@ -189,6 +210,18 @@ function routeOf(resolved) {
189
210
  return typeof provider === 'string' && typeof model === 'string' ? `${provider}/${model}` : 'unknown'
190
211
  }
191
212
 
213
+ /**
214
+ * The effort label a journal row carries: the resolved value when it is a
215
+ * string, otherwise `undefined`. Kept separate from the value itself so a route
216
+ * that hands back a non-string can never be stringified into a fake rung.
217
+ *
218
+ * @param {unknown} value
219
+ * @returns {string | undefined}
220
+ */
221
+ function summaryEffort(value) {
222
+ return typeof value === 'string' ? value : undefined
223
+ }
224
+
192
225
  /**
193
226
  * Log, once per load, whether this entry's namespace is actually served by the
194
227
  * `settings` service — the fact the chip's state depends on.
@@ -396,6 +429,11 @@ export function apply(ctx, rawConfig) {
396
429
  // The scheduler's memory, one entry per conversation: what has been tried, how
397
430
  // long calls have been taking, and how many steps went by without a new result.
398
431
  const auto = createAutoState()
432
+ const lazyWatch = createLazyWatch()
433
+ // Which human turn each session is in. `agent/pre-step` carries no turn number,
434
+ // so the count of claimed (non-plugin) user messages is the boundary: it moves
435
+ // exactly when the user speaks again.
436
+ const lazyTurnKey = new Map()
399
437
  /**
400
438
  * Live stall ceilings per conversation, as computed by the scheduler.
401
439
  * Bounded like `compacted`: a long-lived host process sees thousands of
@@ -481,13 +519,27 @@ export function apply(ctx, rawConfig) {
481
519
  // `attemptId` whose prefix is the session. They carry no turn or step, so the
482
520
  // recorder attributes them to the step that session has open.
483
521
  ctx.on('agent/assistant-stream', (payload) => {
484
- if (!metrics.enabled) return
485
522
  const frame = payload && payload.frame
486
523
  if (!frame || frame.type !== 'chunk' || typeof frame.attemptId !== 'string') return
487
524
  const cut = frame.attemptId.lastIndexOf(':')
488
525
  if (cut <= 0) return
526
+ const sid = frame.attemptId.slice(0, cut)
489
527
  try {
490
- metrics.record({ id: frame.attemptId.slice(0, cut) }, {
528
+ // Lazy mode reads the same stream, and must keep reading when the journal
529
+ // is off: the effort step-down is decided from how much the route actually
530
+ // streamed, not from a usage field this route reports as zero.
531
+ if (lazyWatch.runs.has(sid)) {
532
+ const chunk = frame.chunk
533
+ const kind = chunk && typeof chunk === 'object' ? chunk.type : undefined
534
+ if (kind === 'reasoning-delta') {
535
+ const text = typeof chunk.text === 'string'
536
+ ? chunk.text
537
+ : typeof chunk.delta === 'string' ? chunk.delta : ''
538
+ lazyNoteReasoning(lazyWatch, sid, text.length)
539
+ }
540
+ }
541
+ if (!metrics.enabled) return
542
+ metrics.record({ id: sid }, {
491
543
  type: 'assistant/chunk',
492
544
  time: frame.time,
493
545
  data: { chunk: frame.chunk },
@@ -568,6 +620,25 @@ export function apply(ctx, rawConfig) {
568
620
  }
569
621
  }
570
622
  }
623
+ if (cfg.lazy === true) {
624
+ // Lazy mode's two prompt-side mechanisms: the ladder text on the first
625
+ // step of a turn, and at most two nudges when a turn's streamed reasoning
626
+ // passes its threshold. The tail of a long turn is where over-thinking
627
+ // costs the most and where a reminder changes the next step rather than
628
+ // the one already paid for.
629
+ const key = `${sid}\u0000${claimedUserCount(payload?.messages)}`
630
+ const fresh = lazyTurnKey.get(sid) !== key
631
+ if (fresh) {
632
+ lazyTurnKey.set(sid, key)
633
+ trim(lazyTurnKey, 200)
634
+ }
635
+ const plan = lazyBeginStep(lazyWatch, sid, { firstStep: fresh })
636
+ if (plan && plan.directive) notices.push(createNoticeMessage(plan.directive, LAZY_SUMMARY))
637
+ if (plan && plan.nudge) notices.push(createNoticeMessage(plan.nudge, LAZY_NUDGE_SUMMARY))
638
+ if (plan && initial.debug) {
639
+ appendDebug(initial.debugFile, `lazy v${VERSION}: turn=${key.split('\u0000').pop()} directive=${plan.directive ? 'yes' : 'no'} nudge=${plan.nudge ? 'yes' : 'no'}`)
640
+ }
641
+ }
571
642
  if (cfg.workspaceMap === true) {
572
643
  // One inventory per session, before the model spends a step finding out
573
644
  // what is in the directory (35.7 % of the recorded calls were exactly
@@ -714,6 +785,9 @@ export function apply(ctx, rawConfig) {
714
785
  // The scheduler reads the same observation: a call that returns the same
715
786
  // bytes for the same arguments is the raw material of the long tail.
716
787
  noteTool(auto, agent, observation)
788
+ // An error result is the one signal that the model may need more room to
789
+ // think, so it cancels any pending effort step-down for a couple of steps.
790
+ lazyNoteToolResult(lazyWatch, agent, observation)
717
791
  }
718
792
  } catch {
719
793
  // Observation is best-effort. A failure here must not affect the tool
@@ -729,30 +803,44 @@ export function apply(ctx, rawConfig) {
729
803
  const sid = agentKey(payload?.agent)
730
804
  const agent = sid === GLOBAL_AGENT ? undefined : sid
731
805
  const cfg = configFor(agent)
732
- const effort = resolved?.reasoningEffort
806
+ const baseEffort = resolved?.reasoningEffort
733
807
  const route = routeOf(resolved)
734
- const label = effort === undefined ? undefined : String(effort)
735
-
736
- // Fast mode does not touch the thinking budget, on purpose. The route and the
737
- // effort are the user's contract with the model: a mode that quietly thinks
738
- // less produces a different answer, not a faster one, and the measured public
739
- // benchmarks showed the effort ladder bought no wall-clock on this route
740
- // anyway (0 reasoning tokens in every arm). What is left is the part that
741
- // makes the same thinking finish sooner — wider steps, bounded calls, and a
742
- // breaker for the sessions that only look busy.
808
+
809
+ // The rule this plugin kept for five releases was "never touch the thinking
810
+ // budget", and it is still the rule unless the user turns on the one switch
811
+ // that says otherwise. See `src/lazy.js`: it lowers effort only after a step
812
+ // visibly over-thought, never below the user's own floor, twice a turn at
813
+ // most, and it restores the resolved value on the very next request — the
814
+ // 0.9.x damage was that a rewrite outlived the turn that justified it.
743
815
  if (!cfg.enabled) {
744
- metrics.label(sid, { mode: 'off', gear: 'off', effort: label, route })
816
+ metrics.label(sid, { mode: 'off', gear: 'off', effort: summaryEffort(baseEffort), effortBase: null, route })
745
817
  return resolved
746
818
  }
747
819
 
748
820
  const schedule = cfg.gear === AUTO ? autoSchedule(auto, sid, { emit: false }) : undefined
749
821
  checkParallelCap(cfg, schedule?.parallel)
750
- metrics.label(sid, { mode: 'on', gear: cfg.gear, effort: label, route })
822
+ const planned = cfg.lazy === true && cfg.lazyEffort === true
823
+ ? lazyPlanEffort(lazyWatch, sid, baseEffort)
824
+ : undefined
825
+ const applied = planned && planned.reduced ? planned.effort : baseEffort
826
+ // The journal keeps both halves in one call: `effort` is what the request
827
+ // carried, `effortBase` is what the user chose, so a comparison can tell a
828
+ // lazy step-down from a session that was simply started at a lower rung.
829
+ // `null`, not omitted, on a step that did not step down — otherwise the
830
+ // previous reduction would leak into every following row.
831
+ metrics.label(sid, {
832
+ mode: 'on',
833
+ gear: cfg.gear,
834
+ effort: summaryEffort(applied),
835
+ effortBase: planned && planned.reduced ? summaryEffort(baseEffort) : null,
836
+ route,
837
+ })
751
838
 
752
839
  if (cfg.debug) {
753
840
  appendDebug(
754
841
  cfg.debugFile,
755
- `turn=${payload?.turn} step=${payload?.step} gear=${cfg.gear} effort=${String(label)} (unchanged)`
842
+ `turn=${payload?.turn} step=${payload?.step} gear=${cfg.gear} effort=${String(summaryEffort(baseEffort))}`
843
+ + (planned && planned.reduced ? `->${String(applied)} (lazy step-down)` : ' (unchanged)')
756
844
  + ` route=${route}`
757
845
  + (schedule
758
846
  ? ` parallel=${schedule.parallel} budget=${schedule.stallBudgetMs}ms`
@@ -762,17 +850,18 @@ export function apply(ctx, rawConfig) {
762
850
  )
763
851
  }
764
852
 
765
- return resolved
853
+ return planned && planned.reduced ? { ...resolved, reasoningEffort: applied } : resolved
766
854
  })
767
855
 
768
856
  ctx.logger?.info?.(
769
- '%s v%s loaded: enabled=%s gear=%s parallel=%d adaptive=%s reasoningEffort=untouched metrics=%s',
857
+ '%s v%s loaded: enabled=%s gear=%s parallel=%d adaptive=%s reasoningEffort=%s metrics=%s',
770
858
  TAG,
771
859
  VERSION,
772
860
  initial.enabled,
773
861
  initial.gear,
774
862
  initial.parallel,
775
863
  initial.adaptive === true,
864
+ initial.lazy === true ? (initial.lazyEffort === false ? 'lazy-text-only' : 'lazy') : 'untouched',
776
865
  metrics.enabled ? metrics.file : 'off',
777
866
  )
778
867
  if (initial.legacyGear) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.35",
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).",
3
+ "version": "1.6.1",
4
+ "description": "DeepSeek Harness fast mode: fewer round trips at the same thinking effort. Adaptive parallel tool fan-out, output discipline that never trims the answer, a cancellable stall guard, and one off-by-default switch (lazy) that steps a stalled turn one rung down. By default it never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/regrevia/dsh-fast-mode.git"
package/src/config.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * why the on/off switch and the gear selector exist there without a line of
11
11
  * client code.
12
12
  *
13
- * The seven form fields (`enabled`, `gear`, `parallel`, `sessions`,
13
+ * The form fields (`enabled`, `gear`, `parallel`, `sessions`,
14
14
  * `outputDiscipline`, `stepBudget`, `workspaceMap`) are `.volatile()`: that is
15
15
  * what makes them appear in the generated form *and* what lets an edit reach a
16
16
  * running fiber instead of forcing a remount. A non-volatile declaration is
@@ -18,7 +18,7 @@
18
18
  * keeps only volatile nodes, and `update()` refuses the path with "is not
19
19
  * volatile".
20
20
  *
21
- * The panel's three rows are volatile for exactly that reason: the client half
21
+ * The panel's rows are volatile for exactly that reason: the client half
22
22
  * has one channel to read state from, `describe()`, so a non-volatile field is a
23
23
  * row it can write into `sessions` but never paint — it would keep showing the
24
24
  * built-in default (`late` / 0 / off) while a hand-written `cordis.patch.yml`
@@ -39,9 +39,10 @@
39
39
  * the form.
40
40
  *
41
41
  * `enabled` defaults to **false** on purpose: opt-in is what makes "off means
42
- * completely off" verifiable. Nothing here can change the model's thinking
43
- * effort — the old `floor` / `ladder` / `aliases` knobs are gone, along with the
44
- * code that read them.
42
+ * completely off" verifiable. The old `floor` / `ladder` / `aliases` knobs are
43
+ * gone, along with the code that read them; the one field that can still move
44
+ * effort is `lazy`, and it is off by default because it can change the answer,
45
+ * not only the timing.
45
46
  *
46
47
  * @module dsh-fast-mode/config
47
48
  */
@@ -160,6 +161,21 @@ export const Config = z.object({
160
161
  '开局批量 / Opening batch(面板名「一次读多个文件」): 每轮第一步提醒一次「把已知要做的独立调用放进同一块、shell 命令用 && 串起来」。短任务的钱几乎全花在每步固定成本(首字延迟 ~0.5 s/次)上,少一次往返就省一次;只发一次,不改后续调度。面板可逐会话开;和 `workspaceMap` 一起开时实测 0.742×(11 对),单独开测不到显著,所以默认仍是 false。',
161
162
  ),
162
163
 
164
+ lazy: z
165
+ .boolean()
166
+ .default(false)
167
+ .volatile()
168
+ .description(
169
+ '想太久就少想一点 / Think less when it stalls(面板「想太久就少想一点」): 每轮第一步给一次「先找最省的一级、不扩范围、不砍信任边界校验/数据丢失处理/安全/可访问性」的提示;当上一步流出的思考字符 ≥ 1600、或本轮累计超过 4000/8000 时,把下一个请求的思考强度降一档(最低 low、从不升档、每轮最多两次、工具报错后两步内不降)。每个请求都从用户当下的选择重新读基准,所以下一次请求就恢复。默认关闭:它是唯一会改变答案而不只是时延的开关。',
170
+ ),
171
+
172
+ lazyEffort: z
173
+ .boolean()
174
+ .default(true)
175
+ .description(
176
+ 'lazy 的思考强度半边 / Lazy effort step-down: 设为 false 时 lazy 只发提示文字,一个请求字段都不写。对照臂,用来分辨收益来自提示词还是 provider 真的更低档;默认 true。',
177
+ ),
178
+
163
179
  preferPatch: z
164
180
  .boolean()
165
181
  .default(false)
package/src/effort.js CHANGED
@@ -276,6 +276,15 @@ export function resolveConfig(raw, overrides) {
276
276
  finishLineSteps: gear.directive
277
277
  ? Math.max(0, Math.floor(number(config.finishLineSteps, 0)))
278
278
  : 0,
279
+ /**
280
+ * Lazy mode: the one switch that may cut deliberation instead of only round
281
+ * trips. Off by default, because it changes what the model thinks about and
282
+ * every other switch keeps the "same depth, sooner" promise. `lazyEffort:
283
+ * false` keeps the ladder text and the nudge but never rewrites effort, so
284
+ * the two halves can be measured apart.
285
+ */
286
+ lazy: gear.directive && config.lazy === true,
287
+ lazyEffort: gear.directive && config.lazy === true && config.lazyEffort !== false,
279
288
  /**
280
289
  * The stall guard, the one *hard* bound this plugin owns: it cancels a single
281
290
  * tool call at its deadline. On by default; `false` or a negative
package/src/lazy.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Lazy mode: the one switch in this plugin that is allowed to touch how much the
3
+ * model deliberates.
4
+ *
5
+ * Everything else here buys speed while holding effort still — the promise is
6
+ * "same depth, sooner". This module is the deliberate exception, and it is the
7
+ * only place in the package that rewrites `reasoningEffort`. It exists because
8
+ * a long turn loses time in a way parallelism cannot fix: the model keeps
9
+ * expanding a problem it has already understood. The idea is borrowed from
10
+ * `ponytail` — a lazy senior developer solves the whole problem with the least
11
+ * new code, and is lazy about the solution but never about the reading.
12
+ *
13
+ * Three mechanisms, cheapest first:
14
+ *
15
+ * 1. **The ladder, in the prompt.** One short text asks for the first rung that
16
+ * works and a one-line "what I skipped". Cost is input tokens once per turn;
17
+ * it changes nothing about the harness.
18
+ * 2. **The nudge.** When a turn's streamed reasoning passes a threshold without
19
+ * a new tool result, one notice says so. It is observation, not policy: it
20
+ * repeats what the transcript already proves.
21
+ * 3. **The effort step-down.** Only when a step visibly over-thought (the
22
+ * streamed reasoner block is large) does the next request ask for one rung
23
+ * less. It never goes below `FLOOR` (the user's own floor, `low`), never
24
+ * fires twice in a row after an error, and is capped per turn.
25
+ *
26
+ * What is never cut, in this module or its text: validation at a trust boundary,
27
+ * error handling that protects data, security, accessibility, and the
28
+ * deliverable itself. `DIRECTIVE` says so explicitly, because a model told
29
+ * "write less" drops the wrong things first.
30
+ *
31
+ * ## Why the step-down keeps its own ledger
32
+ *
33
+ * The 0.9.x release lowered effort from step two of *every* turn. Measured
34
+ * later, that bought no wall clock on this route and did real damage: the
35
+ * rewrite reached the provider as a request header, so the composer showed an
36
+ * effort the user never chose, and it stayed there across restarts. So this
37
+ * module lowers effort only after evidence, restores the resolved value on the
38
+ * next request, and is off unless `lazy: true`.
39
+ */
40
+
41
+ /** The prompt section. Short on purpose: it is paid on every request of a turn. */
42
+ export const DIRECTIVE = `Work like a lazy senior developer: the best step is the one not taken.
43
+
44
+ - Read before deciding. Trace the real flow of the code the change touches, then decide. Being lazy about the solution never means being lazy about the reading.
45
+ - Once a step fully does the job, stop planning it. No option, abstraction, or "for later" code nobody asked for.
46
+ - Take the first rung that works: what this repo already has, then the platform or a tool you already hold, then one line, then the minimum that works.
47
+ - Never cut these to save code or time: validation where input crosses a trust boundary, error handling that protects data, security, accessibility, and the deliverable the user asked for.
48
+ - End the turn with one line: what you skipped, and the risk that carries.`
49
+
50
+ const DIRECTIVE_SUMMARY = 'lazy mode: first rung that works, no scope beyond the task'
51
+
52
+ /** Reasoning rungs a route may name, weakest first. */
53
+ export const EFFORT_RUNGS = Object.freeze(['minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
54
+
55
+ /**
56
+ * The lowest rung this module will ask for. Effort is the user's contract; a
57
+ * watchdog may spend a little of the headroom a high setting leaves, and stops
58
+ * where the default would have been.
59
+ */
60
+ const FLOOR = 'low'
61
+
62
+ /**
63
+ * Streamed reasoning characters in one step that count as over-thinking. On the
64
+ * measured route a normal step streams a few hundred characters and a step stuck
65
+ * on scope streams thousands, so this is deliberately not a tight bound.
66
+ */
67
+ const HOT_CHARS = 1600
68
+
69
+ /** Reasoning characters a turn may spend before the first nudge. */
70
+ const NUDGE_CHARS = 4000
71
+
72
+ /** How many notices one turn may receive. Two: one early, one late. */
73
+ const MAX_NUDGES = 2
74
+
75
+ /** How many effort reductions one turn may use. */
76
+ const MAX_REDUCTIONS = 2
77
+
78
+ /** Steps a reduction is off limits for after an error result (the step it fired on plus two). */
79
+ const ERROR_COOLDOWN = 2
80
+
81
+ /**
82
+ * The next rung down, or `undefined` when the value is not a rung this module
83
+ * understands (a route may use its own vocabulary — never guess at it) or is
84
+ * already at `FLOOR`.
85
+ *
86
+ * @param {string} value
87
+ * @returns {string | undefined}
88
+ */
89
+ export function cheaperEffort(value) {
90
+ const at = EFFORT_RUNGS.indexOf(value)
91
+ if (at <= 0) return undefined
92
+ const floor = EFFORT_RUNGS.indexOf(FLOOR)
93
+ if (at <= floor) return undefined
94
+ return EFFORT_RUNGS[at - 1]
95
+ }
96
+
97
+ /** Per-session bookkeeping; one turn at a time is all a session runs. */
98
+ const newTurn = () => ({
99
+ step: 0,
100
+ hot: false,
101
+ stepChars: 0,
102
+ turnChars: 0,
103
+ nudges: 0,
104
+ reductions: 0,
105
+ coolUntil: 0,
106
+ nudgedAt: 0,
107
+ reducedStep: -1,
108
+ })
109
+
110
+ /**
111
+ * Create the per-session ledger. Pure and dependency-free so the policy can be
112
+ * tested without a harness.
113
+ */
114
+ export function createLazyWatch() {
115
+ return { runs: new Map() }
116
+ }
117
+
118
+ /** @param {{runs: Map<string, any>}} watch @param {string} sid */
119
+ const runOf = (watch, sid) => {
120
+ let run = watch.runs.get(sid)
121
+ if (run === undefined) {
122
+ run = newTurn()
123
+ watch.runs.set(sid, run)
124
+ if (watch.runs.size > 64) watch.runs.delete(watch.runs.keys().next().value)
125
+ }
126
+ return run
127
+ }
128
+
129
+ /**
130
+ * Count streamed reasoning characters. Called from the stream tap, i.e. before
131
+ * the step ends, which is what makes the effort decision possible at all: the
132
+ * decision has to be made for the *next* request.
133
+ *
134
+ * @param {{runs: Map<string, any>}} watch @param {string} sid @param {number} chars
135
+ */
136
+ export function noteReasoning(watch, sid, chars) {
137
+ if (!Number.isFinite(chars) || chars <= 0) return
138
+ const run = runOf(watch, sid)
139
+ run.stepChars += chars
140
+ run.turnChars += chars
141
+ }
142
+
143
+ /**
144
+ * Open a step: close the previous one's verdict, and say whether this step is
145
+ * the one that carries the ladder text.
146
+ *
147
+ * @param {{runs: Map<string, any>}} watch @param {string} sid
148
+ * @param {{turn?: number, step?: number, firstStep?: boolean}} where
149
+ * @returns {{directive?: string, nudge?: string} | undefined}
150
+ */
151
+ export function beginStep(watch, sid, where) {
152
+ const run = runOf(watch, sid)
153
+ const step = Number.isFinite(where?.step) ? Math.floor(where.step) : run.step + 1
154
+ const newTurnStart = where?.firstStep === true || step <= 1
155
+ if (newTurnStart) {
156
+ const fresh = newTurn()
157
+ fresh.step = step
158
+ watch.runs.set(sid, fresh)
159
+ return { directive: DIRECTIVE }
160
+ }
161
+ // The previous step's streamed volume is the evidence the next request acts on.
162
+ run.hot = run.stepChars >= HOT_CHARS
163
+ run.stepChars = 0
164
+ run.step = step
165
+ const plan = {}
166
+ if (run.nudges < MAX_NUDGES && run.turnChars >= NUDGE_CHARS * (run.nudges + 1) && run.turnChars > run.nudgedAt) {
167
+ run.nudges += 1
168
+ run.nudgedAt = run.turnChars
169
+ run.hot = true
170
+ plan.nudge = `You have streamed about ${run.turnChars} characters of reasoning this turn. Use what you already have: decide, take the next rung, and say in one line what you skipped. Do not open a new line of investigation unless the last result contradicted an assumption.`
171
+ }
172
+ return Object.keys(plan).length === 0 ? undefined : plan
173
+ }
174
+
175
+ /**
176
+ * A tool result came back. An error is the one signal that the model may need
177
+ * *more* thinking, so it cancels any pending step-down for a couple of steps.
178
+ *
179
+ * @param {{runs: Map<string, any>}} watch @param {string} sid @param {{isError?: boolean}} observation
180
+ */
181
+ export function noteToolResult(watch, sid, observation) {
182
+ if (observation?.isError !== true) return
183
+ const run = runOf(watch, sid)
184
+ run.hot = false
185
+ run.coolUntil = run.step + ERROR_COOLDOWN + 1
186
+ }
187
+
188
+ /**
189
+ * The effort this request should carry.
190
+ *
191
+ * Returns `base` untouched unless the ledger holds evidence: a hot previous
192
+ * step, no pending cooldown, budget left, and a base that is a rung above
193
+ * `FLOOR`. The caller must treat `effort === base` as "nothing happened".
194
+ *
195
+ * @param {{runs: Map<string, any>}} watch @param {string} sid
196
+ * @param {string | undefined} base - the effort the harness resolved.
197
+ * @returns {{effort: string | undefined, reduced: boolean}}
198
+ */
199
+ export function planEffort(watch, sid, base) {
200
+ if (typeof base !== 'string') return { effort: base, reduced: false }
201
+ const run = runOf(watch, sid)
202
+ if (run.reducedStep === run.step) {
203
+ // One step, one reduction: a retry inside the same step must not walk the
204
+ // effort ladder down twice before anything has been measured again.
205
+ return { effort: base, reduced: false }
206
+ }
207
+ if (!run.hot || run.step < run.coolUntil || run.reductions >= MAX_REDUCTIONS) {
208
+ return { effort: base, reduced: false }
209
+ }
210
+ const cheaper = cheaperEffort(base)
211
+ if (cheaper === undefined) return { effort: base, reduced: false }
212
+ run.reductions += 1
213
+ run.reducedStep = run.step
214
+ return { effort: cheaper, reduced: true }
215
+ }
216
+
217
+ /** The notice body for the nudge, built where it is sent (see `index.js`). */
218
+ export const NUDGE_SUMMARY = 'lazy mode: decide with what you have'
219
+
220
+ export { DIRECTIVE_SUMMARY }
package/src/metrics.js CHANGED
@@ -113,6 +113,21 @@ const chunkKind = (chunk) => {
113
113
  return typeof chunk.type === 'string' ? chunk.type : 'unknown'
114
114
  }
115
115
 
116
+ /**
117
+ * The text a delta chunk carries. Adapters spell it `text` or `delta`; anything
118
+ * else is counted as zero characters rather than guessed at, so the column can
119
+ * be read as a lower bound and never as an estimate.
120
+ *
121
+ * @param {any} chunk
122
+ */
123
+ const deltaText = (chunk) => {
124
+ if (!chunk || typeof chunk !== 'object') return ''
125
+ if (typeof chunk.text === 'string') return chunk.text
126
+ if (typeof chunk.delta === 'string') return chunk.delta
127
+ const nested = chunk.delta
128
+ return nested && typeof nested === 'object' && typeof nested.text === 'string' ? nested.text : ''
129
+ }
130
+
116
131
  const payloadOf = (event) => {
117
132
  const data = event && typeof event === 'object' ? event.data : undefined
118
133
  return data && typeof data === 'object' ? data : (event ?? {})
@@ -144,7 +159,7 @@ export function createRecorder(options = {}) {
144
159
  const on = file.length > 0
145
160
  /** @type {Map<string, any>} */
146
161
  const steps = new Map()
147
- /** @type {Map<string, {mode: string, gear: string, effort?: string, route?: string}>} */
162
+ /** @type {Map<string, {mode: string, gear: string, effort?: string, effortBase?: string, route?: string}>} */
148
163
  const labels = new Map()
149
164
  let prepared = false
150
165
 
@@ -205,6 +220,8 @@ export function createRecorder(options = {}) {
205
220
  calls: 0,
206
221
  errors: 0,
207
222
  seen: 0,
223
+ reasoningChars: 0,
224
+ textChars: 0,
208
225
  }
209
226
  steps.set(key, entry)
210
227
  trim(steps, STEPS_MAX)
@@ -222,8 +239,14 @@ export function createRecorder(options = {}) {
222
239
  * Tell the next step rows which stance produced them. Called from the
223
240
  * request hook, which is the only place that knows the resolved gear.
224
241
  *
242
+ * A call may carry only some fields: the omitted ones keep the session's
243
+ * previous value, so a later call cannot blank the gear it does not know
244
+ * about. `effortBase: null` is the one explicit clear, used when a step did
245
+ * *not* step down — without it the previous reduction would be attributed to
246
+ * every following row.
247
+ *
225
248
  * @param {string|undefined} sid
226
- * @param {{mode: string, gear: string, effort?: string, route?: string}} label
249
+ * @param {{mode?: string, gear?: string, effort?: string, effortBase?: string|null, route?: string}} label
227
250
  */
228
251
  label(sid, label) {
229
252
  if (typeof sid !== 'string' || sid.length === 0) return
@@ -232,14 +255,19 @@ export function createRecorder(options = {}) {
232
255
  // not exist is pure retention. (1.5.33 and earlier kept them anyway.)
233
256
  if (!on) return
234
257
  const previous = labels.get(sid)
235
- if (previous && previous.mode === label.mode && previous.gear === label.gear
236
- && previous.effort === label.effort && previous.route === label.route) return
237
- labels.set(sid, {
238
- mode: label.mode,
239
- gear: label.gear,
240
- effort: label.effort,
241
- route: label.route,
242
- })
258
+ const next = {
259
+ mode: label.mode !== undefined ? label.mode : previous && previous.mode,
260
+ gear: label.gear !== undefined ? label.gear : previous && previous.gear,
261
+ effort: label.effort !== undefined ? label.effort : previous && previous.effort,
262
+ effortBase: label.effortBase === null
263
+ ? undefined
264
+ : label.effortBase !== undefined ? label.effortBase : previous && previous.effortBase,
265
+ route: label.route !== undefined ? label.route : previous && previous.route,
266
+ }
267
+ if (previous && previous.mode === next.mode && previous.gear === next.gear
268
+ && previous.effort === next.effort && previous.effortBase === next.effortBase
269
+ && previous.route === next.route) return
270
+ labels.set(sid, next)
243
271
  trim(labels, LABELS_MAX)
244
272
  },
245
273
 
@@ -302,6 +330,13 @@ export function createRecorder(options = {}) {
302
330
  }
303
331
  return
304
332
  }
333
+ // Streaming volume is the only live view of how much the model is
334
+ // deliberating: this route reports zero reasoning *tokens* in `usage`
335
+ // even when it streams a long reasoner block, so the character count is
336
+ // the measurement that can actually be compared across arms.
337
+ const carried = deltaText(data.chunk)
338
+ if (chunkType === 'reasoning-delta') entry.reasoningChars += carried.length
339
+ else if (chunkType === 'text-delta') entry.textChars += carried.length
305
340
  if (entry.chunkFirst === undefined) {
306
341
  entry.chunkFirst = time
307
342
  entry.chunkKind = chunkType
@@ -354,6 +389,7 @@ export function createRecorder(options = {}) {
354
389
  mode: label ? label.mode : 'unknown',
355
390
  gear: label ? label.gear : 'unknown',
356
391
  effort: label ? label.effort : undefined,
392
+ effortBase: label ? label.effortBase : undefined,
357
393
  route: label ? label.route : undefined,
358
394
  // `ttftMs` is queue + prefill: `step/start` to the first streamed chunk.
359
395
  // `decodeMs` is the generation window that follows it. `llmMs` stays the
@@ -370,6 +406,8 @@ export function createRecorder(options = {}) {
370
406
  input: entry.input,
371
407
  output: entry.output,
372
408
  reasoning: entry.reasoning,
409
+ reasoningChars: entry.reasoningChars,
410
+ textChars: entry.textChars,
373
411
  cacheRead: entry.cacheRead,
374
412
  cacheWrite: entry.cacheWrite,
375
413
  assistantMessages: entry.seen,
package/src/state.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * has an entry of its own. Only an edit in `cordis.patch.yml` changes them;
15
15
  * the chip and the panel never write them.
16
16
  * - The `sessions` field carries every per-session choice: `{ sessionId: {
17
- * enabled, gear, discipline, stepBudget, map, ts } }` as a compact JSON
17
+ * enabled, gear, discipline, stepBudget, map, batch, lazy, ts } }` as a compact JSON
18
18
  * string. It is volatile, so a write lands in the running plugin without a
19
19
  * remount.
20
20
  *
@@ -107,6 +107,7 @@ function sanitise(input) {
107
107
  if (Number.isFinite(entry.stepBudget) && entry.stepBudget >= 0) clean.stepBudget = Math.floor(Number(entry.stepBudget))
108
108
  if (typeof entry.map === 'boolean') clean.map = entry.map
109
109
  if (typeof entry.batch === 'boolean') clean.batch = entry.batch
110
+ if (typeof entry.lazy === 'boolean') clean.lazy = entry.lazy
110
111
  if (Number.isFinite(entry.ts)) clean.ts = Number(entry.ts)
111
112
  if (
112
113
  clean.enabled === undefined
@@ -115,6 +116,7 @@ function sanitise(input) {
115
116
  && clean.stepBudget === undefined
116
117
  && clean.map === undefined
117
118
  && clean.batch === undefined
119
+ && clean.lazy === undefined
118
120
  ) continue
119
121
  out[sessionId] = clean
120
122
  }
@@ -140,6 +142,7 @@ export function choiceFor(raw, sessionId) {
140
142
  if (entry.stepBudget !== undefined) choice.stepBudget = entry.stepBudget
141
143
  if (entry.map !== undefined) choice.map = entry.map
142
144
  if (entry.batch !== undefined) choice.batch = entry.batch
145
+ if (entry.lazy !== undefined) choice.lazy = entry.lazy
143
146
  return choice
144
147
  }
145
148
 
@@ -165,6 +168,7 @@ export function configOverridesFor(raw, sessionId) {
165
168
  if (choice.stepBudget !== undefined) overrides.stepBudget = choice.stepBudget
166
169
  if (choice.map !== undefined) overrides.workspaceMap = choice.map
167
170
  if (choice.batch !== undefined) overrides.openBatch = choice.batch
171
+ if (choice.lazy !== undefined) overrides.lazy = choice.lazy
168
172
  return Object.keys(overrides).length === 0 ? undefined : overrides
169
173
  }
170
174
 
@@ -215,11 +219,13 @@ export function writeChoice(raw, sessionId, choice, now) {
215
219
  if (typeof choice.discipline === 'boolean' || choice.discipline === 'late') next.discipline = choice.discipline
216
220
  if (Number.isFinite(choice.stepBudget) && choice.stepBudget >= 0) next.stepBudget = Math.floor(Number(choice.stepBudget))
217
221
  if (typeof choice.map === 'boolean') next.map = choice.map
222
+ if (typeof choice.batch === 'boolean') next.batch = choice.batch
223
+ if (typeof choice.lazy === 'boolean') next.lazy = choice.lazy
218
224
  map[sessionId] = next
219
225
  return { sessions: encodeChoices(map), map }
220
226
  }
221
227
 
222
- /** @typedef {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, batch?: boolean, ts?: number}} SessionEntry */
228
+ /** @typedef {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, batch?: boolean, lazy?: boolean, ts?: number}} SessionEntry */
223
229
 
224
230
  /**
225
231
  * Serialise the map, dropping the oldest entries beyond {@link SESSIONS_LIMIT}.