dsh-context-compression-improved 0.4.0-beta.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +68 -36
- package/CHANGELOG.ko.md +67 -35
- package/CHANGELOG.md +195 -134
- package/CHANGELOG.zh.md +64 -36
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/installation.ja.md +2 -2
- package/docs/installation.ko.md +2 -2
- package/docs/installation.md +103 -78
- package/docs/installation.zh.md +100 -77
- package/docs/repair-log.md +54 -0
- package/package.json +1 -1
- package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
- package/packages/selector/lib/client.d.ts +7 -0
- package/packages/selector/lib/client.js +33 -3
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +112 -3
- package/packages/selector/lib/pruner.d.ts +128 -1
- package/packages/selector/lib/pruner.js +2802 -1374
- package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
- package/packages/selector/src/client/index.ts +1 -1
- package/packages/selector/src/client/preset-options.ts +2 -0
- package/packages/selector/src/index.ts +129 -49
- package/packages/selector/src/profiles.ts +48 -0
- package/packages/selector/src/pruner/content.ts +18 -5
- package/packages/selector/src/pruner/state.ts +3 -0
- package/packages/selector/src/pruner/types.ts +23 -5
- package/packages/selector/src/pruner.ts +297 -162
- package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
- package/packages/selector/src/runtime/audit.ts +40 -2
- package/packages/selector/src/runtime/config.ts +88 -1
- package/packages/selector/src/runtime/measurement.ts +31 -2
- package/packages/selector/src/runtime/reducers.ts +1115 -97
- package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
- package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
- package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
- package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
- package/packages/selector/src/runtime/toolclass.ts +103 -0
- package/packages/selector/src/runtime/types.ts +37 -0
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
- package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
- package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
- package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
- package/packages/selector/tests/runtime/audit.spec.ts +88 -1
- package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
- package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
- package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
- package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
- package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
- package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
- package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
- package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
- package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
- package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
- package/scripts/toolclass-corpus-replay.mjs +281 -0
|
@@ -181,7 +181,6 @@ function reviewSession(ctx: Context, id: string, oldText: string): {
|
|
|
181
181
|
session: Session
|
|
182
182
|
oldResultSeq: number
|
|
183
183
|
} {
|
|
184
|
-
const pruner = ctx.toolResultPruner
|
|
185
184
|
const session = Session.create(SessionId(id))
|
|
186
185
|
const old = appendToolTurn(session, 1, oldText, true)
|
|
187
186
|
appendToolTurn(session, 2, 'newest protected result', true)
|
|
@@ -311,3 +310,73 @@ describe('tokenpilot review pipeline (host integration)', () => {
|
|
|
311
310
|
expect(ctx.toolResultPruner.listReviewProposals(session)).toHaveLength(0)
|
|
312
311
|
})
|
|
313
312
|
})
|
|
313
|
+
|
|
314
|
+
/** Fresh-stage triage must NOT charge the tail-refill penalty: the fresh
|
|
315
|
+
* candidate has never been served, so shaping it causes no cache break.
|
|
316
|
+
* Fixture keeps historyKeepRecentTokens at the production-like 64,000 so
|
|
317
|
+
* history pricing (penalty 57,600) would drop the batch outright — the
|
|
318
|
+
* fresh stage must still land it. */
|
|
319
|
+
describe('tokenpilot review pipeline (fresh-stage exemption)', () => {
|
|
320
|
+
async function freshReviewSetup(ctx: Context): Promise<void> {
|
|
321
|
+
await ctx.plugin(TestSettings).await()
|
|
322
|
+
await ctx.plugin(SelectorHost).await()
|
|
323
|
+
await ctx.settings.update(nsBrand(CONTEXT_COMPRESSION_SETTINGS_NAMESPACE), {
|
|
324
|
+
profile: 'tokenpilot-inspired',
|
|
325
|
+
presetOptions: { reviewMode: true, reviewHighImpactTokens: 1_000_000 },
|
|
326
|
+
})
|
|
327
|
+
await ctx.plugin(ToolResultPruner, {
|
|
328
|
+
profile: 'tokenpilot-inspired',
|
|
329
|
+
freshTriggerTokens: 200,
|
|
330
|
+
freshTargetTokens: 100,
|
|
331
|
+
aggregateTriggerTokens: 1_000_000,
|
|
332
|
+
aggregateTargetTokens: 900_000,
|
|
333
|
+
historyTriggerTokens: 400,
|
|
334
|
+
historyKeepRecentToolCalls: 0,
|
|
335
|
+
historyKeepRecentTokens: 64_000,
|
|
336
|
+
historyMinReclaimTokens: 1,
|
|
337
|
+
}).await()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function freshSession(ctx: Context, id: string, text: string): Session {
|
|
341
|
+
const session = Session.create(SessionId(id))
|
|
342
|
+
appendToolTurn(session, 1, text, true)
|
|
343
|
+
const total = measureForCompaction(ctx, session).totalTokens
|
|
344
|
+
session.append('request/context', {
|
|
345
|
+
provider: 'deepseek',
|
|
346
|
+
model: MODEL,
|
|
347
|
+
contextWindow: Math.floor(total / 0.6),
|
|
348
|
+
})
|
|
349
|
+
// Fresh landing publishes a surface replacement, which requires an open turn.
|
|
350
|
+
session.append('turn/start', { turn: 2 })
|
|
351
|
+
return session
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
it('lands a fresh batch under review mode instead of dropping it', async () => {
|
|
355
|
+
const ctx = await runtimeContext()
|
|
356
|
+
await freshReviewSetup(ctx)
|
|
357
|
+
const audit = captureAudit(ctx)
|
|
358
|
+
const session = freshSession(ctx, 'fresh-review-exempt', 'fresh reviewable evidence '.repeat(400))
|
|
359
|
+
|
|
360
|
+
const result = ctx.toolResultPruner.pruneSession(session, { stage: 'fresh', freshTurn: 1, freshStep: 1 })
|
|
361
|
+
|
|
362
|
+
// Without the exemption the 57,600-token refill penalty prices this batch
|
|
363
|
+
// into the drop band (payback > 3 with Ŝ unknown) and nothing lands.
|
|
364
|
+
expect(result.pruned).toHaveLength(1)
|
|
365
|
+
expect(rewrites(audit.records()).some(entry => entry.component === 'fresh')).toBe(true)
|
|
366
|
+
expect(reviewEvents(audit.records()).filter(entry => entry.event === 'enqueue')).toHaveLength(0)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('keeps the history batch priced with the refill penalty', async () => {
|
|
370
|
+
const ctx = await runtimeContext()
|
|
371
|
+
await freshReviewSetup(ctx)
|
|
372
|
+
const audit = captureAudit(ctx)
|
|
373
|
+
const { session } = reviewSession(ctx, 'history-review-priced', 'old reviewable evidence '.repeat(600))
|
|
374
|
+
|
|
375
|
+
const result = ctx.toolResultPruner.pruneSession(session, { stage: 'pressure' })
|
|
376
|
+
|
|
377
|
+
// History content was already served: the refill penalty is real, Ŝ is
|
|
378
|
+
// unknown, payback > 3 → drop band → nothing lands and nothing queues.
|
|
379
|
+
expect(result.pruned).toHaveLength(0)
|
|
380
|
+
expect(reviewEvents(audit.records()).filter(entry => entry.event === 'enqueue')).toHaveLength(0)
|
|
381
|
+
})
|
|
382
|
+
})
|
|
@@ -55,4 +55,28 @@ describe('tokenpilot read-state helpers', () => {
|
|
|
55
55
|
expect(clusterOmittedLines('fine', 1)).toBe('1 lines omitted (1 info)')
|
|
56
56
|
expect(clusterOmittedLines('fine', 0)).toBeUndefined()
|
|
57
57
|
})
|
|
58
|
+
|
|
59
|
+
// R8 census: a document's dropped content is described by its section
|
|
60
|
+
// headings, not by an error/warn/info histogram that is always 0/0/N.
|
|
61
|
+
it('lists section headings for document content instead of the info census', () => {
|
|
62
|
+
const doc = [
|
|
63
|
+
'# User Guide',
|
|
64
|
+
'Intro paragraph.',
|
|
65
|
+
'## Installation',
|
|
66
|
+
'Run the installer.',
|
|
67
|
+
'## Configuration',
|
|
68
|
+
'Set the flags.',
|
|
69
|
+
'## Troubleshooting',
|
|
70
|
+
'Check the logs.',
|
|
71
|
+
'## Reference',
|
|
72
|
+
'Appendix material.',
|
|
73
|
+
].join('\n')
|
|
74
|
+
const census = clusterOmittedLines(doc, 40)
|
|
75
|
+
expect(census).toBe(
|
|
76
|
+
'40 lines omitted (sections: User Guide · Installation · Configuration · Troubleshooting · Reference)',
|
|
77
|
+
)
|
|
78
|
+
for (const heading of ['Installation', 'Troubleshooting', 'Reference']) {
|
|
79
|
+
expect(census).toContain(heading)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
58
82
|
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { classifyToolSource } from '../../src/runtime/toolclass.ts'
|
|
3
|
+
import { extractCommand, reduceFreshToolResult } from '../../src/runtime/reducers.ts'
|
|
4
|
+
|
|
5
|
+
/** Checklist C1–C15 against the FOUR-class narrowing (tasks v2 G4): web/memory
|
|
6
|
+
* search and research/sql names fall to generic, glob is path-listing, only
|
|
7
|
+
* grep/rg/ripgrep are search, and the content fallback never overrides a
|
|
8
|
+
* whitelist hit. */
|
|
9
|
+
describe('classifyToolSource — name whitelist', () => {
|
|
10
|
+
it('keeps web_search out of search (C1)', () => {
|
|
11
|
+
expect(classifyToolSource('web_search', '')).toBe('generic')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('routes glob to path-listing (C2)', () => {
|
|
15
|
+
expect(classifyToolSource('glob', '')).toBe('path-listing')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps memory/conversation search generic — four classes only (C3, v2)', () => {
|
|
19
|
+
expect(classifyToolSource('memory_search', '')).toBe('generic')
|
|
20
|
+
expect(classifyToolSource('conversation_search', '')).toBe('generic')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('routes grep/rg/ripgrep to search (C4)', () => {
|
|
24
|
+
expect(classifyToolSource('grep', '')).toBe('search')
|
|
25
|
+
expect(classifyToolSource('rg', '')).toBe('search')
|
|
26
|
+
expect(classifyToolSource('ripgrep', '')).toBe('search')
|
|
27
|
+
expect(classifyToolSource('grep_search', '')).toBe('search')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('routes shell/bash/pwsh/terminal to shell-exec (C5)', () => {
|
|
31
|
+
expect(classifyToolSource('shell', '')).toBe('shell')
|
|
32
|
+
expect(classifyToolSource('bash', '')).toBe('shell')
|
|
33
|
+
expect(classifyToolSource('pwsh', '')).toBe('shell')
|
|
34
|
+
expect(classifyToolSource('powershell', '')).toBe('shell')
|
|
35
|
+
expect(classifyToolSource('terminal', '')).toBe('shell')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('routes read/read_file/cat/view/open_file to file-read (C6)', () => {
|
|
39
|
+
expect(classifyToolSource('read', '')).toBe('read')
|
|
40
|
+
expect(classifyToolSource('read_file', '')).toBe('read')
|
|
41
|
+
expect(classifyToolSource('cat', '')).toBe('read')
|
|
42
|
+
expect(classifyToolSource('view', '')).toBe('read')
|
|
43
|
+
expect(classifyToolSource('view_file', '')).toBe('read')
|
|
44
|
+
expect(classifyToolSource('open_file', '')).toBe('read')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('is generic for mutation tools — no file-mutate class in the four-class set (C7, v2)', () => {
|
|
48
|
+
expect(classifyToolSource('write', '')).toBe('generic')
|
|
49
|
+
expect(classifyToolSource('edit', '')).toBe('generic')
|
|
50
|
+
expect(classifyToolSource('apply_patch', '')).toBe('generic')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns generic for any name with an mcp token, before content evidence (C8)', () => {
|
|
54
|
+
expect(classifyToolSource('mcp__server__grep', '', 'src/a.ts:1: x')).toBe('generic')
|
|
55
|
+
expect(classifyToolSource('mcp-grep', '', 'src/a.ts:1: x')).toBe('generic')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('never misroutes `research` into a search class (C9)', () => {
|
|
59
|
+
expect(classifyToolSource('research', '')).toBe('generic')
|
|
60
|
+
expect(classifyToolSource('deep_research', '')).toBe('generic')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('never misroutes `execute_sql` into shell-exec (C10)', () => {
|
|
64
|
+
expect(classifyToolSource('execute_sql', '')).toBe('generic')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('matches tokens exactly — no substring bleed (task_11)', () => {
|
|
68
|
+
expect(classifyToolSource('advanced_search', '')).toBe('generic')
|
|
69
|
+
expect(classifyToolSource('free_search_test', '')).toBe('generic')
|
|
70
|
+
expect(classifyToolSource('theme_global', '')).toBe('generic')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('classifyToolSource — command and content fallback', () => {
|
|
75
|
+
it('routes rg/grep commands to search even from an unknown tool name', () => {
|
|
76
|
+
expect(classifyToolSource('run_tool', 'rg pattern src/')).toBe('search')
|
|
77
|
+
expect(classifyToolSource('run_tool', 'grep -n TODO .')).toBe('search')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('routes find/fd/ls/tree commands to path-listing, not search', () => {
|
|
81
|
+
expect(classifyToolSource('run_tool', 'find . -name "*.ts"')).toBe('path-listing')
|
|
82
|
+
expect(classifyToolSource('run_tool', 'fd config')).toBe('path-listing')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('falls back to search on a path:line:content line (C13)', () => {
|
|
86
|
+
expect(classifyToolSource('unknown_tool', '', 'src/index.ts:12: export const x')).toBe('search')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not let prose colons or URLs masquerade as grep output (C13 guard)', () => {
|
|
90
|
+
expect(classifyToolSource('unknown_tool', '', 'Note: 2024 - something happened')).toBe('generic')
|
|
91
|
+
expect(classifyToolSource('unknown_tool', '', 'see https://host.example:8080 - login')).toBe('generic')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('falls back to path-listing when ≥80% of lines are pure paths (C14)', () => {
|
|
95
|
+
const listing = ['src/index.ts', 'src/main.ts', 'src/util.ts', 'README.md'].join('\n')
|
|
96
|
+
expect(classifyToolSource('unknown_tool', '', listing)).toBe('path-listing')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('never overrides a whitelist hit with content evidence (C15)', () => {
|
|
100
|
+
expect(classifyToolSource('bash', '', 'src/index.ts:12: export const x')).toBe('shell')
|
|
101
|
+
expect(classifyToolSource('glob', '', 'src/index.ts:12: export const x')).toBe('path-listing')
|
|
102
|
+
expect(classifyToolSource('read', '', 'src/index.ts')).toBe('read')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('extractCommand — input key removed (C11/C12)', () => {
|
|
107
|
+
it('returns empty for an input-only payload (C11)', () => {
|
|
108
|
+
expect(extractCommand('{"input":"arbitrary model text"}')).toBe('')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('still reads the command key (C12)', () => {
|
|
112
|
+
expect(extractCommand('{"command":"npm test"}')).toBe('npm test')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('still reads cmd/script fallbacks', () => {
|
|
116
|
+
expect(extractCommand('{"cmd":"pnpm build"}')).toBe('pnpm build')
|
|
117
|
+
expect(extractCommand('{"script":"ls"}')).toBe('ls')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/** End-to-end dispatch guard for the misroute fixes: a `glob` result and an
|
|
122
|
+
* `execute_sql` result must not land on the search reducer even when they
|
|
123
|
+
* exceed every form gate. */
|
|
124
|
+
describe('toolclass dispatch integration', () => {
|
|
125
|
+
const paths = Array.from({ length: 200 }, (_, index) => `pkg/dir${String(index)}/file${String(index)}.ts`)
|
|
126
|
+
const globText = paths.join('\n')
|
|
127
|
+
|
|
128
|
+
it('routes a large glob result away from search-salience (task_11)', () => {
|
|
129
|
+
const output = reduceFreshToolResult({
|
|
130
|
+
toolName: 'glob',
|
|
131
|
+
argumentsText: '{}',
|
|
132
|
+
text: globText,
|
|
133
|
+
budgetChars: 4_000,
|
|
134
|
+
sourceRef: 'session://probe/glob',
|
|
135
|
+
isError: false,
|
|
136
|
+
})
|
|
137
|
+
expect(output).not.toBeNull()
|
|
138
|
+
expect(output!.reducer).not.toBe('search-by-file')
|
|
139
|
+
expect(output!.reducer).not.toBe('search-salience')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('routes a large mcp web_search result away from the search reducer (C1/AD1)', () => {
|
|
143
|
+
const web = Array.from({ length: 60 }, (_, index) =>
|
|
144
|
+
`- [Result ${String(index)}](https://example.com/${String(index)}) — a summary paragraph of the ${String(index)}th hit.`).join('\n')
|
|
145
|
+
const output = reduceFreshToolResult({
|
|
146
|
+
toolName: 'mcp__web__web_search',
|
|
147
|
+
argumentsText: '{}',
|
|
148
|
+
text: web,
|
|
149
|
+
budgetChars: 2_000,
|
|
150
|
+
sourceRef: 'session://probe/web',
|
|
151
|
+
isError: false,
|
|
152
|
+
})
|
|
153
|
+
expect(output).not.toBeNull()
|
|
154
|
+
expect(output!.reducer).not.toBe('search-by-file')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +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
|
+
})
|