Re-reading a file already in the context returns a one-line notice if unchanged, or only a diff if changed, saving context tokens.

重复 Read 去重:模型再次读取一个仍在上下文里的文件时,未变化就只返回一行提示,变化了只返回差异,省掉重复的文件内容。
/clear、ctx-mod 换窗口之后会照常返回全文。cat 文件、sed -n 'a,bp' 文件、head -n N 文件,可以带开头的 cd 目录 &&)与 Read 共用同一份记录:范围完全相同且文件未改时返回提示;带管道、重定向、多个文件、变量或 glob 的命令一律照常执行;不足 300 字符的读取不替换。hooks/register.ts 135 lines1import type { Register } from 'claude-code'
2
3import { contextHasResults, parseReadResult, stripGutter } from './lib/dedup.ts'
4import { parseBashOutput, parseBashRead } from './lib/bash.ts'
5import { createStore, decide, noteEdit } from './lib/handler.ts'
6
7// Module variable on purpose: a hot reload clears it, and an empty cache only means one extra full read (the safe direction).
8const store = createStore()
9/** tool_use_ids whose Read result was replaced: their host-added 1..N line gutter is stripped when the row is stored. */
10const rewritten = new Set<string>()
11
12export const register: Register = on => {
13 // The host numbers a text Read result from startLine; on a rewritten result that gutter (1..N) is noise next to the real line numbers.
14 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
15 try {
16 if (rewritten.size === 0) return next(e)
17 let changed = false
18 const content = e.message.content.map(block => {
19 if (block.type !== 'tool_result') return block
20 const tid = block['tool_use_id']
21 if (typeof tid !== 'string' || !rewritten.has(tid)) return block
22 rewritten.delete(tid)
23 const c = block['content']
24 changed = true
25 if (typeof c === 'string') return { ...block, content: stripGutter(c) }
26 if (Array.isArray(c)) return { ...block, content: c.map(x => (x && typeof x === 'object' && x.type === 'text' && typeof x.text === 'string' ? { ...x, text: stripGutter(x.text) } : x)) }
27 return block
28 })
29 return next(changed ? { ...e, message: { ...e.message, content } } : e)
30 } catch {
31 return next(e)
32 }
33 }).catch(($, e, next) => next(e))
34
35 // Model edits reset the escape hatch: Read(omitted) -> Edit -> Read must give the diff, not the full text.
36 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
37 const r = await next(e)
38 try {
39 if (r.deny === undefined && r.isError !== true) {
40 const a = e as { file_path?: unknown; notebook_path?: unknown }
41 noteEdit(store, e.agentId, a.file_path ?? a.notebook_path)
42 }
43 } catch {
44 /* best effort */
45 }
46 return r
47 }).catch(($, e, next) => next(e))
48
49 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
50 const r = await next(e)
51 try {
52 if (r.deny !== undefined || r.isError === true) return r
53 const parsed = parseReadResult(r.result)
54 if (parsed === undefined) return r
55
56 // Plan A: an earlier Read result counts as "still in the context" only if its tool_result block is in the
57 // Messages-API form of the current conversation (post-compaction / post-/clear / post-ctx-mod window switch).
58 const live = async (ids: string[]): Promise<boolean> => {
59 const messages = e.agentId === undefined ? await $.session.messages({ as: 'api' }) : await $.session.messages({ as: 'api', agentId: e.agentId })
60 return Array.isArray(messages) && contextHasResults(messages, ids, store.probes)
61 }
62
63 const d = await decide(store, { id: e.tool_use_id, agentId: e.agentId, filePath: e.file_path }, parsed, live)
64 try {
65 $.ui.log(`read-dedup: ${d.note} (${e.file_path}) saved=${d.savedChars} total=${store.savedChars}`, { to: 'debug' })
66 } catch {
67 /* logging is best effort */
68 }
69 if (d.content === undefined) return r
70
71 if (e.tool_use_id) {
72 rewritten.add(e.tool_use_id)
73 while (rewritten.size > 200) rewritten.delete(rewritten.values().next().value as string)
74 }
75 const res = r.result as { file: Record<string, unknown> }
76 return {
77 result: {
78 ...res,
79 file: { ...res.file, content: d.content, numLines: d.content.split('\n').length, startLine: 1 },
80 },
81 context: r.context,
82 } as typeof r
83 } catch {
84 return r
85 }
86 }).catch(($, e, next) => next(e))
87
88 // A Bash command that is one plain file read (cat / sed -n 'a,bp' / head -n N, optionally behind cd DIR &&)
89 // shares the Read cache: an unchanged same-range hit gets the same stub a Read would get, everything else
90 // runs as usual and is only recorded. Any part the parser does not fully understand -> run as usual.
91 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
92 const r = await next(e)
93 try {
94 if (r.deny !== undefined || r.isError === true) return r
95 const args = e as { command?: unknown; run_in_background?: unknown }
96 if (args.run_in_background === true || typeof args.command !== 'string') return r
97 // $.session.cwd() is the session's directory; a subagent may run elsewhere, so its relative paths are not ours.
98 if (e.agentId !== undefined) return r
99 if (!/^\s*(?:cd|cat|sed|head)(?:\s|$)/.test(args.command)) return r
100 const res = r.result as Record<string, unknown> | null
101 if (res === null || typeof res !== 'object') return r
102 const stderr = res['stderr']
103 if (stderr !== undefined && (typeof stderr !== 'string' || stderr.trim() !== '')) return r
104 if (res['interrupted'] === true || res['isImage'] === true) return r
105 if (res['backgroundTaskId'] !== undefined || res['backgroundedByUser'] === true) return r
106 if (res['backgroundedByTurnAbort'] === true || res['backgroundedToDeliverMessage'] === true) return r
107 if (res['backgroundCwdHint'] !== undefined || res['ghRateLimitHint'] !== undefined) return r
108 if (res['persistedOutputPath'] !== undefined || res['rawOutputPath'] !== undefined) return r
109 if (res['timedOutAfterMs'] !== undefined || res['returnCodeInterpretation'] !== undefined) return r
110 if (res['staleReadFileStateHint'] !== undefined) return r
111 if (Array.isArray(res['structuredContent']) && res['structuredContent'].length > 0) return r
112
113 const cmd = parseBashRead(args.command, await $.session.cwd())
114 if (cmd === undefined) return r
115 const parsed = parseBashOutput(cmd, res['stdout'])
116 if (parsed === undefined) return r
117
118 const live = async (ids: string[]): Promise<boolean> => {
119 const messages = await $.session.messages({ as: 'api' })
120 return Array.isArray(messages) && contextHasResults(messages, ids, store.probes)
121 }
122 const d = await decide(store, { id: e.tool_use_id, agentId: e.agentId, filePath: parsed.filePath }, parsed, live, { onlySame: true })
123 try {
124 $.ui.log('read-dedup(bash): ' + d.note + ' (' + parsed.filePath + ') saved=' + d.savedChars + ' total=' + store.savedChars, { to: 'debug' })
125 } catch {
126 /* logging is best effort */
127 }
128 if (d.content === undefined) return r
129 return { result: { ...res, stdout: d.content, stderr: '' }, context: r.context } as typeof r
130 } catch {
131 return r
132 }
133 }).catch(($, e, next) => next(e))
134}
135hooks/lib/dedup.ts 436 lines1// Pure logic of read-dedup: path normalisation, Read-result parsing, line alignment, diff text, cache bookkeeping.
2// No host access here (the module runtime has no Node/DOM), so it is unit-testable in plain node.
3
4/** One cached line: its text, and the tool_use_id of the Read result that put it in the context. */
5export type Line = { t: string; src: string }
6/** line number -> line, as of the last time the file was read. */
7export type Lines = Map<number, Line>
8
9export const CONTEXT_LINES = 3
10export const MAX_CHANGE_RATIO = 0.3
11/** How many cached lines before/after the requested range may take part in the alignment. */
12export const WINDOW = 400
13export const MAX_CELLS = 8_000_000
14/** A read whose full text is shorter than this is never worth rewriting. */
15export const MIN_SAVE_CHARS = 300
16
17export type Op = { k: 'eq' | 'add' | 'del'; o?: number; n?: number }
18
19export type Parsed = { filePath: string; start: number; lines: string[]; total: number }
20
21// ---------- path ----------
22
23/** Absolute path -> comparison key: slashes unified, `.`/`..` resolved, case folded on Windows-style paths. */
24export function normPath(p: unknown): string | undefined {
25 if (typeof p !== 'string' || p === '') return undefined
26 let s = p.replace(/\\/g, '/')
27 const isDrive = /^[A-Za-z]:\//.test(s)
28 const isUnc = s.startsWith('//')
29 if (!isDrive && !isUnc && !s.startsWith('/')) return undefined // relative: do not guess
30 const prefix = isDrive ? s.slice(0, 2) : isUnc ? '//' : ''
31 const rest = s.slice(prefix.length)
32 const out: string[] = []
33 for (const seg of rest.split('/')) {
34 if (seg === '' || seg === '.') continue
35 if (seg === '..') {
36 out.pop()
37 continue
38 }
39 out.push(seg)
40 }
41 s = prefix + '/' + out.join('/')
42 return isDrive || isUnc ? s.toLowerCase() : s
43}
44
45/**
46 * Absolute or cwd-relative path -> an absolute path with slashes unified and '.'/'..' resolved, case kept
47 * (normPath folds case for the comparison key). Undefined when it cannot be made absolute.
48 */
49export function resolvePath(cwd: unknown, p: unknown): string | undefined {
50 if (typeof p !== 'string' || p === '') return undefined
51 const s = p.replace(/\\/g, '/')
52 const isAbs = /^[A-Za-z]:\//.test(s) || s.startsWith('//') || s.startsWith('/')
53 let full: string
54 if (isAbs) full = s
55 else {
56 if (typeof cwd !== 'string' || cwd === '') return undefined
57 full = cwd.replace(/\\/g, '/').replace(/\/+$/, '') + '/' + s
58 }
59 const drive = /^[A-Za-z]:/.exec(full)
60 const unc = drive === null && full.startsWith('//')
61 const prefix = drive !== null ? full.slice(0, 2) : unc ? '//' : ''
62 const out: string[] = []
63 for (const seg of full.slice(prefix.length).split('/')) {
64 if (seg === '' || seg === '.') continue
65 if (seg === '..') {
66 out.pop()
67 continue
68 }
69 out.push(seg)
70 }
71 if (out.length === 0 && prefix === '') return '/'
72 return prefix + '/' + out.join('/')
73}
74
75// ---------- parsing ----------
76
77/**
78 * Reads the plain-text Read record: `{ type: 'text', file: { filePath, content, numLines, startLine, totalLines } }`.
79 * `content` is the raw text; a `N<TAB|→>text` numbered form is stripped too. Anything that does not add up -> undefined.
80 */
81export function parseReadResult(res: unknown): Parsed | undefined {
82 if (typeof res !== 'object' || res === null) return undefined
83 const r = res as { type?: unknown; file?: unknown }
84 if (r.type !== 'text' || typeof r.file !== 'object' || r.file === null) return undefined
85 const f = r.file as { filePath?: unknown; content?: unknown; numLines?: unknown; startLine?: unknown; totalLines?: unknown }
86 if (typeof f.filePath !== 'string' || typeof f.content !== 'string') return undefined
87 const start = f.startLine
88 const numLines = f.numLines
89 if (typeof start !== 'number' || !Number.isInteger(start) || start < 1) return undefined
90 if (typeof numLines !== 'number' || !Number.isInteger(numLines) || numLines < 1) return undefined
91 let lines = f.content.split('\n').map(l => (l.endsWith('\r') ? l.slice(0, -1) : l))
92 if (lines.length === numLines + 1 && lines[lines.length - 1] === '') lines = lines.slice(0, -1)
93 if (lines.length !== numLines) return undefined
94 // Defensive: content already carrying "N<TAB>text" / "N→text" for consecutive N from startLine.
95 const numbered = lines.map(l => /^\s*(\d+)[\t→](.*)$/s.exec(l))
96 if (numbered.every((m, i) => m !== null && Number(m[1]) === start + i)) {
97 lines = numbered.map(m => (m as RegExpExecArray)[2] as string)
98 }
99 const total = typeof f.totalLines === 'number' && f.totalLines >= 1 ? f.totalLines : start + numLines - 1
100 return { filePath: f.filePath, start, lines, total }
101}
102
103// ---------- alignment ----------
104
105/** Aligns the freshly read lines `nt` (numbered from `a`) with the cached lines. Undefined when too large to align. */
106export function align(old: Lines, a: number, nt: string[], total: number): Op[] | undefined {
107 const n = nt.length
108 const b = a + n - 1
109 const ops: Op[] = []
110 let p = 0
111 while (p < n && old.get(a + p)?.t === nt[p]) p++
112 for (let i = 0; i < p; i++) ops.push({ k: 'eq', o: a + i, n: a + i })
113 const reachesEnd = b >= total
114 if (p < n || reachesEnd) {
115 const from = p === 0 ? Math.max(1, a - WINDOW) : a + p
116 let to = b + WINDOW
117 if (reachesEnd) for (const k of old.keys()) if (k > to) to = k // the file may have shrunk: look at every cached tail line
118 const oNums: number[] = []
119 const oTxt: string[] = []
120 for (let k = from; k <= to; k++) {
121 const l = old.get(k)
122 if (l !== undefined) {
123 oNums.push(k)
124 oTxt.push(l.t)
125 }
126 }
127 const nn = n - p
128 const m = oNums.length
129 if ((nn + 1) * (m + 1) > MAX_CELLS) return undefined
130 // intern strings to ints
131 const ids = new Map<string, number>()
132 const intern = (s: string): number => {
133 let v = ids.get(s)
134 if (v === undefined) {
135 v = ids.size
136 ids.set(s, v)
137 }
138 return v
139 }
140 const X = new Int32Array(nn)
141 for (let i = 0; i < nn; i++) X[i] = intern(nt[p + i] as string)
142 const Y = new Int32Array(m)
143 for (let j = 0; j < m; j++) Y[j] = intern(oTxt[j] as string)
144 const W = m + 1
145 const L = new Int32Array((nn + 1) * W)
146 for (let i = nn - 1; i >= 0; i--) {
147 for (let j = m - 1; j >= 0; j--) {
148 L[i * W + j] =
149 X[i] === Y[j] ? (L[(i + 1) * W + j + 1] as number) + 1 : Math.max(L[(i + 1) * W + j] as number, L[i * W + j + 1] as number)
150 }
151 }
152 const mid: Op[] = []
153 let i = 0
154 let j = 0
155 while (i < nn && j < m) {
156 if (X[i] === Y[j]) {
157 mid.push({ k: 'eq', o: oNums[j], n: a + p + i })
158 i++
159 j++
160 } else if ((L[i * W + j + 1] as number) >= (L[(i + 1) * W + j] as number)) {
161 mid.push({ k: 'del', o: oNums[j] })
162 j++
163 } else {
164 mid.push({ k: 'add', n: a + p + i })
165 i++
166 }
167 }
168 while (i < nn) mid.push({ k: 'add', n: a + p + i++ })
169 while (j < m) mid.push({ k: 'del', o: oNums[j++] })
170 for (const op of mid) ops.push(op)
171 }
172 // Drop "deletions" that only reflect the alignment window reaching beyond the requested range.
173 let fe = -1
174 let le = -1
175 for (let i = 0; i < ops.length; i++) {
176 if ((ops[i] as Op).k === 'eq') {
177 if (fe < 0) fe = i
178 le = i
179 }
180 }
181 const out: Op[] = []
182 for (let i = 0; i < ops.length; i++) {
183 const op = ops[i] as Op
184 if (op.k !== 'del') {
185 out.push(op)
186 continue
187 }
188 const o = op.o as number
189 if (fe < 0) {
190 if (o >= a && (o <= b || reachesEnd)) out.push(op)
191 } else if (i < fe) {
192 const first = ops[fe] as Op
193 // a range that starts at line 1 has nothing before it: every leading old line is a real deletion
194 if (a === 1 || o - ((first.o as number) - (first.n as number)) >= a) out.push(op)
195 } else if (i > le) {
196 const last = ops[le] as Op
197 if (reachesEnd || o - ((last.o as number) - (last.n as number)) <= b) out.push(op)
198 } else {
199 out.push(op)
200 }
201 }
202 return out
203}
204
205// ---------- planning ----------
206
207export type Plan = {
208 kind: 'same' | 'shifted' | 'diff' | 'full'
209 reason?: string
210 ops?: Op[]
211 /** the one-line message (same/shifted) or the diff text (diff) */
212 text?: string
213 /** new-line numbers the diff text prints */
214 displayed?: Set<number>
215 /** tool_use_ids whose Read results must still be in the context for the elision to be honest */
216 need: string[]
217}
218
219const rangeText = (a: number, b: number) => (a === b ? `${a}` : `${a}–${b}`)
220
221export function sameMessage(path: string, a: number, b: number): string {
222 return `[read-dedup] ${path} 第 ${rangeText(a, b)} 行与本上下文中此前读取的内容相同(未改动),未重复返回。如确需原文,立即再读一次同一范围。`
223}
224
225export function shiftedMessage(path: string, a: number, b: number, deltas: number[]): string {
226 const d = deltas.length === 1 ? `新行号 = 旧行号 ${(deltas[0] as number) >= 0 ? '+' : '-'} ${Math.abs(deltas[0] as number)}` : '各处偏移量不同'
227 return `[read-dedup] ${path} 第 ${rangeText(a, b)} 行的文本与本上下文中此前读取的内容相同,但文件别处有增删,行号已偏移(${d}),未重复返回。如确需原文,立即再读一次同一范围。`
228}
229
230export function planRead(old: Lines, a: number, nt: string[], total: number, path: string, fullLen: number): Plan {
231 const n = nt.length
232 const b = a + n - 1
233 const ops = align(old, a, nt, total)
234 if (!ops) return { kind: 'full', reason: 'too-large', need: [] }
235 // "unseen": added lines before the first / after the last matched line whose (shift-corrected) old number was never cached,
236 // i.e. the part of the range that only extends past what was read before. Shown on its own, not counted as change.
237 let fe = -1
238 let le = -1
239 for (let i = 0; i < ops.length; i++) {
240 if ((ops[i] as Op).k === 'eq') {
241 if (fe < 0) fe = i
242 le = i
243 }
244 }
245 if (fe < 0) return { kind: 'full', reason: 'no-overlap', ops, need: [] }
246 const unseen = new Array<boolean>(ops.length).fill(false)
247 const firstEq = ops[fe] as Op
248 const lastEq = ops[le] as Op
249 for (let i = fe - 1; i >= 0; i--) {
250 const op = ops[i] as Op
251 if (op.k === 'del') continue
252 if (old.has((op.n as number) + (firstEq.o as number) - (firstEq.n as number))) break
253 unseen[i] = true
254 }
255 for (let i = le + 1; i < ops.length; i++) {
256 const op = ops[i] as Op
257 if (op.k === 'del') continue
258 if (old.has((op.n as number) + (lastEq.o as number) - (lastEq.n as number))) break
259 unseen[i] = true
260 }
261 let adds = 0
262 let dels = 0
263 let unseenCount = 0
264 const deltas = new Set<number>()
265 for (let i = 0; i < ops.length; i++) {
266 const op = ops[i] as Op
267 if (unseen[i]) unseenCount++
268 else if (op.k === 'add') adds++
269 else if (op.k === 'del') dels++
270 else deltas.add((op.n as number) - (op.o as number))
271 }
272 const changes = adds + dels
273 const seenN = n - unseenCount
274 const srcOf = (o: number) => (old.get(o) as Line).src
275 if (changes === 0 && unseenCount === 0) {
276 const need = [...new Set(ops.map(op => srcOf(op.o as number)))]
277 const shifted = [...deltas].some(d => d !== 0)
278 return shifted
279 ? { kind: 'shifted', ops, text: shiftedMessage(path, a, b, [...deltas]), need }
280 : { kind: 'same', ops, text: sameMessage(path, a, b), need }
281 }
282 if (changes > MAX_CHANGE_RATIO * seenN) return { kind: 'full', reason: `changed ${changes}/${seenN} > 30%`, ops, need: [] }
283
284 // hunks: every change plus CONTEXT_LINES equal lines each side
285 const keep = new Array<boolean>(ops.length).fill(false)
286 for (let c = 0; c < ops.length; c++) {
287 if ((ops[c] as Op).k === 'eq' || unseen[c]) continue
288 keep[c] = true
289 for (const dir of [-1, 1]) {
290 let cnt = 0
291 for (let t = c + dir; t >= 0 && t < ops.length && !unseen[t] && cnt < CONTEXT_LINES; t += dir) {
292 keep[t] = true
293 if ((ops[t] as Op).k === 'eq') cnt++
294 }
295 }
296 }
297 const displayed = new Set<number>()
298 const body: string[] = []
299 let i = 0
300 while (i < ops.length) {
301 if (unseen[i]) {
302 const block: string[] = []
303 let first = 0
304 let last = 0
305 for (; i < ops.length && unseen[i]; i++) {
306 const op = ops[i] as Op
307 if (op.k === 'del') continue
308 const num = op.n as number
309 if (block.length === 0) first = num
310 last = num
311 displayed.add(num)
312 block.push(`${num} ${nt[num - a]}`)
313 }
314 body.push(`@@ 此前未见 第 ${rangeText(first, last)} 行(原文) @@`, ...block)
315 continue
316 }
317 if (!keep[i]) {
318 i++
319 continue
320 }
321 const hunk: string[] = []
322 let first: number | undefined
323 let last: number | undefined
324 for (; i < ops.length && keep[i]; i++) {
325 const op = ops[i] as Op
326 if (op.k === 'del') {
327 hunk.push(`-${op.o}\t${(old.get(op.o as number) as Line).t}`)
328 continue
329 }
330 const num = op.n as number
331 first ??= num
332 last = num
333 displayed.add(num)
334 hunk.push(`${op.k === 'add' ? '+' : ' '}${num}\t${nt[num - a]}`)
335 }
336 body.push(first === undefined ? '@@ @@' : `@@ 新第 ${rangeText(first, last as number)} 行 @@`, ...hunk)
337 }
338 const head =
339 `[read-dedup] ${path} 第 ${rangeText(a, b)} 行:部分或全部此前读取过,只返回变动和此前未见的行(diff 风格:" "上下文,"-"已删除(旧行号),"+"新增/改动(新行号);` +
340 `"此前未见"块为原文;未列出的行与此前读取相同,行号以新行号为准)。如确需原文,立即再读一次同一范围。`
341 const text = [head, ...body].join('\n')
342 if (text.length >= fullLen) return { kind: 'full', reason: 'diff-not-smaller', ops, need: [] }
343 const need = new Set<string>()
344 for (const op of ops) if (op.k === 'eq' && !displayed.has(op.n as number)) need.add(srcOf(op.o as number))
345 return { kind: 'diff', ops, text, displayed, need: [...need] }
346}
347
348/** Removes a leading `1<TAB>`, `2<TAB>`, ... gutter (consecutive from 1, also `N→`) from the lines that carry it; other lines are kept as they are. */
349export function stripGutter(text: string): string {
350 let next = 1
351 return text
352 .split('\n')
353 .map(l => {
354 const m = /^\s*(\d+)[\t→]/.exec(l)
355 if (m === null || Number(m[1]) !== next) return l
356 next++
357 return l.slice(m[0].length)
358 })
359 .join('\n')
360}
361
362// ---------- cache update ----------
363
364/** Folds this read into the cached lines. `displayed` = new-line numbers whose text is in this call's result ('all' for a full result). */
365export function commit(old: Lines, a: number, nt: string[], id: string, ops: Op[] | undefined, displayed: Set<number> | 'all'): void {
366 const n = nt.length
367 const b = a + n - 1
368 const oldOf = new Map<number, number>() // new number -> matched old number
369 let shifted = ops === undefined
370 for (const op of ops ?? []) {
371 if (op.k === 'eq') {
372 oldOf.set(op.n as number, op.o as number)
373 if (op.n !== op.o) shifted = true
374 }
375 }
376 const fresh: Array<[number, Line]> = []
377 for (let i = 0; i < n; i++) {
378 const num = a + i
379 const o = oldOf.get(num)
380 const keepOld = displayed !== 'all' && !displayed.has(num) && o !== undefined
381 fresh.push([num, { t: nt[i] as string, src: keepOld ? (old.get(o as number) as Line).src : id }])
382 }
383 for (const op of ops ?? []) if (op.k === 'del') old.delete(op.o as number)
384 if (shifted) {
385 for (const k of [...old.keys()]) if (k < a || k > b) old.delete(k)
386 }
387 for (let k = a; k <= b; k++) old.delete(k)
388 for (const [k, l] of fresh) old.set(k, l)
389}
390
391/** A short ASCII fragment of the text a result shows, to recognise that result in the context later. '' if none fits. */
392export function makeProbe(lines: string[]): string {
393 for (const l of lines) {
394 const t = l.trim()
395 if (t.length >= 6 && /^[\x20-\x7e]+$/.test(t)) return t.slice(0, 48)
396 }
397 return ''
398}
399
400const CLEARED = /^\s*\[(old )?(tool )?(result|output|content)[^\]]{0,80}(cleared|removed|truncated|omitted|elided)/i
401
402/** Whether a tool_result's text still looks like the original (not cleared by micro-compaction). */
403export function resultStillHolds(text: string, probe: string): boolean {
404 if (text.trim() === '') return false
405 if (CLEARED.test(text)) return false
406 return probe === '' || text.includes(probe)
407}
408
409/** Text of a tool_result block's content (string or text blocks). */
410function blockText(content: unknown): string {
411 if (typeof content === 'string') return content
412 if (!Array.isArray(content)) return ''
413 return content.map(c => (c && typeof c === 'object' && typeof (c as { text?: unknown }).text === 'string' ? (c as { text: string }).text : '')).join('')
414}
415
416/** Whether every tool_use_id in `ids` still has its (uncleared) result in the Messages-API rows `messages`. */
417export function contextHasResults(messages: unknown[], ids: string[], probes: Map<string, string>): boolean {
418 const want = new Set(ids)
419 if (want.size === 0) return true
420 const ok = new Set<string>()
421 for (const m of messages) {
422 if (!m || typeof m !== 'object') continue
423 const msg = m as { role?: unknown; content?: unknown }
424 if (msg.role !== 'user' || !Array.isArray(msg.content)) continue
425 for (const block of msg.content) {
426 if (!block || typeof block !== 'object') continue
427 const b = block as { type?: unknown; tool_use_id?: unknown; is_error?: unknown; content?: unknown }
428 if (b.type !== 'tool_result') continue
429 const tid = b.tool_use_id
430 if (typeof tid !== 'string' || !want.has(tid) || b.is_error === true) continue
431 if (resultStillHolds(blockText(b.content), probes.get(tid) ?? '')) ok.add(tid)
432 }
433 }
434 return ok.size === want.size
435}
436hooks/lib/bash.ts 204 lines1// Recognises Bash commands that are one plain file read (cat FILE, sed -n 'A,Bp' FILE,
2// head -n N FILE, optionally behind `cd DIR &&`) and turns their output into the same
3// "path + line range" record a Read gives. Anything it cannot parse exactly -> undefined (run as usual).
4import { normPath, resolvePath } from './dedup.ts'
5import type { Parsed } from './dedup.ts'
6
7export type BashRead = {
8 kind: 'cat' | 'sed' | 'head'
9 /** absolute path with slashes unified (case kept) */
10 filePath: string
11 /** first line number, 1-based */
12 start: number
13 /** last line number the command asks for; undefined for cat (to the end of the file) */
14 end?: number
15}
16
17type Word = { k: 'w'; text: string }
18type Tok = Word | { k: 'op'; text: '&&' }
19
20/** Shell metacharacters that end "one simple read": a pipe, a redirect, a list, a subshell, a comment. */
21const META = new Set(['|', ';', '<', '>', '`', '(', ')', '#', '\n', '\r'])
22/** Characters a path may not carry: expansion, globbing, brace/tilde expansion. */
23const UNSAFE = /[$`*?\[\]{}~!|;<>]/
24
25/** A tiny POSIX-ish tokenizer: quotes concatenate, backslash escapes outside them, && is the only operator. */
26function tokenize(s: string): Tok[] | undefined {
27 const out: Tok[] = []
28 let cur = ''
29 let started = false
30 let i = 0
31 const push = (): void => {
32 if (started) out.push({ k: 'w', text: cur })
33 cur = ''
34 started = false
35 }
36 while (i < s.length) {
37 const c = s[i] as string
38 if (c === ' ' || c === '\t') {
39 push()
40 i++
41 continue
42 }
43 if (META.has(c)) return undefined
44 if (c === '&') {
45 if (s[i + 1] !== '&') return undefined
46 push()
47 out.push({ k: 'op', text: '&&' })
48 i += 2
49 continue
50 }
51 if (c === "'") {
52 const j = s.indexOf("'", i + 1)
53 if (j < 0) return undefined
54 cur += s.slice(i + 1, j)
55 started = true
56 i = j + 1
57 continue
58 }
59 if (c === '"') {
60 let j = i + 1
61 let closed = false
62 while (j < s.length) {
63 const d = s[j] as string
64 if (d === '"') {
65 closed = true
66 j++
67 break
68 }
69 if (d === '\\') {
70 const e = s[j + 1]
71 if (e === undefined || e === '\n') return undefined
72 if (e === '"' || e === '\\' || e === '$' || e === '`') {
73 cur += e
74 j += 2
75 } else {
76 cur += d
77 j++
78 }
79 continue
80 }
81 cur += d
82 j++
83 }
84 if (!closed) return undefined
85 started = true
86 i = j
87 continue
88 }
89 if (c === '\\') {
90 const e = s[i + 1]
91 if (e === undefined || e === '\n') return undefined
92 cur += e
93 started = true
94 i += 2
95 continue
96 }
97 if (UNSAFE.test(c)) return undefined
98 cur += c
99 started = true
100 i++
101 }
102 push()
103 return out
104}
105
106function safePath(t: string): boolean {
107 return t !== '' && !t.startsWith('-') && !UNSAFE.test(t)
108}
109
110function make(kind: BashRead['kind'], base: unknown, raw: string, start: number, end?: number): BashRead | undefined {
111 if (!safePath(raw)) return undefined
112 const filePath = resolvePath(base, raw)
113 if (filePath === undefined) return undefined
114 return end === undefined ? { kind, filePath, start } : { kind, filePath, start, end }
115}
116
117/**
118 * `cat FILE`, `sed -n 'A,Bp' FILE`, `head -n N FILE`, each optionally behind `cd DIR &&`.
119 * cwd is the session directory (absolute); undefined means a relative path cannot be placed.
120 */
121export function parseBashRead(command: unknown, cwd: unknown): BashRead | undefined {
122 if (typeof command !== 'string') return undefined
123 const src = command.trim()
124 if (src === '') return undefined
125 const toks = tokenize(src)
126 if (toks === undefined || toks.length === 0) return undefined
127
128 let i = 0
129 let base = cwd
130 const first = toks[0] as Tok
131 if (first.k === 'w' && first.text === 'cd') {
132 if (toks.length < 4) return undefined
133 const dir = toks[1] as Tok
134 const sep = toks[2] as Tok
135 if (dir.k !== 'w' || sep.k !== 'op') return undefined
136 if (!safePath(dir.text)) return undefined
137 const resolved = resolvePath(base, dir.text)
138 if (resolved === undefined) return undefined
139 base = resolved
140 i = 3
141 }
142
143 const cmd = toks[i] as Tok | undefined
144 if (cmd === undefined || cmd.k !== 'w') return undefined
145 if (cmd.text === 'cat') {
146 if (toks.length !== i + 2) return undefined
147 const file = toks[i + 1] as Tok
148 if (file.k !== 'w') return undefined
149 return make('cat', base, file.text, 1)
150 }
151 if (cmd.text === 'sed') {
152 if (toks.length !== i + 4) return undefined
153 const flag = toks[i + 1] as Tok
154 const script = toks[i + 2] as Tok
155 const file = toks[i + 3] as Tok
156 if (flag.k !== 'w' || flag.text !== '-n' || script.k !== 'w' || file.k !== 'w') return undefined
157 const m = /^(\d{1,9})(?:,(\d{1,9}))?p$/.exec(script.text)
158 if (m === null) return undefined
159 const a = Number(m[1] ?? '')
160 const b = m[2] === undefined ? a : Number(m[2])
161 if (a < 1 || b < a) return undefined
162 return make('sed', base, file.text, a, b)
163 }
164 if (cmd.text === 'head') {
165 if (toks.length !== i + 4) return undefined
166 const flag = toks[i + 1] as Tok
167 const count = toks[i + 2] as Tok
168 const file = toks[i + 3] as Tok
169 if (flag.k !== 'w' || flag.text !== '-n' || count.k !== 'w' || file.k !== 'w') return undefined
170 if (!/^\d{1,9}$/.test(count.text)) return undefined
171 const n = Number(count.text)
172 if (n < 1) return undefined
173 return make('head', base, file.text, 1, n)
174 }
175 return undefined
176}
177
178/** One trailing newline is the line terminator, not a line; CRLF loses its \r (as Read's parser does). */
179function splitLines(text: string): string[] {
180 let s = text
181 if (s.endsWith('\n')) s = s.slice(0, -1)
182 return s.split('\n').map(l => (l.endsWith('\r') ? l.slice(0, -1) : l))
183}
184
185/** A cat/sed/head output larger than this is left alone: the host persists such output anyway. */
186const MAX_OUTPUT_CHARS = 2_000_000
187
188/**
189 * Turns the command's stdout into a Read-shaped record. Undefined when the output cannot be that read
190 * (empty, binary, larger than MAX_OUTPUT_CHARS, or more lines than the command asked for).
191 */
192export function parseBashOutput(read: BashRead, stdout: unknown): Parsed | undefined {
193 if (typeof stdout !== 'string' || stdout === '' || stdout.includes('\0')) return undefined
194 if (stdout.length > MAX_OUTPUT_CHARS) return undefined
195 const lines = splitLines(stdout)
196 if (lines.length === 0) return undefined
197 if (read.kind === 'cat') return { filePath: read.filePath, start: 1, lines, total: lines.length }
198 const want = (read.end as number) - read.start + 1
199 if (lines.length > want) return undefined
200 // Fewer lines than asked for: the file ended here, so its total is known. Otherwise it is at least end.
201 const total = lines.length < want ? read.start + lines.length - 1 : Number.MAX_SAFE_INTEGER
202 return { filePath: read.filePath, start: read.start, lines, total }
203}
204hooks/lib/handler.ts 111 lines1// The decision for one successful plain-text Read: pass it through, or replace its text. Host-free (liveness is injected).
2import { MIN_SAVE_CHARS, align, commit, makeProbe, normPath, planRead } from './dedup.ts'
3import type { Lines, Parsed } from './dedup.ts'
4
5export type Store = {
6 /** `${agentId}|${normPath}` -> cached lines */
7 files: Map<string, Lines>
8 /** same key -> the last Read of that file by that agent */
9 last: Map<string, { range: string; omitted: boolean }>
10 /** tool_use_id -> a fragment of the text that result showed */
11 probes: Map<string, string>
12 /** total characters saved by replacing duplicate Read results */
13 savedChars: number
14}
15
16const MAX_FILES = 100
17const MAX_LINES_PER_FILE = 60000
18const MAX_PROBES = 4000
19
20export function createStore(): Store {
21 return { files: new Map(), last: new Map(), probes: new Map(), savedChars: 0 }
22}
23
24export type ReadCall = { id: string | undefined; agentId: string | undefined; filePath: string }
25/** `content` set -> show this instead of the real text; unset -> pass the real result through. */
26export type Decision = { content?: string; note: string; savedChars: number }
27/**
28 * Bash reads share this state with Read, but only an unchanged same-range hit may replace command output;
29 * a diff/shifted/full outcome runs as it is (and is still recorded by the pass-through path).
30 */
31export type DecideOptions = {
32 onlySame?: boolean
33 /** Skip the "not worth replacing" floor. A Bash hit is always an identical repeat of the same command, so its stub is the point, not the byte saving. */
34 minSave?: number
35}
36
37function remember(store: Store, key: string, lines: Lines): void {
38 store.files.delete(key)
39 if (lines.size <= MAX_LINES_PER_FILE) store.files.set(key, lines)
40 while (store.files.size > MAX_FILES) {
41 const oldest = store.files.keys().next().value as string
42 store.files.delete(oldest)
43 store.last.delete(oldest)
44 }
45}
46
47function setProbe(store: Store, id: string, probe: string): void {
48 store.probes.set(id, probe)
49 while (store.probes.size > MAX_PROBES) store.probes.delete(store.probes.keys().next().value as string)
50}
51
52/** An Edit/Write/NotebookEdit of the file finished: a re-read after it is not a "doubt", so drop the escape-hatch flag (cached lines stay -> next Read gives a diff). */
53export function noteEdit(store: Store, agentId: string | undefined, filePath: unknown): void {
54 const norm = normPath(filePath)
55 if (!norm) return
56 const key = `${agentId ?? ''}|${norm}`
57 const prev = store.last.get(key)
58 if (prev) prev.omitted = false
59}
60
61export async function decide(
62 store: Store,
63 call: ReadCall,
64 parsed: Parsed,
65 live: (ids: string[]) => Promise<boolean>,
66 opts?: DecideOptions,
67): Promise<Decision> {
68 const { id } = call
69 if (!id) return { note: 'no tool_use_id', savedChars: 0 }
70 const norm = normPath(parsed.filePath) ?? normPath(call.filePath)
71 if (!norm) return { note: 'path not absolute', savedChars: 0 }
72 const key = `${call.agentId ?? ''}|${norm}`
73 const a = parsed.start
74 const nt = parsed.lines
75 const b = a + nt.length - 1
76 const range = `${a}-${b}`
77 const fullLen = nt.reduce((s, l) => s + l.length + 1, 0)
78 const entry = store.files.get(key)
79 const prev = store.last.get(key)
80
81 const passFull = (note: string, ops?: Parameters<typeof commit>[4]): Decision => {
82 const lines: Lines = entry ?? new Map()
83 commit(lines, a, nt, id, entry ? (ops ?? align(entry, a, nt, parsed.total)) : undefined, 'all')
84 remember(store, key, lines)
85 setProbe(store, id, makeProbe(nt))
86 store.last.set(key, { range, omitted: false })
87 return { note, savedChars: 0 }
88 }
89
90 if (!entry) return passFull('first read of this file')
91 if (prev?.omitted && prev.range === range) return passFull('escape hatch: same range read again right after an omission')
92 if (fullLen < (opts?.minSave ?? MIN_SAVE_CHARS)) return passFull('small read')
93
94 const plan = planRead(entry, a, nt, parsed.total, call.filePath, fullLen)
95 if (plan.kind === 'full') return passFull(`full: ${plan.reason}`, plan.ops)
96 if (opts?.onlySame === true && plan.kind !== 'same') return passFull(`bash: ${plan.kind}`, plan.ops)
97 if (plan.need.length > 0 && !(await live(plan.need))) return passFull('full: an earlier Read result is no longer in the context', plan.ops)
98
99 const text = plan.text as string
100 const saved = Math.max(0, fullLen - text.length)
101 const shown = plan.displayed ?? new Set<number>()
102 commit(entry, a, nt, id, plan.ops, shown)
103 remember(store, key, entry)
104 if (plan.kind === 'diff') {
105 setProbe(store, id, makeProbe([...shown].sort((x, y) => x - y).map(n => nt[n - a] as string)))
106 }
107 store.last.set(key, { range, omitted: true })
108 store.savedChars += saved
109 return { content: text, note: plan.kind, savedChars: saved }
110}
111