dsh-context-compression-improved 0.5.1 → 0.5.3

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.
Files changed (57) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -83
  3. package/CHANGELOG.ko.md +143 -82
  4. package/CHANGELOG.md +278 -212
  5. package/CHANGELOG.zh.md +131 -77
  6. package/docs/installation.md +103 -103
  7. package/docs/installation.zh.md +100 -100
  8. package/package.json +1 -1
  9. package/packages/selector/cordis.patch.yml +5 -6
  10. package/packages/selector/lib/advisor-state.js +4 -231
  11. package/packages/selector/lib/client.d.ts +0 -24
  12. package/packages/selector/lib/client.js +6 -501
  13. package/packages/selector/lib/index.d.ts +4 -10
  14. package/packages/selector/lib/index.js +16 -234
  15. package/packages/selector/lib/pruner.d.ts +13 -248
  16. package/packages/selector/lib/pruner.js +148 -552
  17. package/packages/selector/src/client/EstimatorControls.tsx +0 -101
  18. package/packages/selector/src/client/index.ts +0 -17
  19. package/packages/selector/src/client/locales.ts +0 -38
  20. package/packages/selector/src/client/preset-options.ts +3 -2
  21. package/packages/selector/src/client/settings-section.tsx +8 -17
  22. package/packages/selector/src/index.ts +24 -271
  23. package/packages/selector/src/profiles.ts +4 -27
  24. package/packages/selector/src/pruner/state.ts +2 -25
  25. package/packages/selector/src/pruner.ts +75 -403
  26. package/packages/selector/src/runtime/audit.ts +27 -21
  27. package/packages/selector/src/runtime/config.ts +6 -32
  28. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  29. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -133
  30. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  31. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  32. package/packages/selector/src/runtime/types.ts +0 -17
  33. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  34. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  35. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  36. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  37. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  38. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  39. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  40. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  41. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  42. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  43. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  44. package/packages/selector/tests/settings-seat.client.spec.ts +45 -14
  45. package/scripts/toolclass-corpus-replay.mjs +281 -281
  46. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  47. package/packages/selector/src/client/review-scope.ts +0 -16
  48. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  49. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  50. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  51. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  52. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  53. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  54. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  55. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  56. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  57. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -1,281 +1,281 @@
1
- #!/usr/bin/env node
2
- /**
3
- * toolclass-corpus-replay.mjs — 真实日志离线复算(task_5 / G3)。
4
- *
5
- * 对 `~/.dsh/sessions/**\/session*.jsonl.zstd` 多帧 zstd 会话日志:
6
- * 扫魔数 28 B5 2F FD 逐帧解压(帧去重:并发写入会落重复帧)
7
- * → 配对 tool/call ↔ tool/result(callId)
8
- * → 直调 reduceFreshToolResult(内部无门槛,门槛在 planFresh;本脚本自带
9
- * budgetChars 模拟,不测端到端 —— 必读①:验收必须在 reducer 层)
10
- * → 输出 ToolClass 分发矩阵 / reducer 命中率 / 压缩率 / 误路由对照 /
11
- * freshTriggerTokens 敏感性表。
12
- *
13
- * 用法:
14
- * node scripts/toolclass-corpus-replay.mjs [--dir <sessionsDir>] [--limit <N>]
15
- * [--session <substring>] [--min-chars <N>] [--budget-ratio <f>] [--out <report.md>]
16
- *
17
- * 依赖:先 `pnpm build` 生成 packages/selector/lib/pruner.js。
18
- * 样本偏差声明(必读⑨/RK-5):--limit 取的是体积最大的会话(偏长会话),
19
- * 结论不得外推到全体会话;报告须标注会话数/样本数/时间范围。
20
- * ⚠️ 口径标注:自 7a1972a(字符基准闸门)起,运行时决策按字符(characters)执行,
21
- * 本脚本输出的压缩率/预算均为 reducer 层字符口径;tokens 字段仅为遥测派生(chars/4.0),
22
- * 不得当作运行时决策依据。
23
- * ⚠️ --min-chars 默认 14000 只是**本脚本的样本过滤下限**,与运行时
24
- * `READ_TOC_MIN_CHARS`(reducers.ts)数值撞值但**毫无派生关系**:运行时的
25
- * fresh 门槛是 freshTriggerTokens(8192 tok ≈ 29.5k 字符),恒高于 14k 字符,
26
- * 本脚本过滤值从不参与运行时行为(findings §16)。
27
- */
28
-
29
- import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
30
- import { homedir } from 'node:os'
31
- import { join, relative } from 'node:path'
32
- import { zstdDecompressSync } from 'node:zlib'
33
-
34
- const REPO_ROOT = join(import.meta.dirname, '..')
35
- const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
36
-
37
- function parseArgs(argv) {
38
- const args = {
39
- dir: join(homedir(), '.dsh', 'sessions'),
40
- limit: 20,
41
- session: '',
42
- minChars: 14_000,
43
- budgetRatio: 0.75,
44
- out: '',
45
- }
46
- for (let index = 2; index < argv.length; index++) {
47
- const key = argv[index]
48
- const value = argv[index + 1]
49
- if (key === '--dir') { args.dir = value; index++ }
50
- else if (key === '--limit') { args.limit = Number(value); index++ }
51
- else if (key === '--session') { args.session = value; index++ }
52
- else if (key === '--min-chars') { args.minChars = Number(value); index++ }
53
- else if (key === '--budget-ratio') { args.budgetRatio = Number(value); index++ }
54
- else if (key === '--out') { args.out = value; index++ }
55
- else { console.error(`unknown arg ${key}`); process.exit(2) }
56
- }
57
- return args
58
- }
59
-
60
- /** 多帧 zstd:扫魔数逐帧解压;对解出的 JSONL 行全局去重(等价于帧去重,
61
- * 且对"重复帧但行交错"的场景更稳)。 */
62
- function decodeMultiFrameZstd(buffer) {
63
- const offsets = []
64
- let position = 0
65
- for (;;) {
66
- const index = buffer.indexOf(ZSTD_MAGIC, position)
67
- if (index < 0) break
68
- offsets.push(index)
69
- position = index + 1
70
- }
71
- const chunks = []
72
- let failedFrames = 0
73
- for (let index = 0; index < offsets.length; index++) {
74
- const chunk = buffer.subarray(offsets[index], index + 1 < offsets.length ? offsets[index + 1] : buffer.length)
75
- try {
76
- chunks.push(zstdDecompressSync(chunk))
77
- } catch {
78
- failedFrames += 1
79
- }
80
- }
81
- const seen = new Set()
82
- const lines = []
83
- for (const chunk of chunks) {
84
- for (const line of chunk.toString('utf8').split('\n')) {
85
- if (line.trim() === '' || seen.has(line)) continue
86
- seen.add(line)
87
- lines.push(line)
88
- }
89
- }
90
- return { lines, frames: offsets.length, failedFrames }
91
- }
92
-
93
- function listSessionFiles(dir, sessionFilter) {
94
- const files = []
95
- const walk = current => {
96
- let entries
97
- try { entries = readdirSync(current, { withFileTypes: true }) } catch { return }
98
- for (const entry of entries) {
99
- const path = join(current, entry.name)
100
- if (entry.isDirectory()) walk(path)
101
- else if (entry.name.startsWith('session') && entry.name.endsWith('.jsonl.zstd')) files.push(path)
102
- }
103
- }
104
- walk(dir)
105
- return files.filter(path => sessionFilter === '' || path.includes(sessionFilter))
106
- }
107
-
108
- /** tool/call ↔ tool/result 配对;返回 {toolName, arguments, text, isError, seq, time}。 */
109
- function extractToolResults(lines) {
110
- const calls = new Map()
111
- const results = []
112
- for (const line of lines) {
113
- let event
114
- try { event = JSON.parse(line) } catch { continue }
115
- if (event?.type === 'tool/call') {
116
- const callId = event.data?.callId
117
- if (typeof callId === 'string') {
118
- calls.set(callId, { name: event.data?.name ?? '', arguments: event.data?.arguments ?? '{}' })
119
- }
120
- } else if (event?.type === 'tool/result') {
121
- const message = event.data?.message
122
- const callId = message?.source?.callId
123
- const block = message?.content?.[0]
124
- const text = block?.content?.find(part => part?.type === 'text')?.text
125
- if (typeof callId !== 'string' || typeof text !== 'string') continue
126
- results.push({
127
- callId,
128
- toolName: calls.get(callId)?.name ?? '(unknown)',
129
- argumentsText: calls.get(callId)?.arguments ?? '{}',
130
- text,
131
- isError: block?.isError === true,
132
- seq: event.seq,
133
- time: event.time,
134
- })
135
- }
136
- }
137
- return results
138
- }
139
-
140
- /** v1 时代的旧分类(子串正则),仅用于误路由对照,不参与现行为。 */
141
- function legacyIsSearchTool(name, command) {
142
- return /(?:grep|search|glob|find|ripgrep|rg)/.test(name)
143
- || /(?:^|\s)(?:rg|grep|find|fd)\s/.test(command)
144
- }
145
- function legacyCommandKeys(argumentsText) {
146
- try {
147
- const parsed = JSON.parse(argumentsText)
148
- if (typeof parsed !== 'object' || parsed === null) return ''
149
- for (const key of ['command', 'cmd', 'script', 'input']) {
150
- if (typeof parsed[key] === 'string') return parsed[key]
151
- }
152
- } catch { /* ignore */ }
153
- return ''
154
- }
155
-
156
- async function main() {
157
- const args = parseArgs(process.argv)
158
- const lib = await import(new URL(`file://${join(REPO_ROOT, 'packages/selector/lib/pruner.js').replace(/\\/g, '/')}`).href)
159
- const { reduceFreshToolResult, resolvePolicy, COMPRESSION_PROFILES } = lib
160
- if (typeof reduceFreshToolResult !== 'function') throw new Error('built lib missing reduceFreshToolResult — run `pnpm build` first')
161
-
162
- const policy = resolvePolicy({}, 'balanced')
163
- console.log(`[policy] balanced: freshTriggerTokens = ${policy.freshTriggerTokens} | freshTargetTokens = ${policy.freshTargetTokens} | freshEnabled = ${policy.freshEnabled}`)
164
-
165
- const files = listSessionFiles(args.dir, args.session)
166
- .map(path => ({ path, size: statSync(path).size }))
167
- .sort((a, b) => b.size - a.size)
168
- .slice(0, args.limit)
169
- console.log(`[corpus] ${files.length} session file(s) (limit=${args.limit}, min-chars=${args.minChars}, dir=${args.dir})`)
170
- console.log(`[note] --min-chars is a sample filter for this script only; it is NOT the runtime READ_TOC_MIN_CHARS and does not gate runtime behavior`)
171
-
172
- const dispatch = new Map() // toolName → toolClass → { reducer → count }
173
- const reducerHits = new Map()
174
- const classTotals = new Map()
175
- const misroutes = []
176
- let totalIn = 0
177
- let totalOut = 0
178
- let reduced = 0
179
- let failOpen = 0
180
- let sessions = 0
181
- let minTime = Number.POSITIVE_INFINITY
182
- let maxTime = Number.NEGATIVE_INFINITY
183
- const readSizes = []
184
-
185
- for (const file of files) {
186
- sessions += 1
187
- const raw = readFileSync(file.path)
188
- const { lines, frames, failedFrames } = decodeMultiFrameZstd(raw)
189
- if (failedFrames > 0) console.warn(` [warn] ${relative(args.dir, file.path)}: ${failedFrames}/${frames} frame(s) failed to decode`)
190
- for (const result of extractToolResults(lines)) {
191
- if (result.text.length < args.minChars) continue
192
- minTime = Math.min(minTime, result.time)
193
- maxTime = Math.max(maxTime, result.time)
194
- const budgetChars = Math.floor(result.text.length * args.budgetRatio)
195
- const output = reduceFreshToolResult({
196
- toolName: result.toolName,
197
- argumentsText: result.argumentsText,
198
- text: result.text,
199
- budgetChars,
200
- sourceRef: `session://replay/${String(result.seq)}`,
201
- isError: result.isError,
202
- codeSkeleton: true,
203
- })
204
- const command = legacyCommandKeys(result.argumentsText)
205
- const lowered = result.toolName.toLowerCase()
206
- const legacySearch = legacyIsSearchTool(lowered, command)
207
- const nowSearchRoute = output?.reducer === 'search-by-file' || output?.reducer === 'search-salience'
208
- if (legacySearch && !nowSearchRoute && misroutes.length < 20) {
209
- misroutes.push({
210
- tool: result.toolName,
211
- chars: result.text.length,
212
- head: Array.from(result.text.slice(0, 80).replace(/\n/g, '\\n')),
213
- now: output?.reducer ?? 'fail-open',
214
- })
215
- }
216
- void legacySearch
217
- // 分发矩阵:工具名 → 现派发 reducer → 计数
218
- const byTool = dispatch.get(result.toolName) ?? {}
219
- const key = output === null ? 'fail-open' : output.reducer
220
- byTool[key] = (byTool[key] ?? 0) + 1
221
- dispatch.set(result.toolName, byTool)
222
- reducerHits.set(key, (reducerHits.get(key) ?? 0) + 1)
223
- classTotals.set(result.toolName, (classTotals.get(result.toolName) ?? 0) + 1)
224
- totalIn += result.text.length
225
- if (output !== null) {
226
- reduced += 1
227
- totalOut += output.text.length
228
- } else {
229
- failOpen += 1
230
- }
231
- if (result.toolName.toLowerCase().match(/read|cat|view/)) readSizes.push(result.text.length)
232
- }
233
- }
234
-
235
- const lines = []
236
- lines.push(`# toolclass corpus replay — ${new Date().toISOString()}`)
237
- lines.push(`- sessions: ${sessions} (top by size; BIAS: favors long sessions — do not extrapolate)`)
238
- lines.push(`- sample window (event time): ${Number.isFinite(minTime) ? new Date(minTime).toISOString() : 'n/a'} … ${Number.isFinite(maxTime) ? new Date(maxTime).toISOString() : 'n/a'}`)
239
- lines.push(`- samples ≥ ${args.minChars} chars: ${reduced + failOpen} (reduced ${reduced}, fail-open ${failOpen})`)
240
- lines.push(`- verify pass rate (non-null): ${reduced + failOpen === 0 ? 'n/a' : `${(reduced / (reduced + failOpen) * 100).toFixed(1)}%`}`)
241
- lines.push(`- compression: ${totalIn} → ${totalOut} chars (${totalIn === 0 ? 'n/a' : `${(totalOut / totalIn * 100).toFixed(1)}%`})`)
242
- lines.push('')
243
- lines.push('## reducer hit rate')
244
- for (const [key, count] of [...reducerHits.entries()].sort((a, b) => b[1] - a[1])) {
245
- lines.push(`- ${key}: ${count}`)
246
- }
247
- lines.push('')
248
- lines.push('## dispatch matrix (toolName → reducer → count)')
249
- for (const [toolName, byReducer] of [...dispatch.entries()]
250
- .sort((a, b) => Object.values(b[1]).reduce((x, y) => x + y, 0) - Object.values(a[1]).reduce((x, y) => x + y, 0))) {
251
- lines.push(`- ${toolName}: ${JSON.stringify(byReducer)}`)
252
- }
253
- lines.push('')
254
- lines.push('## fixed misroutes (legacy substring → search, now dispatched elsewhere)')
255
- for (const sample of misroutes) {
256
- lines.push(`- ${sample.tool} (${sample.chars} chars) → ${sample.now} | head: ${sample.head.join('')}`)
257
- }
258
- if (misroutes.length === 0) lines.push('- (none in this sample)')
259
- lines.push('')
260
- lines.push('## freshTriggerTokens sensitivity (read-class results, chars thresholds)')
261
- for (const trigger of [8_192, 6_144, 4_096, 2_048]) {
262
- const t36 = trigger * 3.6
263
- const t40 = trigger * 4.0
264
- const above36 = readSizes.filter(size => size > t36).length
265
- const above40 = readSizes.filter(size => size > t40).length
266
- lines.push(`- trigger ${trigger}: read samples > ${t40.toFixed(0)} chars (4.0 c/t): ${above40}/${readSizes.length}; > ${t36.toFixed(0)} (3.6 c/t): ${above36}/${readSizes.length}`)
267
- }
268
- lines.push('')
269
- lines.push(`> profiles available: ${COMPRESSION_PROFILES.join(', ')}`)
270
- const report = lines.join('\n')
271
- console.log('\n' + report)
272
- if (args.out !== '') {
273
- writeFileSync(args.out, report, 'utf8')
274
- console.log(`[written] ${args.out}`)
275
- }
276
- }
277
-
278
- main().catch(error => {
279
- console.error(error)
280
- process.exit(1)
281
- })
1
+ #!/usr/bin/env node
2
+ /**
3
+ * toolclass-corpus-replay.mjs — 真实日志离线复算(task_5 / G3)。
4
+ *
5
+ * 对 `~/.dsh/sessions/**\/session*.jsonl.zstd` 多帧 zstd 会话日志:
6
+ * 扫魔数 28 B5 2F FD 逐帧解压(帧去重:并发写入会落重复帧)
7
+ * → 配对 tool/call ↔ tool/result(callId)
8
+ * → 直调 reduceFreshToolResult(内部无门槛,门槛在 planFresh;本脚本自带
9
+ * budgetChars 模拟,不测端到端 —— 必读①:验收必须在 reducer 层)
10
+ * → 输出 ToolClass 分发矩阵 / reducer 命中率 / 压缩率 / 误路由对照 /
11
+ * freshTriggerTokens 敏感性表。
12
+ *
13
+ * 用法:
14
+ * node scripts/toolclass-corpus-replay.mjs [--dir <sessionsDir>] [--limit <N>]
15
+ * [--session <substring>] [--min-chars <N>] [--budget-ratio <f>] [--out <report.md>]
16
+ *
17
+ * 依赖:先 `pnpm build` 生成 packages/selector/lib/pruner.js。
18
+ * 样本偏差声明(必读⑨/RK-5):--limit 取的是体积最大的会话(偏长会话),
19
+ * 结论不得外推到全体会话;报告须标注会话数/样本数/时间范围。
20
+ * ⚠️ 口径标注:自 7a1972a(字符基准闸门)起,运行时决策按字符(characters)执行,
21
+ * 本脚本输出的压缩率/预算均为 reducer 层字符口径;tokens 字段仅为遥测派生(chars/4.0),
22
+ * 不得当作运行时决策依据。
23
+ * ⚠️ --min-chars 默认 14000 只是**本脚本的样本过滤下限**,与运行时
24
+ * `READ_TOC_MIN_CHARS`(reducers.ts)数值撞值但**毫无派生关系**:运行时的
25
+ * fresh 门槛是 freshTriggerTokens(8192 tok ≈ 29.5k 字符),恒高于 14k 字符,
26
+ * 本脚本过滤值从不参与运行时行为(findings §16)。
27
+ */
28
+
29
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
30
+ import { homedir } from 'node:os'
31
+ import { join, relative } from 'node:path'
32
+ import { zstdDecompressSync } from 'node:zlib'
33
+
34
+ const REPO_ROOT = join(import.meta.dirname, '..')
35
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
36
+
37
+ function parseArgs(argv) {
38
+ const args = {
39
+ dir: join(homedir(), '.dsh', 'sessions'),
40
+ limit: 20,
41
+ session: '',
42
+ minChars: 14_000,
43
+ budgetRatio: 0.75,
44
+ out: '',
45
+ }
46
+ for (let index = 2; index < argv.length; index++) {
47
+ const key = argv[index]
48
+ const value = argv[index + 1]
49
+ if (key === '--dir') { args.dir = value; index++ }
50
+ else if (key === '--limit') { args.limit = Number(value); index++ }
51
+ else if (key === '--session') { args.session = value; index++ }
52
+ else if (key === '--min-chars') { args.minChars = Number(value); index++ }
53
+ else if (key === '--budget-ratio') { args.budgetRatio = Number(value); index++ }
54
+ else if (key === '--out') { args.out = value; index++ }
55
+ else { console.error(`unknown arg ${key}`); process.exit(2) }
56
+ }
57
+ return args
58
+ }
59
+
60
+ /** 多帧 zstd:扫魔数逐帧解压;对解出的 JSONL 行全局去重(等价于帧去重,
61
+ * 且对"重复帧但行交错"的场景更稳)。 */
62
+ function decodeMultiFrameZstd(buffer) {
63
+ const offsets = []
64
+ let position = 0
65
+ for (;;) {
66
+ const index = buffer.indexOf(ZSTD_MAGIC, position)
67
+ if (index < 0) break
68
+ offsets.push(index)
69
+ position = index + 1
70
+ }
71
+ const chunks = []
72
+ let failedFrames = 0
73
+ for (let index = 0; index < offsets.length; index++) {
74
+ const chunk = buffer.subarray(offsets[index], index + 1 < offsets.length ? offsets[index + 1] : buffer.length)
75
+ try {
76
+ chunks.push(zstdDecompressSync(chunk))
77
+ } catch {
78
+ failedFrames += 1
79
+ }
80
+ }
81
+ const seen = new Set()
82
+ const lines = []
83
+ for (const chunk of chunks) {
84
+ for (const line of chunk.toString('utf8').split('\n')) {
85
+ if (line.trim() === '' || seen.has(line)) continue
86
+ seen.add(line)
87
+ lines.push(line)
88
+ }
89
+ }
90
+ return { lines, frames: offsets.length, failedFrames }
91
+ }
92
+
93
+ function listSessionFiles(dir, sessionFilter) {
94
+ const files = []
95
+ const walk = current => {
96
+ let entries
97
+ try { entries = readdirSync(current, { withFileTypes: true }) } catch { return }
98
+ for (const entry of entries) {
99
+ const path = join(current, entry.name)
100
+ if (entry.isDirectory()) walk(path)
101
+ else if (entry.name.startsWith('session') && entry.name.endsWith('.jsonl.zstd')) files.push(path)
102
+ }
103
+ }
104
+ walk(dir)
105
+ return files.filter(path => sessionFilter === '' || path.includes(sessionFilter))
106
+ }
107
+
108
+ /** tool/call ↔ tool/result 配对;返回 {toolName, arguments, text, isError, seq, time}。 */
109
+ function extractToolResults(lines) {
110
+ const calls = new Map()
111
+ const results = []
112
+ for (const line of lines) {
113
+ let event
114
+ try { event = JSON.parse(line) } catch { continue }
115
+ if (event?.type === 'tool/call') {
116
+ const callId = event.data?.callId
117
+ if (typeof callId === 'string') {
118
+ calls.set(callId, { name: event.data?.name ?? '', arguments: event.data?.arguments ?? '{}' })
119
+ }
120
+ } else if (event?.type === 'tool/result') {
121
+ const message = event.data?.message
122
+ const callId = message?.source?.callId
123
+ const block = message?.content?.[0]
124
+ const text = block?.content?.find(part => part?.type === 'text')?.text
125
+ if (typeof callId !== 'string' || typeof text !== 'string') continue
126
+ results.push({
127
+ callId,
128
+ toolName: calls.get(callId)?.name ?? '(unknown)',
129
+ argumentsText: calls.get(callId)?.arguments ?? '{}',
130
+ text,
131
+ isError: block?.isError === true,
132
+ seq: event.seq,
133
+ time: event.time,
134
+ })
135
+ }
136
+ }
137
+ return results
138
+ }
139
+
140
+ /** v1 时代的旧分类(子串正则),仅用于误路由对照,不参与现行为。 */
141
+ function legacyIsSearchTool(name, command) {
142
+ return /(?:grep|search|glob|find|ripgrep|rg)/.test(name)
143
+ || /(?:^|\s)(?:rg|grep|find|fd)\s/.test(command)
144
+ }
145
+ function legacyCommandKeys(argumentsText) {
146
+ try {
147
+ const parsed = JSON.parse(argumentsText)
148
+ if (typeof parsed !== 'object' || parsed === null) return ''
149
+ for (const key of ['command', 'cmd', 'script', 'input']) {
150
+ if (typeof parsed[key] === 'string') return parsed[key]
151
+ }
152
+ } catch { /* ignore */ }
153
+ return ''
154
+ }
155
+
156
+ async function main() {
157
+ const args = parseArgs(process.argv)
158
+ const lib = await import(new URL(`file://${join(REPO_ROOT, 'packages/selector/lib/pruner.js').replace(/\\/g, '/')}`).href)
159
+ const { reduceFreshToolResult, resolvePolicy, COMPRESSION_PROFILES } = lib
160
+ if (typeof reduceFreshToolResult !== 'function') throw new Error('built lib missing reduceFreshToolResult — run `pnpm build` first')
161
+
162
+ const policy = resolvePolicy({}, 'balanced')
163
+ console.log(`[policy] balanced: freshTriggerTokens = ${policy.freshTriggerTokens} | freshTargetTokens = ${policy.freshTargetTokens} | freshEnabled = ${policy.freshEnabled}`)
164
+
165
+ const files = listSessionFiles(args.dir, args.session)
166
+ .map(path => ({ path, size: statSync(path).size }))
167
+ .sort((a, b) => b.size - a.size)
168
+ .slice(0, args.limit)
169
+ console.log(`[corpus] ${files.length} session file(s) (limit=${args.limit}, min-chars=${args.minChars}, dir=${args.dir})`)
170
+ console.log(`[note] --min-chars is a sample filter for this script only; it is NOT the runtime READ_TOC_MIN_CHARS and does not gate runtime behavior`)
171
+
172
+ const dispatch = new Map() // toolName → toolClass → { reducer → count }
173
+ const reducerHits = new Map()
174
+ const classTotals = new Map()
175
+ const misroutes = []
176
+ let totalIn = 0
177
+ let totalOut = 0
178
+ let reduced = 0
179
+ let failOpen = 0
180
+ let sessions = 0
181
+ let minTime = Number.POSITIVE_INFINITY
182
+ let maxTime = Number.NEGATIVE_INFINITY
183
+ const readSizes = []
184
+
185
+ for (const file of files) {
186
+ sessions += 1
187
+ const raw = readFileSync(file.path)
188
+ const { lines, frames, failedFrames } = decodeMultiFrameZstd(raw)
189
+ if (failedFrames > 0) console.warn(` [warn] ${relative(args.dir, file.path)}: ${failedFrames}/${frames} frame(s) failed to decode`)
190
+ for (const result of extractToolResults(lines)) {
191
+ if (result.text.length < args.minChars) continue
192
+ minTime = Math.min(minTime, result.time)
193
+ maxTime = Math.max(maxTime, result.time)
194
+ const budgetChars = Math.floor(result.text.length * args.budgetRatio)
195
+ const output = reduceFreshToolResult({
196
+ toolName: result.toolName,
197
+ argumentsText: result.argumentsText,
198
+ text: result.text,
199
+ budgetChars,
200
+ sourceRef: `session://replay/${String(result.seq)}`,
201
+ isError: result.isError,
202
+ codeSkeleton: true,
203
+ })
204
+ const command = legacyCommandKeys(result.argumentsText)
205
+ const lowered = result.toolName.toLowerCase()
206
+ const legacySearch = legacyIsSearchTool(lowered, command)
207
+ const nowSearchRoute = output?.reducer === 'search-by-file' || output?.reducer === 'search-salience'
208
+ if (legacySearch && !nowSearchRoute && misroutes.length < 20) {
209
+ misroutes.push({
210
+ tool: result.toolName,
211
+ chars: result.text.length,
212
+ head: Array.from(result.text.slice(0, 80).replace(/\n/g, '\\n')),
213
+ now: output?.reducer ?? 'fail-open',
214
+ })
215
+ }
216
+ void legacySearch
217
+ // 分发矩阵:工具名 → 现派发 reducer → 计数
218
+ const byTool = dispatch.get(result.toolName) ?? {}
219
+ const key = output === null ? 'fail-open' : output.reducer
220
+ byTool[key] = (byTool[key] ?? 0) + 1
221
+ dispatch.set(result.toolName, byTool)
222
+ reducerHits.set(key, (reducerHits.get(key) ?? 0) + 1)
223
+ classTotals.set(result.toolName, (classTotals.get(result.toolName) ?? 0) + 1)
224
+ totalIn += result.text.length
225
+ if (output !== null) {
226
+ reduced += 1
227
+ totalOut += output.text.length
228
+ } else {
229
+ failOpen += 1
230
+ }
231
+ if (result.toolName.toLowerCase().match(/read|cat|view/)) readSizes.push(result.text.length)
232
+ }
233
+ }
234
+
235
+ const lines = []
236
+ lines.push(`# toolclass corpus replay — ${new Date().toISOString()}`)
237
+ lines.push(`- sessions: ${sessions} (top by size; BIAS: favors long sessions — do not extrapolate)`)
238
+ lines.push(`- sample window (event time): ${Number.isFinite(minTime) ? new Date(minTime).toISOString() : 'n/a'} … ${Number.isFinite(maxTime) ? new Date(maxTime).toISOString() : 'n/a'}`)
239
+ lines.push(`- samples ≥ ${args.minChars} chars: ${reduced + failOpen} (reduced ${reduced}, fail-open ${failOpen})`)
240
+ lines.push(`- verify pass rate (non-null): ${reduced + failOpen === 0 ? 'n/a' : `${(reduced / (reduced + failOpen) * 100).toFixed(1)}%`}`)
241
+ lines.push(`- compression: ${totalIn} → ${totalOut} chars (${totalIn === 0 ? 'n/a' : `${(totalOut / totalIn * 100).toFixed(1)}%`})`)
242
+ lines.push('')
243
+ lines.push('## reducer hit rate')
244
+ for (const [key, count] of [...reducerHits.entries()].sort((a, b) => b[1] - a[1])) {
245
+ lines.push(`- ${key}: ${count}`)
246
+ }
247
+ lines.push('')
248
+ lines.push('## dispatch matrix (toolName → reducer → count)')
249
+ for (const [toolName, byReducer] of [...dispatch.entries()]
250
+ .sort((a, b) => Object.values(b[1]).reduce((x, y) => x + y, 0) - Object.values(a[1]).reduce((x, y) => x + y, 0))) {
251
+ lines.push(`- ${toolName}: ${JSON.stringify(byReducer)}`)
252
+ }
253
+ lines.push('')
254
+ lines.push('## fixed misroutes (legacy substring → search, now dispatched elsewhere)')
255
+ for (const sample of misroutes) {
256
+ lines.push(`- ${sample.tool} (${sample.chars} chars) → ${sample.now} | head: ${sample.head.join('')}`)
257
+ }
258
+ if (misroutes.length === 0) lines.push('- (none in this sample)')
259
+ lines.push('')
260
+ lines.push('## freshTriggerTokens sensitivity (read-class results, chars thresholds)')
261
+ for (const trigger of [8_192, 6_144, 4_096, 2_048]) {
262
+ const t36 = trigger * 3.6
263
+ const t40 = trigger * 4.0
264
+ const above36 = readSizes.filter(size => size > t36).length
265
+ const above40 = readSizes.filter(size => size > t40).length
266
+ lines.push(`- trigger ${trigger}: read samples > ${t40.toFixed(0)} chars (4.0 c/t): ${above40}/${readSizes.length}; > ${t36.toFixed(0)} (3.6 c/t): ${above36}/${readSizes.length}`)
267
+ }
268
+ lines.push('')
269
+ lines.push(`> profiles available: ${COMPRESSION_PROFILES.join(', ')}`)
270
+ const report = lines.join('\n')
271
+ console.log('\n' + report)
272
+ if (args.out !== '') {
273
+ writeFileSync(args.out, report, 'utf8')
274
+ console.log(`[written] ${args.out}`)
275
+ }
276
+ }
277
+
278
+ main().catch(error => {
279
+ console.error(error)
280
+ process.exit(1)
281
+ })