dsh-fast-mode 1.5.34 → 1.5.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.35(1.5.34 的补包:把 `bench/soak.mjs` 打进 npm 包)
4
+
5
+ - 1.5.34 的 `BENCHMARK.md` 让人「自己跑一遍浸泡」,但 `bench/soak.mjs` 没在 `package.json` 的 `files` 里——装了包的人照着跑会发现文件不存在。这一版只补这一行 `files`(自包含:只 import `../index.js`),行为零改动,测试仍 313 全绿。
6
+
3
7
  ## 1.5.34(稳定性版:插件里最后 6 个随宿主任期增长的缓存全部封顶,附一个可重跑的浸泡测试)
4
8
 
5
9
  按外部评审这一轮的建议,不新增任何提醒时机、重复提醒或预算旋钮,默认值与全部开关一律不动。只做一件事:**让插件在「几周不重启的宿主」里不再积累任何东西**,并把这件事变成能重跑的数字。
package/bench/soak.mjs ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Soak the plugin in a long-lived host process and watch the heap.
4
+ *
5
+ * 1.5.34 bounded every long-lived cache the plugin owns (`compacted`,
6
+ * `mapSent`, `autoBudgets`, `startedAt`, and the metrics sink's labels/steps/
7
+ * open/pending sets). A unit test can check the mapping logic but not the
8
+ * growth, because the caches are private to the closure. This script mounts the
9
+ * real plugin exactly like the harness does and then plays a host that never
10
+ * restarts: thousands of sessions, each with its own turns and tool calls,
11
+ * interleaved so every cache sees churn.
12
+ *
13
+ * It reports heap after warm-up and at the end. Growth stays flat when the caps
14
+ * work; without them the heap climbs with the session count and a real host
15
+ * eventually dies.
16
+ *
17
+ * node --expose-gc bench/soak.mjs # 4000 sessions, 4 turns each
18
+ * node --expose-gc bench/soak.mjs --sessions 8000 # longer
19
+ * node --expose-gc bench/soak.mjs --metrics # exercise the metrics sink
20
+ * # (temp file, then removed)
21
+ *
22
+ * Two notes from building this. The event feed has to be *awaited*: firing
23
+ * `agent/request` without awaiting left 16 000 promises pending and reported
24
+ * +49 MB that belonged to the test, not the plugin. And the heap baseline is
25
+ * taken after a warm-up batch, because the first sessions pay for lazily built
26
+ * module state that is not growth.
27
+ */
28
+ import { rmSync } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { join } from 'node:path'
31
+
32
+ import { apply } from '../index.js'
33
+
34
+ /** Same fake-Cordis shape as `test/plugin.test.js`, trimmed to what the soak drives. */
35
+ function fakeContext(services = {}) {
36
+ const handlers = new Map()
37
+ return {
38
+ handlers,
39
+ on(event, handler) {
40
+ handlers.set(event, handler)
41
+ return () => handlers.delete(event)
42
+ },
43
+ inject(names, callback) {
44
+ if (!names.every(name => services[name])) return undefined
45
+ const child = {
46
+ ...services,
47
+ get: name => services[name],
48
+ effect(setup) {
49
+ const disposer = setup()
50
+ return typeof disposer === 'function' ? disposer : () => {}
51
+ },
52
+ }
53
+ return callback(child)
54
+ },
55
+ get: name => services[name],
56
+ logger: { info() {}, warn() {}, debug() {} },
57
+ fire(event, ...args) {
58
+ const handler = handlers.get(event)
59
+ return handler ? handler(...args) : undefined
60
+ },
61
+ }
62
+ }
63
+
64
+ const llm = {
65
+ resolveModelInfo: () => ({
66
+ reasoning: { efforts: ['none', 'minimal', 'low', 'medium', 'high'].map(id => ({ id })), defaultEffort: 'high' },
67
+ }),
68
+ }
69
+
70
+ const argOf = (flag, fallback) => {
71
+ const hit = process.argv.indexOf(flag)
72
+ return hit > -1 ? Number(process.argv[hit + 1]) : fallback
73
+ }
74
+
75
+ const SESSIONS = argOf('--sessions', 4000)
76
+ const TURNS = argOf('--turns', 4)
77
+ const STEPS = 3
78
+
79
+ const mb = () => process.memoryUsage().heapUsed / 1024 / 1024
80
+
81
+ /**
82
+ * Measure retained heap, not allocated heap. Without a collection the number is
83
+ * mostly garbage from the steps just driven, which is what makes a healthy run
84
+ * look like it leaks. `--expose-gc` is required for a meaningful number; without
85
+ * it the script says so instead of reporting a false failure.
86
+ */
87
+ const settle = () => {
88
+ if (typeof global.gc === 'function') {
89
+ global.gc()
90
+ global.gc()
91
+ return true
92
+ }
93
+ return false
94
+ }
95
+
96
+ /** One turn: the pre-step notice path plus a request, as the harness orders them. */
97
+ const SKIP_EVENT = process.argv.includes('--no-event')
98
+ const SKIP_REQUEST = process.argv.includes('--no-request')
99
+
100
+ async function drive(ctx, session, turn, bump) {
101
+ const agent = { id: session.id, session }
102
+ const emit = (event) => {
103
+ if (!SKIP_EVENT) ctx.fire('session/event', session, event)
104
+ }
105
+ emit({ type: 'turn/start', turn })
106
+ for (let step = 1; step <= STEPS; step += 1) {
107
+ emit({ type: 'step/start', turn, step, time: Date.now() })
108
+ if (!SKIP_REQUEST) {
109
+ await ctx.fire(
110
+ 'agent/request',
111
+ { agent, turn, step, signal: new AbortController().signal },
112
+ async () => ({ provider: 'company', model: 'deepseek-v4.1-flash', reasoningEffort: 'medium' }),
113
+ )
114
+ }
115
+ emit({ type: 'assistant/message', turn, step, time: Date.now(), usage: { inputTokens: 200, outputTokens: 30 } })
116
+ emit({ type: 'step/end', turn, step, time: Date.now() })
117
+ bump()
118
+ }
119
+ // Compaction happens on a turn boundary in the real host; emitting it here keeps
120
+ // the post-compaction reminder cache under churn.
121
+ if (turn % 2 === 1) emit({ type: 'turn/compacted', turn })
122
+ }
123
+
124
+ async function main() {
125
+ const ctx = fakeContext({ llm })
126
+ // Metrics and the experiment file off: a soak must not touch the developer's
127
+ // home directory, and it must not be steered by a live experiment file.
128
+ const OFF = process.argv.includes('--off')
129
+ // `--metrics` writes the same JSONL sink a real session writes, to a temp file
130
+ // outside the home directory, so the label/step caches see the same churn.
131
+ const METRICS = process.argv.includes('--metrics')
132
+ const metricsFile = METRICS ? join(tmpdir(), `dsh-fast-mode-soak-${process.pid}.jsonl`) : ''
133
+ apply(ctx, { enabled: !OFF, metrics: METRICS, metricsFile, benchmarkFile: '' })
134
+
135
+ // `--one-id` reuses a single session id, which separates per-session caches
136
+ // from anything retained per call.
137
+ const ONE = process.argv.includes('--one-id')
138
+ const sessions = Array.from({ length: SESSIONS }, (_, i) => ({ id: ONE ? 'soak-shared' : `soak-${i}` }))
139
+ const started = Date.now()
140
+ let steps = 0
141
+ const bump = () => { steps += 1 }
142
+
143
+ // Warm-up: the first sessions pay for module-level lazily built things
144
+ // (regexes, the gear table), which would otherwise look like growth.
145
+ for (const session of sessions.slice(0, Math.min(200, SESSIONS))) {
146
+ for (let turn = 0; turn < TURNS; turn += 1) await drive(ctx, session, turn, bump)
147
+ }
148
+ const collected = settle()
149
+ const warm = mb()
150
+
151
+ for (const session of sessions) {
152
+ for (let turn = 0; turn < TURNS; turn += 1) await drive(ctx, session, turn, bump)
153
+ }
154
+ settle()
155
+ const end = mb()
156
+
157
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
158
+ const growth = end - warm
159
+ const perSession = growth / SESSIONS
160
+ console.log(`sessions ${SESSIONS} × ${TURNS} turns, ${steps} steps`)
161
+ console.log(`heap warm ${warm.toFixed(2)} MB`)
162
+ console.log(`heap end ${end.toFixed(2)} MB`)
163
+ console.log(`growth ${growth >= 0 ? '+' : ''}${growth.toFixed(2)} MB over ${SESSIONS} sessions`)
164
+ console.log(`per session ${(perSession * 1024).toFixed(1)} KB`)
165
+ console.log(`elapsed ${elapsed} s`)
166
+ if (!collected) console.log('note run with --expose-gc for a retained-heap number')
167
+ // Without the caps this adds a Map entry and a Set entry per session; V8's own
168
+ // noise is a few MB, so the threshold is generous on purpose — this checks
169
+ // "bounded", not "exact".
170
+ if (METRICS) {
171
+ // The sink is a diagnostic file, not a result: leave nothing behind.
172
+ try {
173
+ rmSync(metricsFile, { force: true })
174
+ rmSync(`${metricsFile}.1`, { force: true })
175
+ } catch {
176
+ // Temp file; a failed cleanup must not fail the soak.
177
+ }
178
+ console.log(`metrics ${steps} steps written, caches trimmed`)
179
+ }
180
+ if (perSession > 0.004) {
181
+ console.error('FAIL: heap grows with the session count — a cache is unbounded')
182
+ process.exitCode = 1
183
+ }
184
+ }
185
+
186
+ main().catch((error) => {
187
+ console.error(error)
188
+ process.exitCode = 1
189
+ })
package/docs/REFERENCE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 参考手册:原理、配置与边界
2
2
 
3
- 本手册描述 **dsh-fast-mode 1.5.34** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.33 面板为五行、五行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
3
+ 本手册描述 **dsh-fast-mode 1.5.35** 的实际行为(默认配置下的注入行为与 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
 
package/index.js CHANGED
@@ -98,7 +98,7 @@ const TAG = '[fast-mode]'
98
98
  * Shipped identity of this build. Traced into the debug log so a stale install is
99
99
  * visible at a glance, and pinned by a test against `package.json`.
100
100
  */
101
- export const VERSION = '1.5.34'
101
+ export const VERSION = '1.5.35'
102
102
  const GLOBAL_AGENT = '__global__'
103
103
 
104
104
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.34",
3
+ "version": "1.5.35",
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",
@@ -46,6 +46,7 @@
46
46
  "bench/ablate.py",
47
47
  "bench/turns.py",
48
48
  "bench/replay.mjs",
49
+ "bench/soak.mjs",
49
50
  "docs/",
50
51
  "LICENSE"
51
52
  ],