Trims long shell output, Grep results and subagent reports before Claude reads them (head, tail and error lines kept); the untrimmed text is saved to a file…

hooks/register.ts 88 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { TOOLS, diet, kindOf, textOf } from './diet'
4import type { Block } from './diet'
5
6// Rewrites what the model reads from a long shell, search or subagent result,
7// never what the person sees: the transcript row keeps its full output on
8// screen, and the whole text is saved to a file the model can Read when the
9// trimmed part matters.
10
11const join = (...parts: string[]) => {
12 const sep = parts[0]?.includes('\\') ? '\\' : '/'
13 return parts.map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p)).join(sep)
14}
15
16// Claude Code names a project's transcript folder after its path, every
17// character but letters and digits turned into `-` (D:\mods -> D--mods).
18const projectSlug = (path: string) => path.replace(/[^A-Za-z0-9]/g, '-')
19
20async function configDir($: EngineInterface): Promise<string | undefined> {
21 const config = await $.env.get('CLAUDE_CONFIG_DIR')
22 if (config !== undefined) return config
23 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
24 return home === undefined ? undefined : join(home, '.claude')
25}
26
27// Where the untrimmed outputs go. Preferred: the session's own tool-results folder,
28// where Claude Code saves the outputs it spills itself and which Claude may read
29// without a permission prompt; used once the session's transcript is found there.
30// Fallback: <config dir>/output-diet/<session>.
31async function saveDir($: EngineInterface): Promise<string | undefined> {
32 const config = await configDir($)
33 if (config === undefined) return undefined
34 const session = await $.session.id()
35
36 for (const project of new Set([await $.session.root(), await $.session.cwd()])) {
37 const folder = join(config, 'projects', projectSlug(project))
38 if (await $.fs.exists(join(folder, `${session}.jsonl`)).catch(() => false)) {
39 return join(folder, session, 'tool-results')
40 }
41 }
42
43 return join(config, 'output-diet', session)
44}
45
46export const register: Register = on => {
47 let trimmed = 0
48 let charsKeptOut = 0
49 let dir: string | undefined
50
51 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
52 if (e.origin.kind !== 'tool' || !TOOLS.has(e.origin.tool)) return next(e)
53 dir ??= await saveDir($)
54 if (dir === undefined) return next(e)
55
56 let changed = false
57 const content: Block[] = []
58 for (const block of e.message.content as Block[]) {
59 const text = block.type === 'tool_result' ? textOf(block.content) : undefined
60 if (text === undefined) {
61 content.push(block)
62 continue
63 }
64 const path = join(dir, `output-diet-${String(block.tool_use_id ?? e.uuid)}.txt`)
65 const slim = diet(text, path, kindOf(e.origin.tool))
66 if (slim === undefined) {
67 content.push(block)
68 continue
69 }
70 // Only trim once the full text is safely on disk.
71 const saved = await $.fs.write(path, text).then(() => true, () => false)
72 if (!saved) {
73 content.push(block)
74 continue
75 }
76 content.push({ ...block, content: slim })
77 changed = true
78 trimmed += 1
79 charsKeptOut += text.length - slim.length
80 }
81
82 if (!changed) return next(e)
83 $.ui.status(`${trimmed} trimmed · ${charsKeptOut.toLocaleString('en-US')} chars saved`)
84
85 return next({ ...e, message: { ...e.message, content } })
86 })
87}
88hooks/diet.ts 79 lines1// The trimming itself: pure, so the tests exercise it directly.
2
3export const TOOLS = new Set(['Bash', 'PowerShell', 'BashOutput', 'Grep', 'Agent', 'Task'])
4
5// Shell output keeps its head, tail and error lines; search results keep their
6// first matches; a subagent's report keeps its opening and its conclusion.
7export type Kind = 'shell' | 'search' | 'report'
8
9export const kindOf = (tool: string): Kind =>
10 tool === 'Grep' ? 'search' : tool === 'Agent' || tool === 'Task' ? 'report' : 'shell'
11
12const SEARCH_HEAD = 100
13const REPORT_HEAD = 6_000
14const REPORT_TAIL = 2_000
15
16// Claude Code itself cuts the middle out of a long shell result before any
17// mod sees it; this trims what is left to its head, tail and error lines.
18const MAX_LINES = 120
19const MAX_CHARS = 8_000
20
21const HEAD = 30
22const TAIL = 50
23const MAX_SIGNAL = 40
24const MAX_LINE_CHARS = 400
25
26const SIGNAL = /\b(error|errors|fail|failed|failure|exception|panic|traceback|fatal|assert|warning|denied|not found|undefined)\b|ERR!|✗|✘|×/i
27
28export type Block = { type: string; [field: string]: unknown }
29
30export const textOf = (content: unknown): string | undefined => {
31 if (typeof content === 'string') return content
32 if (!Array.isArray(content)) return undefined
33 const blocks = content as Block[]
34 if (!blocks.every(b => b.type === 'text' && typeof b.text === 'string')) return undefined
35 return blocks.map(b => b.text as string).join('\n')
36}
37
38const clip = (line: string) => (line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)} [… line cut]` : line)
39
40export const diet = (text: string, savedAt: string, kind: Kind = 'shell'): string | undefined => {
41 const lines = text.split('\n')
42 if (lines.length <= MAX_LINES && text.length <= MAX_CHARS) return undefined
43
44 // Short on purpose: the model reads these notes.
45 if (kind === 'search') {
46 const body = lines.slice(0, SEARCH_HEAD).map(clip).join('\n')
47 return `[output-diet: first ${SEARCH_HEAD}/${lines.length} lines shown; narrow the search, or read all in ${savedAt}]\n\n${body}`
48 }
49 if (kind === 'report') {
50 if (text.length <= MAX_CHARS) return undefined
51 const cut = text.length - REPORT_HEAD - REPORT_TAIL
52 const shown = (REPORT_HEAD + REPORT_TAIL).toLocaleString('en-US')
53 return `[output-diet: ${shown}/${text.length.toLocaleString('en-US')} chars of the report shown; all in ${savedAt}]\n\n` +
54 `${text.slice(0, REPORT_HEAD)}\n… [${cut.toLocaleString('en-US')} chars omitted]\n${text.slice(-REPORT_TAIL)}`
55 }
56
57 const head = lines.slice(0, HEAD)
58 const tailStart = Math.max(HEAD, lines.length - TAIL)
59 const tail = lines.slice(tailStart)
60 const signal: string[] = []
61 for (let i = HEAD; i < tailStart && signal.length < MAX_SIGNAL; i++) {
62 if (SIGNAL.test(lines[i])) signal.push(`${String(i + 1).padStart(6)}│ ${clip(lines[i])}`)
63 }
64
65 const shown = head.length + signal.length + tail.length
66 const body = [
67 ...head.map(clip),
68 ...(tailStart > HEAD ? [`… [lines ${HEAD + 1}–${tailStart} omitted${signal.length ? '; error/warning lines from them:' : ''}]`] : []),
69 ...signal,
70 ...(signal.length ? ['…'] : []),
71 ...tail.map(clip),
72 ].join('\n')
73
74 // Short on purpose: the model reads it.
75 const note = `[output-diet: ${shown}/${lines.length} lines shown; all ${lines.length} in ${savedAt}]`
76
77 return `${note}\n\n${body}`
78}
79