dsh-fast-mode 1.5.32 → 1.5.34

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
@@ -2392,6 +2392,27 @@ python3 bench/suite-run.py --tasks debugfix,migrate --arms def15,combo3 --pairs
2392
2392
  - **时点臂仍在 HOLD,不进默认。** `combo2` 比 `combo3` 多出来的那一截(0.669 vs 0.742)指向 `d2g`,但两个臂的样本不是同一批 pair,n = 22 vs 11 也不足以给「多 7 个百分点」定论;`d2g` 在 `codeimpl` 上的负收益(见上一节结论 2)没有被这一轮推翻,默认仍是 `disciplineAfterSteps: 4`。
2393
2393
  - **诚实边界**:各一个块、两个任务族(`debugfix` 短、`migrate` 长),`combo3` 的符号检验 p = 0.113 只算方向提示;锚点臂有 2 格(`migrate` pair 2、11)判 `leak` 被剔除,剔除后仍配对 22 对,`combo2` 24 格全绿、无一格 `leak`。想复现就重跑上面两条命令,`bench/arms/combo2.yml`、`combo3.yml` 在仓库里。
2394
2394
 
2395
+ ## 提速有多少是从「非模型时间」里挤出来的:没有,只占墙钟 5 % 且组合臂更轻
2396
+
2397
+ 外部评审担心的是「你把成本挪到插件自己身上,墙钟看着快了其实更重」。`wall`(任务下达到进程退出)与 `llm`(花在模型调用里的时间)两列本来就在每一行里,差值就是一切非模型开销:工具执行、宿主调度、插件每步的活。新脚本把这块单拉出来,成对看:
2398
+
2399
+ ```
2400
+ python3 bench/overhead.py --blocks COMBO2 --base def15 --arm combo2
2401
+ python3 bench/overhead.py --blocks COMBO3 --base def15 --arm combo3
2402
+ ```
2403
+
2404
+ | 口径 | 默认臂 | 组合臂 |
2405
+ |---|---|---|
2406
+ | `combo2`(22 对)每步非模型开销 | 0.64 s | **0.38 s** |
2407
+ | `combo2` 非模型开销占墙钟 | 5.9 % | 6.6 % |
2408
+ | `combo2` 成对开销比 | — | 中位 **0.953×**,16/22 对更轻 |
2409
+ | `combo3`(11 对)每步非模型开销 | 0.30 s | 0.39 s |
2410
+ | `combo3` 成对开销比 | — | 中位 **0.940×**,8/11 对更轻 |
2411
+
2412
+ - **提速不来自压榨工具时间。** 非模型开销本来只占墙钟 3–8 %,就算压到 0 也只有几个百分点;实测组合臂的这块绝对值反而略轻(成对中位 0.94–0.95×,各 16/22 与 8/11 对更轻),因为总步数少了、固定开销也少收几遍。占比从 5.9 % 涨到 6.6 % 只是分母(墙钟)变小,不是开销变重。
2413
+ - **每步开销没有系统性变大。** `combo2` 每步 0.38 s vs 默认(同块)0.64 s;`combo3` 是 0.39 s vs 0.30 s,方向相反但绝对值都不到 0.1 s/步,且成对中位仍是 0.94×。插件每步的活是几十毫秒级的字符串拼接与一次 `inject`,落在这两列的噪声里,解释不了 30 % 的墙钟差。
2414
+ - **读法**:这一块不能用来证明快,只能用来排除「快的代价藏在别处」。墙钟比(0.687 中位 / 几何 0.669)与 `llm` 比的落点一致,说明省下来的确实是模型回合。
2415
+
2395
2416
  ## 同一配置再跑一遍会差多少:块级差 4 %,任务级差 15–31 %(REP2,10 对)
2396
2417
 
2397
2418
  外部评审提的第一条是稳定性。这一块不新增任何开关,只把 `migrate`+`debugfix` 的同一批格子**用同一套配置再跑一遍**(`REP2`,20 次运行,顺序 ABBA 与之前的块一致),看同配置的两次独立测量互相差多少:
@@ -2440,3 +2461,75 @@ python3 bench/textshare.py --limit 5
2440
2461
  ```
2441
2462
 
2442
2463
  可见文字只占(可见文字 + 工具参数)的 1.28 %;把推理也算进来,**推理占模型全部发出字符的 99.6 %**。所以提速不来自「少说话」:省下来的是**步数**与**每步的推理量**,而提醒文案只是同时压这两者的手段。`bench/mechanism.py` 的报告里也多了 `prose / args / reasoning / emitted` 四行(168 对同题同配的结论:`reasoning` 几何比 0.75,`emitted` 0.82)。
2464
+
2465
+ ## 常驻宿主的堆:把最后 6 个无界缓存封顶,并用一个「不重启的宿主」证明它是平的(1.5.34)
2466
+
2467
+ 外部评审这一轮的建议是「先做稳定性修复,暂时不要加旋钮」。插件里能威胁稳定性的东西不多,但有一类是真的:**所有缓存都以永不重复的 id 为键**(会话 id、`callId`、`${sid}${cwd}`),而宿主的进程是活几周不重启的。1.5.32 封了 3 个,这一轮把剩下的全封了,并且补了一个黑盒浸泡脚本,让「它是平的」变成每次都能重跑的数字,而不是一句断言。
2468
+
2469
+ ### A. 审计:插件自己持有的 8 个缓存
2470
+
2471
+ | 缓存 | 键 | 之前 | 现在 |
2472
+ | --- | --- | --- | --- |
2473
+ | `compacted`(`index.js`) | 会话 id | 200 条 FIFO | 200(改用 `trim`) |
2474
+ | `mapSent`(`index.js`) | `${sid}${cwd}` | **无界** | 500 |
2475
+ | `autoBudgets`(`index.js`) | 会话 id | 200 条 FIFO | 200(改用 `trim`) |
2476
+ | `startedAt`(`index.js`) | `callId` | **无界**(结果没回来的调用不会被 `delete`) | 2000 |
2477
+ | `warnedCaps` | `callId` | 有界 | 不变 |
2478
+ | `labels`(`src/metrics.js`) | 会话 id | **无界**,且 sink 关闭时也写 | 2000,且 sink 关闭时直接不写 |
2479
+ | `steps` / `open` / `pendingChunk`(`src/metrics.js`) | 步 / 会话 | **无界** | 2000 / 2000 / 1000 |
2480
+ | `sessions`(`src/auto.js`) | 会话 id | 200(LRU) | 200(改用 `trim`) |
2481
+
2482
+ 封顶全部走同一个新工具 `src/bounded.js` 的 `trim(cache, cap)`:插入顺序即淘汰顺序,返回同一个缓存以便链式使用,`cap <= 0` 时不动。之前每个缓存各写一遍淘汰循环,写法不一致是漏掉下一个缓存的原因。被淘汰不等于写错行:`labels` 里没了的会话,它的测量行记成 `mode: 'unknown'`,而不是借用别人的档位——这一条直接写成了测试(`test/bounded.test.js`)。
2483
+
2484
+ ### B. 先修的是测量工具本身:它最初报了个假泄漏
2485
+
2486
+ 新写的 `bench/soak.mjs` 用与 `test/plugin.test.js` 相同的假 Cordis 挂载真插件,然后扮演一个从不重启的宿主:N 个会话 × 4 轮,每轮走 `step/start` → `agent/request` → 工具调用 → `step/end`。第一次跑 4000 会话报 **+63.67 MB / 16.3 KB per session / FAIL**,而且 `--off` 一模一样——说明增长不在插件逻辑里。二分两下就定位了:
2487
+
2488
+ | 控制 | 4000 会话的增长 |
2489
+ | --- | --- |
2490
+ | `--off`(插件禁用) | +63.67 MB(同样) |
2491
+ | `--no-event`(不发事件流) | +49.29 MB |
2492
+ | `--no-request`(不发 `agent/request`) | **−0.55 MB** |
2493
+ | `--one-id`(4000 个会话共用一个 id) | +49.30 MB(同样) |
2494
+
2495
+ 问题在**脚本**:`ctx.fire('agent/request', …)` 没有 `await`,16000 次调用留下 16000 个 pending 的 Promise 链,堆自然涨,而且与「会话数」无关而与「调用数」有关(`--one-id` 一样涨就是这个原因)。改成 `await` 后同规模是 **−0.61 MB**。这条写进脚本头注释里:浸泡测试自己的悬挂 Promise 会伪装成插件的泄漏,先怀疑它。
2496
+
2497
+ (`/tmp/reqctl.mjs` 的独立控制给出同一结论:16000 次 await 过的请求 ≈ 2.43 MB,即 152 B/次,正是 `labels`;不 await 是 3 KB/次。)
2498
+
2499
+ ### C. 封顶之后:20000 个会话、242 400 步,堆是平的
2500
+
2501
+ ```
2502
+ node --expose-gc bench/soak.mjs --sessions 20000 --metrics
2503
+ sessions 20000 × 4 turns, 242400 steps
2504
+ heap warm 7.26 MB
2505
+ heap end 5.62 MB
2506
+ growth -1.64 MB over 20000 sessions
2507
+ per session -0.1 KB
2508
+ metrics 242400 steps written, caches trimmed
2509
+ ```
2510
+
2511
+ `--metrics` 是这一轮加的口子:让测量 sink 真的写盘(写进临时文件,跑完删掉),这样 `labels`/`steps`/`open`/`pendingChunk` 四个缓存也被搅动。带它跑 4000 会话是 **−0.58 MB**,不带它跑 20000 会话是 **−1.64 MB**;阈值是 0.004 KB/会话,失败时退出码非 0,可以直接进 CI。
2512
+
2513
+ 单点对照(`/tmp/labelctl.mjs`,20000 次 `label()`):
2514
+
2515
+ | 版本 | 堆增长 |
2516
+ | --- | --- |
2517
+ | 1.5.33(无界,sink 关闭也写) | **2.48 MB**(124 B/会话) |
2518
+ | 1.5.34(sink 关闭直接不写) | **0.01 MB** |
2519
+ | 1.5.34(sink 打开,封顶 2000) | **0.01 MB**(20000 次写入后仍只留 2000 条) |
2520
+
2521
+ 2.5 MB 不是能把宿主撑爆的量级——**这一条要诚实说清:真正的收益不是省了 2.5 MB,而是「插件里不再有随运行时长单调增长的缓存」**,而这个约束来自本机真实崩溃日志里的一条硬规则(堆内存要有上限)。上限也确实换了东西:`sink 关闭时不再记 label` 是一次纯粹的白拿。
2522
+
2523
+ ### D. 同一轮里对已发布版本的回归复核(`V1533`,12 次运行)
2524
+
2525
+ 改稳定性不能只看测试,还要确认「已发布的 1.5.33 源码在跑起来时没退化」。所以先跑了一班 `debugfix`+`migrate` 的 3 对 ABBA 块(`off` 与 `def15` 同块交替),用的是仓库源码(`node_modules/dsh-fast-mode` 指回仓库):
2526
+
2527
+ | 口径 | `def15` | `off` |
2528
+ | --- | --- | --- |
2529
+ | 6 次运行合计墙钟 | 654.0 s(**0.50×**) | 1304.5 s |
2530
+ | 输出 token | 60 135(0.57×) | 104 492 |
2531
+ | 步数 | 45(0.46×) | 99 |
2532
+ | 绿/运行 | **6/6** | 6/6 |
2533
+ | 逐对更快 | 5/6 | — |
2534
+
2535
+ 两族的逐对几何分别是 `debugfix` 0.50×、`migrate` 0.50×,与历史块同量级(这两个族本来就是插件收益最大的地方),说明封顶没有动到任何行为路径。
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.34(稳定性版:插件里最后 6 个随宿主任期增长的缓存全部封顶,附一个可重跑的浸泡测试)
4
+
5
+ 按外部评审这一轮的建议,不新增任何提醒时机、重复提醒或预算旋钮,默认值与全部开关一律不动。只做一件事:**让插件在「几周不重启的宿主」里不再积累任何东西**,并把这件事变成能重跑的数字。
6
+
7
+ - **审计了插件自己持有的 8 个缓存,其中 6 个之前是无界的。** `mapSent`(`${sid}${cwd}`)、`startedAt`(`callId`,结果没回来的调用不会被 `delete`)、测量 sink 的 `labels`/`steps`/`open`/`pendingChunk` 都不封顶;`compacted`、`autoBudgets`、`sessions` 有上限但各写一遍淘汰循环。现在全部走同一个 `src/bounded.js` 的 `trim(cache, cap)`(插入顺序即淘汰顺序,`cap <= 0` 不动),上限分别是 500 / 2000 / 2000 / 2000 / 2000 / 1000。上限远高于任何真实并发量,正常使用不会淘汰活条目。
8
+ - **sink 关闭时不再记 label。** `agent/request` 每个请求都会调 `label()`,而默认配置根本不写测量文件——1.5.33 及以前照样把每个会话的档位记进一个 Map。20000 次 `label()` 的堆增长因此从 **2.48 MB 降到 0.01 MB**(`/tmp/labelctl.mjs`)。淘汰也不会写错行:被淘汰会话的测量行记 `mode: 'unknown'`,不再可能借用别人的档位,这一条是 `test/bounded.test.js` 的断言。
9
+ - **新增 `bench/soak.mjs`:一个从不重启的假宿主。** 用与 `test/plugin.test.js` 相同的假 Cordis 挂载真插件,跑 N 个会话 × 4 轮、每轮 `step/start` → `agent/request` → 工具调用 → `step/end`。20000 会话 / 242400 步:堆增长 **−1.64 MB(−0.1 KB/会话)**;加 `--metrics`(真的写盘,跑完删临时文件)跑 4000 会话是 −0.58 MB。阈值 0.004 KB/会话,超了退出码非 0。
10
+ - **这个脚本第一次跑报的是假泄漏,值得记下。** 4000 会话 +63.67 MB,`--off` 一模一样;二分到 `--no-request` 变 −0.55 MB,`--one-id` 仍 +49 MB——根因是脚本自己 `ctx.fire('agent/request', …)` 没 `await`,16000 个 pending Promise 冒充成插件泄漏。改成 `await` 后同规模 −0.61 MB。脚本头注释里写清了这条,免得下次又去插件里找。
11
+ - **同时复核了已发布源码没退化(`V1533`,12 次运行):** `debugfix`+`migrate` 3 对 ABBA,`def15` 6 次合计墙钟 **654.0 s(0.50×)**、步数 0.46×、输出 0.57×、逐对 5/6 更快、两臂 **6/6 全绿**,与历史块同量级。
12
+ - 测试 308 → **313 全绿**(新增 `test/bounded.test.js` 5 条)。动 `index.js`、`src/metrics.js`、`src/auto.js`、`src/bounded.js`、`bench/soak.mjs`、`BENCHMARK.md`、`docs/REFERENCE.md`;顺手把 `README.en.md` 里「右键有四行设置」改成五行(1.5.33 加了第五行,英文版漏改)。面板、配置、默认值零改动。
13
+
14
+ ## 1.5.33(把「多个开关一起开」里那个真正省时的开关做进面板:第五行「一次读多个文件」)
15
+
16
+ - **面板多了第五行,`openBatch` 第一次能被用户点到。** 1.5.32 实测的 `combo3`(默认 + `workspaceMap` + `openBatch`,11 对 0.742×)里有一半来自 `openBatch`,但它之前只在配置里,面板够不着——等于「测出来了但用户开不了」。这一版把它做成和「先列文件清单」同级的逐会话开关:写 `sessions` 列里本会话条目的 `batch`,宿主侧映射成 `openBatch`(`src/state.js` 的 `configOverridesFor`),不动共享默认值,只影响本会话。
17
+ - **默认仍然关。** 单独开 `openBatch` 测不到显著(0.882×,CI 跨 1),只有和清单一起开才有效;面板 tooltip 写的就是这句后果,不写成「更快」。默认值、`d2g` 的 HOLD 状态都不变。
18
+ - **两侧字段表对齐。** `client.js` 的 `sanitiseChoices` 与 `src/state.js` 的 `sanitise` 同步接受 `batch`,避免出现「客户端写进去、宿主读出来丢掉」的假开关(这正是 1.5.17 那批死按钮的成因)。测试补了三处:状态层的 `batch → openBatch` 映射、面板行的默认值/逐会话写入、以及「共享 `openBatch` 为真但本会话条目为假」时面板按条目画。
19
+ - 动 `client.js`、`src/state.js`、`src/config.js`(描述文案)、两份 README、`docs/REFERENCE.md`;测试 306 → 308 全绿。
20
+
3
21
  ## 1.5.32(先回答「多个开关一起开」:三开 **0.669×**、22 对里 18 对更快、隐藏测试 22/22 绿;附带一个内存上限修复)
4
22
 
5
23
  - **新增两个组合臂并真的跑了一遍**(此前只有逐个开关的消融,从没跑过「一起开」):`combo2` = 默认 + `d2g` + `workspaceMap` + `openBatch`,`combo3` = 默认 + `workspaceMap` + `openBatch`。同块内与默认臂按 `(任务, pair)` 配对:
package/README.en.md CHANGED
@@ -103,13 +103,16 @@ 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 three shared settings:
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:
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
+ - **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.
111
112
 
112
- All three apply from the next request. No dropdown, no multiplier estimate.
113
+ All five rows take effect from the next request, per conversation. No dropdown, no multiplier estimate.
114
+
115
+ **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.
113
116
 
114
117
  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.
115
118
 
package/README.md CHANGED
@@ -103,14 +103,17 @@
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
 
113
- 四项都写在本会话的条目里,对下一个回合生效;另一个会话开不开、写成什么样,互不影响。没有下拉,没有倍数估算。
114
+ 五项都写在本会话的条目里,对下一个回合生效;另一个会话开不开、写成什么样,互不影响。没有下拉,没有倍数估算。
115
+
116
+ **几个一起开会不会更快?** 会,但只有实测过的那几个。默认 + `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) 的「直接跑一次多个开关一起开」。
114
117
 
115
118
  思路参考了 `dsh-batch-tool-calls` 与 `dsh-ops`(批量调用、有界执行),但它们的规则是静态的,这里的宽度来自会话自身的调用证据;不需要同时装。压缩类插件可以共存。
116
119
 
package/client.js CHANGED
@@ -102,6 +102,8 @@ window.__ModuleLoader__.load({
102
102
  'panel.budgetTitle': '到上限先汇报,不截断任务',
103
103
  'panel.map': '先列文件清单',
104
104
  'panel.mapTitle': '开局把目录里的文件交给模型,省掉先翻一遍目录',
105
+ 'panel.batch': '一次读多个文件',
106
+ 'panel.batchTitle': '把互不依赖的读取合成一批发出,少几个来回',
105
107
  }
106
108
 
107
109
  const en = {
@@ -125,6 +127,8 @@ window.__ModuleLoader__.load({
125
127
  'panel.budgetTitle': 'Reports at the cap; never truncates the task',
126
128
  'panel.map': 'File list first',
127
129
  'panel.mapTitle': 'Hands the directory listing to the model up front, skipping a lookup',
130
+ 'panel.batch': 'Read several files at once',
131
+ 'panel.batchTitle': 'Batches independent reads into one call, fewer round trips',
128
132
  }
129
133
 
130
134
  const CSS = [
@@ -186,6 +190,8 @@ window.__ModuleLoader__.load({
186
190
  discipline: true,
187
191
  /** The workspace inventory switch for this conversation, off by default. */
188
192
  inventory: false,
193
+ /** Batches independent reads into one call for this conversation. */
194
+ batch: false,
189
195
  /** This conversation's step cap; 0 means no cap. */
190
196
  stepBudget: 0,
191
197
  /** The right-click panel is open. Store-backed, like every other paint. */
@@ -314,6 +320,7 @@ window.__ModuleLoader__.load({
314
320
  if (typeof entry.discipline === 'boolean' || entry.discipline === 'late') clean.discipline = entry.discipline
315
321
  if (Number.isFinite(entry.stepBudget) && entry.stepBudget >= 0) clean.stepBudget = Math.floor(Number(entry.stepBudget))
316
322
  if (typeof entry.map === 'boolean') clean.map = entry.map
323
+ if (typeof entry.batch === 'boolean') clean.batch = entry.batch
317
324
  if (Number.isFinite(entry.ts)) clean.ts = Number(entry.ts)
318
325
  if (
319
326
  clean.enabled === undefined
@@ -321,6 +328,7 @@ window.__ModuleLoader__.load({
321
328
  && clean.discipline === undefined
322
329
  && clean.stepBudget === undefined
323
330
  && clean.map === undefined
331
+ && clean.batch === undefined
324
332
  ) continue
325
333
  out[sessionId] = clean
326
334
  }
@@ -369,7 +377,7 @@ window.__ModuleLoader__.load({
369
377
  *
370
378
  * @param {Record<string, any>} map
371
379
  * @param {string|undefined} sessionId
372
- * @param {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean}} patch
380
+ * @param {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, batch?: boolean}} patch
373
381
  */
374
382
  function withOwn(map, sessionId, patch) {
375
383
  const next = { ...map }
@@ -382,6 +390,7 @@ window.__ModuleLoader__.load({
382
390
  if (typeof patch.discipline === 'boolean' || patch.discipline === 'late') entry.discipline = patch.discipline
383
391
  if (Number.isFinite(patch.stepBudget) && patch.stepBudget >= 0) entry.stepBudget = Math.floor(Number(patch.stepBudget))
384
392
  if (typeof patch.map === 'boolean') entry.map = patch.map
393
+ if (typeof patch.batch === 'boolean') entry.batch = patch.batch
385
394
  next[sessionId] = entry
386
395
  return next
387
396
  }
@@ -525,6 +534,9 @@ window.__ModuleLoader__.load({
525
534
  const seenMap = own !== undefined && typeof own.map === 'boolean'
526
535
  ? own.map
527
536
  : value.workspaceMap === true
537
+ const seenBatch = own !== undefined && typeof own.batch === 'boolean'
538
+ ? own.batch
539
+ : value.openBatch === true
528
540
  store.phase = 'ready'
529
541
  store.error = ''
530
542
  store.ns = view.ns
@@ -534,6 +546,7 @@ window.__ModuleLoader__.load({
534
546
  store.discipline = seenDiscipline
535
547
  store.stepBudget = seenBudget
536
548
  store.inventory = seenMap
549
+ store.batch = seenBatch
537
550
  // A write we have not seen echoed yet wins: the Loader applies the new
538
551
  // section asynchronously, and flipping back to the old paint in that
539
552
  // window would read as a failed click.
@@ -587,7 +600,7 @@ window.__ModuleLoader__.load({
587
600
  *
588
601
  * @param {{settings: {update: (ns: string, patch: object, revision?: number) => Promise<any>}}} remote
589
602
  * @param {{enabled?: boolean, gear?: string, outputDiscipline?: boolean|'late', stepBudget?: number, workspaceMap?: boolean}} [patch]
590
- * @param {{entry?: {enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean}}} [options]
603
+ * @param {{entry?: {enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, batch?: boolean}}} [options]
591
604
  * @returns {Promise<boolean>} whether the write was accepted
592
605
  */
593
606
  async function write(remote, patch, options = {}) {
@@ -610,6 +623,7 @@ window.__ModuleLoader__.load({
610
623
  if (typeof entry.discipline === 'boolean' || entry.discipline === 'late') store.discipline = entry.discipline !== false
611
624
  if (typeof entry.stepBudget === 'number') store.stepBudget = entry.stepBudget
612
625
  if (typeof entry.map === 'boolean') store.inventory = entry.map
626
+ if (typeof entry.batch === 'boolean') store.batch = entry.batch
613
627
  }
614
628
  if (typeof shared.enabled === 'boolean') {
615
629
  store.enabled = shared.enabled
@@ -620,6 +634,7 @@ window.__ModuleLoader__.load({
620
634
  else if (shared.outputDiscipline !== undefined) store.discipline = true
621
635
  if (typeof shared.stepBudget === 'number') store.stepBudget = shared.stepBudget
622
636
  if (typeof shared.workspaceMap === 'boolean') store.inventory = shared.workspaceMap
637
+ if (typeof shared.openBatch === 'boolean') store.batch = shared.openBatch
623
638
  emit()
624
639
  try {
625
640
  unwrap(await withTimeout(remote.settings.update(store.ns, outgoing, store.revision), 15000, 'settings/update'))
@@ -635,6 +650,7 @@ window.__ModuleLoader__.load({
635
650
  store.discipline = before.discipline
636
651
  store.stepBudget = before.stepBudget
637
652
  store.inventory = before.inventory
653
+ store.batch = before.batch
638
654
  store.pending = undefined
639
655
  store.writeError = describeError(error)
640
656
  emit()
@@ -843,6 +859,13 @@ window.__ModuleLoader__.load({
843
859
  emit()
844
860
  }
845
861
 
862
+ /** Same per-conversation tier as the file list, one call for many reads. */
863
+ const toggleBatch = () => {
864
+ void write(remote, undefined, { entry: { batch: !store.batch } })
865
+ store.panelOpen = false
866
+ emit()
867
+ }
868
+
846
869
  return react.createElement(
847
870
  'div',
848
871
  { className: 'fm-panel', role: 'menu', 'aria-label': t('panel.title') },
@@ -866,6 +889,15 @@ window.__ModuleLoader__.load({
866
889
  store.inventory,
867
890
  t('panel.mapTitle'),
868
891
  ),
892
+ row(
893
+ 'menuitemcheckbox',
894
+ t('panel.batch'),
895
+ t(store.batch ? 'panel.on' : 'panel.off'),
896
+ canWrite,
897
+ toggleBatch,
898
+ store.batch,
899
+ t('panel.batchTitle'),
900
+ ),
869
901
  react.createElement('div', { className: 'fm-panel__scope' }, t('panel.scope')),
870
902
  )
871
903
  }
package/docs/REFERENCE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 参考手册:原理、配置与边界
2
2
 
3
- 本手册描述 **dsh-fast-mode 1.5.17** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.17 面板为四行、四行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
3
+ 本手册描述 **dsh-fast-mode 1.5.34** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.33 面板为五行、五行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
4
4
 
5
5
  **唯一的契约:Fast 模式从不改变思考强度、thinking 预算或模型。** `agent/request` 钩子只做一件事——`await next()`,读一下请求携带的 `reasoningEffort` 记进测量,然后原样返回。用户选的是哪一档(会话里的 UI 选择,或者 profile 里 agent-default-model 固定下来的那一档),每个请求就用哪一档。
6
6
 
@@ -9,7 +9,7 @@
9
9
  ## 交互细节:闪电开关与会话归属
10
10
 
11
11
  - **一个图标就是开关。** 会话输入区的活动槽(`conversation.input.activity`)里只有一个闪电按钮:关闭时空心,开启时实心。点一下切换,再点一下切回;这一下只写当前会话。没有档位选择器,没有箭头,没有"选择 gear"的二级菜单。
12
- - **右键是面板。** 右键闪电打开四行,四行都只影响当前会话:"更快"(`enabled`)、"少写过程"(`outputDiscipline`)、"最多几步"(`stepBudget`,循环 0/15/30)、"先列文件清单"(`workspaceMap`)。四行都把选择写进 `sessions` 列里本会话的条目,不碰共享默认值,下一个回合生效;只读或写入中时整块禁用,写失败回滚。没有条目的会话沿用配置里的默认值(`enabled`/`gear`/`outputDiscipline`/`stepBudget`/`workspaceMap`)。面板不暴露 `disciplineAfterSteps`、`disciplineEarlyCalls`、`shapeGate`、`finishLineSteps`、`shellParallel`、`disciplineRepeatAtSteps` 这些未证实或已判 HOLD 的旋钮。
12
+ - **右键是面板。** 右键闪电打开五行,五行都只影响当前会话:"更快"(`enabled`)、"少写过程"(`outputDiscipline`)、"最多几步"(`stepBudget`,循环 0/15/30)、"先列文件清单"(`workspaceMap`)、"一次读多个文件"(`openBatch`)。五行都把选择写进 `sessions` 列里本会话的条目,不碰共享默认值,下一个回合生效;只读或写入中时整块禁用,写失败回滚。没有条目的会话沿用配置里的默认值(`enabled`/`gear`/`outputDiscipline`/`stepBudget`/`workspaceMap`/`openBatch`)。面板不暴露 `disciplineAfterSteps`、`disciplineEarlyCalls`、`shapeGate`、`finishLineSteps`、`shellParallel`、`disciplineRepeatAtSteps` 这些未证实或已判 HOLD 的旋钮。
13
13
  - **会话之间互不影响,这是硬约束。** 每个会话的 `enabled`、`gear`、`outputDiscipline`、`stepBudget`、`workspaceMap` 都从 `sessions` 列里自己那一条解析:A 会话 `更快 + 少写过程`、B 会话 `更快 + 不写过程` 同时成立,C 会话没点过就沿用配置默认值。面板和闪电只写本会话的条目,从不写共享默认值;条目最多保留 200 个,按 `ts` 淘汰最旧的,被淘汰的会话回到默认值。
14
14
  - **按钮只表达一个问题:这个会话开不开。** `aria-pressed` 就是状态,tooltip 写清"仅本会话 · 思考强度不变 · 点击关闭"。为什么快、快在哪里、单次调用上限这类说明放在插件介绍(`meta.description` 与配置项描述)里,不再占用输入区。
15
15
  - **实时行引用的是被执行的数字**,而不是产品形容词:
@@ -165,7 +165,7 @@
165
165
  | `parallel` | 由档位推导,`auto` 下为 4 | 并行上限的初始值,1–8。`auto` 只把它当起点;设为 1 表示不注入批量提示段。 |
166
166
  | `sessions` | `''` | 插件自动维护的会话级覆盖,见上一节。**不要手改。** |
167
167
  | `outputDiscipline` | `'late'` | 输出纪律。`late`(默认)前 4 步不写任何输出约束,第 4 步起由调度器提醒一次;`true` 全程生效;`false` 关闭输出纪律,仍注入并行与有界调用规则。 |
168
- | `openBatch` | `false` | 开局批量:每轮第一步发一次「把已知要做的独立调用放进同一块、shell 命令用 `&&` 串起来」。短任务的钱几乎全是每步固定成本(首字延迟 ~0.5 s/次),少一次往返就省一次;只影响第一轮第一步,长任务可忽略。旧批次里它不优于默认,但那些数字已因测量缺陷撤回(运行目录在仓库内、指标按字节偏移切片),尚无干净重测。保持在 `false`,只作实验旋钮。 |
168
+ | `openBatch` | `false` | 开局批量:每轮第一步发一次「把已知要做的独立调用放进同一块、shell 命令用 `&&` 串起来」。短任务的钱几乎全是每步固定成本(首字延迟 ~0.5 s/次),少一次往返就省一次;只影响第一轮第一步,长任务可忽略。旧批次里它不优于默认,但那些数字已因测量缺陷撤回(运行目录在仓库内、指标按字节偏移切片),尚无干净重测。保持在 `false`,但 1.5.33 起进面板(「一次读多个文件」),逐会话可开;和 `workspaceMap` 一起开 11 对 0.742×,再加 `disciplineAfterSteps: 2` 三开 22 对 0.669×〔CI 0.554–0.799〕,单独开仍测不到显著。 |
169
169
  | `shapeGate` | `false` | 形状闸门:本轮累计调用 ≥ 4 且其中写入类(不在只读名单里的工具)占比 ≥ 60 % 时,**整轮不再注入任何提醒**,行为退回原生。判断只用已有的工具名,不发请求、不加请求体字段。它给的是「不适合就别管我」的退路:写密集型的任务没有可并行的调用,每条提醒都只是一次它用不上的往返。显式设了 `stepBudget` 时该收尾线优先于闸门——「别加速」不等于「无视我叫停」。默认关,因为它会同时关掉本可以救回长尾的收尾提醒。 |
170
170
  | `preferPatch` | `false` | 整文件写入之后提醒一次「改已有文件时只发改动的行(edit hunk),不要重发整个文件」。本步 `write` 的正文 ≥ 2000 字符才武装,之后一步发一次,每轮最多一次;文本明确要求结果与原文件逐字节相同,不限制写什么、也不影响何时结束。默认关闭:它只作用于模型的输出形式(工具调用参数),正确性由文件内容决定、与发送形式无关;实测是否真省墙钟见 `BENCHMARK.md` 的 `pp16` 臂。 |
171
171
  | `compactRereadHint` | false | 压缩提醒里多一句「本回合写过的文件不要为确认整份重读」;不新增消息。实验旋钮,默认关 |
@@ -335,6 +335,7 @@ dsh-fast-mode-experiment stop # 结束实验
335
335
  - **设置只能由这次点击改,且带 revision。** 没有后台静默改配置的路径;冲突写入会失败并回滚,原因是 tooltip。
336
336
  - **计量是本地的。** 只写 `metricsFile`;`--lan` 是唯一的出网路径,只读,需要显式 token。
337
337
  - **看门狗只改 signal。** 调用方原来的 signal 在 `finally` 里恢复;取消后调用体的 rejection 被吞掉,不会变成未处理的拒绝。
338
+ - **每个缓存都有上限。** 插件持有的 8 个缓存全部以永不重复的 id 为键(会话 id、`callId`、`${sid}${cwd}`),而宿主进程会活几周,所以一个都不留无界:`compacted` 200、`mapSent` 500、`autoBudgets` 200、`startedAt` 2000、`warnedCaps` 有界、测量 sink 的 `labels` 2000 / `steps` 2000 / `open` 2000 / `pendingChunk` 1000、`src/auto.js` 的会话状态 200(LRU)。淘汰统一走 `src/bounded.js` 的 `trim(cache, cap)`,插入顺序即淘汰顺序。上限远高于真实并发量,所以正常使用不会淘汰活条目;真的淘汰了也不会写错行——测量行记 `mode: 'unknown'`,不会借用别人的档位。sink 关闭时 `label()` 直接不写(默认配置就是关闭)。`bench/soak.mjs` 用 20000 个会话的黑盒浸泡验证这条(−0.1 KB/会话)。
338
339
 
339
340
  ## 诚实的限制
340
341
 
@@ -369,7 +370,7 @@ dsh-fast-mode-experiment stop # 结束实验
369
370
  - `src/metrics.js`:JSONL 计量与汇总;
370
371
  - `src/benchmark.js`:按会话哈希分配;
371
372
  - `src/peers.js`:冲突插件探测;
372
- - `client.js`:闪电开关、右键面板(四行,按会话写入 `sessions` 列)。
373
+ - `client.js`:闪电开关、右键面板(五行,按会话写入 `sessions` 列)。
373
374
 
374
375
  跑测试:
375
376
 
package/index.js CHANGED
@@ -60,6 +60,7 @@ import { appendFileSync } from 'node:fs'
60
60
  import { homedir } from 'node:os'
61
61
  import { join } from 'node:path'
62
62
 
63
+ import { trim } from './src/bounded.js'
63
64
  import { CONTROL, createAssignment } from './src/benchmark.js'
64
65
  import { contentChars, GEARS, resolveConfig, stableKey } from './src/effort.js'
65
66
  import { buildGuidance, GUIDANCE_ORDER, GUIDANCE_SECTION } from './src/guidance.js'
@@ -97,9 +98,17 @@ const TAG = '[fast-mode]'
97
98
  * Shipped identity of this build. Traced into the debug log so a stale install is
98
99
  * visible at a glance, and pinned by a test against `package.json`.
99
100
  */
100
- export const VERSION = '1.5.32'
101
+ export const VERSION = '1.5.34'
101
102
  const GLOBAL_AGENT = '__global__'
102
103
 
104
+ /**
105
+ * Ceilings for the two caches keyed by ids that never repeat. Both are read
106
+ * by the *current* session or call, so the caps sit far above any live working
107
+ * set and only exist to keep a process that never restarts from growing.
108
+ */
109
+ const MAP_SENT_MAX = 500
110
+ const STARTED_MAX = 2000
111
+
103
112
  /**
104
113
  * Insert notices after the last message the step claims to be answering.
105
114
  *
@@ -280,7 +289,11 @@ export function apply(ctx, rawConfig) {
280
289
  // The same listener remembers the compactions, so the two things that read the
281
290
  // event stream are read in one place and in one order.
282
291
  const compacted = new Set()
283
- /** `${sid}${cwd}` pairs already given a workspace inventory. */
292
+ /**
293
+ * `${sid}${cwd}` pairs already given a workspace inventory. Capped for the
294
+ * same reason as `compacted`: the entry is only useful while that session is
295
+ * current, and a host that stays up for weeks accumulates them.
296
+ */
284
297
  const mapSent = new Set()
285
298
  /**
286
299
  * @param {{id?: string}} session
@@ -295,8 +308,8 @@ export function apply(ctx, rawConfig) {
295
308
  if (!sid) return
296
309
  // Bounded: a long-lived process sees thousands of sessions, and only the
297
310
  // next step of a just-compacted one needs the flag.
298
- if (compacted.size >= 200) compacted.delete(compacted.values().next().value)
299
311
  compacted.add(sid)
312
+ trim(compacted, 200)
300
313
  } catch {
301
314
  // A reminder that cannot be scheduled must not affect the event stream.
302
315
  }
@@ -390,7 +403,12 @@ export function apply(ctx, rawConfig) {
390
403
  * oldest entries are dropped instead of kept forever.
391
404
  */
392
405
  const autoBudgets = new Map()
393
- /** @type {Map<string, number>} */
406
+ /**
407
+ * Call id → start time. Deleted on `tools/post-execute`; a call whose result
408
+ * never arrives would otherwise keep its timestamp for the life of the
409
+ * process, so the map is capped as well.
410
+ * @type {Map<string, number>}
411
+ */
394
412
  const startedAt = new Map()
395
413
  /** Parallel caps already warned about. */
396
414
  const warnedCaps = new Set()
@@ -535,8 +553,8 @@ export function apply(ctx, rawConfig) {
535
553
  noteStep(auto, sid)
536
554
  notePolicy(auto, sid, cfg)
537
555
  const schedule = autoSchedule(auto, sid)
538
- if (autoBudgets.size >= 200) autoBudgets.delete(autoBudgets.keys().next().value)
539
556
  autoBudgets.set(sid, schedule.stallBudgetMs)
557
+ trim(autoBudgets, 200)
540
558
  if (schedule.directive) {
541
559
  notices.push(createNoticeMessage(schedule.directive.text, schedule.directive.summary))
542
560
  if (initial.debug) {
@@ -569,6 +587,7 @@ export function apply(ctx, rawConfig) {
569
587
  }
570
588
  if (map) {
571
589
  mapSent.add(key)
590
+ trim(mapSent, MAP_SENT_MAX)
572
591
  notices.push(createNoticeMessage(map, 'auto: workspace inventory'))
573
592
  }
574
593
  }
@@ -649,7 +668,10 @@ export function apply(ctx, rawConfig) {
649
668
  }
650
669
 
651
670
  ctx.on('tools/pre-execute', async (exec, next) => {
652
- if (exec && typeof exec.callId === 'string') startedAt.set(exec.callId, Date.now())
671
+ if (exec && typeof exec.callId === 'string') {
672
+ startedAt.set(exec.callId, Date.now())
673
+ trim(startedAt, STARTED_MAX)
674
+ }
653
675
  return next()
654
676
  })
655
677
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.32",
3
+ "version": "1.5.34",
4
4
  "description": "DeepSeek Harness fast mode: same thinking effort, fewer round trips. An adaptive parallel tool fan-out, output discipline that never trims the answer, and a cancellable stall guard. Never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",
package/src/auto.js CHANGED
@@ -37,6 +37,8 @@
37
37
 
38
38
  import { statSync } from 'node:fs'
39
39
  import { isAbsolute, resolve as resolvePath } from 'node:path'
40
+
41
+ import { trim } from './bounded.js'
40
42
  /** The gear id the adaptive scheduler runs under. */
41
43
  export const AUTO = 'auto'
42
44
 
@@ -299,10 +301,7 @@ function entryOf(state, agent) {
299
301
  turnUnsafe: 0,
300
302
  }
301
303
  state.sessions.set(key, entry)
302
- if (state.sessions.size > AUTO_SESSIONS) {
303
- const oldest = state.sessions.keys().next().value
304
- if (oldest !== undefined) state.sessions.delete(oldest)
305
- }
304
+ trim(state.sessions, AUTO_SESSIONS)
306
305
  return entry
307
306
  }
308
307
 
package/src/bounded.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Keep a long-lived cache inside a fixed number of entries.
3
+ *
4
+ * Every cache in this plugin is keyed by something that never repeats — a
5
+ * session id, a `callId`, a `${sid}${cwd}` pair. A host process stays up for
6
+ * weeks, so an uncapped cache grows with the number of sessions it has ever
7
+ * seen, and eventually the host dies of it. `index.js`, `auto.js` and
8
+ * `src/metrics.js` all take this one shape, so a cache added later cannot
9
+ * forget the cap by writing its own loop differently.
10
+ *
11
+ * Insertion order is the eviction order for both `Map` and `Set`, so the first
12
+ * key returned by `keys()` is the oldest write — unless the caller re-inserts on
13
+ * read, which is what the session cache does to get least-recently-used order.
14
+ *
15
+ * Deleting an entry never breaks a row: a session whose label was evicted is
16
+ * recorded as `unknown`, not as somebody else's mode.
17
+ *
18
+ * @template {Map<any, any> | Set<any>} T
19
+ * @param {T} cache
20
+ * @param {number} cap
21
+ * @returns {T} the same cache, so it can be used as an expression
22
+ */
23
+ export function trim(cache, cap) {
24
+ if (!(cap > 0)) return cache
25
+ while (cache.size > cap) {
26
+ const oldest = cache.keys().next().value
27
+ if (oldest === undefined) break
28
+ cache.delete(oldest)
29
+ }
30
+ return cache
31
+ }
package/src/config.js CHANGED
@@ -157,7 +157,7 @@ export const Config = z.object({
157
157
  .boolean()
158
158
  .default(false)
159
159
  .description(
160
- '开局批量 / Opening batch: 每轮第一步提醒一次「把已知要做的独立调用放进同一块、shell 命令用 && 串起来」。短任务的钱几乎全花在每步固定成本(首字延迟 ~0.5 s/次)上,少一次往返就省一次;只发一次,不改后续调度。',
160
+ '开局批量 / Opening batch(面板名「一次读多个文件」): 每轮第一步提醒一次「把已知要做的独立调用放进同一块、shell 命令用 && 串起来」。短任务的钱几乎全花在每步固定成本(首字延迟 ~0.5 s/次)上,少一次往返就省一次;只发一次,不改后续调度。面板可逐会话开;和 `workspaceMap` 一起开时实测 0.742×(11 对),单独开测不到显著,所以默认仍是 false。',
161
161
  ),
162
162
 
163
163
  preferPatch: z
package/src/metrics.js CHANGED
@@ -21,11 +21,28 @@
21
21
  import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
22
22
  import { dirname } from 'node:path'
23
23
 
24
+ import { trim } from './bounded.js'
24
25
  import { findUsage, readUsage } from './usage.js'
25
26
 
26
27
  /** The measurement format. Bumped when a field changes meaning. */
27
28
  export const METRICS_VERSION = 2
28
29
 
30
+ /**
31
+ * Ceilings for the four long-lived caches below. A host process lives for weeks
32
+ * and these caches are keyed by ids that never repeat, so "one entry per session
33
+ * it has ever seen" is an unbounded ceiling in disguise. The caps are far above
34
+ * anything one process holds at a time (the harness runs a handful of sessions
35
+ * and a few calls concurrently), so in practice they never evict a live entry —
36
+ * they exist so a long uptime cannot turn into a heap that only a restart fixes.
37
+ */
38
+ export const LABELS_MAX = 2000
39
+ /** In-flight step accumulators; one per open step across all sessions. */
40
+ export const STEPS_MAX = 2000
41
+ /** Which step a session's first streamed chunk belongs to. */
42
+ export const OPEN_MAX = 2000
43
+ /** A chunk that arrived before its `step/start`. */
44
+ export const PENDING_MAX = 1000
45
+
29
46
  /**
30
47
  * The size at which the step journal is rotated to `<file>.1`.
31
48
  *
@@ -190,6 +207,7 @@ export function createRecorder(options = {}) {
190
207
  seen: 0,
191
208
  }
192
209
  steps.set(key, entry)
210
+ trim(steps, STEPS_MAX)
193
211
  return { key, entry }
194
212
  }
195
213
 
@@ -209,6 +227,10 @@ export function createRecorder(options = {}) {
209
227
  */
210
228
  label(sid, label) {
211
229
  if (typeof sid !== 'string' || sid.length === 0) return
230
+ // With the sink off nothing will ever read this map, and the request hook
231
+ // calls this for every session: remembering labels for a file that does
232
+ // not exist is pure retention. (1.5.33 and earlier kept them anyway.)
233
+ if (!on) return
212
234
  const previous = labels.get(sid)
213
235
  if (previous && previous.mode === label.mode && previous.gear === label.gear
214
236
  && previous.effort === label.effort && previous.route === label.route) return
@@ -218,6 +240,7 @@ export function createRecorder(options = {}) {
218
240
  effort: label.effort,
219
241
  route: label.route,
220
242
  })
243
+ trim(labels, LABELS_MAX)
221
244
  },
222
245
 
223
246
  /** The last label recorded for a session. */
@@ -244,6 +267,8 @@ export function createRecorder(options = {}) {
244
267
  entry.started = time
245
268
  steps.set(key, entry)
246
269
  open.set(sid, key)
270
+ trim(steps, STEPS_MAX)
271
+ trim(open, OPEN_MAX)
247
272
  const early = pendingChunk.get(sid)
248
273
  if (early) {
249
274
  entry.chunkFirst = early.time
@@ -271,7 +296,10 @@ export function createRecorder(options = {}) {
271
296
  if (!entry) {
272
297
  // The live stream can arrive a few milliseconds before the durable
273
298
  // `step/start`; hold the first token until the step exists.
274
- if (!pendingChunk.has(sid)) pendingChunk.set(sid, { time, kind: chunkType })
299
+ if (!pendingChunk.has(sid)) {
300
+ pendingChunk.set(sid, { time, kind: chunkType })
301
+ trim(pendingChunk, PENDING_MAX)
302
+ }
275
303
  return
276
304
  }
277
305
  if (entry.chunkFirst === undefined) {
package/src/state.js CHANGED
@@ -106,6 +106,7 @@ function sanitise(input) {
106
106
  if (typeof entry.discipline === 'boolean' || entry.discipline === 'late') clean.discipline = entry.discipline
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
+ if (typeof entry.batch === 'boolean') clean.batch = entry.batch
109
110
  if (Number.isFinite(entry.ts)) clean.ts = Number(entry.ts)
110
111
  if (
111
112
  clean.enabled === undefined
@@ -113,6 +114,7 @@ function sanitise(input) {
113
114
  && clean.discipline === undefined
114
115
  && clean.stepBudget === undefined
115
116
  && clean.map === undefined
117
+ && clean.batch === undefined
116
118
  ) continue
117
119
  out[sessionId] = clean
118
120
  }
@@ -137,6 +139,7 @@ export function choiceFor(raw, sessionId) {
137
139
  if (entry.discipline !== undefined) choice.discipline = entry.discipline
138
140
  if (entry.stepBudget !== undefined) choice.stepBudget = entry.stepBudget
139
141
  if (entry.map !== undefined) choice.map = entry.map
142
+ if (entry.batch !== undefined) choice.batch = entry.batch
140
143
  return choice
141
144
  }
142
145
 
@@ -161,6 +164,7 @@ export function configOverridesFor(raw, sessionId) {
161
164
  if (choice.discipline !== undefined) overrides.outputDiscipline = choice.discipline
162
165
  if (choice.stepBudget !== undefined) overrides.stepBudget = choice.stepBudget
163
166
  if (choice.map !== undefined) overrides.workspaceMap = choice.map
167
+ if (choice.batch !== undefined) overrides.openBatch = choice.batch
164
168
  return Object.keys(overrides).length === 0 ? undefined : overrides
165
169
  }
166
170
 
@@ -215,7 +219,7 @@ export function writeChoice(raw, sessionId, choice, now) {
215
219
  return { sessions: encodeChoices(map), map }
216
220
  }
217
221
 
218
- /** @typedef {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, ts?: number}} SessionEntry */
222
+ /** @typedef {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, batch?: boolean, ts?: number}} SessionEntry */
219
223
 
220
224
  /**
221
225
  * Serialise the map, dropping the oldest entries beyond {@link SESSIONS_LIMIT}.