dsh-context-compression-improved 0.1.1 → 0.2.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.
Files changed (148) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/workflows/ci.yml +39 -0
  3. package/CHANGELOG.ja.md +39 -0
  4. package/CHANGELOG.ko.md +39 -0
  5. package/CHANGELOG.md +135 -0
  6. package/CHANGELOG.zh.md +39 -0
  7. package/CONTRIBUTING.md +22 -0
  8. package/README.ja.md +104 -0
  9. package/README.ko.md +103 -0
  10. package/README.md +89 -12
  11. package/README.zh.md +87 -12
  12. package/SECURITY.md +18 -0
  13. package/THIRD_PARTY_NOTICES.md +7 -31
  14. package/docs/installation.ja.md +76 -0
  15. package/docs/installation.ko.md +76 -0
  16. package/docs/installation.md +76 -0
  17. package/docs/installation.zh.md +76 -0
  18. package/docs/repair-log.md +582 -0
  19. package/eslint.config.js +30 -0
  20. package/package.json +84 -81
  21. package/packages/selector/LICENSE +21 -0
  22. package/packages/selector/README.md +26 -0
  23. package/packages/selector/README.zh.md +26 -0
  24. package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
  25. package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
  26. package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
  27. package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
  28. package/packages/selector/lib/style.css +308 -0
  29. package/packages/selector/package.json +115 -0
  30. package/{screenshots.json → packages/selector/screenshots.json} +6 -6
  31. package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
  32. package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
  33. package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
  34. package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
  35. package/packages/selector/src/client/EstimatorControls.tsx +281 -0
  36. package/packages/selector/src/client/decode.ts +49 -0
  37. package/packages/selector/src/client/index.ts +111 -0
  38. package/packages/selector/src/client/locales.ts +198 -0
  39. package/packages/selector/src/client/preset-options.ts +70 -0
  40. package/packages/selector/src/client/settings-section.tsx +126 -0
  41. package/packages/selector/src/css-modules.d.ts +6 -0
  42. package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
  43. package/packages/selector/src/estimator-catalog.ts +104 -0
  44. package/packages/selector/src/index.ts +327 -0
  45. package/packages/selector/src/invariant.ts +113 -0
  46. package/packages/selector/src/preset-overlay.ts +567 -0
  47. package/packages/selector/src/profiles.ts +342 -0
  48. package/packages/selector/src/pruner/content.ts +188 -0
  49. package/packages/selector/src/pruner/session.ts +94 -0
  50. package/packages/selector/src/pruner/state.ts +43 -0
  51. package/packages/selector/src/pruner/tuning.ts +23 -0
  52. package/packages/selector/src/pruner/types.ts +60 -0
  53. package/packages/selector/src/pruner.ts +2144 -0
  54. package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
  55. package/packages/selector/src/runtime/audit.ts +215 -0
  56. package/packages/selector/src/runtime/config.ts +613 -0
  57. package/packages/selector/src/runtime/custom-policy.ts +278 -0
  58. package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
  59. package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
  60. package/packages/selector/src/runtime/measurement.ts +403 -0
  61. package/packages/selector/src/runtime/reducers.ts +656 -0
  62. package/packages/selector/src/runtime/retrieve.ts +457 -0
  63. package/packages/selector/src/runtime/session-events.ts +17 -0
  64. package/packages/selector/src/runtime/tail-trim.ts +166 -0
  65. package/packages/selector/src/runtime/token-count.ts +72 -0
  66. package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
  67. package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
  68. package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
  69. package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
  70. package/packages/selector/src/runtime/types.ts +309 -0
  71. package/packages/selector/src/runtime/value.ts +48 -0
  72. package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
  73. package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
  74. package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
  75. package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
  76. package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
  77. package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
  78. package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
  79. package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
  80. package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
  81. package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
  82. package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
  83. package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
  84. package/packages/selector/tests/profiles.client.spec.tsx +434 -0
  85. package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
  86. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
  87. package/packages/selector/tests/runtime/audit.spec.ts +129 -0
  88. package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
  89. package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
  90. package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
  91. package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
  92. package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
  93. package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
  94. package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
  95. package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
  96. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
  97. package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
  98. package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
  99. package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
  100. package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
  101. package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
  102. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
  103. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
  104. package/packages/selector/tests/runtime/value.spec.ts +23 -0
  105. package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
  106. package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
  107. package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
  108. package/packages/selector/tests/support/mock-adapter.ts +37 -0
  109. package/packages/selector/tests/support/ui-primitives.tsx +34 -0
  110. package/packages/selector/tsconfig.json +11 -0
  111. package/packages/selector/tsdown.client.config.ts +102 -0
  112. package/packages/selector/tsdown.config.ts +20 -0
  113. package/pnpm-workspace.yaml +19 -0
  114. package/scripts/capture-profile-baseline.ts +80 -0
  115. package/scripts/generate-tokenizer-fixtures.py +81 -0
  116. package/scripts/generate-vision-fixtures.py +208 -0
  117. package/scripts/packed-components-smoke.ts +713 -0
  118. package/scripts/packed-install-e2e.ts +1072 -0
  119. package/scripts/verify-release.ts +300 -0
  120. package/tests/TEST_INVENTORY.md +42 -0
  121. package/tsconfig.base.json +18 -0
  122. package/tsconfig.json +7 -0
  123. package/tsconfig.scripts.json +13 -0
  124. package/tsconfig.tests.json +15 -0
  125. package/vitest.built.config.ts +9 -0
  126. package/vitest.config.ts +43 -0
  127. /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
  128. /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
  129. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
  130. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
  131. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
  132. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
  133. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
  134. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
  135. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
  136. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
  137. /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
  138. /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
  139. /package/{lib → packages/selector/lib}/client.d.ts +0 -0
  140. /package/{lib → packages/selector/lib}/client.js +0 -0
  141. /package/{lib → packages/selector/lib}/config.js +0 -0
  142. /package/{lib → packages/selector/lib}/index.d.ts +0 -0
  143. /package/{lib → packages/selector/lib}/index.js +0 -0
  144. /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
  145. /package/{lib → packages/selector/lib}/invariant.js +0 -0
  146. /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
  147. /package/{lib → packages/selector/lib}/pruner.js +0 -0
  148. /package/{lib → packages/selector/lib}/tail-trim.js +0 -0
@@ -0,0 +1,656 @@
1
+ /** Deterministic, evidence-backed reducers for fresh tool results. */
2
+
3
+ import { codePointLength } from './config.ts'
4
+
5
+ /** Input shared by every fresh-result reducer. */
6
+ export interface ReducerInput {
7
+ readonly toolName: string
8
+ readonly argumentsText: string
9
+ readonly text: string
10
+ readonly budgetChars: number
11
+ readonly sourceRef: string
12
+ readonly isError: boolean
13
+ /**
14
+ * Orthogonal user gate for the `hypa-code-skeleton` candidate. Absent or
15
+ * false keeps source-code content on its existing head/tail reducers.
16
+ */
17
+ readonly codeSkeleton?: boolean
18
+ }
19
+
20
+ /** One verified reducer candidate. */
21
+ export interface ReducerOutput {
22
+ readonly text: string
23
+ readonly reducer: string
24
+ readonly lossy: boolean
25
+ }
26
+
27
+ const ANSI_PATTERN = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/gu
28
+ const IMPORTANT_PATTERN = new RegExp([
29
+ String.raw`\b(?:error|failed|failure|fatal|panic|exception|warning|warn|conflict|denied|forbidden|`,
30
+ String.raw`timeout|timed out|not found|cannot|unable|invalid|exit(?:ed)?\s+(?:code|status)|traceback|`,
31
+ String.raw`assert(?:ion)?|segmentation fault|oom|out of memory)\b`,
32
+ ].join(''), 'i')
33
+ const STATUS_PATTERN = new RegExp([
34
+ String.raw`\b(?:success|succeeded|passed|installed|added|removed|updated|built|compiled|`,
35
+ String.raw`tests?\s+(?:passed|failed)|exit(?:ed)?\s+(?:code|status))\b`,
36
+ ].join(''), 'i')
37
+ const PATH_LINE_PATTERN = /^(.*?):(\d+)(?::\d+)?(?::|\s+-\s+)(.*)$/
38
+ const GIT_STATUS_PATTERN = new RegExp([
39
+ String.raw`^(?:On branch|Your branch|HEAD detached|Changes |Untracked |Unmerged |\s*(?:modified|deleted|`,
40
+ String.raw`new file|renamed|both modified):)`,
41
+ ].join(''), 'i')
42
+ const CODE_IMPORT_PATTERN = new RegExp([
43
+ String.raw`^\s*(?:import\b|from\s+[\w.]+\s+import\b|use\s+\w|package\s+|#include\b|`,
44
+ String.raw`using\s+[\w.]+;|require\s*\(|extern\s+crate\b)`,
45
+ ].join(''))
46
+ const CODE_STRUCTURE_PATTERN = new RegExp([
47
+ String.raw`^\s*(?:@[\w.]+|export\s+|default\s+|declare\s+|abstract\s+|public\s+|private\s+|protected\s+|`,
48
+ String.raw`internal\s+|static\s+|final\s+|sealed\s+|override\s+|pub(?:\([^)]*\))?\s+|async\s+|unsafe\s+)*`,
49
+ String.raw`(?:function\b|class\b|interface\b|enum\b|struct\b|impl\b|trait\b|type\s+\w|fn\s|func\b|`,
50
+ String.raw`def\s|module\b|namespace\b|sub\s)`,
51
+ ].join(''))
52
+ const PYTHON_STRUCTURE_PATTERN = /^\s*(?:async\s+)?def\s|^\s*class\s/
53
+ const CODE_DECORATOR_PATTERN = /^\s*@[\w.]+/
54
+ const CODE_COMMENT_PATTERN = /^\s*(?:\/\/|#|\/\*|\*)/
55
+
56
+ /**
57
+ * Select a reducer from verified tool, command, and content evidence.
58
+ * @param input - original result text, recovery source, and output budget.
59
+ * @returns a verified candidate, or `null` when every reducer fails open.
60
+ */
61
+ export function reduceFreshToolResult(input: ReducerInput): ReducerOutput | null {
62
+ const normalized = normalizeTerminalText(input.text)
63
+ const prepared = { ...input, text: normalized }
64
+ const command = extractCommand(input.argumentsText)
65
+ const name = input.toolName.toLowerCase()
66
+ const candidates: Array<() => ReducerOutput | null> = []
67
+
68
+ if (looksLikeJson(normalized)) candidates.push(() => reduceJson(prepared))
69
+ if (isSearchTool(name, command)) candidates.push(() => reduceSearch(prepared))
70
+ if (isGitCommand(name, command)) candidates.push(() => reduceGit(prepared, command))
71
+ if (isPackageCommand(command)) candidates.push(() => reducePatternLog(prepared, 'hypa-package', packagePattern()))
72
+ if (isBuildOrTestCommand(command)) candidates.push(() => reducePatternLog(prepared, 'hypa-build-test', buildPattern()))
73
+ if (input.codeSkeleton === true && looksLikeSourceCode(normalized)) candidates.push(() => reduceCodeSkeleton(prepared))
74
+ if (isReadTool(name)) candidates.push(() => reduceHead(prepared, 'pi-head'))
75
+ if (isShellTool(name) || command !== '') candidates.push(() => reduceShell(prepared))
76
+ candidates.push(() => reduceSalient(prepared, 'generic-salience'))
77
+
78
+ for (const make of candidates) {
79
+ const candidate = make()
80
+ if (candidate !== null && verifyReduction(input, candidate)) return candidate
81
+ }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Build a recoverable placeholder for an old tool result.
87
+ * @param input - tool identity, source reference, size, status, and retained evidence.
88
+ * @returns a lossy placeholder that cites the immutable source event.
89
+ */
90
+ export function historicalPlaceholder(input: {
91
+ readonly toolName: string
92
+ readonly sourceRef: string
93
+ readonly charsBefore: number
94
+ readonly isError: boolean
95
+ readonly text: string
96
+ readonly compact?: boolean
97
+ }): ReducerOutput {
98
+ const anchor = input.compact ? '' : importantAnchor(input.text, 360)
99
+ const lines = [
100
+ '[Old tool result content cleared from active context]',
101
+ `tool: ${input.toolName || 'unknown'}`,
102
+ `status: ${input.isError ? 'error' : 'completed'}`,
103
+ `original_chars: ${String(input.charsBefore)}`,
104
+ `source: ${input.sourceRef}`,
105
+ 'retrieve: context_compression_retrieve({"ref":"' + input.sourceRef + '"})',
106
+ ]
107
+ if (anchor !== '') lines.push(`retained_anchor: ${anchor}`)
108
+ return {
109
+ text: lines.join('\n'),
110
+ reducer: input.compact ? 'pair-preserving-tail-aging' : 'historical-tool-result-aging',
111
+ lossy: true,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Validate shrinkage, budget, recovery, and error retention.
117
+ * @param input - original reducer input and its safety requirements.
118
+ * @param output - candidate reduced text and reducer metadata.
119
+ * @returns whether the candidate is safe to land.
120
+ */
121
+ export function verifyReduction(input: ReducerInput, output: ReducerOutput): boolean {
122
+ const before = codePointLength(input.text)
123
+ const after = codePointLength(output.text)
124
+ if (after <= 0 || after >= before || after > input.budgetChars) return false
125
+ if (output.lossy && !output.text.includes(input.sourceRef)) return false
126
+ if ((input.isError || IMPORTANT_PATTERN.test(input.text))
127
+ && !IMPORTANT_PATTERN.test(output.text) && !output.text.includes('status: error')) return false
128
+ return true
129
+ }
130
+
131
+ /**
132
+ * Strip ANSI, collapse carriage-return progress redraws, and fold exact repeats.
133
+ * @param text - raw terminal output.
134
+ * @returns normalized terminal text.
135
+ */
136
+ export function normalizeTerminalText(text: string): string {
137
+ const withoutAnsi = text.replace(ANSI_PATTERN, '')
138
+ const logical = withoutAnsi.split('\n').map((line) => {
139
+ const redraws = line.split('\r').filter(part => part !== '')
140
+ return redraws.at(-1) ?? ''
141
+ })
142
+ const folded: string[] = []
143
+ let previous: string | undefined
144
+ let count = 0
145
+ const flush = (): void => {
146
+ if (previous === undefined) return
147
+ folded.push(previous)
148
+ if (count > 1) folded.push(`[previous line repeated ${String(count - 1)} more times]`)
149
+ }
150
+ for (const line of logical) {
151
+ if (line === previous) {
152
+ count++
153
+ continue
154
+ }
155
+ flush()
156
+ previous = line
157
+ count = 1
158
+ }
159
+ flush()
160
+ return folded.join('\n')
161
+ }
162
+
163
+ function reduceHead(input: ReducerInput, reducer: string): ReducerOutput | null {
164
+ const marker = omissionMarker(input, reducer)
165
+ const available = input.budgetChars - codePointLength(marker) - 1
166
+ if (available <= 0) return null
167
+ const head = takeWholeLinesFromHead(input.text, available)
168
+ if (head === input.text || head === '') return null
169
+ return { text: `${head}\n${marker}`, reducer, lossy: true }
170
+ }
171
+
172
+ function reduceTail(input: ReducerInput, reducer: string): ReducerOutput | null {
173
+ const marker = omissionMarker(input, reducer)
174
+ const available = input.budgetChars - codePointLength(marker) - 1
175
+ if (available <= 0) return null
176
+ const tail = takeWholeLinesFromTail(input.text, available)
177
+ if (tail === input.text || tail === '') return null
178
+ return { text: `${marker}\n${tail}`, reducer, lossy: true }
179
+ }
180
+
181
+ function reduceJson(input: ReducerInput): ReducerOutput | null {
182
+ let value: unknown
183
+ try {
184
+ value = JSON.parse(input.text)
185
+ } catch {
186
+ return null
187
+ }
188
+ const minified = JSON.stringify(value)
189
+ if (codePointLength(minified) < codePointLength(input.text)
190
+ && codePointLength(minified) <= input.budgetChars) {
191
+ return { text: minified, reducer: 'json-minify', lossy: false }
192
+ }
193
+ const envelope = {
194
+ $dsh_compression: {
195
+ kind: 'json-preview',
196
+ source: input.sourceRef,
197
+ original_chars: codePointLength(input.text),
198
+ },
199
+ value: shrinkJson(value, 0),
200
+ }
201
+ const text = JSON.stringify(envelope, null, 2)
202
+ if (codePointLength(text) <= input.budgetChars) {
203
+ return { text, reducer: 'json-structure-preview', lossy: true }
204
+ }
205
+ return null
206
+ }
207
+
208
+ function shrinkJson(value: unknown, depth: number): unknown {
209
+ if (depth >= 5) {
210
+ if (Array.isArray(value)) return `[array length=${String(value.length)} omitted]`
211
+ if (typeof value === 'object' && value !== null) return '[object omitted]'
212
+ return value
213
+ }
214
+ if (Array.isArray(value)) {
215
+ if (value.length <= 8) return value.map(entry => shrinkJson(entry, depth + 1))
216
+ return [
217
+ ...value.slice(0, 3).map(entry => shrinkJson(entry, depth + 1)),
218
+ { $dsh_omitted_items: value.length - 5 },
219
+ ...value.slice(-2).map(entry => shrinkJson(entry, depth + 1)),
220
+ ]
221
+ }
222
+ if (typeof value !== 'object' || value === null) {
223
+ if (typeof value === 'string' && codePointLength(value) > 800) {
224
+ return `${Array.from(value).slice(0, 500).join('')}…[${String(codePointLength(value) - 700)} chars omitted]…${Array.from(value).slice(-200).join('')}`
225
+ }
226
+ return value
227
+ }
228
+ const entries = Object.entries(value)
229
+ const important = entries.filter(([key]) => /error|warn|status|code|message|path|file|line|summary/i.test(key))
230
+ const selected = entries.length <= 18
231
+ ? entries
232
+ : [...entries.slice(0, 10), ...important.filter(entry => !entries.slice(0, 10).includes(entry)).slice(0, 6), ...entries.slice(-2)]
233
+ const result: Record<string, unknown> = {}
234
+ for (const [key, entry] of selected) result[key] = shrinkJson(entry, depth + 1)
235
+ if (selected.length < entries.length) result.$dsh_omitted_keys = entries.length - selected.length
236
+ return result
237
+ }
238
+
239
+ function reduceSearch(input: ReducerInput): ReducerOutput | null {
240
+ const lines = splitLines(input.text)
241
+ const groups = new Map<string, Array<{ line: string; important: boolean }>>()
242
+ const ungrouped: Array<{ line: string; important: boolean }> = []
243
+ for (const line of lines) {
244
+ const match = PATH_LINE_PATTERN.exec(line)
245
+ const row = { line, important: IMPORTANT_PATTERN.test(line) }
246
+ if (match === null) {
247
+ ungrouped.push(row)
248
+ continue
249
+ }
250
+ const path = match[1] ?? '<unknown>'
251
+ const bucket = groups.get(path) ?? []
252
+ bucket.push(row)
253
+ groups.set(path, bucket)
254
+ }
255
+ if (groups.size === 0) return reduceSalient(input, 'search-salience')
256
+ const selected: string[] = []
257
+ let omitted = 0
258
+ for (const [path, rows] of groups) {
259
+ const keep = new Set<number>([0, rows.length - 1])
260
+ rows.forEach((row, index) => { if (row.important) keep.add(index) })
261
+ for (let index = 0; index < rows.length && keep.size < 5; index++) keep.add(index)
262
+ const indexes = [...keep].filter(index => index >= 0).sort((a, b) => a - b)
263
+ selected.push(`## ${path} (${String(rows.length)} matches)`)
264
+ for (const index of indexes) {
265
+ const row = rows[index]
266
+ if (row !== undefined) selected.push(row.line)
267
+ }
268
+ omitted += rows.length - indexes.length
269
+ }
270
+ for (const row of ungrouped.filter(row => row.important).slice(0, 12)) selected.push(row.line)
271
+ const header = `[search results compressed; ${String(omitted)} matches omitted; source: ${input.sourceRef}]`
272
+ const text = fitLines([header, ...selected], input.budgetChars, input.sourceRef)
273
+ return text === null ? null : { text, reducer: 'search-by-file', lossy: true }
274
+ }
275
+
276
+ function reduceGit(input: ReducerInput, command: string): ReducerOutput | null {
277
+ const lines = splitLines(input.text)
278
+ const lower = command.toLowerCase()
279
+ let keep: string[]
280
+ let reducer: string
281
+ if (/\bgit\s+(?:diff|show)\b/.test(lower)) {
282
+ reducer = 'hypa-git-diff'
283
+ keep = lines.filter(line => /^(?:diff --git|index |--- |\+\+\+ |@@ |[+-](?![+-]))/.test(line)
284
+ || IMPORTANT_PATTERN.test(line))
285
+ } else if (/\bgit\s+(?:status|switch|checkout|merge|rebase|cherry-pick)\b/.test(lower)) {
286
+ reducer = 'hypa-git-status'
287
+ keep = lines.filter(line => GIT_STATUS_PATTERN.test(line)
288
+ || IMPORTANT_PATTERN.test(line))
289
+ } else {
290
+ reducer = 'hypa-git-log'
291
+ keep = lines.filter(line => /^(?:commit\s+[0-9a-f]+|Author:|Date:|[0-9a-f]{7,}\s)/i.test(line)
292
+ || IMPORTANT_PATTERN.test(line))
293
+ }
294
+ if (keep.length === 0) return reduceSalient(input, reducer)
295
+ const header = `[git output compressed; source: ${input.sourceRef}]`
296
+ const text = fitLines([header, ...keep, ...lines.slice(-8)], input.budgetChars, input.sourceRef)
297
+ return text === null ? null : { text, reducer, lossy: true }
298
+ }
299
+
300
+ function reducePatternLog(input: ReducerInput, reducer: string, pattern: RegExp): ReducerOutput | null {
301
+ const lines = splitLines(input.text)
302
+ const important = lines.filter(line => pattern.test(line) || IMPORTANT_PATTERN.test(line) || STATUS_PATTERN.test(line))
303
+ const header = `[command output compressed by ${reducer}; source: ${input.sourceRef}]`
304
+ const text = fitLines([header, ...important, ...lines.slice(-20)], input.budgetChars, input.sourceRef)
305
+ return text === null ? null : { text, reducer, lossy: true }
306
+ }
307
+
308
+ function reduceShell(input: ReducerInput): ReducerOutput | null {
309
+ const lines = splitLines(input.text)
310
+ const important = lines.filter(line => IMPORTANT_PATTERN.test(line))
311
+ if (important.length === 0) return reduceTail(input, 'pi-tail')
312
+ const header = `[shell/log output compressed; source: ${input.sourceRef}]`
313
+ const text = fitLines([header, ...important, '--- final output ---', ...lines.slice(-40)], input.budgetChars, input.sourceRef)
314
+ return text === null ? null : { text, reducer: 'shell-salience-tail', lossy: true }
315
+ }
316
+
317
+ function reduceSalient(input: ReducerInput, reducer: string): ReducerOutput | null {
318
+ const lines = splitLines(input.text)
319
+ if (lines.length < 3) return reduceHead(input, reducer)
320
+ const marker = omissionMarker(input, reducer)
321
+ const headBudget = Math.max(1, Math.floor((input.budgetChars - codePointLength(marker)) * 0.34))
322
+ const tailBudget = headBudget
323
+ const head = takeWholeLinesFromHead(input.text, headBudget)
324
+ const tail = takeWholeLinesFromTail(input.text, tailBudget)
325
+ const salient = lines.filter(line => IMPORTANT_PATTERN.test(line) || STATUS_PATTERN.test(line)).slice(0, 24)
326
+ const text = fitLines([head, ...salient, marker, tail], input.budgetChars, input.sourceRef)
327
+ return text === null ? null : { text, reducer, lossy: true }
328
+ }
329
+
330
+ /**
331
+ * Keep a source-file skeleton: imports, decorators, declaration signatures,
332
+ * comments at brace depth zero, and every error-signalling line, eliding the
333
+ * remaining bodies with counted markers. Covers brace languages (TS/JS, Rust,
334
+ * Go, Java, C family) and indent blocks (Python); unknown syntax fails open to
335
+ * the next candidate. Output is compressed evidence, not required to parse.
336
+ * @param input - original result text, recovery source, and output budget.
337
+ * @returns a verified candidate, or `null` when the text is not code-like.
338
+ */
339
+ function reduceCodeSkeleton(input: ReducerInput): ReducerOutput | null {
340
+ const lines = splitLines(input.text)
341
+ const kept: string[] = []
342
+ let elided = 0
343
+ const flushElided = (): void => {
344
+ if (elided > 0) kept.push(`[... ${String(elided)} lines elided ...]`)
345
+ elided = 0
346
+ }
347
+ let depth = 0
348
+ let index = 0
349
+ const elideBraceBody = (): void => {
350
+ const startDepth = depth
351
+ index += 1
352
+ while (index < lines.length && depth > startDepth) {
353
+ const body = lines[index]
354
+ if (body === undefined) break
355
+ if (IMPORTANT_PATTERN.test(body)) {
356
+ flushElided()
357
+ kept.push(body)
358
+ } else {
359
+ elided += 1
360
+ }
361
+ depth += braceDelta(body)
362
+ index += 1
363
+ }
364
+ flushElided()
365
+ }
366
+ const keepPythonSignature = (signatureLine: string): void => {
367
+ // The signature line is already kept; every path below must advance the
368
+ // cursor past it so the caller's `continue` cannot revisit the same line.
369
+ index += 1
370
+ if (/:\s*$/.test(signatureLine)) {
371
+ elideIndentedBody(leadingIndent(signatureLine))
372
+ return
373
+ }
374
+ // Multi-line signature: keep continuation lines until the colon, then
375
+ // elide the indented body at the colon line's indent.
376
+ for (let guard = 0; guard < 6 && index < lines.length; guard += 1) {
377
+ const next = lines[index]
378
+ if (next === undefined) break
379
+ if (next.trim() !== '' && leadingIndent(next) <= leadingIndent(signatureLine)) break
380
+ flushElided()
381
+ kept.push(next)
382
+ index += 1
383
+ if (/:\s*$/.test(next)) {
384
+ elideIndentedBody(leadingIndent(next))
385
+ return
386
+ }
387
+ if (next.trim() !== '' && !/[:,(]\s*$/.test(next)) break
388
+ }
389
+ }
390
+ const elideIndentedBody = (indent: number): void => {
391
+ while (index < lines.length) {
392
+ const body = lines[index]
393
+ if (body === undefined) break
394
+ if (body.trim() !== '' && leadingIndent(body) <= indent) break
395
+ if (IMPORTANT_PATTERN.test(body)) {
396
+ flushElided()
397
+ kept.push(body)
398
+ index += 1
399
+ continue
400
+ }
401
+ if (isCodeStructureLine(body) || CODE_DECORATOR_PATTERN.test(body)) {
402
+ flushElided()
403
+ kept.push(body)
404
+ keepPythonSignature(body)
405
+ continue
406
+ }
407
+ elided += 1
408
+ index += 1
409
+ }
410
+ flushElided()
411
+ }
412
+ while (index < lines.length) {
413
+ const line = lines[index]
414
+ if (line === undefined) break
415
+ const delta = braceDelta(line)
416
+ if (IMPORTANT_PATTERN.test(line)) {
417
+ flushElided()
418
+ kept.push(line)
419
+ depth += delta
420
+ index += 1
421
+ continue
422
+ }
423
+ if (isCodeStructureLine(line) || CODE_IMPORT_PATTERN.test(line) || CODE_DECORATOR_PATTERN.test(line)) {
424
+ flushElided()
425
+ kept.push(line)
426
+ depth += delta
427
+ if (delta > 0) {
428
+ elideBraceBody()
429
+ continue
430
+ }
431
+ if (PYTHON_STRUCTURE_PATTERN.test(line)) {
432
+ keepPythonSignature(line)
433
+ continue
434
+ }
435
+ // Brace-language signature continuation: keep following lines until one
436
+ // opens a block, then elide that block.
437
+ let opened = false
438
+ for (let guard = 0; guard < 6 && index + 1 < lines.length; guard += 1) {
439
+ const next = lines[index + 1]
440
+ if (next === undefined) break
441
+ const nextDelta = braceDelta(next)
442
+ if (nextDelta === 0 && next.trim() !== '' && !/[:,(]\s*$/.test(next)) break
443
+ flushElided()
444
+ kept.push(next)
445
+ depth += nextDelta
446
+ index += 1
447
+ if (nextDelta > 0) {
448
+ opened = true
449
+ break
450
+ }
451
+ }
452
+ if (opened) elideBraceBody()
453
+ else index += 1
454
+ continue
455
+ }
456
+ if (depth === 0 && CODE_COMMENT_PATTERN.test(line)) {
457
+ flushElided()
458
+ kept.push(line)
459
+ } else {
460
+ elided += 1
461
+ }
462
+ depth += delta
463
+ index += 1
464
+ }
465
+ flushElided()
466
+ return finishSkeleton(kept, lines, input)
467
+ }
468
+
469
+ function finishSkeleton(
470
+ kept: readonly string[],
471
+ lines: readonly string[],
472
+ input: ReducerInput,
473
+ ): ReducerOutput | null {
474
+ const header = `[code output compressed by hypa-code-skeleton; source: ${input.sourceRef}]`
475
+ const text = fitLines([header, ...kept, ...lines.slice(-4)], input.budgetChars, input.sourceRef)
476
+ return text === null ? null : { text, reducer: 'hypa-code-skeleton', lossy: true }
477
+ }
478
+
479
+ /** Net brace delta of one line, ignoring braces inside string literals. */
480
+ function braceDelta(line: string): number {
481
+ let delta = 0
482
+ let quote: string | null = null
483
+ for (let position = 0; position < line.length; position += 1) {
484
+ const char = line[position]
485
+ if (quote !== null) {
486
+ if (char === '\\') position += 1
487
+ else if (char === quote) quote = null
488
+ continue
489
+ }
490
+ if (char === '"' || char === "'" || char === '`') {
491
+ quote = char
492
+ continue
493
+ }
494
+ if (char === '{') delta += 1
495
+ else if (char === '}') delta -= 1
496
+ }
497
+ return delta
498
+ }
499
+
500
+ function leadingIndent(line: string): number {
501
+ return codePointLength(line) - codePointLength(line.trimStart())
502
+ }
503
+
504
+ function isCodeStructureLine(line: string): boolean {
505
+ return CODE_STRUCTURE_PATTERN.test(line) || PYTHON_STRUCTURE_PATTERN.test(line)
506
+ }
507
+
508
+ /**
509
+ * Require content evidence of source code: enough declaration, import, or
510
+ * decorator lines among a bounded prefix. Failing this keeps prose, logs, and
511
+ * data on their existing reducers.
512
+ * @param text - normalized result text.
513
+ * @returns whether the text qualifies as source code.
514
+ */
515
+ function looksLikeSourceCode(text: string): boolean {
516
+ const lines = splitLines(text)
517
+ if (lines.length < 12) return false
518
+ let evidence = 0
519
+ for (const line of lines.slice(0, 400)) {
520
+ if (isCodeStructureLine(line) || CODE_IMPORT_PATTERN.test(line) || CODE_DECORATOR_PATTERN.test(line)) {
521
+ evidence += 1
522
+ if (evidence >= 3) return true
523
+ }
524
+ }
525
+ return false
526
+ }
527
+
528
+ function omissionMarker(input: ReducerInput, reducer: string): string {
529
+ return `[... ${reducer} omitted content; original_chars=${String(codePointLength(input.text))}; source=${input.sourceRef}; retrieve with context_compression_retrieve ...]`
530
+ }
531
+
532
+ function importantAnchor(text: string, maxChars: number): string {
533
+ const lines = splitLines(normalizeTerminalText(text))
534
+ const chosen = lines.find(line => IMPORTANT_PATTERN.test(line)) ?? lines.at(-1) ?? ''
535
+ return Array.from(chosen.trim()).slice(0, maxChars).join('')
536
+ }
537
+
538
+ function fitLines(lines: readonly string[], budgetChars: number, requiredRef: string): string | null {
539
+ const unique: string[] = []
540
+ const seen = new Set<string>()
541
+ for (const line of lines) {
542
+ if (line === '' || seen.has(line)) continue
543
+ seen.add(line)
544
+ unique.push(line)
545
+ }
546
+ const output: string[] = []
547
+ let used = 0
548
+ for (const line of unique) {
549
+ const cost = codePointLength(line) + (output.length === 0 ? 0 : 1)
550
+ if (used + cost > budgetChars) continue
551
+ output.push(line)
552
+ used += cost
553
+ }
554
+ const text = output.join('\n')
555
+ return text.includes(requiredRef) ? text : null
556
+ }
557
+
558
+ function takeWholeLinesFromHead(text: string, budgetChars: number): string {
559
+ const output: string[] = []
560
+ let used = 0
561
+ for (const line of splitLines(text)) {
562
+ const cost = codePointLength(line) + (output.length === 0 ? 0 : 1)
563
+ if (used + cost > budgetChars) break
564
+ output.push(line)
565
+ used += cost
566
+ }
567
+ if (output.length === 0) return Array.from(text).slice(0, budgetChars).join('')
568
+ return output.join('\n')
569
+ }
570
+
571
+ function takeWholeLinesFromTail(text: string, budgetChars: number): string {
572
+ const lines = splitLines(text)
573
+ const output: string[] = []
574
+ let used = 0
575
+ for (let index = lines.length - 1; index >= 0; index--) {
576
+ const line = lines[index]
577
+ if (line === undefined) continue
578
+ const cost = codePointLength(line) + (output.length === 0 ? 0 : 1)
579
+ if (used + cost > budgetChars) break
580
+ output.unshift(line)
581
+ used += cost
582
+ }
583
+ if (output.length === 0) return Array.from(text).slice(-budgetChars).join('')
584
+ return output.join('\n')
585
+ }
586
+
587
+ function splitLines(text: string): string[] {
588
+ const lines = text.split('\n')
589
+ if (text.endsWith('\n')) lines.pop()
590
+ return lines
591
+ }
592
+
593
+ function extractCommand(argumentsText: string): string {
594
+ try {
595
+ const parsed = JSON.parse(argumentsText) as unknown
596
+ if (typeof parsed !== 'object' || parsed === null) return ''
597
+ const record = parsed as Record<string, unknown>
598
+ for (const key of ['command', 'cmd', 'script', 'input']) {
599
+ const value = record[key]
600
+ if (typeof value === 'string') return value
601
+ }
602
+ } catch {
603
+ return ''
604
+ }
605
+ return ''
606
+ }
607
+
608
+ function looksLikeJson(text: string): boolean {
609
+ const trimmed = text.trim()
610
+ return (trimmed.startsWith('{') && trimmed.endsWith('}'))
611
+ || (trimmed.startsWith('[') && trimmed.endsWith(']'))
612
+ }
613
+
614
+ function isReadTool(name: string): boolean {
615
+ return /(?:^|[-_/])(?:read|cat|view|open_file)(?:$|[-_/])/.test(name)
616
+ }
617
+
618
+ function isSearchTool(name: string, command: string): boolean {
619
+ return /(?:grep|search|glob|find|ripgrep|rg)/.test(name)
620
+ || /(?:^|\s)(?:rg|grep|find|fd)\s/.test(command)
621
+ }
622
+
623
+ function isShellTool(name: string): boolean {
624
+ return /(?:bash|shell|terminal|powershell|pwsh|exec|command)/.test(name)
625
+ }
626
+
627
+ function isGitCommand(name: string, command: string): boolean {
628
+ return name.includes('git') || /(?:^|\s)git\s/.test(command)
629
+ }
630
+
631
+ function isPackageCommand(command: string): boolean {
632
+ return /(?:^|\s)(?:npm|pnpm|yarn|bun|pip|pip3|uv|poetry)\s/.test(command)
633
+ }
634
+
635
+ function isBuildOrTestCommand(command: string): boolean {
636
+ const pattern = new RegExp([
637
+ String.raw`(?:^|\s)(?:tsc|dotnet\s+(?:build|test)|pytest|cargo\s+(?:build|test|check)|go\s+test|mvn\s+test|`,
638
+ String.raw`gradle|npm\s+(?:test|run\s+build)|pnpm\s+(?:test|build|lint)|yarn\s+(?:test|build|lint))\b`,
639
+ ].join(''))
640
+ return pattern.test(command)
641
+ }
642
+
643
+ function packagePattern(): RegExp {
644
+ return new RegExp([
645
+ String.raw`(?:ERR!|WARN|warning|error|failed|conflict|peer dep|added\s+\d+|removed\s+\d+|installed|success|`,
646
+ String.raw`up to date|packages?\s+(?:added|removed|changed)|resolution|No matching distribution|Could not find a version)`,
647
+ ].join(''), 'i')
648
+ }
649
+
650
+ function buildPattern(): RegExp {
651
+ return new RegExp([
652
+ String.raw`(?:error\s+TS\d+|warning\s+TS\d+|FAILED|FAIL\b|AssertionError|expected|actual|`,
653
+ String.raw`tests?\s+(?:run|passed|failed|skipped)|Build\s+(?:succeeded|FAILED)|\d+\s+Error\(s\)|`,
654
+ String.raw`\d+\s+Warning\(s\)|Finished\s+test|compilation failed)`,
655
+ ].join(''), 'i')
656
+ }