SLOPSHOPPER

read-dedup

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

guard
★ 3v0.1.0MITupdated 2026-10-20wdsewdser-bit/claude-code-mods/read-dedup
A shopper browsing a rack in a slop shop
README

read-dedup

重复 Read 去重:模型再次读取一个仍在上下文里的文件时,未变化就只返回一行提示,变化了只返回差异,省掉重复的文件内容。

  • 只有当前对话(Messages API 形态)里确实还留着之前那次 Read 的结果时才去重;压缩、/clear、ctx-mod 换窗口之后会照常返回全文。
  • 模型自己 Edit / Write 过的文件,下一次 Read 返回差异而不是提示。
  • 主会话里单条的 Bash 纯读取(cat 文件、sed -n 'a,bp' 文件、head -n N 文件,可以带开头的 cd 目录 &&)与 Read 共用同一份记录:范围完全相同且文件未改时返回提示;带管道、重定向、多个文件、变量或 glob 的命令一律照常执行;不足 300 字符的读取不替换。
  • 主会话和每个子代理分开记录。
  • 缓存只在内存里,热加载后清空,最多多读一次全文。
Source 4 files
hooks/register.ts 135 lines
1import 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}
135
hooks/lib/dedup.ts 436 lines
1// 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}
436
hooks/lib/bash.ts 204 lines
1// 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}
204
hooks/lib/handler.ts 111 lines
1// 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