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.
Files changed (71) hide show
  1. package/CHANGELOG.ja.md +68 -36
  2. package/CHANGELOG.ko.md +67 -35
  3. package/CHANGELOG.md +195 -134
  4. package/CHANGELOG.zh.md +64 -36
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +1 -1
  8. package/README.zh.md +1 -1
  9. package/docs/installation.ja.md +2 -2
  10. package/docs/installation.ko.md +2 -2
  11. package/docs/installation.md +103 -78
  12. package/docs/installation.zh.md +100 -77
  13. package/docs/repair-log.md +54 -0
  14. package/package.json +1 -1
  15. package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
  16. package/packages/selector/lib/client.d.ts +7 -0
  17. package/packages/selector/lib/client.js +33 -3
  18. package/packages/selector/lib/index.d.ts +7 -0
  19. package/packages/selector/lib/index.js +112 -3
  20. package/packages/selector/lib/pruner.d.ts +128 -1
  21. package/packages/selector/lib/pruner.js +2802 -1374
  22. package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
  23. package/packages/selector/src/client/index.ts +1 -1
  24. package/packages/selector/src/client/preset-options.ts +2 -0
  25. package/packages/selector/src/index.ts +129 -49
  26. package/packages/selector/src/profiles.ts +48 -0
  27. package/packages/selector/src/pruner/content.ts +18 -5
  28. package/packages/selector/src/pruner/state.ts +3 -0
  29. package/packages/selector/src/pruner/types.ts +23 -5
  30. package/packages/selector/src/pruner.ts +297 -162
  31. package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
  32. package/packages/selector/src/runtime/audit.ts +40 -2
  33. package/packages/selector/src/runtime/config.ts +88 -1
  34. package/packages/selector/src/runtime/measurement.ts +31 -2
  35. package/packages/selector/src/runtime/reducers.ts +1115 -97
  36. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
  37. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
  38. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
  39. package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
  40. package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
  41. package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
  42. package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
  43. package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
  44. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
  45. package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
  46. package/packages/selector/src/runtime/toolclass.ts +103 -0
  47. package/packages/selector/src/runtime/types.ts +37 -0
  48. package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
  49. package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
  50. package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
  51. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
  52. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
  53. package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
  54. package/packages/selector/tests/runtime/audit.spec.ts +88 -1
  55. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
  56. package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
  57. package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
  58. package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
  59. package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
  60. package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
  61. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
  62. package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
  63. package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
  64. package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
  65. package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
  66. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
  67. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
  68. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
  69. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
  70. package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
  71. package/scripts/toolclass-corpus-replay.mjs +281 -0
@@ -1,6 +1,7 @@
1
1
  /** Deterministic, evidence-backed reducers for fresh tool results. */
2
2
 
3
3
  import { codePointLength } from './config.ts'
4
+ import { classifyToolSource } from './toolclass.ts'
4
5
 
5
6
  /** Input shared by every fresh-result reducer. */
6
7
  export interface ReducerInput {
@@ -22,6 +23,89 @@ export interface ReducerOutput {
22
23
  readonly text: string
23
24
  readonly reducer: string
24
25
  readonly lossy: boolean
26
+ /**
27
+ * Structured telemetry (task_4c/G7): how many ORIGINAL-event lines the
28
+ * reducer elided, when the reducer knows it. Never printed into `text` —
29
+ * host-side logging is what turns this into the compress→retrieve M/N ratio.
30
+ */
31
+ readonly elidedLines?: number
32
+ }
33
+
34
+ /** Internal face every reducer sees: the normalized text plus its line mapping. */
35
+ type PreparedInput = ReducerInput & {
36
+ readonly lines: readonly NormalizedLine[]
37
+ /** Gutter-stripped view of `text`; form detection reads this, never `text`. */
38
+ readonly contentText: string
39
+ }
40
+
41
+ /**
42
+ * Optional side-channel ranking (S1a/S1b) handed to the form-dispatched
43
+ * reducers. Selection and order ONLY — the mechanical fold stays the sole
44
+ * content authority, and `undefined` reproduces the mechanical output
45
+ * byte-for-byte. The ranking itself always comes from outside the reducers:
46
+ * the mechanical layer never calls a model.
47
+ */
48
+ export interface ReductionRanking {
49
+ /** Search file paths, most relevant first (S1a). Unknown paths are ignored. */
50
+ readonly files?: readonly string[]
51
+ /** Document section heading texts, most relevant first (S1b). */
52
+ readonly sections?: readonly string[]
53
+ }
54
+
55
+ /**
56
+ * Per-file search node summaries for the S1a rank prompt (SC8: the judgment
57
+ * input must carry a content sample, not just the identifier). Derived from
58
+ * the raw event text.
59
+ */
60
+ export function searchNodeSummaries(text: string): readonly { readonly id: string, readonly count: number, readonly sample: string }[] {
61
+ const groups = new Map<string, { count: number, sample: string }>()
62
+ for (const line of splitLines(text)) {
63
+ const match = PATH_LINE_PATTERN.exec(line)
64
+ if (match === null) continue
65
+ const path = match[1] ?? '<unknown>'
66
+ const bucket = groups.get(path) ?? { count: 0, sample: Array.from(line.trim()).slice(0, 160).join('') }
67
+ bucket.count += 1
68
+ groups.set(path, bucket)
69
+ }
70
+ return [...groups.entries()].map(([id, bucket]) => ({ id, count: bucket.count, sample: bucket.sample }))
71
+ }
72
+
73
+ /**
74
+ * Per-section document node summaries for the S1b rank prompt: heading text,
75
+ * level, section character mass, and the section's first content line (AD9:
76
+ * headings are the author's structure, not the relevance structure).
77
+ */
78
+ export function documentSectionSummaries(text: string): readonly { readonly id: string, readonly level: number, readonly chars: number, readonly sample: string }[] {
79
+ const lines = splitLines(text)
80
+ const headings: { id: string, level: number, line: number }[] = []
81
+ for (let index = 0; index < lines.length; index++) {
82
+ const match = /^#{1,6}\s+(.*)$/.exec(lines[index] ?? '')
83
+ if (match !== null) headings.push({ id: match[1]!.trim(), level: (lines[index]!.match(/^#+/) ?? ['#'])[0]!.length, line: index })
84
+ }
85
+ return headings.map((heading, position) => {
86
+ const from = heading.line + 1
87
+ const to = position + 1 < headings.length ? headings[position + 1]!.line : lines.length
88
+ let chars = 0
89
+ let sample = ''
90
+ for (let index = from; index < to; index++) {
91
+ const line = lines[index] ?? ''
92
+ if (line.trim() === '') continue
93
+ chars += line.length + 1
94
+ if (sample === '') sample = Array.from(line.trim()).slice(0, 160).join('')
95
+ }
96
+ return { id: heading.id, level: heading.level, chars, sample }
97
+ })
98
+ }
99
+
100
+ /** Ranked-first ordering: ranked ids keep their rank, the rest append in order. */
101
+ function rankedFirst<T extends { readonly id: string }>(items: readonly T[], ranking: readonly string[] | undefined): readonly T[] {
102
+ if (ranking === undefined || ranking.length === 0) return items
103
+ const ranked = new Map<string, T>()
104
+ for (const id of ranking) {
105
+ const found = items.find(item => item.id === id)
106
+ if (found !== undefined && !ranked.has(id)) ranked.set(id, found)
107
+ }
108
+ return [...ranked.values(), ...items.filter(item => !ranked.has(item.id))]
25
109
  }
26
110
 
27
111
  const ANSI_PATTERN = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/gu
@@ -52,27 +136,109 @@ const CODE_STRUCTURE_PATTERN = new RegExp([
52
136
  const PYTHON_STRUCTURE_PATTERN = /^\s*(?:async\s+)?def\s|^\s*class\s/
53
137
  const CODE_DECORATOR_PATTERN = /^\s*@[\w.]+/
54
138
  const CODE_COMMENT_PATTERN = /^\s*(?:\/\/|#|\/\*|\*)/
139
+ const MARKDOWN_HEADING_PATTERN = /^#{1,6}\s+\S/
140
+ const LIST_ITEM_PATTERN = /^\s*(?:[-*+]|\d+[.)])\s+\S/
141
+ const TABLE_ROW_PATTERN = /^\s*\|/
142
+ const FENCE_PATTERN = /^\s*(?:```|~~~)/
143
+ const UUID_PATTERN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
144
+ const LONG_HEX_PATTERN = /\b[0-9a-fA-F]{64,}\b/g
145
+ const LONG_BASE64_PATTERN = /[A-Za-z0-9+/]{200,}={0,2}/g
146
+ const HTML_TAG_PATTERN = /<!DOCTYPE html|<html\b|<head\b|<div\b|<span\b|<script\b|<style\b|<body\b|<p>|<table\b|<a\s/i
147
+ const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g
148
+ const HTML_DROPPED_ELEMENTS = /<(script|style|noscript|svg|head)\b[^>]*>[\s\S]*?<\/\1\s*>/gi
149
+ const HTML_DATA_URI_PATTERN = /\s(?:src|href)="data:[^"]*"/gi
150
+ const HTML_TAG_PATTERN_FULL = /<([a-z][a-z0-9]*)((?:\s[^<>]*?)?)\/?>/gi
151
+ const HTML_INLINE_TAG_PATTERN = /<\/?(?:em|strong|b|i|u|s|code|small|sub|sup|span|br)\b[^<>]*>/gi
152
+ const HTML_WHITELISTED_ATTRIBUTES = /\s(?:href|src|alt|title|id)="[^"]*"/gi
153
+ const ADJACENT_REPEAT_MARKER = '[previous line repeated'
154
+ /** Non-adjacent folding only pays off once a line recurs enough to beat the marker cost. */
155
+ const NON_ADJACENT_FOLD_THRESHOLD = 3
156
+ /** Read-output line-number gutter added unconditionally by the host's `formatReadOutput`. */
157
+ const READ_GUTTER_PATTERN = /^(\d+): ?/
158
+
159
+ /**
160
+ * Block-level read-gutter detection (GF-1). The host prefixes read output with
161
+ * `N: ` line numbers unconditionally and cannot be configured off. The gutter
162
+ * is only recognized when the block as a whole reads like a numbered listing —
163
+ * enough non-empty lines, a large majority guttered, and the numbers strictly
164
+ * increasing — so prose like `12:30 pm` (one stray gutter-looking line) is
165
+ * never stripped. The stripping happens on the CONTENT view only; the output
166
+ * view keeps the gutter because it is the model's only inline locator into the
167
+ * original file (and its measured cost, 9.16% of read bodies, never gets
168
+ * retrieved anyway).
169
+ */
170
+ function hasReadGutter(lines: readonly string[]): boolean {
171
+ let nonEmpty = 0
172
+ let guttered = 0
173
+ let previousNumber = 0
174
+ for (const line of lines) {
175
+ if (line.trim() === '') continue
176
+ nonEmpty += 1
177
+ const match = READ_GUTTER_PATTERN.exec(line)
178
+ if (match === null) continue
179
+ const number = Number(match[1])
180
+ if (number <= previousNumber) return false
181
+ previousNumber = number
182
+ guttered += 1
183
+ }
184
+ return nonEmpty >= 4 && guttered / nonEmpty >= 0.75
185
+ }
186
+
187
+ /**
188
+ * Replace long opaque literals with length summaries (R12). Data URIs, base64
189
+ * blobs, and long hex dumps are pure noise in a compressed view; the prefix is
190
+ * kept so the model can still recognize the value. Short strings are never
191
+ * touched, and replacements never span lines, so the line mapping survives.
192
+ */
193
+ function placeholderizeLongStrings(line: string): string {
194
+ if (!/[0-9a-zA-Z+/]{32}/.test(line) && !/\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-/.test(line)) return line
195
+ let result = line.replace(UUID_PATTERN, '[uuid]')
196
+ result = result.replace(LONG_HEX_PATTERN, (match) => `[hex ${String(match.length)} chars: ${match.slice(0, 16)}…]`)
197
+ result = result.replace(LONG_BASE64_PATTERN, (match) => `[base64 ${String(match.length)} chars: ${match.slice(0, 16)}…]`)
198
+ return result
199
+ }
55
200
 
56
201
  /**
57
202
  * Select a reducer from verified tool, command, and content evidence.
58
203
  * @param input - original result text, recovery source, and output budget.
59
204
  * @returns a verified candidate, or `null` when every reducer fails open.
60
205
  */
61
- export function reduceFreshToolResult(input: ReducerInput): ReducerOutput | null {
62
- const normalized = normalizeTerminalText(input.text)
63
- const prepared = { ...input, text: normalized }
206
+ export function reduceFreshToolResult(input: ReducerInput, ranking?: ReductionRanking): ReducerOutput | null {
207
+ const normalized = normalizeTerminalLines(input.text)
208
+ const prepared: PreparedInput = {
209
+ ...input,
210
+ text: normalized.text,
211
+ lines: normalized.folded,
212
+ contentText: normalized.contentText,
213
+ }
64
214
  const command = extractCommand(input.argumentsText)
65
215
  const name = input.toolName.toLowerCase()
216
+ const toolClass = classifyToolSource(input.toolName, command, normalized.contentText)
217
+ // TOC-first (G6): for a large read-class result the structure lines ARE the
218
+ // table of contents. The code skeleton gets a vote before head/tail
219
+ // truncation, without waiting for the orthogonal `codeSkeleton` user gate.
220
+ const readTocFirst = toolClass === 'read' && codePointLength(normalized.contentText) >= READ_TOC_MIN_CHARS
66
221
  const candidates: Array<() => ReducerOutput | null> = []
67
222
 
68
- if (looksLikeJson(normalized)) candidates.push(() => reduceJson(prepared))
69
- if (isSearchTool(name, command)) candidates.push(() => reduceSearch(prepared))
223
+ if (looksLikeJson(normalized.contentText)) candidates.push(() => reduceJson(prepared))
224
+ // R10-B (bundled/minified JS): must sit before every line-anchored candidate
225
+ // — a bundle's statement structure lives INSIDE lines, not at line starts.
226
+ if (looksLikeMinified(normalized.contentText)) candidates.push(() => reduceBundledJs(prepared))
227
+ if (toolClass === 'search') candidates.push(() => reduceSearch(prepared, ranking?.files))
70
228
  if (isGitCommand(name, command)) candidates.push(() => reduceGit(prepared, command))
71
229
  if (isPackageCommand(command)) candidates.push(() => reducePatternLog(prepared, 'hypa-package', packagePattern()))
72
230
  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))
231
+ if (looksLikeSourceCode(normalized.contentText)) {
232
+ if (input.codeSkeleton === true) candidates.push(() => reduceCodeSkeleton(prepared))
233
+ else if (readTocFirst) candidates.push(() => tocGuardedCodeSkeleton(prepared))
234
+ }
235
+ // Form-dispatched prose candidates (R8/R8b): classification reads content
236
+ // shape only — never the tool name, the path extension, or the command.
237
+ if (looksLikeHtml(normalized.contentText)) candidates.push(() => reduceHtml(prepared))
238
+ if (looksLikeDocument(normalized.contentText)) candidates.push(() => reduceDocSkeleton(prepared, ranking?.sections))
239
+ if (toolClass === 'shell' || command !== '') candidates.push(() => reduceShell(prepared))
240
+ candidates.push(() => reduceProseKeep(prepared))
241
+ if (toolClass === 'read') candidates.push(() => reduceHead(prepared, 'pi-head'))
76
242
  candidates.push(() => reduceSalient(prepared, 'generic-salience'))
77
243
 
78
244
  for (const make of candidates) {
@@ -96,19 +262,37 @@ export function historicalPlaceholder(input: {
96
262
  readonly compact?: boolean
97
263
  }): ReducerOutput {
98
264
  const anchor = input.compact ? '' : importantAnchor(input.text, 360)
265
+ const normalized = normalizeTerminalLines(input.text)
266
+ // The placeholder replaces the ENTIRE result, so the elided span is the
267
+ // whole original event (task_4c/G7 telemetry; audit record only).
268
+ const lastFolded = normalized.folded.at(-1)
269
+ const elidedLines = lastFolded === undefined
270
+ ? undefined
271
+ : (lastFolded.originalLineEnd ?? lastFolded.originalLine)
272
+ // The retrieve hint starts at the anchor's ORIGINAL line: that is the one
273
+ // row of context worth re-reading first (R9b site).
274
+ const anchorLine = input.compact
275
+ ? undefined
276
+ : (normalized.folded
277
+ .find(line => IMPORTANT_PATTERN.test(line.text))
278
+ ?? undefined)?.originalLine
279
+ const retrieveHint = anchorLine === undefined
280
+ ? `retrieve: context_compression_retrieve({"ref":"${input.sourceRef}"})`
281
+ : `retrieve: context_compression_retrieve({"ref":"${input.sourceRef}","start_line":${String(anchorLine)},"max_lines":${String(RETRIEVE_HINT_MAX_LINES)}})`
99
282
  const lines = [
100
283
  '[Old tool result content cleared from active context]',
101
284
  `tool: ${input.toolName || 'unknown'}`,
102
285
  `status: ${input.isError ? 'error' : 'completed'}`,
103
286
  `original_chars: ${String(input.charsBefore)}`,
104
287
  `source: ${input.sourceRef}`,
105
- 'retrieve: context_compression_retrieve({"ref":"' + input.sourceRef + '"})',
288
+ retrieveHint,
106
289
  ]
107
290
  if (anchor !== '') lines.push(`retained_anchor: ${anchor}`)
108
291
  return {
109
292
  text: lines.join('\n'),
110
293
  reducer: input.compact ? 'pair-preserving-tail-aging' : 'historical-tool-result-aging',
111
294
  lossy: true,
295
+ ...(elidedLines === undefined ? {} : { elidedLines }),
112
296
  }
113
297
  }
114
298
 
@@ -134,51 +318,309 @@ export function verifyReduction(input: ReducerInput, output: ReducerOutput): boo
134
318
  * @returns normalized terminal text.
135
319
  */
136
320
  export function normalizeTerminalText(text: string): string {
321
+ return normalizeTerminalLines(text).text
322
+ }
323
+
324
+ /** One folded output line that remembers its place in the original event. */
325
+ export interface NormalizedLine {
326
+ /** Output view: byte-identical with the original event line (gutter kept). */
327
+ readonly text: string
328
+ /**
329
+ * Content view: the same line with a block-detected read gutter (`N: `)
330
+ * stripped. Form detection, skeleton retention, and repeat keys read this;
331
+ * the printed output never does (GF-1 dual view).
332
+ */
333
+ readonly content: string
334
+ /** 1-based line number in the ORIGINAL event text (before normalization). */
335
+ readonly originalLine: number
336
+ /**
337
+ * Last original line this entry covers. Only the synthetic repeat marker
338
+ * spans more than one original line (the occurrences it replaces).
339
+ */
340
+ readonly originalLineEnd?: number
341
+ }
342
+
343
+ export interface NormalizedTerminal {
344
+ /** Adjacent-duplicate-folded lines; `folded.map(l => l.text).join('\n')` is `text`. */
345
+ readonly folded: readonly NormalizedLine[]
346
+ /** The folded text, byte-identical with `normalizeTerminalText`. */
347
+ readonly text: string
348
+ /** The folded text with the read gutter stripped (detection view). */
349
+ readonly contentText: string
350
+ }
351
+
352
+ /**
353
+ * Structured normalization (R9a): `retrieve` reads the original event, so any
354
+ * line number a reducer prints must resolve against the ORIGINAL text, not the
355
+ * normalized surface. ANSI stripping and `\r` redraw collapse never change the
356
+ * line count (logical lines are 1:1 with original lines); only the adjacent
357
+ * duplicate fold drops lines, so every folded entry carries the original line
358
+ * (range) it was kept from.
359
+ */
360
+ export function normalizeTerminalLines(text: string): NormalizedTerminal {
137
361
  const withoutAnsi = text.replace(ANSI_PATTERN, '')
138
362
  const logical = withoutAnsi.split('\n').map((line) => {
139
363
  const redraws = line.split('\r').filter(part => part !== '')
140
- return redraws.at(-1) ?? ''
364
+ return placeholderizeLongStrings(redraws.at(-1) ?? '')
141
365
  })
142
- const folded: string[] = []
366
+ const stripGutter = hasReadGutter(logical)
367
+ const folded: NormalizedLine[] = []
143
368
  let previous: string | undefined
369
+ let firstText = ''
144
370
  let count = 0
145
- const flush = (): void => {
371
+ let firstOriginal = 0
372
+ const flush = (nextOriginal: number): void => {
146
373
  if (previous === undefined) return
147
- folded.push(previous)
148
- if (count > 1) folded.push(`[previous line repeated ${String(count - 1)} more times]`)
374
+ folded.push({ text: firstText, content: previous, originalLine: firstOriginal })
375
+ if (count > 1) {
376
+ const marker = `[previous line repeated ${String(count - 1)} more times]`
377
+ folded.push({
378
+ text: marker,
379
+ content: marker,
380
+ originalLine: firstOriginal + 1,
381
+ originalLineEnd: nextOriginal - 1,
382
+ })
383
+ }
149
384
  }
150
- for (const line of logical) {
151
- if (line === previous) {
385
+ logical.forEach((line, index) => {
386
+ const originalLine = index + 1
387
+ const content = stripGutter ? line.replace(READ_GUTTER_PATTERN, '') : line
388
+ if (content === previous) {
152
389
  count++
153
- continue
390
+ return
154
391
  }
155
- flush()
156
- previous = line
392
+ flush(originalLine)
393
+ previous = content
394
+ firstText = line
157
395
  count = 1
396
+ firstOriginal = originalLine
397
+ })
398
+ flush(logical.length + 1)
399
+ const result = foldNonAdjacentRepeats(folded)
400
+ return {
401
+ folded: result,
402
+ text: result.map(line => line.text).join('\n'),
403
+ contentText: result.map(line => line.content).join('\n'),
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Fold non-adjacent exact repeats (R11). Adjacent folding runs FIRST and only
409
+ * handles consecutive runs (0.03–0.32% of real duplicate content); separated
410
+ * repeats reached 8.37% in large results. Each surviving occurrence — a kept
411
+ * line plus its optional adjacent-repeat marker — is one unit; once a text
412
+ * recurs ≥ threshold times, the first unit is kept and every later unit is
413
+ * replaced by ONE counted marker citing the original-event span it covers.
414
+ * A pure consecutive run forms a single unit, so this pass is a no-op on it
415
+ * and can never double-fold the adjacent marker.
416
+ */
417
+ function foldNonAdjacentRepeats(folded: readonly NormalizedLine[]): NormalizedLine[] {
418
+ interface Unit { readonly lead: NormalizedLine, repeat?: NormalizedLine }
419
+ const units: Unit[] = []
420
+ for (const entry of folded) {
421
+ if (entry.text.startsWith(ADJACENT_REPEAT_MARKER) && units.length > 0) {
422
+ units[units.length - 1]!.repeat = entry
423
+ } else {
424
+ units.push({ lead: entry })
425
+ }
158
426
  }
159
- flush()
160
- return folded.join('\n')
427
+ const totals = new Map<string, number>()
428
+ for (const unit of units) totals.set(unit.lead.content, (totals.get(unit.lead.content) ?? 0) + 1)
429
+ if (totals.size === units.length) return [...folded]
430
+ // Precompute, per repeated text, where the first kept occurrence and the
431
+ // last folded occurrence sit in the ORIGINAL event. Keys are the CONTENT
432
+ // view: guttered read output numbers every line, so `900: )` and `950: )`
433
+ // are different strings in the output view but the same content.
434
+ const firstOriginal = new Map<string, number>()
435
+ const lastOriginalEnd = new Map<string, number>()
436
+ for (const unit of units) {
437
+ const content = unit.lead.content
438
+ if (totals.get(content)! < NON_ADJACENT_FOLD_THRESHOLD) continue
439
+ if (!firstOriginal.has(content)) firstOriginal.set(content, unit.lead.originalLine)
440
+ const end = unit.repeat?.originalLineEnd ?? unit.lead.originalLineEnd ?? unit.lead.originalLine
441
+ lastOriginalEnd.set(content, end)
442
+ }
443
+ const seen = new Map<string, number>()
444
+ const result: NormalizedLine[] = []
445
+ for (const unit of units) {
446
+ const content = unit.lead.content
447
+ const total = totals.get(content)!
448
+ if (total < NON_ADJACENT_FOLD_THRESHOLD) {
449
+ result.push(unit.lead)
450
+ if (unit.repeat !== undefined) result.push(unit.repeat)
451
+ continue
452
+ }
453
+ if (!seen.has(content)) {
454
+ seen.set(content, 1)
455
+ result.push(unit.lead)
456
+ if (unit.repeat !== undefined) result.push(unit.repeat)
457
+ continue
458
+ }
459
+ const ordinal = (seen.get(content) ?? 1) + 1
460
+ seen.set(content, ordinal)
461
+ if (ordinal > 2) continue
462
+ const end = lastOriginalEnd.get(content)!
463
+ const marker = `[× ${String(total)} total: same as line ${String(firstOriginal.get(content)!)}; original lines ${String(unit.lead.originalLine)}-${String(end)}]`
464
+ result.push({
465
+ text: marker,
466
+ content: marker,
467
+ originalLine: unit.lead.originalLine,
468
+ originalLineEnd: end,
469
+ })
470
+ }
471
+ return result
472
+ }
473
+
474
+ /** Default line window a retrieve hint suggests the model paste. */
475
+ const RETRIEVE_HINT_MAX_LINES = 80
476
+
477
+ /**
478
+ * TOC-first (G6): read-class results at or above this size let the code
479
+ * skeleton compete before head/tail truncation. The 14,000-char boundary is
480
+ * the studied real-read cohort (findings §7), well above p90 of actual reads
481
+ * so ordinary results keep their existing dispatch.
482
+ */
483
+ const READ_TOC_MIN_CHARS = 14_000
484
+ /**
485
+ * A skeleton whose output is dominated by elision markers is worse than
486
+ * head/tail for the model (task_4b risk: structure-poor files degenerate into
487
+ * "almost all markers") — above this marker-char share the TOC candidate fails
488
+ * open to the prose reducers.
489
+ */
490
+ const TOC_MARKER_RATIO_LIMIT = 0.5
491
+
492
+ /**
493
+ * Fail-open wrapper for the TOC-first code-skeleton candidate: a skeleton that
494
+ * degenerates into mostly-elision markers (minified bundles, generated files)
495
+ * returns null so the prose head/tail pair takes over.
496
+ */
497
+ function tocGuardedCodeSkeleton(input: PreparedInput): ReducerOutput | null {
498
+ const output = reduceCodeSkeleton(input)
499
+ if (output === null) return null
500
+ const total = codePointLength(output.text)
501
+ const markerChars = output.text.split('\n')
502
+ .filter(line => line.startsWith('[...'))
503
+ .reduce((sum, line) => sum + codePointLength(line) + 1, 0)
504
+ return markerChars / total > TOC_MARKER_RATIO_LIMIT ? null : output
161
505
  }
162
506
 
163
- function reduceHead(input: ReducerInput, reducer: string): ReducerOutput | null {
507
+ /** R10a thresholds: one giant line, uniformly fat lines, or very few fat lines. */
508
+ const MINIFIED_MAX_LINE_CHARS = 2_000
509
+ const MINIFIED_AVG_LINE_CHARS = 300
510
+ const MINIFIED_FEW_LINES = 40
511
+ const MINIFIED_FEW_LINES_TOTAL_CHARS = 20_000
512
+
513
+ /**
514
+ * Require form evidence of a bundled/minified module (R10a): line-anchored
515
+ * reducers cannot see inside a 135k-character line, and R9 line ranges on a
516
+ * 53-line bundle cannot address anything smaller than the whole file.
517
+ * @param text - normalized result text.
518
+ * @returns whether the text reads as a bundled/minified module.
519
+ */
520
+ export function looksLikeMinified(text: string): boolean {
521
+ const lines = splitLines(text)
522
+ if (lines.length === 0) return false
523
+ let total = 0
524
+ let max = 0
525
+ for (const line of lines) {
526
+ const length = line.length
527
+ total += length
528
+ if (length > max) max = length
529
+ }
530
+ if (max > MINIFIED_MAX_LINE_CHARS) return true
531
+ if (total / lines.length > MINIFIED_AVG_LINE_CHARS) return true
532
+ return lines.length < MINIFIED_FEW_LINES && total > MINIFIED_FEW_LINES_TOTAL_CHARS
533
+ }
534
+
535
+ /**
536
+ * Statement-level declaration patterns scanned GLOBALLY per line: a bundle's
537
+ * statements are separated by `;` / `},{` / `);` inside one physical line, so
538
+ * line-anchored matching is useless here. Reserved-name traces (`exports.*`,
539
+ * `module.exports`) are extracted first and called out in the header because
540
+ * minifiers rename local symbols.
541
+ */
542
+ const BUNDLED_DECLARATION_PATTERNS: readonly RegExp[] = [
543
+ /\bexports\.([A-Za-z_$][\w$]*)\s*=/g,
544
+ /\bmodule\.exports\s*=\s*([A-Za-z_$][\w$]*)/g,
545
+ /\b(?:function|class)\s+([A-Za-z_$][\w$]*)/g,
546
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,
547
+ /\b([A-Za-z_$][\w$]*)\s*:\s*function\b/g,
548
+ ]
549
+ const SOURCEMAP_DIRECTIVE = '//# sourceMappingURL='
550
+
551
+ /**
552
+ * Bundled/minified JS directory (R10-B). The useful first answer is the
553
+ * declaration/export directory — WHAT the bundle exposes — plus one honest
554
+ * whole-span marker: the host's continuation is line-addressed, so a
555
+ * line-range retrieve on a 53-line bundle hands back the whole file (R10d:
556
+ * character-range retrieval is a separate, undecided extension).
557
+ */
558
+ function reduceBundledJs(input: PreparedInput): ReducerOutput | null {
559
+ const declarations = new Map<string, number>()
560
+ for (const line of input.lines) {
561
+ for (const pattern of BUNDLED_DECLARATION_PATTERNS) {
562
+ pattern.lastIndex = 0
563
+ let match = pattern.exec(line.content)
564
+ while (match !== null) {
565
+ const symbol = match[1]
566
+ if (symbol !== undefined && !declarations.has(symbol)) declarations.set(symbol, line.originalLine)
567
+ match = pattern.exec(line.content)
568
+ }
569
+ }
570
+ }
571
+ if (declarations.size === 0) return null
572
+ const hasSourceMap = input.contentText.includes(SOURCEMAP_DIRECTIVE)
573
+ const entries = [...declarations.entries()].sort((a, b) => a[1] - b[1])
574
+ const header = `[bundled/minified JS detected; ${String(entries.length)} declarations; minified symbols may be renamed — exports.*/module.exports traces are the reliable ones;${hasSourceMap ? ' source map present, prefer reading the original source;' : ''} source: ${input.sourceRef}; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}"}) (line-addressed: single-line bundles come back whole)]`
575
+ const kept = [header, ...entries.slice(0, 400).map(([symbol, line]) => `${symbol} (line ${String(line)})`)]
576
+ const start = input.lines[0]?.originalLine ?? 1
577
+ const end = originalEnd(input.lines, input.lines.length - 1)
578
+ if (end > start) kept.push(elidedRangeMarker(start, end))
579
+ const text = fitLines(kept, input.budgetChars, input.sourceRef)
580
+ return text === null ? null : { text, reducer: 'bundled-js-directory', lossy: true }
581
+ }
582
+
583
+ /**
584
+ * Continuous-mask marker (R9b): cites the ORIGINAL-event line range it elides
585
+ * and carries a pasteable retrieve hint starting at the first elided line.
586
+ * Falls back to the compact plain marker when the hint would not fit.
587
+ */
588
+ function reduceHead(input: PreparedInput, reducer: string): ReducerOutput | null {
164
589
  const marker = omissionMarker(input, reducer)
165
590
  const available = input.budgetChars - codePointLength(marker) - 1
166
591
  if (available <= 0) return null
167
592
  const head = takeWholeLinesFromHead(input.text, available)
168
593
  if (head === input.text || head === '') return null
594
+ const keptCount = head.split('\n').length
595
+ const firstElided = input.lines[keptCount]
596
+ if (firstElided !== undefined) {
597
+ const elidedEnd = originalEnd(input.lines, input.lines.length - 1)
598
+ const ranged = rangeOmissionMarker(input, reducer, firstElided.originalLine, elidedEnd)
599
+ if (codePointLength(head) + codePointLength(ranged) + 1 <= input.budgetChars) {
600
+ return { text: `${head}\n${ranged}`, reducer, lossy: true }
601
+ }
602
+ }
169
603
  return { text: `${head}\n${marker}`, reducer, lossy: true }
170
604
  }
171
605
 
172
- function reduceTail(input: ReducerInput, reducer: string): ReducerOutput | null {
606
+ function reduceTail(input: PreparedInput, reducer: string): ReducerOutput | null {
173
607
  const marker = omissionMarker(input, reducer)
174
608
  const available = input.budgetChars - codePointLength(marker) - 1
175
609
  if (available <= 0) return null
176
610
  const tail = takeWholeLinesFromTail(input.text, available)
177
611
  if (tail === input.text || tail === '') return null
612
+ const firstKept = input.lines.length - tail.split('\n').length
613
+ if (firstKept > 0) {
614
+ const elidedEnd = originalEnd(input.lines, firstKept - 1)
615
+ const ranged = rangeOmissionMarker(input, reducer, input.lines[0]!.originalLine, elidedEnd)
616
+ if (codePointLength(ranged) + codePointLength(tail) + 1 <= input.budgetChars) {
617
+ return { text: `${ranged}\n${tail}`, reducer, lossy: true }
618
+ }
619
+ }
178
620
  return { text: `${marker}\n${tail}`, reducer, lossy: true }
179
621
  }
180
622
 
181
- function reduceJson(input: ReducerInput): ReducerOutput | null {
623
+ function reduceJson(input: PreparedInput): ReducerOutput | null {
182
624
  let value: unknown
183
625
  try {
184
626
  value = JSON.parse(input.text)
@@ -236,97 +678,642 @@ function shrinkJson(value: unknown, depth: number): unknown {
236
678
  return result
237
679
  }
238
680
 
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) }
681
+ /**
682
+ * Two-tier search folding (R10). L1 is a LOSSLESS per-file locator —
683
+ * `## <path> (<N> matches) L12,L15,…` one line number per hit, taken from
684
+ * the hit's own `path:line` prefix (falling back to the original-event line).
685
+ * L2 is the content quota, water-filled round-robin so no file vanishes and
686
+ * no file runs more than one row ahead of another; the budget is reserved for
687
+ * L1 first. When L1 itself cannot fit, the shortfall is ANNOUNCED
688
+ * (withheld file/match counts) — never silently truncated. Outputs without
689
+ * any `path:line` form fail open to salience.
690
+ */
691
+ function reduceSearch(input: PreparedInput, fileRanking?: readonly string[]): ReducerOutput | null {
692
+ interface Row { readonly text: string, readonly fileLine: number, readonly important: boolean }
693
+ const groups = new Map<string, Row[]>()
694
+ const ungrouped: Row[] = []
695
+ input.lines.forEach((line) => {
696
+ const match = PATH_LINE_PATTERN.exec(line.text)
697
+ const row: Row = {
698
+ text: line.text,
699
+ fileLine: match !== null ? Number(match[2]) : line.originalLine,
700
+ important: IMPORTANT_PATTERN.test(line.text),
701
+ }
246
702
  if (match === null) {
247
703
  ungrouped.push(row)
248
- continue
704
+ return
249
705
  }
250
706
  const path = match[1] ?? '<unknown>'
251
707
  const bucket = groups.get(path) ?? []
252
708
  bucket.push(row)
253
709
  groups.set(path, bucket)
254
- }
710
+ })
255
711
  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)
712
+
713
+ const totalMatches = [...groups.values()].reduce((sum, rows) => sum + rows.length, 0)
714
+ const locatorFor = (path: string, rows: readonly Row[]): string =>
715
+ `## ${path} (${String(rows.length)} matches) ${rows.map(row => `L${String(row.fileLine)}`).join(',')}`
716
+ // Rows within a file are offered to the quota important-first, then by
717
+ // line. Files are visited in side-channel rank order when one was supplied
718
+ // (ranked files first, the rest in original order) the water-filling
719
+ // round order is the only thing ranking changes.
720
+ const perFile = new Map<string, Row[]>()
721
+ for (const entry of rankedFirst([...groups.entries()].map(([id, rows]) => ({ id, rows })), fileRanking)) {
722
+ perFile.set(entry.id, [...entry.rows].sort((a, b) => a.important === b.important
723
+ ? a.fileLine - b.fileLine
724
+ : a.important ? -1 : 1))
725
+ }
726
+ const allLocators = [...perFile.keys()].map(path => locatorFor(path, groups.get(path)!))
727
+
728
+ const headerFor = (l2Rows: number, omitted: number): string =>
729
+ `[search results compressed; ${String(groups.size)} files, ${String(totalMatches)} matches; ${String(l2Rows)} content rows shown, ${String(omitted)} matches omitted; source: ${input.sourceRef}; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}"})]`
730
+
731
+ // L2 water-filling: one unchosen row per file per round, so no file
732
+ // disappears and no file outpaces another by more than one round. Important
733
+ // rows are offered first within each file.
734
+ const fillL2 = (output: string[], quotaChars: number): { shown: number, omitted: number } => {
735
+ let used = 0
736
+ let shown = 0
737
+ let round = 0
738
+ let progress = true
739
+ while (progress && round < 512) {
740
+ progress = false
741
+ for (const rows of perFile.values()) {
742
+ if (round >= rows.length) continue
743
+ const row = rows[round]!
744
+ const cost = codePointLength(row.text) + 1
745
+ if (used + cost > quotaChars) continue
746
+ output.push(row.text)
747
+ used += cost
748
+ shown += 1
749
+ progress = true
750
+ }
751
+ round += 1
752
+ }
753
+ // Locator-less important rows keep their bounded salience slot.
754
+ for (const row of ungrouped.filter(entry => entry.important).slice(0, 12)) {
755
+ const cost = codePointLength(row.text) + 1
756
+ if (used + cost > quotaChars) break
757
+ output.push(row.text)
758
+ used += cost
759
+ shown += 1
760
+ }
761
+ return { shown, omitted: totalMatches - shown }
762
+ }
763
+
764
+ const finish = (output: readonly string[]): ReducerOutput | null => {
765
+ const text = output.join('\n')
766
+ return text.includes(input.sourceRef) ? { text, reducer: 'search-by-file', lossy: true } : null
767
+ }
768
+
769
+ const headerProbe = headerFor(0, 0)
770
+ const budget = input.budgetChars - codePointLength(headerProbe) - 2
771
+ if (budget <= 0) return null
772
+ const locatorCost = allLocators.reduce((sum, line) => sum + codePointLength(line) + 1, 0)
773
+ if (locatorCost > budget) {
774
+ // Visible degradation: include locators while they fit, ANNOUNCE the rest.
775
+ // The announcement line's own cost is reserved up front so the final text
776
+ // stays inside the budget and survives verifyReduction.
777
+ const announcementReserve = 160
778
+ const output: string[] = []
779
+ let used = 0
780
+ let withheldFiles = 0
781
+ let withheldMatches = 0
782
+ for (let index = 0; index < allLocators.length; index++) {
783
+ const cost = codePointLength(allLocators[index]!) + 1 + announcementReserve
784
+ if (used + cost > budget) {
785
+ withheldFiles = allLocators.length - index
786
+ withheldMatches = totalMatches
787
+ - [...groups.values()].slice(0, index).reduce((sum, rows) => sum + rows.length, 0)
788
+ break
789
+ }
790
+ output.push(allLocators[index]!)
791
+ used += cost - announcementReserve
792
+ }
793
+ if (withheldFiles > 0) {
794
+ output.push(`[L1 locator partially withheld: ${String(withheldFiles)} file(s) / ${String(withheldMatches)} matches' line lists did not fit the budget; retrieve for the full hit list]`)
795
+ }
796
+ const { shown, omitted } = fillL2(output, Math.max(0, budget - used - (withheldFiles > 0 ? announcementReserve : 0)))
797
+ output.unshift(headerFor(shown, omitted + withheldMatches))
798
+ return finish(output)
799
+ }
800
+ const output: string[] = [...allLocators]
801
+ const { shown, omitted } = fillL2(output, budget - locatorCost)
802
+ output.unshift(headerFor(shown, omitted))
803
+ return finish(output)
804
+ }
805
+
806
+ function reduceGit(input: PreparedInput, command: string): ReducerOutput | null {
807
+ const lines = input.lines
278
808
  const lower = command.toLowerCase()
279
809
  let keep: string[]
280
810
  let reducer: string
281
811
  if (/\bgit\s+(?:diff|show)\b/.test(lower)) {
282
812
  reducer = 'hypa-git-diff'
283
- keep = lines.filter(line => /^(?:diff --git|index |--- |\+\+\+ |@@ |[+-](?![+-]))/.test(line)
813
+ keep = lines.map(line => line.text).filter(line => /^(?:diff --git|index |--- |\+\+\+ |@@ |[+-](?![+-]))/.test(line)
284
814
  || IMPORTANT_PATTERN.test(line))
285
815
  } else if (/\bgit\s+(?:status|switch|checkout|merge|rebase|cherry-pick)\b/.test(lower)) {
286
816
  reducer = 'hypa-git-status'
287
- keep = lines.filter(line => GIT_STATUS_PATTERN.test(line)
817
+ keep = lines.map(line => line.text).filter(line => GIT_STATUS_PATTERN.test(line)
288
818
  || IMPORTANT_PATTERN.test(line))
289
819
  } else {
290
820
  reducer = 'hypa-git-log'
291
- keep = lines.filter(line => /^(?:commit\s+[0-9a-f]+|Author:|Date:|[0-9a-f]{7,}\s)/i.test(line)
821
+ keep = lines.map(line => line.text).filter(line => /^(?:commit\s+[0-9a-f]+|Author:|Date:|[0-9a-f]{7,}\s)/i.test(line)
292
822
  || IMPORTANT_PATTERN.test(line))
293
823
  }
294
824
  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)
825
+ const header = `[git output compressed; ${scannedTotals(input, keep.length)}; source: ${input.sourceRef}; scatter-masked: retrieve with context_compression_retrieve({"ref":"${input.sourceRef}","query":"<keyword>"}) for missed rows]`
826
+ const text = fitLines([header, ...keep, ...lines.slice(-8).map(line => line.text)], input.budgetChars, input.sourceRef)
297
827
  return text === null ? null : { text, reducer, lossy: true }
298
828
  }
299
829
 
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)
830
+ function reducePatternLog(input: PreparedInput, reducer: string, pattern: RegExp): ReducerOutput | null {
831
+ const lines = input.lines
832
+ const kept = lines.filter(line => pattern.test(line.text) || IMPORTANT_PATTERN.test(line.text) || STATUS_PATTERN.test(line.text))
833
+ const header = `[command output compressed by ${reducer}; ${scannedTotals(input, kept.length)}; source: ${input.sourceRef}; scatter-masked: retrieve with context_compression_retrieve({"ref":"${input.sourceRef}","query":"<keyword>"}) for missed rows]`
834
+ const text = fitLines([header, ...kept.map(line => line.text), ...lines.slice(-20).map(line => line.text)], input.budgetChars, input.sourceRef)
305
835
  return text === null ? null : { text, reducer, lossy: true }
306
836
  }
307
837
 
308
- function reduceShell(input: ReducerInput): ReducerOutput | null {
309
- const lines = splitLines(input.text)
310
- const important = lines.filter(line => IMPORTANT_PATTERN.test(line))
838
+ function reduceShell(input: PreparedInput): ReducerOutput | null {
839
+ const lines = input.lines
840
+ const important = lines.filter(line => IMPORTANT_PATTERN.test(line.text))
311
841
  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)
842
+ const header = `[shell/log output compressed; ${scannedTotals(input, important.length)}; source: ${input.sourceRef}; scatter-masked: retrieve with context_compression_retrieve({"ref":"${input.sourceRef}","query":"<keyword>"}) for missed rows]`
843
+ const text = fitLines([header, ...important.map(line => line.text), '--- final output ---', ...lines.slice(-40).map(line => line.text)], input.budgetChars, input.sourceRef)
314
844
  return text === null ? null : { text, reducer: 'shell-salience-tail', lossy: true }
315
845
  }
316
846
 
317
- function reduceSalient(input: ReducerInput, reducer: string): ReducerOutput | null {
318
- const lines = splitLines(input.text)
847
+ function reduceSalient(input: PreparedInput, reducer: string): ReducerOutput | null {
848
+ const lines = input.lines
319
849
  if (lines.length < 3) return reduceHead(input, reducer)
320
850
  const marker = omissionMarker(input, reducer)
321
851
  const headBudget = Math.max(1, Math.floor((input.budgetChars - codePointLength(marker)) * 0.34))
322
852
  const tailBudget = headBudget
323
853
  const head = takeWholeLinesFromHead(input.text, headBudget)
324
854
  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)
855
+ const salient = lines.filter(line => IMPORTANT_PATTERN.test(line.text) || STATUS_PATTERN.test(line.text)).slice(0, 24)
856
+ const keptCount = head.split('\n').length + salient.length + tail.split('\n').length
857
+ const text = fitLines([head, ...salient.map(line => line.text), `${marker} [${scannedTotals(input, keptCount)}]`, tail], input.budgetChars, input.sourceRef)
327
858
  return text === null ? null : { text, reducer, lossy: true }
328
859
  }
329
860
 
861
+ /**
862
+ * Require content evidence of a structured document: enough Markdown heading
863
+ * lines among a bounded prefix. Pure form evidence — tool names, path
864
+ * extensions, and commands are never read (MCP output has no predictable
865
+ * identity). Real logs and build output carry no `#`-heading lines, which is
866
+ * the misjudgment guard.
867
+ * @param text - normalized result text.
868
+ * @returns whether the text qualifies as a structured document.
869
+ */
870
+ export function looksLikeDocument(text: string): boolean {
871
+ const lines = splitLines(text)
872
+ let headings = 0
873
+ // C26 (AD5): the window covers 600 lines so a document whose headings only
874
+ // start past line 400 still reaches doc-skeleton; the `^#{1,6}\s+\S` anchor
875
+ // itself is unchanged (RK-4 — pure logs carry no heading lines).
876
+ for (const line of lines.slice(0, 600)) {
877
+ if (MARKDOWN_HEADING_PATTERN.test(line)) {
878
+ headings += 1
879
+ if (headings >= 3) return true
880
+ }
881
+ }
882
+ return false
883
+ }
884
+
885
+ /** One R9-spec elision marker: an original-event line range plus its count. */
886
+ function elidedRangeMarker(start: number, end: number): string {
887
+ return `[... lines ${String(start)}-${String(end)} elided (${String(end - start + 1)} lines) ...]`
888
+ }
889
+
890
+ /**
891
+ * Require content evidence of HTML: enough lines carrying real markup tags
892
+ * among a bounded prefix. Angle-bracket prose (TS generics, comparisons) does
893
+ * not match the tag list, which is the misjudgment guard.
894
+ */
895
+ function looksLikeHtml(text: string): boolean {
896
+ const lines = splitLines(text)
897
+ let tags = 0
898
+ for (const line of lines.slice(0, 400)) {
899
+ if (HTML_TAG_PATTERN.test(line)) {
900
+ tags += 1
901
+ if (tags >= 3) return true
902
+ }
903
+ }
904
+ return false
905
+ }
906
+
907
+ const HTML_DROPPED_OPEN = /<(script|style|noscript|svg|head)\b[^>]*>/i
908
+
909
+ /** One stage-1 survivor; `marker` entries are synthetic repeated-block folds. */
910
+ interface HtmlSlimEntry { readonly text: string, readonly index: number, readonly marker?: boolean }
911
+
912
+ const HTML_BLOCK_MIN_LINES = 2
913
+ const HTML_BLOCK_MAX_LINES = 8
914
+ const HTML_BLOCK_MIN_OCCURRENCES = 3
915
+ const HTML_TABLE_TAG_PATTERN = /<table\b|<tr\b|<th\b|<\/tr\b|<\/table\b/i
916
+
917
+ /**
918
+ * Deterministic repeated-block folding for slimmed HTML (R4/RK-3): contiguous
919
+ * runs of 2–8 non-table lines whose digit-normalized signature recurs ≥3 times
920
+ * keep their first occurrence; every later occurrence becomes ONE counted
921
+ * marker. Tables never fold, and different copy never shares a signature —
922
+ * only counter/number drift does.
923
+ */
924
+ export function foldRepeatedHtmlBlocks(
925
+ slim: readonly { readonly text: string, readonly index: number }[],
926
+ originalLineFor: (index: number) => number,
927
+ ): readonly HtmlSlimEntry[] {
928
+ const signatureOf = (from: number, length: number): string | null => {
929
+ let signature = `${String(length)}|`
930
+ for (let position = from; position < from + length; position++) {
931
+ const text = slim[position]!.text
932
+ if (HTML_TABLE_TAG_PATTERN.test(text)) return null
933
+ signature += `${text.replace(/\d+/g, '#').replace(/\s+/g, ' ').trim()}\n`
934
+ }
935
+ return signature
936
+ }
937
+ const counts = new Map<string, { count: number, first: number }>()
938
+ for (let length = HTML_BLOCK_MIN_LINES; length <= HTML_BLOCK_MAX_LINES; length++) {
939
+ for (let start = 0; start + length <= slim.length; start++) {
940
+ const signature = signatureOf(start, length)
941
+ if (signature === null) continue
942
+ const bucket = counts.get(signature)
943
+ if (bucket === undefined) counts.set(signature, { count: 1, first: start })
944
+ else bucket.count += 1
945
+ }
946
+ }
947
+ const result: HtmlSlimEntry[] = []
948
+ let position = 0
949
+ while (position < slim.length) {
950
+ let foldedLength = 0
951
+ let matched: { count: number, first: number } | undefined
952
+ for (let length = HTML_BLOCK_MAX_LINES; length >= HTML_BLOCK_MIN_LINES; length--) {
953
+ if (position + length > slim.length) continue
954
+ const signature = signatureOf(position, length)
955
+ const bucket = signature === null ? undefined : counts.get(signature)
956
+ if (bucket !== undefined && bucket.count >= HTML_BLOCK_MIN_OCCURRENCES) {
957
+ foldedLength = length
958
+ matched = bucket
959
+ break
960
+ }
961
+ }
962
+ if (matched === undefined) {
963
+ result.push({ text: slim[position]!.text, index: slim[position]!.index })
964
+ position += 1
965
+ continue
966
+ }
967
+ if (matched.first === position) {
968
+ for (let offset = 0; offset < foldedLength; offset++) {
969
+ result.push({ text: slim[position + offset]!.text, index: slim[position + offset]!.index })
970
+ }
971
+ } else {
972
+ const firstLine = originalLineFor(slim[matched.first]!.index)
973
+ result.push({
974
+ text: `[×${String(matched.count)} repeated block, first at line ${String(firstLine)}]`,
975
+ index: slim[position]!.index,
976
+ marker: true,
977
+ })
978
+ }
979
+ position += foldedLength
980
+ }
981
+ return result
982
+ }
983
+
984
+ /**
985
+ * Two-stage HTML reduction (R13). HTML previously fell into `pi-head`, which
986
+ * keeps exactly the useless `<head>` metadata and drops the body.
987
+ *
988
+ * Stage 1 (`html-slim`) is a deterministic, line-aligned slimming pass:
989
+ * comments, script/style/noscript/svg/head elements (single- or multi-line),
990
+ * data URIs, non-whitelisted attributes, and inline-tag markup disappear;
991
+ * every surviving line keeps its original-event position for the R9 ranges.
992
+ * Stage 2 (`html-skeleton`) runs only when the slim output still exceeds the
993
+ * budget: heading hierarchy, each section's first line, and table header rows
994
+ * survive; the rest is elided with original-event line ranges.
995
+ */
996
+ function reduceHtml(input: PreparedInput): ReducerOutput | null {
997
+ interface SlimLine { readonly text: string, readonly index: number }
998
+ const slim: SlimLine[] = []
999
+ let dropping: string | null = null
1000
+ input.lines.forEach((line, index) => {
1001
+ let text = line.content
1002
+ if (dropping !== null) {
1003
+ const close = new RegExp(`</${dropping}\\s*>`, 'i').exec(text)
1004
+ if (close === null) return
1005
+ text = text.slice(close.index + close[0].length)
1006
+ dropping = null
1007
+ }
1008
+ text = text.replace(HTML_COMMENT_PATTERN, '')
1009
+ text = text.replace(HTML_DROPPED_ELEMENTS, '')
1010
+ const open = HTML_DROPPED_OPEN.exec(text)
1011
+ if (open !== null) {
1012
+ const close = new RegExp(`</${open[1] ?? ''}\\s*>`, 'i').exec(text.slice(open.index))
1013
+ if (close !== null) {
1014
+ const end = open.index + open[0].length + close.index + close[0].length
1015
+ text = text.slice(0, open.index) + text.slice(end)
1016
+ } else {
1017
+ dropping = open[1] ?? null
1018
+ text = text.slice(0, open.index)
1019
+ }
1020
+ }
1021
+ text = text.replace(HTML_DATA_URI_PATTERN, '')
1022
+ text = text.replace(HTML_TAG_PATTERN_FULL, (match: string, name: string, attrs: string) =>
1023
+ `<${name}${attrs.match(HTML_WHITELISTED_ATTRIBUTES)?.join('') ?? ''}>`)
1024
+ text = text.replace(HTML_INLINE_TAG_PATTERN, '')
1025
+ text = text.trim()
1026
+ if (text !== '') slim.push({ text, index })
1027
+ })
1028
+ if (slim.length === 0) return null
1029
+
1030
+ const buildHeader = (reducer: string, firstElided?: { readonly start: number }): string => {
1031
+ const startLine = firstElided === undefined ? '' : `,"start_line":${String(firstElided.start)},"max_lines":${String(RETRIEVE_HINT_MAX_LINES)}`
1032
+ return `[html compressed by ${reducer}; source: ${input.sourceRef}; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}"${startLine}})]`
1033
+ }
1034
+
1035
+ // Third level (R4): deterministic repeated-block folding. Nav/footer/
1036
+ // boilerplate runs of ≥2 slimmed lines that recur ≥3 times collapse to a
1037
+ // counted marker citing the first occurrence's original line. Table blocks
1038
+ // never fold (their rows are the payload), and the signature normalizes
1039
+ // digits so counter-only variants still match while different copy does not.
1040
+ const foldedSlim = foldRepeatedHtmlBlocks(slim, index =>
1041
+ input.lines[index]?.originalLine ?? 1)
1042
+
1043
+ const slimChars = foldedSlim.reduce((sum, line) => sum + codePointLength(line.text) + 1, 0)
1044
+ if (slimChars + 160 <= input.budgetChars) {
1045
+ const text = fitLines([buildHeader('html-slim'), ...foldedSlim.map(line => line.text)], input.budgetChars, input.sourceRef)
1046
+ if (text !== null) return { text, reducer: 'html-slim', lossy: true }
1047
+ }
1048
+
1049
+ // Stage 2: tag-aware skeleton over the slimmed lines.
1050
+ const keep = new Array<boolean>(foldedSlim.length).fill(false)
1051
+ let tableRows = 0
1052
+ let lastHeading = -2
1053
+ for (let position = 0; position < foldedSlim.length; position++) {
1054
+ const text = foldedSlim[position]!.text
1055
+ if (foldedSlim[position]!.marker === true) {
1056
+ keep[position] = true
1057
+ continue
1058
+ }
1059
+ if (/<h[1-6]\b/i.test(text)) {
1060
+ keep[position] = true
1061
+ lastHeading = position
1062
+ continue
1063
+ }
1064
+ if (lastHeading === position - 1) {
1065
+ // First content line of the section keeps one sentence of context.
1066
+ keep[position] = true
1067
+ continue
1068
+ }
1069
+ if (/<table\b|<tr\b|<th\b/i.test(text)) {
1070
+ // Header + separator rows survive, matching the reduceDocSkeleton
1071
+ // `< 2` convention — one lone row is not table structure (R4).
1072
+ if (tableRows < 2) keep[position] = true
1073
+ tableRows += 1
1074
+ continue
1075
+ }
1076
+ if (!/<\/(tr|table)\b/i.test(text)) tableRows = 0
1077
+ if (IMPORTANT_PATTERN.test(text)) keep[position] = true
1078
+ }
1079
+ const kept: string[] = []
1080
+ let position = 0
1081
+ let firstElided: number | undefined
1082
+ let elidedLines = 0
1083
+ while (position < foldedSlim.length) {
1084
+ if (keep[position]) {
1085
+ kept.push(foldedSlim[position]!.text)
1086
+ position += 1
1087
+ continue
1088
+ }
1089
+ const runStart = position
1090
+ while (position < foldedSlim.length && !keep[position]) position += 1
1091
+ const start = input.lines[foldedSlim[runStart]!.index]!.originalLine
1092
+ const end = originalEnd(input.lines, foldedSlim[position - 1]!.index)
1093
+ if (firstElided === undefined) firstElided = start
1094
+ elidedLines += end - start + 1
1095
+ kept.push(elidedRangeMarker(start, end))
1096
+ }
1097
+ const text = fitLines([buildHeader('html-skeleton', firstElided === undefined ? undefined : { start: firstElided }), ...kept], input.budgetChars, input.sourceRef)
1098
+ return text === null ? null : { text, reducer: 'html-skeleton', lossy: true, elidedLines }
1099
+ }
1100
+
1101
+ /** Original-event end line of folded entry `lines[index]`. */
1102
+ function originalEnd(lines: readonly NormalizedLine[], index: number): number {
1103
+ const line = lines[index]
1104
+ return line?.originalLineEnd ?? line?.originalLine ?? 0
1105
+ }
1106
+
1107
+ /**
1108
+ * Keep a document skeleton: the heading hierarchy, each section's first and
1109
+ * last content line, list-item starts, table headers, and fence markers,
1110
+ * eliding the remaining bodies with R9 line-range markers. Fails open (null)
1111
+ * when nothing is elidable or the budget cannot be met, so the next candidate
1112
+ * takes over.
1113
+ */
1114
+ function reduceDocSkeleton(input: PreparedInput, sectionRanking?: readonly string[]): ReducerOutput | null {
1115
+ const lines = input.lines
1116
+ const keep = new Array<boolean>(lines.length).fill(false)
1117
+ const headingIndex: number[] = []
1118
+ let inFence = false
1119
+ for (let index = 0; index < lines.length; index++) {
1120
+ const line = lines[index]!.content
1121
+ if (FENCE_PATTERN.test(line)) {
1122
+ inFence = !inFence
1123
+ keep[index] = true
1124
+ continue
1125
+ }
1126
+ if (!inFence && MARKDOWN_HEADING_PATTERN.test(line)) {
1127
+ headingIndex.push(index)
1128
+ keep[index] = true
1129
+ continue
1130
+ }
1131
+ if (IMPORTANT_PATTERN.test(line)) keep[index] = true
1132
+ else if (!inFence && LIST_ITEM_PATTERN.test(line)) keep[index] = true
1133
+ }
1134
+ // Table blocks keep their first two rows (header + separator) in every
1135
+ // variant — table structure is part of the skeleton, not section content.
1136
+ let tableRows = 0
1137
+ let fenceOpen = false
1138
+ for (let index = 0; index < lines.length; index++) {
1139
+ const line = lines[index]!.content
1140
+ if (FENCE_PATTERN.test(line)) {
1141
+ fenceOpen = !fenceOpen
1142
+ tableRows = 0
1143
+ continue
1144
+ }
1145
+ if (fenceOpen || line.trim() === '') continue
1146
+ if (TABLE_ROW_PATTERN.test(line)) {
1147
+ if (tableRows < 2) keep[index] = true
1148
+ tableRows += 1
1149
+ continue
1150
+ }
1151
+ tableRows = 0
1152
+ }
1153
+ const sectionStarts = [-1, ...headingIndex]
1154
+ const sectionEnds = [...headingIndex, lines.length]
1155
+ const sections = headingIndex.map((heading, position) => ({
1156
+ id: lines[heading]!.content.replace(/^#+\s*/, '').trim(),
1157
+ heading,
1158
+ from: heading + 1,
1159
+ to: position + 1 < headingIndex.length ? headingIndex[position + 1]! : lines.length,
1160
+ }))
1161
+ // Mechanical floor: every section keeps its first and last content line.
1162
+ const floorKeep = new Array<boolean>(lines.length).fill(false)
1163
+ for (let section = 0; section < sectionStarts.length; section++) {
1164
+ const from = sectionStarts[section]! + 1
1165
+ const to = sectionEnds[section]!
1166
+ let first = -1
1167
+ let last = -1
1168
+ for (let index = from; index < to; index++) {
1169
+ if (lines[index]!.content.trim() === '') continue
1170
+ if (first === -1) first = index
1171
+ last = index
1172
+ }
1173
+ if (first !== -1) floorKeep[first] = true
1174
+ if (last !== -1) floorKeep[last] = true
1175
+ }
1176
+
1177
+ /** Emit the skeleton for one keep-set: header, kept lines, R9 range markers. */
1178
+ const assemble = (flags: readonly boolean[], budget: number): { text: string | null, firstElided?: number, elidedLines: number } => {
1179
+ const kept: string[] = []
1180
+ let index = 0
1181
+ let firstElided: number | undefined
1182
+ let elidedLines = 0
1183
+ while (index < lines.length) {
1184
+ if (flags[index]) {
1185
+ kept.push(lines[index]!.text)
1186
+ index += 1
1187
+ continue
1188
+ }
1189
+ const runStart = index
1190
+ while (index < lines.length && !flags[index]) index += 1
1191
+ const start = lines[runStart]!.originalLine
1192
+ const end = originalEnd(lines, index - 1)
1193
+ if (firstElided === undefined) firstElided = start
1194
+ elidedLines += end - start + 1
1195
+ kept.push(elidedRangeMarker(start, end))
1196
+ }
1197
+ const hint = firstElided === undefined
1198
+ ? ''
1199
+ : `,"start_line":${String(firstElided)},"max_lines":${String(RETRIEVE_HINT_MAX_LINES)}`
1200
+ kept.unshift(`[document compressed by doc-skeleton; source: ${input.sourceRef}; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}"${hint}})]`)
1201
+ const text = fitLines(kept, budget, input.sourceRef)
1202
+ return { text, ...(firstElided === undefined ? {} : { firstElided }), elidedLines }
1203
+ }
1204
+
1205
+ const combine = (base: readonly boolean[], overlay: readonly boolean[]): boolean[] =>
1206
+ lines.map((_, index) => (base[index] ?? false) || (overlay[index] ?? false))
1207
+
1208
+ // Structural lines (headings/fences/important/list items) are part of every
1209
+ // variant; the mechanical floor rides on top of them.
1210
+ const mechanicalKeep = combine(keep, floorKeep)
1211
+ const mechanical = assemble(mechanicalKeep, input.budgetChars)
1212
+ if (mechanical.text === null) return null
1213
+ if (sectionRanking === undefined || sectionRanking.length === 0) {
1214
+ return { text: mechanical.text, reducer: 'doc-skeleton', lossy: true, elidedLines: mechanical.elidedLines }
1215
+ }
1216
+
1217
+ // Ranked mode (S1b): floors (every heading + each section's first line)
1218
+ // are reserved first; the remaining budget is filled most relevant-first,
1219
+ // line by line, with an EXACT assembly check per line so the final output
1220
+ // never exceeds min(budgetChars, inputChars - 1) — a ranked skeleton must
1221
+ // always shrink the text at least as much as verifyReduction demands.
1222
+ const cap = Math.min(input.budgetChars, codePointLength(input.text) - 1)
1223
+ const rankedFloor = new Array<boolean>(lines.length).fill(false)
1224
+ // First lines only: a ranked unselected section falls back to its first
1225
+ // sentence, so the mechanical last-line floor must NOT ride along.
1226
+ for (const section of sections) {
1227
+ for (let index = section.from; index < section.to; index++) {
1228
+ if (lines[index]!.content.trim() === '') continue
1229
+ rankedFloor[index] = true
1230
+ break
1231
+ }
1232
+ }
1233
+ /** Exact packed-output size of a keep-set: header + kept lines + markers. */
1234
+ const packedSize = (flags: readonly boolean[]): number => {
1235
+ let size = 180 /* header with hint */
1236
+ let index = 0
1237
+ while (index < lines.length) {
1238
+ if (flags[index]) {
1239
+ size += codePointLength(lines[index]!.text) + 1
1240
+ index += 1
1241
+ continue
1242
+ }
1243
+ const runStart = index
1244
+ while (index < lines.length && !flags[index]) index += 1
1245
+ size += codePointLength(elidedRangeMarker(lines[runStart]!.originalLine, originalEnd(lines, index - 1))) + 1
1246
+ }
1247
+ return size
1248
+ }
1249
+ const fill = new Array<boolean>(lines.length).fill(false)
1250
+ for (const section of rankedFirst(sections, sectionRanking)) {
1251
+ for (let index = section.from; index < section.to; index++) {
1252
+ if (rankedFloor[index] || fill[index] || lines[index]!.content.trim() === '') continue
1253
+ fill[index] = true
1254
+ if (packedSize(combine(combine(keep, rankedFloor), fill)) > cap) {
1255
+ fill[index] = false
1256
+ break
1257
+ }
1258
+ }
1259
+ }
1260
+ const ranked = assemble(combine(combine(keep, rankedFloor), fill), cap)
1261
+ return ranked.text === null
1262
+ ? null
1263
+ : { text: ranked.text, reducer: 'doc-skeleton', lossy: true, elidedLines: ranked.elidedLines }
1264
+ }
1265
+
1266
+ /**
1267
+ * Universal prose fallback (R8b, the main force): keep the head AND the tail
1268
+ * of any non-code text and one R9 line-range marker for everything elided in
1269
+ * between. Unstructured prose (85%+ of large results) previously landed on
1270
+ * head-only truncation; a tail keep preserves conclusions and closing state.
1271
+ * Fails open for code-like text and when the budget cannot hold both ends.
1272
+ */
1273
+ function reduceProseKeep(input: PreparedInput): ReducerOutput | null {
1274
+ const lines = input.lines
1275
+ if (lines.length < 8) return null
1276
+ if (looksLikeSourceCode(input.contentText)) return null
1277
+ const first = lines[0]!
1278
+ const last = lines[lines.length - 1]!
1279
+ const tailLine = last.originalLineEnd ?? last.originalLine
1280
+ const markerTemplate = elidedRangeMarker(first.originalLine, tailLine)
1281
+ const sourceNoteTemplate = `; source: ${input.sourceRef}; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}"})`
1282
+ const reserved = codePointLength(markerTemplate) + codePointLength(sourceNoteTemplate) + 2
1283
+ const bodyBudget = input.budgetChars - reserved
1284
+ if (bodyBudget <= 0) return null
1285
+ const headBudget = Math.floor(bodyBudget / 2)
1286
+ const tailBudget = bodyBudget - headBudget
1287
+ let headCount = 0
1288
+ let used = 0
1289
+ while (headCount < lines.length) {
1290
+ const cost = codePointLength(lines[headCount]!.text) + (headCount === 0 ? 0 : 1)
1291
+ if (used + cost > headBudget) break
1292
+ used += cost
1293
+ headCount += 1
1294
+ }
1295
+ let tailCount = 0
1296
+ used = 0
1297
+ while (tailCount < lines.length - headCount) {
1298
+ const index = lines.length - 1 - tailCount
1299
+ const cost = codePointLength(lines[index]!.text) + (tailCount === 0 ? 0 : 1)
1300
+ if (used + cost > tailBudget) break
1301
+ used += cost
1302
+ tailCount += 1
1303
+ }
1304
+ if (headCount === 0 || tailCount === 0 || headCount + tailCount >= lines.length) return null
1305
+ const elidedStart = lines[headCount]!.originalLine
1306
+ const elidedEnd = originalEnd(lines, lines.length - tailCount - 1)
1307
+ if (elidedEnd < elidedStart) return null
1308
+ const sourceNote = `; source: ${input.sourceRef}; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}","start_line":${String(elidedStart)},"max_lines":${String(RETRIEVE_HINT_MAX_LINES)}})`
1309
+ const text = [
1310
+ ...lines.slice(0, headCount).map(line => line.text),
1311
+ elidedRangeMarker(elidedStart, elidedEnd) + sourceNote,
1312
+ ...lines.slice(lines.length - tailCount).map(line => line.text),
1313
+ ].join('\n')
1314
+ return { text, reducer: 'prose-keep', lossy: true, elidedLines: elidedEnd - elidedStart + 1 }
1315
+ }
1316
+
330
1317
  /**
331
1318
  * Keep a source-file skeleton: imports, decorators, declaration signatures,
332
1319
  * comments at brace depth zero, and every error-signalling line, eliding the
@@ -336,12 +1323,22 @@ function reduceSalient(input: ReducerInput, reducer: string): ReducerOutput | nu
336
1323
  * @param input - original result text, recovery source, and output budget.
337
1324
  * @returns a verified candidate, or `null` when the text is not code-like.
338
1325
  */
339
- function reduceCodeSkeleton(input: ReducerInput): ReducerOutput | null {
340
- const lines = splitLines(input.text)
1326
+ function reduceCodeSkeleton(input: PreparedInput): ReducerOutput | null {
1327
+ // The skeleton logic reads the CONTENT view (gutter-stripped) so structure
1328
+ // lines survive the host's read gutter; markers still cite originalLine.
1329
+ const lines = input.lines.map(line => line.content)
341
1330
  const kept: string[] = []
342
1331
  let elided = 0
1332
+ let elidedTotal = 0
1333
+ let firstElided: { readonly start: number, readonly end: number } | undefined
343
1334
  const flushElided = (): void => {
344
- if (elided > 0) kept.push(`[... ${String(elided)} lines elided ...]`)
1335
+ if (elided > 0) {
1336
+ const start = input.lines[index - elided]?.originalLine ?? 0
1337
+ const end = originalEnd(input.lines, index - 1)
1338
+ if (firstElided === undefined) firstElided = { start, end }
1339
+ kept.push(elidedRangeMarker(start, end))
1340
+ elidedTotal += elided
1341
+ }
345
1342
  elided = 0
346
1343
  }
347
1344
  let depth = 0
@@ -463,17 +1460,22 @@ function reduceCodeSkeleton(input: ReducerInput): ReducerOutput | null {
463
1460
  index += 1
464
1461
  }
465
1462
  flushElided()
466
- return finishSkeleton(kept, lines, input)
1463
+ return finishSkeleton(kept, lines, input, firstElided, elidedTotal)
467
1464
  }
468
1465
 
469
1466
  function finishSkeleton(
470
1467
  kept: readonly string[],
471
1468
  lines: readonly string[],
472
- input: ReducerInput,
1469
+ input: PreparedInput,
1470
+ firstElided: { readonly start: number, readonly end: number } | undefined,
1471
+ elidedLines: number,
473
1472
  ): ReducerOutput | null {
474
- const header = `[code output compressed by hypa-code-skeleton; source: ${input.sourceRef}]`
1473
+ const hint = firstElided === undefined
1474
+ ? ''
1475
+ : `; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}","start_line":${String(firstElided.start)},"max_lines":${String(RETRIEVE_HINT_MAX_LINES)}})`
1476
+ const header = `[code output compressed by hypa-code-skeleton; source: ${input.sourceRef}${hint}]`
475
1477
  const text = fitLines([header, ...kept, ...lines.slice(-4)], input.budgetChars, input.sourceRef)
476
- return text === null ? null : { text, reducer: 'hypa-code-skeleton', lossy: true }
1478
+ return text === null ? null : { text, reducer: 'hypa-code-skeleton', lossy: true, elidedLines }
477
1479
  }
478
1480
 
479
1481
  /** Net brace delta of one line, ignoring braces inside string literals. */
@@ -525,10 +1527,31 @@ function looksLikeSourceCode(text: string): boolean {
525
1527
  return false
526
1528
  }
527
1529
 
528
- function omissionMarker(input: ReducerInput, reducer: string): string {
1530
+ function omissionMarker(input: PreparedInput, reducer: string): string {
529
1531
  return `[... ${reducer} omitted content; original_chars=${String(codePointLength(input.text))}; source=${input.sourceRef}; retrieve with context_compression_retrieve ...]`
530
1532
  }
531
1533
 
1534
+ /**
1535
+ * Continuous-mask marker (R9b): an original-event line range plus a pasteable
1536
+ * retrieve hint whose start_line is the first elided line. Line numbers point
1537
+ * at the RAW event because retrieve reads raw events (D8).
1538
+ */
1539
+ function rangeOmissionMarker(
1540
+ input: PreparedInput,
1541
+ reducer: string,
1542
+ elidedStart: number,
1543
+ elidedEnd: number,
1544
+ ): string {
1545
+ return `[... lines ${String(elidedStart)}-${String(elidedEnd)} elided (${String(elidedEnd - elidedStart + 1)} lines); ${reducer}; original_chars=${String(codePointLength(input.text))}; source=${input.sourceRef}; retrieve with context_compression_retrieve({"ref":"${input.sourceRef}","start_line":${String(elidedStart)},"max_lines":${String(RETRIEVE_HINT_MAX_LINES)}}) ...]`
1546
+ }
1547
+
1548
+ /** Scatter-mask note (R9b): totals instead of fragmented per-run ranges. */
1549
+ function scannedTotals(input: PreparedInput, kept: number): string {
1550
+ const last = input.lines[input.lines.length - 1]
1551
+ const lastLine = Math.max(last?.originalLineEnd ?? 0, last?.originalLine ?? 0, input.lines.length)
1552
+ return `lines 1-${String(lastLine)} scanned, ${String(kept)} kept`
1553
+ }
1554
+
532
1555
  function importantAnchor(text: string, maxChars: number): string {
533
1556
  const lines = splitLines(normalizeTerminalText(text))
534
1557
  const chosen = lines.find(line => IMPORTANT_PATTERN.test(line)) ?? lines.at(-1) ?? ''
@@ -590,12 +1613,20 @@ function splitLines(text: string): string[] {
590
1613
  return lines
591
1614
  }
592
1615
 
593
- function extractCommand(argumentsText: string): string {
1616
+ /**
1617
+ * Extract the command argument from a tool call's arguments (task_10/AD2):
1618
+ * ONLY `command` / `cmd` / `script` are command keys. `input` was removed —
1619
+ * any MCP tool with a non-empty `input` string parameter would otherwise be
1620
+ * misrouted into the shell reducer, the single largest misroute source.
1621
+ * @param argumentsText - raw JSON arguments of the tool call.
1622
+ * @returns the command string, or '' when absent.
1623
+ */
1624
+ export function extractCommand(argumentsText: string): string {
594
1625
  try {
595
1626
  const parsed = JSON.parse(argumentsText) as unknown
596
1627
  if (typeof parsed !== 'object' || parsed === null) return ''
597
1628
  const record = parsed as Record<string, unknown>
598
- for (const key of ['command', 'cmd', 'script', 'input']) {
1629
+ for (const key of ['command', 'cmd', 'script']) {
599
1630
  const value = record[key]
600
1631
  if (typeof value === 'string') return value
601
1632
  }
@@ -611,19 +1642,6 @@ function looksLikeJson(text: string): boolean {
611
1642
  || (trimmed.startsWith('[') && trimmed.endsWith(']'))
612
1643
  }
613
1644
 
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
1645
  function isGitCommand(name: string, command: string): boolean {
628
1646
  return name.includes('git') || /(?:^|\s)git\s/.test(command)
629
1647
  }