dsh-context-compression-improved 0.4.0-beta.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +68 -36
- package/CHANGELOG.ko.md +67 -35
- package/CHANGELOG.md +195 -134
- package/CHANGELOG.zh.md +64 -36
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/installation.ja.md +2 -2
- package/docs/installation.ko.md +2 -2
- package/docs/installation.md +103 -78
- package/docs/installation.zh.md +100 -77
- package/docs/repair-log.md +54 -0
- package/package.json +1 -1
- package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
- package/packages/selector/lib/client.d.ts +7 -0
- package/packages/selector/lib/client.js +33 -3
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +112 -3
- package/packages/selector/lib/pruner.d.ts +128 -1
- package/packages/selector/lib/pruner.js +2802 -1374
- package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
- package/packages/selector/src/client/index.ts +1 -1
- package/packages/selector/src/client/preset-options.ts +2 -0
- package/packages/selector/src/index.ts +129 -49
- package/packages/selector/src/profiles.ts +48 -0
- package/packages/selector/src/pruner/content.ts +18 -5
- package/packages/selector/src/pruner/state.ts +3 -0
- package/packages/selector/src/pruner/types.ts +23 -5
- package/packages/selector/src/pruner.ts +297 -162
- package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
- package/packages/selector/src/runtime/audit.ts +40 -2
- package/packages/selector/src/runtime/config.ts +88 -1
- package/packages/selector/src/runtime/measurement.ts +31 -2
- package/packages/selector/src/runtime/reducers.ts +1115 -97
- package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
- package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
- package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
- package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
- package/packages/selector/src/runtime/toolclass.ts +103 -0
- package/packages/selector/src/runtime/types.ts +37 -0
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
- package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
- package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
- package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
- package/packages/selector/tests/runtime/audit.spec.ts +88 -1
- package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
- package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
- package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
- package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
- package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
- package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
- package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
- package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
- package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
- package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
- package/scripts/toolclass-corpus-replay.mjs +281 -0
|
@@ -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 =
|
|
63
|
-
const prepared = {
|
|
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
|
-
|
|
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 (
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
366
|
+
const stripGutter = hasReadGutter(logical)
|
|
367
|
+
const folded: NormalizedLine[] = []
|
|
143
368
|
let previous: string | undefined
|
|
369
|
+
let firstText = ''
|
|
144
370
|
let count = 0
|
|
145
|
-
|
|
371
|
+
let firstOriginal = 0
|
|
372
|
+
const flush = (nextOriginal: number): void => {
|
|
146
373
|
if (previous === undefined) return
|
|
147
|
-
folded.push(previous)
|
|
148
|
-
if (count > 1)
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
390
|
+
return
|
|
154
391
|
}
|
|
155
|
-
flush()
|
|
156
|
-
previous =
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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:
|
|
301
|
-
const lines =
|
|
302
|
-
const
|
|
303
|
-
const header = `[command output compressed by ${reducer}; source: ${input.sourceRef}]`
|
|
304
|
-
const text = fitLines([header, ...
|
|
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:
|
|
309
|
-
const lines =
|
|
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:
|
|
318
|
-
const lines =
|
|
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
|
|
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:
|
|
340
|
-
|
|
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)
|
|
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:
|
|
1469
|
+
input: PreparedInput,
|
|
1470
|
+
firstElided: { readonly start: number, readonly end: number } | undefined,
|
|
1471
|
+
elidedLines: number,
|
|
473
1472
|
): ReducerOutput | null {
|
|
474
|
-
const
|
|
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:
|
|
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
|
-
|
|
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'
|
|
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
|
}
|