Trims huge tool outputs (logs, JSON, diffs, grep, file lists) before they reach Claude's context, and saves the full text to a file Claude can read

Trims huge tool outputs before they reach Claude's context: logs, test runs, builds, tables, JSON, diffs, grep results and file lists become short digests with the errors, the summary and the shape of the data. It knows jest, vitest, node --test, pytest, go test, cargo, tsc, eslint, package installs and docker build, and shows only what changed when a command runs again. The full output is saved to a file Claude can read.
Works in the Claude Code terminal and the Claude desktop app (Code tab). Requires Claude Code v2.1.287 or later.
/diet opens the panel; /diet report prints the same in the transcript/diet on · off · dry, /diet capture (off by default), /diet keep <text>, /diet 16k (the threshold in characters), /diet demo · reset# diet:off to a command to keep its output wholeContext Diet is one hooks module, hooks/register.tsx, with its compressors in hooks/diet.ts, hooks/tools.ts and hooks/text.ts: readable TypeScript with no dependencies.
Sends: nothing. It makes no network requests and sends no conversation, file or other data anywhere.
Programs it starts: none.
What it reads. Through the Claude Code mods API: the tool calls Claude makes (the tool's name, the Bash command, and the file or path a Read, Grep or Bash call names, to label each output and to notice when Claude opens a saved full output again) and the results of Bash, Grep, Glob and MCP tools as they are about to be stored. From disk: an output file Claude Code itself saved under ~/.claude/projects/…/tool-results/ when it moved a very long output out of the conversation, to digest the whole output instead of its first 2 KB.
What it changes. The text of those tool results that is longer than the threshold (8,000 characters by default): the model reads the digest with a note saying what was kept and where the full output is. It never changes Read, Edit, Write, WebFetch, or MCP answers that are not JSON, and never decides whether a tool runs.
Commands it rewrites: none by default. Only after you run /diet capture on, a Bash call that is a single test or build command (pytest, jest, vitest, go test, cargo test/build, tsc, eslint, npm/pnpm/yarn test/build/lint, make, gradle, mvn, dotnet test…; never one with pipes, redirects, ;, &, background or watch modes) runs as { <command>\n} > .context-diet/run-NN.log 2>&1; __diet_rc=$?; cat .context-diet/run-NN.log; (exit $__diet_rc): the same command in the same shell, its output saved to a file and printed, with its own exit code. /diet capture off stops it.
What it writes. The full text of each output it shortens, to .context-diet/out-NNN.txt in the session's working directory (300 names, reused in turn), and with capture on the output of each captured command to .context-diet/run-NN.log (20 names, reused in turn), and .context-diet/.gitignore so git ignores the folder. A counter, all-time totals, the /diet keep list of each project and the last error are kept in the plugin's own store on your machine. The last outputs of up to 20 commands are held in memory for the session, to show what changed when a command runs again. It never writes build, settings, start-up or instruction files.
Commands it adds. /diet. It runs no slash commands itself.
Events it hooks. session.append (tool results only), to shorten long outputs; tool.call, to note which tool and command each result belongs to and, after the call, to keep a Bash call's own output for the digest; it passes every call on unchanged unless capture is on (above); session.start, to register /diet; ui.render and ui.close, for its panel.
Full documentation, screenshots and the changelog: https://github.com/drkokorev/context-diet
hooks/register.tsx 739 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { DietCut, DietPrefs, DietStats, DietView } from '../types'
5import { diet, dietDelta, fmtTokens, tokensOf } from './diet'
6
7// Context Diet: trims huge tool outputs before they reach the model's context.
8// The model reads a shortened text with a note on what was cut; the full
9// output is saved under .context-diet/ in the project, where Claude can read it.
10
11const PANE = 'context-diet'
12const DIR = '.context-diet'
13const SLOTS = 300
14const LOG_SIZE = 30
15const THRESHOLDS = [4_000, 8_000, 16_000, 32_000]
16const BIG_CUT_TOKENS = 20_000
17
18const EMPTY_STATS: DietStats = {
19 startedAt: 0,
20 cuts: 0,
21 rawChars: 0,
22 keptChars: 0,
23 passed: 0,
24 upgrades: 0,
25 rereads: 0,
26 recovered: 0,
27 byTool: {},
28 log: [],
29 lifetimeSaved: 0,
30 lifetimeCuts: 0,
31}
32
33export const DEFAULT_PREFS: DietPrefs = {
34 isOn: true,
35 threshold: 8_000,
36 isStatusOn: true,
37 isToastOn: true,
38 isDry: false,
39 isCapture: false,
40 boost: {},
41}
42
43const stats = atom({ plugin: 'context-diet', key: 'stats' } as const, EMPTY_STATS)
44const prefs = atom({ plugin: 'context-diet', key: 'prefs' } as const, DEFAULT_PREFS)
45const view = atom({ plugin: 'context-diet', key: 'view' } as const, { openCut: 0, isPlaced: false, reason: '' } as DietView)
46
47type Dollar = EngineInterface
48type DietElements = Pick<Elements['terminal'] | Elements['desktop'], 'Box' | 'Text' | 'Button'>
49type CallInfo = { tool: string; label: string; command?: string; sig?: string; capture?: string }
50/** The last full output of a command, to show only what changed when it runs again. */
51type Run = { raw: string; at: number }
52
53// tool calls by tool_use_id, so a result can be traced to its command
54const calls = new Map<string, CallInfo>()
55// the last output of each command this session, by the command's text
56const runs = new Map<string, Run>()
57// saved full outputs by path, so a later Read of one is counted against its cut
58const saved = new Map<string, string>()
59// this project's commands whose output stays whole (/diet keep)
60let keep: string[] = []
61let cwd = ''
62let slot = -1
63// a Bash command's whole output by tool_use_id, taken before Claude Code shortens it
64const fulls = new Map<string, string>()
65// folders whose .gitignore is known to be in place
66const readyDirs = new Set<string>()
67
68const shorten = (text: string, max: number) => {
69 const flat = text.replace(/\s+/g, ' ').trim()
70 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
71}
72
73/** mcp__github__list_issues reads as github·list_issues */
74const toolName = (tool: string) => (tool.startsWith('mcp__') ? tool.split('__').slice(1).join('·') : tool)
75
76const fmtChars = (n: number) => (n >= 1000 ? `${fmtTokens(n)}` : String(n))
77const plural = (n: number, word: string) => `${n.toLocaleString('en-US')} ${word}${n === 1 ? '' : 's'}`
78
79/** A command's first two words past env assignments and `cd …&&`: "npm test", "cargo build". */
80export const sigOf = (tool: string, command?: string) => {
81 if (!command) return tool
82 const words = command
83 .replace(/^(?:\s*cd\s+\S+\s*&&)+/, '')
84 .trim()
85 .split(/\s+/)
86 .filter(w => !/^\w+=/.test(w))
87 // past launchers (python -m, npx, uv run…) to the tool itself
88 while (words.length > 1 && /^(?:python[\d.]*|py|npx|bunx|pnpm|yarn|uv|poetry|pipenv|bundle|go|cargo|npm)$/.test(words[0] ?? '')) {
89 if (/^(?:npx|bunx)$/.test(words[0] ?? '')) words.splice(0, 1)
90 else if (/^(?:-m|run|exec|x)$/.test(words[1] ?? '')) words.splice(0, 2)
91 else break
92 }
93 // the tool and its subcommand (npm test, go test, cargo build), never a flag, its value or a path
94 const [first, second] = words
95 if (!first) return tool
96 return second && /^[a-z][a-z0-9_-]*$/.test(second) ? `${first} ${second}` : first
97}
98
99/** Which tools' outputs get cut; MCP tools only when they answer JSON. */
100const isDietTool = (tool: string) => ['Bash', 'BashOutput', 'TaskOutput', 'Grep', 'Glob'].includes(tool) || tool.startsWith('mcp__')
101
102const labelOf = (tool: string, args: Record<string, unknown>) => {
103 if (typeof args.command === 'string') return shorten(args.command, 60)
104 if (typeof args.pattern === 'string') return shorten(`${tool} ${args.pattern}${typeof args.path === 'string' ? ` in ${args.path}` : ''}`, 60)
105 if (tool.startsWith('mcp__')) return tool.split('__').slice(1).join(' · ')
106 return tool
107}
108
109/** The text of a tool_result's content, or undefined when it holds images or documents. */
110const textOf = (content: unknown): string | undefined => {
111 if (typeof content === 'string') return content
112 if (!Array.isArray(content)) return undefined
113 const blocks = content as { type?: string; text?: unknown }[]
114 if (blocks.some(b => b.type !== 'text' || typeof b.text !== 'string')) return undefined
115 return blocks.map(b => b.text as string).join('\n')
116}
117
118export const rememberCall = (id: string, info: CallInfo) => {
119 calls.set(id, info)
120 if (calls.size > 500) calls.delete(calls.keys().next().value as string)
121}
122
123const loadSlot = async ($: Dollar) => {
124 if (slot < 0) slot = Number((await $.store.get('slot')) ?? 0) || 0
125}
126
127const takeSlot = () => {
128 slot = (Math.max(0, slot) % SLOTS) + 1
129 return String(slot).padStart(3, '0')
130}
131
132const ensureDir = async ($: Dollar, root: string) => {
133 if (readyDirs.has(root)) return
134 const ignore = `${root}/${DIR}/.gitignore`
135 if (!(await $.fs.exists(ignore))) {
136 await $.fs.write(ignore, '# Context Diet keeps full tool outputs here. Nothing in this folder belongs in git.\n*\n')
137 }
138 readyDirs.add(root)
139}
140
141/** The session's working directory now: it can change during a session, so it is asked each time. */
142const rootOf = async ($: Dollar) => {
143 cwd = (await $.session.cwd().catch(() => cwd)) || cwd
144 return cwd
145}
146
147const pushStatus = async ($: Dollar) => {
148 const p = await read($, prefs)
149 if (!p.isStatusOn) {
150 $.ui.status(undefined)
151 return
152 }
153 if (!p.isOn) {
154 $.ui.status('◇ diet off')
155 return
156 }
157 const s = await read($, stats)
158 if (!s.cuts) {
159 $.ui.status(undefined)
160 return
161 }
162 $.ui.status(`◇ diet${p.isDry ? ' dry' : ''} −${fmtTokens(tokensOf(s.rawChars - s.keptChars))} tok · ${plural(s.cuts, 'cut')}${s.rereads ? ` · ${s.rereads} reopened` : ''}`)
163}
164
165type Decision = { text: string; cut: DietCut; save: string } | { skip: string }
166type Where = {
167 root: string
168 now: number
169 slot: () => string
170 agentId?: string
171 /** outputs Claude Code already saved to a file, by that file's path: their full text */
172 persisted?: Record<string, string>
173 /** earlier outputs of the same commands */
174 runs?: ReadonlyMap<string, Run>
175 /** commands whose output stays whole in this project */
176 keep?: readonly string[]
177 /** whole Bash outputs by tool_use_id, from before Claude Code shortened them */
178 fulls?: ReadonlyMap<string, string>
179}
180
181const hhmm = (at: number) => new Date(at).toTimeString().slice(0, 5)
182
183// Claude Code moves a very long output to a file itself and shows only its start
184const PERSISTED = /^\s*<persisted-output>[\s\S]*?Full output saved to: (\S+)/
185
186export const persistedPath = (text: string) => PERSISTED.exec(text)?.[1]
187
188/** Cuts one tool result's text if it is long and the cut saves enough. */
189const cutOne = (raw: string, info: CallInfo, p: DietPrefs, where: Where, savedAt?: string, shortenedTo?: number): Decision => {
190 const sig = info.sig ?? sigOf(info.tool, info.command)
191 if (raw.length <= p.threshold * (p.boost?.[sig] ?? 1)) return { skip: 'short' }
192 if (info.command && /\.context-diet\/|diet:off/.test(info.command)) return { skip: 'excluded' }
193 if (info.command && where.keep?.some(k => info.command?.includes(k))) return { skip: 'kept' }
194 // a digest in place of Claude Code's preview stays near the preview's size
195 const target = savedAt ? 3_000 : Math.max(2_000, Math.round(p.threshold / 2))
196 let out
197 try {
198 const before = info.command ? where.runs?.get(info.command) : undefined
199 const delta = before ? dietDelta(before.raw, raw, target, hhmm(before.at)) : undefined
200 const fresh = diet({ text: raw, tool: info.tool, command: info.command, target })
201 out = delta && delta.text.length < fresh.text.length ? delta : fresh
202 } catch {
203 return { skip: 'error' }
204 }
205 if (info.tool.startsWith('mcp__') && out.kind !== 'json') return { skip: 'prose' }
206 if (out.text.length > raw.length * 0.7 || raw.length - out.text.length < 2_000) return { skip: 'small gain' }
207
208 const path = savedAt ?? info.capture ?? (p.isDry ? '(dry run: not saved)' : `${where.root}/${DIR}/out-${where.slot()}.txt`)
209 const size = raw.length.toLocaleString('en-US')
210 const note = savedAt
211 ? `[Context Diet: digest of a ${size}-character ${info.tool} output that Claude Code saved to ${path}. Kept: ${out.kept}. Read that file before answering about anything not shown here.]`
212 : shortenedTo !== undefined
213 ? `[Context Diet: Claude Code would have shown only ${shortenedTo.toLocaleString('en-US')} characters of this ${size}-character ${info.tool} output, cutting its middle and end; here is a digest of the whole output. Kept: ${out.kept}. Full output: ${path}. Read it before answering about anything not shown here.]`
214 : `[Context Diet: ${info.tool} output cut from ${size} to ${out.text.length.toLocaleString('en-US')} characters. Kept: ${out.kept}. Full output: ${path}. Read it before answering about anything not shown here.]`
215 const cut: DietCut = {
216 at: where.now,
217 tool: info.tool,
218 label: info.label,
219 kind: out.kind,
220 // what Claude would otherwise have read: Claude Code's shortened text, when it shortened it
221 raw: shortenedTo ?? raw.length,
222 kept: out.text.length + note.length,
223 path,
224 preview: out.text.slice(0, 1200),
225 keptWhat: out.kept,
226 sig,
227 ...(where.agentId ? { agentId: where.agentId } : {}),
228 ...(savedAt ? { isUpgrade: true as const } : {}),
229 ...(p.isDry ? { isDry: true as const } : {}),
230 ...(shortenedTo !== undefined ? { recoveredFrom: shortenedTo } : {}),
231 }
232 return { text: `${note}\n${out.text}`, cut, save: raw }
233}
234
235type Block = { type: string; [field: string]: unknown }
236type Save = { path: string; text: string }
237
238/**
239 * Cuts the long tool results among a row's blocks; the rest pass as they are.
240 * Pure: the caller writes `saves` to disk before the row is stored.
241 */
242export const dietBlocks = (blocks: readonly Block[], origin: { kind?: string; tool?: string }, p: DietPrefs, where: Where) => {
243 const cuts: DietCut[] = []
244 const saves: Save[] = []
245 const skipped: string[] = []
246 const content: Block[] = []
247 const seen: { command: string; raw: string }[] = []
248 if (!p.isOn) return { content: [...blocks], cuts, saves, skipped, seen }
249 for (const block of blocks) {
250 if (block.type !== 'tool_result') {
251 content.push(block)
252 continue
253 }
254 const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : ''
255 const known = calls.get(id)
256 const tool = known?.tool ?? (origin.kind === 'tool' && origin.tool ? origin.tool : 'unknown')
257 const shown = textOf(block.content)
258 const savedAt = shown === undefined ? undefined : persistedPath(shown)
259 const whole = where.fulls?.get(id)
260 // Claude Code shortened it (a failing command's output is cut to about 10,000 characters): digest the whole one
261 const recovered = shown !== undefined && savedAt === undefined && whole !== undefined && whole.length > shown.length + 500 ? whole : undefined
262 const raw = recovered ?? (savedAt !== undefined && where.persisted?.[savedAt] !== undefined ? where.persisted[savedAt] : shown)
263 if (raw === undefined || !isDietTool(tool)) {
264 content.push(block)
265 continue
266 }
267 const decision = cutOne(raw, known ?? { tool, label: tool }, p, where, raw === shown || recovered ? undefined : savedAt, recovered ? shown?.length : undefined)
268 if (known?.command && raw.length > p.threshold / 2) seen.push({ command: known.command, raw })
269 if ('skip' in decision) {
270 if (decision.skip !== 'short') skipped.push(decision.skip)
271 content.push(block)
272 continue
273 }
274 calls.delete(id)
275 cuts.push(decision.cut)
276 // a dry run measures only: the model reads the output as it came
277 if (p.isDry) {
278 content.push(block)
279 continue
280 }
281 if (!decision.cut.isUpgrade && !decision.cut.path.endsWith('.log')) saves.push({ path: decision.cut.path, text: decision.save })
282 content.push({ ...block, content: typeof block.content === 'string' ? decision.text : [{ type: 'text', text: decision.text }] })
283 }
284 return { content, cuts, saves, skipped, seen }
285}
286
287/** The whole stdout and stderr of a Bash call as the tool returned them, before the model's view is shortened. */
288export const fullTextOf = (result: unknown): string | undefined => {
289 const r = result as { isError?: boolean; result?: unknown; deny?: string } | undefined
290 if (!r || r.deny !== undefined) return undefined
291 const body = r.result as { stdout?: unknown; stderr?: unknown; persistedOutputPath?: unknown; isImage?: unknown } | string | undefined
292 if (typeof body === 'string') return body
293 if (!body || body.isImage || typeof body.persistedOutputPath === 'string') return undefined
294 const out = typeof body.stdout === 'string' ? body.stdout : ''
295 const err = typeof body.stderr === 'string' ? body.stderr : ''
296 return out && err ? `${out}\n${err}` : out || err || undefined
297}
298
299const keepFull = (id: string, result: unknown) => {
300 const full = fullTextOf(result)
301 if (!full || full.length > 4_000_000) return
302 fulls.set(id, full)
303 if (fulls.size > 20) fulls.delete(fulls.keys().next().value as string)
304}
305
306// test runners and build tools whose whole output /diet capture saves
307const CAPTURABLE =
308 /^(?:cd\s+\S+\s*&&\s*)?(?:\w+=\S+\s+)*(?:(?:npx|bunx|pnpm(?:\s+exec)?|yarn|uv\s+run|poetry\s+run|pipenv\s+run|python[\d.]*\s+-m|bundle\s+exec)\s+)?(?:pytest|jest|vitest|mocha|tsc|eslint|ruff|mypy|rspec|phpunit|go\s+(?:test|build|vet)|cargo\s+(?:test|build|check|clippy)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|check)|make(?:\s+[\w-]+)?|gradle|\.\/gradlew|mvn|dotnet\s+(?:test|build))\b/
309
310const shellQuote = (text: string) => `'${text.replace(/'/g, "'\\''")}'`
311
312/**
313 * A test or build command rewritten to save its whole output before Claude Code
314 * can shorten it: run in the same shell (so a `cd` still sticks), output to the
315 * file, then printed, with the command's own exit code. Undefined for anything
316 * else: pipes, redirects, background jobs, several commands, watch modes.
317 */
318export const captureCommand = (command: string, file: string): string | undefined => {
319 const plain = command.trim()
320 if (!CAPTURABLE.test(plain)) return undefined
321 const rest = plain.replace(/^cd\s+\S+\s*&&\s*/, '')
322 if (/[|<>;`\n]|\$\(|&|--watch\b|\bwatch\b|diet:off/.test(rest)) return undefined
323 return `{ ${plain}\n} > ${shellQuote(file)} 2>&1; __diet_rc=$?; cat ${shellQuote(file)}; (exit $__diet_rc)`
324}
325
326let runSlot = 0
327
328const rememberRun = (command: string, raw: string, at: number) => {
329 if (raw.length > 2_000_000) return
330 runs.delete(command)
331 runs.set(command, { raw, at })
332 if (runs.size > 20) runs.delete(runs.keys().next().value as string)
333}
334
335/**
336 * Claude opened a saved full output: the digest was not enough. Counted on the
337 * cut, and after two re-reads of one command's outputs (half its cuts or more)
338 * that command is cut only when twice as long.
339 */
340const countReread = async ($: Dollar, path: string) => {
341 const sig = saved.get(path) ?? ''
342 const s = await update($, stats, cur => ({
343 ...cur,
344 rereads: (cur.rereads ?? 0) + 1,
345 log: cur.log.map(c => (c.path === path ? { ...c, rereads: (c.rereads ?? 0) + 1 } : c)),
346 }))
347 const ofSig = s.log.filter(c => (c.sig ?? c.tool) === sig)
348 const rereads = ofSig.reduce((n, c) => n + (c.rereads ?? 0), 0)
349 if (rereads >= 2 && rereads >= ofSig.length / 2) {
350 const p = await update($, prefs, cur => ({ ...cur, boost: { ...cur.boost, [sig]: Math.min(8, (cur.boost?.[sig] ?? 1) * 2) } }))
351 $.ui.toast(`Context Diet: Claude kept opening the full output of "${sig}", so it is now cut only past ${fmtChars(p.threshold * (p.boost[sig] ?? 1))} characters.`)
352 await update($, stats, cur => ({ ...cur, log: cur.log.map(c => ((c.sig ?? c.tool) === sig ? { ...c, rereads: 0 } : c)) }))
353 }
354 $.ui.invalidate('ui.render')
355}
356
357export const recordCut = async ($: Dollar, cut: DietCut) => {
358 if (cut.isUpgrade) {
359 await update($, stats, cur => ({ ...cur, upgrades: (cur.upgrades ?? 0) + 1, log: [...cur.log, cut].slice(-LOG_SIZE) }))
360 await pushStatus($)
361 $.ui.invalidate('ui.render')
362 return
363 }
364 const s = await update($, stats, cur => {
365 const t = cur.byTool[cut.tool] ?? { cuts: 0, raw: 0, kept: 0 }
366 return {
367 ...cur,
368 cuts: cur.cuts + 1,
369 recovered: (cur.recovered ?? 0) + (cut.recoveredFrom !== undefined ? 1 : 0),
370 rawChars: cur.rawChars + cut.raw,
371 keptChars: cur.keptChars + cut.kept,
372 byTool: { ...cur.byTool, [cut.tool]: { cuts: t.cuts + 1, raw: t.raw + cut.raw, kept: t.kept + cut.kept } },
373 log: [...cur.log, cut].slice(-LOG_SIZE),
374 lifetimeSaved: cur.lifetimeSaved + (cut.isDry ? 0 : cut.raw - cut.kept),
375 lifetimeCuts: cur.lifetimeCuts + (cut.isDry ? 0 : 1),
376 }
377 })
378 if (!cut.isDry) void $.store.set('lifetime', { saved: s.lifetimeSaved, cuts: s.lifetimeCuts })
379 await pushStatus($)
380 $.ui.invalidate('ui.render')
381 const p = await read($, prefs)
382 const tokens = tokensOf(cut.raw - cut.kept)
383 if (p.isToastOn && (s.cuts === 1 || tokens >= BIG_CUT_TOKENS)) {
384 $.ui.toast(
385 cut.isDry
386 ? `Context Diet (dry run) would trim this ${cut.tool} output: ${fmtChars(cut.raw)} → ${fmtChars(cut.kept)} chars, ≈${fmtTokens(tokens)} tokens. Nothing was changed`
387 : `Context Diet trimmed ${cut.tool} output: ${fmtChars(cut.raw)} → ${fmtChars(cut.kept)} chars, ≈${fmtTokens(tokens)} tokens saved. /diet for details`,
388 )
389 }
390}
391
392/** Sample cuts for /diet demo, so the panel can be seen before a long session. */
393const demoStats = (s: DietStats, now: number, root: string): DietStats => {
394 const sample: [string, string, DietCut['kind'], number, number, string, string][] = [
395 ['Bash', 'npm test', 'jest', 182_400, 1_310, 'every failure with its message and code frame, the summary', '[jest] 611 passing files not listed, 1 failing\n> acme-web@2.4.0 test\n… [409 lines cut] …\nFAIL src/billing/invoice.test.ts\n ● invoice › applies VAT for EU customers\n Expected: 121.5\n Received: 120\nTests: 1 failed, 4231 passed, 4232 total'],
396 ['Bash', 'git diff', 'diff', 96_800, 3_950, "every file's header and counts, 1 lockfile/generated file reduced to counts, every hunk", '3 files changed, +4012 −36\ndiff --git a/src/app.ts b/src/app.ts\n@@ -10,6 +10,7 @@\n- const b = 2\n+ const b = 3\ndiff --git a/package-lock.json b/package-lock.json\n [lockfile/generated: +3990 −30 lines cut]'],
397 ['Grep', 'Grep useSession in src', 'grep', 41_200, 3_600, 'match counts for every file, the first 3 matches in each', '612 matches in 48 files\nsrc/app/session.ts (41)\n 12: export function useSession() {\n … 40 more'],
398 ['mcp__github__list_issues', 'github · list_issues', 'json', 233_000, 3_980, 'the structure of an array of 300 items: every key, the first items of each array, strings shortened', '[\n {\n "number": 812,\n "title": "Crash on start",\n "body": "Steps…(+2,410 chars)"\n },\n "… 297 more items (keys: number, title, body, labels, user)"\n]'],
399 ['Bash', 'find . -name "*.ts"', 'paths', 64_500, 2_870, "every directory's count and its first 8 names", '2,140 paths in 96 directories\nsrc/components/ (88): Button.ts, Card.ts, …'],
400 ]
401 const log: DietCut[] = sample.map(([tool, label, kind, raw, kept, keptWhat, preview], i) => ({
402 at: now - (sample.length - i) * 240_000,
403 tool,
404 label,
405 kind,
406 raw,
407 kept,
408 keptWhat,
409 preview,
410 path: `${root}/${DIR}/out-00${i + 1}.txt`,
411 ...(i === 2 ? { rereads: 1 } : {}),
412 }))
413 const byTool: DietStats['byTool'] = {}
414 for (const c of log) {
415 const t = byTool[c.tool] ?? { cuts: 0, raw: 0, kept: 0 }
416 byTool[c.tool] = { cuts: t.cuts + 1, raw: t.raw + c.raw, kept: t.kept + c.kept }
417 }
418 const raw = log.reduce((n, c) => n + c.raw, 0)
419 const kept = log.reduce((n, c) => n + c.kept, 0)
420 return { ...s, cuts: log.length, rawChars: raw, keptChars: kept, byTool, log, rereads: 1, lifetimeSaved: Math.max(s.lifetimeSaved, (raw - kept) * 14), lifetimeCuts: Math.max(s.lifetimeCuts, 73) }
421}
422
423const buildReport = (s: DietStats, p: DietPrefs) => {
424 const saved = s.rawChars - s.keptChars
425 const lines = [
426 `Context Diet is ${p.isOn ? (p.isDry ? 'in a dry run (nothing is changed; figures show what would be saved)' : 'on') : 'off'}${p.isOn && p.isCapture ? ', with capture' : ''}: cuts Bash, Grep, Glob and MCP JSON outputs over ${p.threshold.toLocaleString('en-US')} characters (≈${fmtTokens(tokensOf(p.threshold))} tokens).`,
427 s.cuts
428 ? `This session: ${plural(s.cuts, 'cut')}, ${fmtChars(s.rawChars)} → ${fmtChars(s.keptChars)} characters, ≈${fmtTokens(tokensOf(saved))} tokens saved (−${Math.round((saved / Math.max(1, s.rawChars)) * 100)}%).`
429 : 'This session: nothing cut yet.',
430 `All time: ${plural(s.lifetimeCuts, 'cut')}, ≈${fmtTokens(tokensOf(s.lifetimeSaved))} tokens saved.`,
431 ]
432 if (s.upgrades) lines.push(`Very long outputs Claude Code saved to a file: ${s.upgrades}, each shown as a digest of the whole output instead of its first lines.`)
433 if (s.recovered) lines.push(`Outputs Claude Code would have cut in the middle and end (failing commands): ${s.recovered}, each digested whole, so the failures at the end were kept.`)
434 const cutCount = s.cuts + (s.upgrades ?? 0)
435 if (cutCount) lines.push(`Claude opened a saved full output ${plural(s.rereads ?? 0, 'time')} after ${plural(cutCount, 'cut')}${s.rereads ? ': where that happens often, the command is cut less.' : '.'}`)
436 const boosted = Object.entries(p.boost ?? {})
437 if (boosted.length) lines.push(`Cut less because Claude kept opening their full output: ${boosted.map(([sig, x]) => `"${sig}" (past ${fmtChars(p.threshold * x)} chars)`).join(', ')}.`)
438 const tools = Object.entries(s.byTool).sort((a, b) => b[1].raw - b[1].kept - (a[1].raw - a[1].kept))
439 if (tools.length) {
440 lines.push('', 'By tool:')
441 for (const [tool, t] of tools) lines.push(` ${toolName(tool).padEnd(20)} ${plural(t.cuts, 'cut').padEnd(8)} ≈${fmtTokens(tokensOf(t.raw - t.kept))} tokens saved`)
442 }
443 if (s.log.length) {
444 lines.push('', 'Latest cuts (full output in the file):')
445 for (const c of [...s.log].reverse().slice(0, 10)) {
446 lines.push(` ${new Date(c.at).toTimeString().slice(0, 5)} ${c.tool} ${c.label} ${c.isUpgrade ? `digest of ${fmtChars(c.raw)}` : `${fmtChars(c.raw)} → ${fmtChars(c.kept)}`}${c.kind === 'delta' ? ' (changes only)' : ''}${c.recoveredFrom !== undefined ? ' (whole output, not Claude Code\'s shortened one)' : ''}${c.rereads ? ` reopened ×${c.rereads}` : ''} ${c.path}`)
447 }
448 }
449 return lines.join('\n')
450}
451
452const openPane = async ($: Dollar) => {
453 const opened = await $.ui.open({ id: PANE, title: 'Context Diet', rows: 22 })
454 await update($, view, cur => ({ ...cur, isPlaced: opened.isPlaced, reason: opened.isPlaced ? '' : opened.reason }))
455 return opened
456}
457
458const setThreshold = async ($: Dollar, threshold: number) => {
459 await update($, prefs, p => ({ ...p, threshold }))
460 $.ui.invalidate('ui.render')
461}
462
463const toggleOn = async ($: Dollar) => {
464 await update($, prefs, p => ({ ...p, isOn: !p.isOn }))
465 await pushStatus($)
466 $.ui.invalidate('ui.render')
467}
468
469const toggleDry = async ($: Dollar) => {
470 await update($, prefs, p => ({ ...p, isOn: true, isDry: !p.isDry }))
471 await pushStatus($)
472 $.ui.invalidate('ui.render')
473}
474
475const toggleCut = async ($: Dollar, at: number) => {
476 await update($, view, v => ({ ...v, openCut: v.openCut === at ? 0 : at }))
477 $.ui.invalidate('ui.render')
478}
479
480const drawDiet = async ($: Dollar, els: DietElements, columns: number) => {
481 const { Box, Text, Button } = els
482 const s = await read($, stats)
483 const p = await read($, prefs)
484 const v = await read($, view)
485 const saved = s.rawChars - s.keptChars
486 const pct = Math.round((saved / Math.max(1, s.rawChars)) * 100)
487 const width = Math.max(30, columns)
488 const tools = Object.entries(s.byTool).sort((a, b) => b[1].raw - b[1].kept - (a[1].raw - a[1].kept))
489 const recent = [...s.log].reverse().slice(0, 8)
490 const nextThreshold = THRESHOLDS[(THRESHOLDS.indexOf(p.threshold) + 1) % THRESHOLDS.length] ?? 8_000
491
492 return (
493 <Box flexDirection="column">
494 <Box flexDirection="row" gap={1} flexWrap="wrap">
495 <Text bold color="green">◇ CONTEXT DIET</Text>
496 <Text color={p.isOn && !p.isDry ? 'green' : 'yellow'}>{p.isOn ? (p.isDry ? 'dry run: measuring only' : 'on') : 'off'}</Text>
497 <Text dimColor>{`cuts outputs over ≈${fmtTokens(tokensOf(p.threshold))} tokens`}</Text>
498 </Box>
499 <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
500 <Text dimColor>{p.isDry ? 'would save' : 'saved'}</Text>
501 <Text bold color="green">{`≈${fmtTokens(tokensOf(saved))} tokens`}</Text>
502 <Text dimColor>{s.cuts ? `${plural(s.cuts, 'cut')} · ${fmtChars(s.rawChars)} → ${fmtChars(s.keptChars)} chars (−${pct}%)` : 'nothing cut yet'}</Text>
503 </Box>
504 <Box flexDirection="row" gap={1}>
505 <Text dimColor>all-time</Text>
506 <Text>{`≈${fmtTokens(tokensOf(s.lifetimeSaved))} tokens · ${plural(s.lifetimeCuts, 'cut')}`}</Text>
507 </Box>
508 {s.cuts + (s.upgrades ?? 0) ? (
509 <Box flexDirection="row" gap={1}>
510 <Text dimColor>reopened</Text>
511 <Text color={(s.rereads ?? 0) > (s.cuts + (s.upgrades ?? 0)) / 3 ? 'yellow' : undefined}>{`${plural(s.rereads ?? 0, 'time')} Claude opened a full output after a cut`}</Text>
512 </Box>
513 ) : null}
514 {s.upgrades ? (
515 <Box flexDirection="row" gap={1}>
516 <Text dimColor>digests</Text>
517 <Text>{`${s.upgrades} very long output${s.upgrades > 1 ? 's' : ''} shown whole-file, not just the start`}</Text>
518 </Box>
519 ) : null}
520 {tools.length ? (
521 <Box flexDirection="column" marginTop={1}>
522 <Text bold>BY TOOL</Text>
523 {tools.map(([tool, t]) => (
524 <Box key={`tool-${tool}`} flexDirection="row" gap={1}>
525 <Text>{toolName(tool).slice(0, 20).padEnd(20)}</Text>
526 <Text dimColor>{plural(t.cuts, 'cut').padEnd(8)}</Text>
527 <Text color="green">{`≈${fmtTokens(tokensOf(t.raw - t.kept))} tok`}</Text>
528 </Box>
529 ))}
530 </Box>
531 ) : null}
532 <Box flexDirection="column" marginTop={1}>
533 <Text bold>LATEST CUTS</Text>
534 {recent.length === 0 ? (
535 <Text dimColor>{`Outputs from Bash, Grep, Glob and MCP tools longer than ${p.threshold.toLocaleString('en-US')} characters are trimmed here; the full text goes to ${DIR}/.`}</Text>
536 ) : (
537 recent.map(c => {
538 const isOpen = v.openCut === c.at
539 const head = `${isOpen ? '▾' : '▸'} ${new Date(c.at).toTimeString().slice(0, 5)} ${toolName(c.tool)} ${c.isUpgrade ? `digest ${fmtChars(c.raw)}` : `${fmtChars(c.raw)}→${fmtChars(c.kept)}`}${c.kind === 'delta' ? ' Δ' : ''}${c.rereads ? ` ↻${c.rereads}` : ''}`
540 const label = shorten(c.label, Math.max(10, width - head.length - 3))
541 return (
542 <Box key={`cut-${c.at}`} flexDirection="column">
543 <Button key={`cut-${c.at}`} plain label={`${head} ${label}`} onPress={() => toggleCut($, c.at)} />
544 {isOpen ? (
545 <Box flexDirection="column" paddingLeft={2}>
546 <Text dimColor>{`${c.kind} · kept ${c.keptWhat}${c.agentId ? ' · from a subagent' : ''}`}</Text>
547 <Text color="cyan">{c.path}</Text>
548 {c.preview
549 .split('\n')
550 .slice(0, 8)
551 .map((line, i) => (
552 <Text key={`pv-${c.at}-${i}`} dimColor>{shorten(line, width - 4) || ' '}</Text>
553 ))}
554 </Box>
555 ) : null}
556 </Box>
557 )
558 })
559 )}
560 </Box>
561 <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
562 <Button key="toggle" variant={p.isOn ? undefined : 'primary'} label={p.isOn ? 'Turn off' : 'Turn on'} onPress={() => toggleOn($)} />
563 <Button key="threshold" label={`Cut over: ≈${fmtTokens(tokensOf(p.threshold))} tok`} onPress={() => setThreshold($, nextThreshold)} />
564 <Button key="dry" label={p.isDry ? 'Dry run: on' : 'Dry run: off'} onPress={() => toggleDry($)} />
565 </Box>
566 </Box>
567 )
568}
569
570const THRESHOLD_ARG = /^(?:threshold\s+)?(\d+(?:\.\d+)?)\s*(k)?$/i
571
572export const register: Register = on => {
573 on('session.start', async ($, e, next) => {
574 const started = await next(e)
575 cwd = e.cwd
576 keep = ((await $.store.get(`keep:${e.cwd}`)) ?? []) as string[]
577 await $.command.register({ name: 'diet', description: 'Context Diet: show what was trimmed, or turn it on/off', argumentHint: '[on|off|dry|capture|keep <text>|report|<chars>]' })
578 await update($, prefs, p => ({ ...DEFAULT_PREFS, ...p }))
579 await update($, stats, cur => ({ ...EMPTY_STATS, ...cur }))
580 const current = await read($, stats)
581 if (current.startedAt === 0) {
582 const lifetime = ((await $.store.get('lifetime')) ?? {}) as { saved?: number; cuts?: number }
583 const now = await $.clock.now()
584 await update($, stats, s => ({ ...EMPTY_STATS, ...s, startedAt: now, lifetimeSaved: lifetime.saved ?? 0, lifetimeCuts: lifetime.cuts ?? 0 }))
585 }
586 await pushStatus($)
587 return started
588 })
589
590 on('tool.call', async ($, e, next) => {
591 const args = e as unknown as Record<string, unknown>
592 const id = typeof args.tool_use_id === 'string' ? args.tool_use_id : undefined
593 const command = typeof args.command === 'string' ? args.command : undefined
594 const p = await read($, prefs)
595 let capture: string | undefined
596 let wrapped: string | undefined
597 if (id && e.tool === 'Bash' && command && p.isOn && !p.isDry && p.isCapture && args.run_in_background !== true) {
598 const root = await rootOf($)
599 runSlot = (runSlot % 20) + 1
600 capture = `${root}/${DIR}/run-${String(runSlot).padStart(2, '0')}.log`
601 wrapped = captureCommand(command, capture)
602 if (wrapped) await ensureDir($, root).catch(() => (wrapped = undefined))
603 if (!wrapped) capture = undefined
604 }
605 if (id && isDietTool(e.tool)) rememberCall(id, { tool: e.tool, label: labelOf(e.tool, args), command, sig: sigOf(e.tool, command), ...(capture ? { capture } : {}) })
606 const target = [args.file_path, args.path, command].filter((v): v is string => typeof v === 'string').join(' ')
607 const opened = [...saved.keys()].find(path => target.includes(path))
608 if (opened) await countReread($, opened)
609 const result = await next(wrapped ? ({ ...e, command: wrapped } as typeof e) : e)
610 if (id && e.tool === 'Bash') keepFull(id, result)
611 return result
612 })
613
614 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
615 const p = await read($, prefs)
616 if (!p.isOn) return next(e)
617 const root = await rootOf($)
618 await loadSlot($)
619 const persisted: Record<string, string> = {}
620 for (const block of e.message.content) {
621 const shown = block.type === 'tool_result' ? textOf(block.content) : undefined
622 const path = shown === undefined ? undefined : persistedPath(shown)
623 if (path) persisted[path] = await $.fs.read(path).catch(() => undefined) ?? ''
624 }
625 for (const [path, text] of Object.entries(persisted)) if (!text) delete persisted[path]
626 const now = await $.clock.now()
627 const whole = new Map(fulls)
628 for (const block of e.message.content) {
629 const info = block.type === 'tool_result' ? calls.get(String(block.tool_use_id)) : undefined
630 if (info?.capture) {
631 const text = await $.fs.read(info.capture).catch(() => undefined)
632 if (text) whole.set(String(block.tool_use_id), text)
633 }
634 }
635 const where = { root, now, slot: takeSlot, persisted, runs, keep, fulls: whole, ...(e.agentId ? { agentId: e.agentId } : {}) }
636 const { content, cuts, saves, skipped, seen } = dietBlocks(e.message.content, e.origin as { kind?: string; tool?: string }, p, where)
637 for (const run of seen) rememberRun(run.command, run.raw, now)
638 if (skipped.length) await update($, stats, s => ({ ...s, passed: s.passed + skipped.length }))
639 if (!cuts.length) return next(e)
640 if (p.isDry) {
641 const stored = await next(e)
642 for (const cut of cuts) await recordCut($, cut)
643 return stored
644 }
645 try {
646 if (saves.length) await ensureDir($, root)
647 for (const save of saves) await $.fs.write(save.path, save.text)
648 } catch (error) {
649 // without the saved file the digest would point nowhere: Claude reads the output whole
650 readyDirs.delete(root)
651 await $.store.set('lastError', `${new Date(now).toISOString()} could not save the full output under ${root}/${DIR}: ${String(error)}`).catch(() => undefined)
652 return next(e)
653 }
654 void $.store.set('slot', slot)
655 for (const cut of cuts) saved.set(cut.path, cut.sig ?? cut.tool)
656 const stored = await next({ ...e, message: { ...e.message, content } })
657 for (const cut of cuts) await recordCut($, cut)
658 return stored
659 }).catch(async ($, e, next) => {
660 // the output reaches Claude whole; the reason is kept for /diet report
661 const error = next.error as { message?: string } | undefined
662 await $.store.set('lastError', `${new Date(await $.clock.now()).toISOString()} ${error?.message ?? String(next.error)}`).catch(() => undefined)
663 return next(e)
664 })
665
666 on('command.run', { command: 'diet' }, async ($, e) => {
667 const arg = e.args.trim().toLowerCase()
668 if (arg === 'on' || arg === 'off') {
669 await update($, prefs, p => ({ ...p, isOn: arg === 'on', isDry: false }))
670 await pushStatus($)
671 $.ui.invalidate('ui.render')
672 return { text: `Context Diet is ${arg}.` }
673 }
674 const m = THRESHOLD_ARG.exec(arg)
675 if (m) {
676 const chars = Math.round(Number(m[1]) * (m[2] ? 1000 : 1))
677 if (chars < 1000) return { text: 'Context Diet: the threshold is in characters and must be at least 1000 (for example /diet 8000 or /diet 8k).' }
678 await setThreshold($, chars)
679 return { text: `Context Diet now cuts outputs over ${chars.toLocaleString('en-US')} characters (≈${fmtTokens(tokensOf(chars))} tokens).` }
680 }
681 if (arg === 'dry' || arg === 'dry on' || arg === 'dry off') {
682 const p = await update($, prefs, cur => ({ ...cur, isOn: true, isDry: arg === 'dry' ? !cur.isDry : arg === 'dry on' }))
683 await pushStatus($)
684 $.ui.invalidate('ui.render')
685 return { text: p.isDry ? 'Context Diet dry run: outputs reach Claude whole; /diet shows what would have been cut and saved. /diet dry off to cut for real.' : 'Context Diet dry run is off: long outputs are cut again.' }
686 }
687 if (arg === 'capture' || arg === 'capture on' || arg === 'capture off') {
688 const p = await update($, prefs, cur => ({ ...cur, isCapture: arg === 'capture' ? !cur.isCapture : arg === 'capture on' }))
689 return {
690 text: p.isCapture
691 ? 'Context Diet capture is on: test and build commands (pytest, jest, go test, cargo, tsc, npm test…) save their whole output to .context-diet/run-NN.log before Claude Code can shorten it, so the failures at the end of a long failing run are kept. The command Claude Code runs becomes `{ <command>\n} > <file> 2>&1; …`, so a permission rule like Bash(pytest:*) may no longer match it and you may be asked. /diet capture off to stop.'
692 : 'Context Diet capture is off: commands run exactly as Claude writes them.',
693 }
694 }
695 const keepArg = /^(keep|unkeep)(?:\s+(.+))?$/i.exec(e.args.trim())
696 if (keepArg) {
697 const root = await rootOf($)
698 const text = keepArg[2]?.trim()
699 if (text) {
700 keep = keepArg[1]?.toLowerCase() === 'keep' ? [...new Set([...keep, text])] : keep.filter(k => k !== text)
701 await $.store.set(`keep:${root}`, keep)
702 }
703 return { text: keep.length ? `Context Diet keeps whole, in ${root}, the output of any command containing:\n${keep.map(k => ` ${k}`).join('\n')}\n(/diet unkeep <text> removes one)` : `Context Diet: nothing is kept whole in ${root}. /diet keep <text> keeps the output of any command containing that text.` }
704 }
705 if (arg === 'demo') {
706 const now = await $.clock.now()
707 await update($, stats, s => ({ ...demoStats(s, now, cwd || '/your/project') }))
708 $.ui.invalidate('ui.render')
709 await pushStatus($)
710 return { text: 'Context Diet: the panel shows sample cuts now. /diet reset clears them.' }
711 }
712 if (arg === 'reset') {
713 await update($, stats, s => ({ ...EMPTY_STATS, startedAt: s.startedAt, lifetimeSaved: s.lifetimeSaved, lifetimeCuts: s.lifetimeCuts }))
714 await update($, view, v => ({ ...v, openCut: 0 }))
715 $.ui.invalidate('ui.render')
716 await pushStatus($)
717 return { text: 'Context Diet: this session\'s figures are cleared.' }
718 }
719 const s = await read($, stats)
720 const p = await read($, prefs)
721 if (arg === 'report') {
722 const lastError = (await $.store.get('lastError')) as string | undefined
723 return { text: `${buildReport(s, p)}${lastError ? `\n\nLast error (that output reached Claude whole): ${lastError}` : ''}` }
724 }
725 if (arg) return { text: 'Usage: /diet [on|off|dry|capture [on|off]|keep <text>|unkeep <text>|report|demo|reset|<chars>], for example /diet 16k' }
726 const opened = await openPane($)
727 if (opened.isPlaced) return { text: 'Context Diet panel opened.' }
728 return { text: `${buildReport(s, p)}\n\n(The side panel is not available here: ${opened.reason}.)` }
729 }).catch(($, e, next) => ({ text: `Context Diet: /diet failed: ${String(next.error)}` }))
730
731 on('ui.close', { id: PANE }, async ($, e, next) => {
732 const closed = await next(e)
733 await update($, view, v => ({ ...v, isPlaced: false }))
734 return closed
735 })
736
737 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawDiet($, $.ui.resolve(e), e.props.bodyColumns))
738}
739hooks/diet.ts 496 lines1// Context Diet's compressors: pure functions from a tool's raw output to a
2// shorter text that keeps what an agent needs (errors, summaries, structure).
3// Nothing here touches the engine, so the tests call these directly.
4
5import { clip, levelOf, normalize, plural } from './text'
6import { digestTool } from './tools'
7import type { ToolKind } from './tools'
8
9export { normalize }
10
11export type DietKind = 'log' | 'json' | 'diff' | 'grep' | 'paths' | 'table' | 'delta' | ToolKind
12
13export type DietInput = {
14 text: string
15 tool: string
16 /** Bash's command, when the output came from Bash */
17 command?: string
18 /** how many characters the shortened text should aim for */
19 target: number
20}
21
22export type DietOutput = {
23 text: string
24 kind: DietKind
25 /** what the shortened text kept, in a few words, for the note above it */
26 kept: string
27}
28
29const LOCKFILE =
30 /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|Pipfile\.lock|uv\.lock|composer\.lock|Gemfile\.lock|go\.sum|flake\.lock|Podfile\.lock|packages\.lock\.json)$|\.min\.(?:js|css)$|\.map$|(?:^|\/)(?:dist|build|vendor|node_modules|__snapshots__)\//
31
32const isPathLike = (line: string) => /^\.{0,2}\/?[\w@.+~-][\w@.+~/ -]*$/.test(line) && !/\s{2,}/.test(line) && (line.includes('/') || /\.\w{1,8}$/.test(line))
33
34export const detectKind = (text: string, tool: string, command?: string): DietKind => {
35 const trimmed = text.trim()
36 if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && trimmed.length < 8_000_000) {
37 try {
38 JSON.parse(trimmed)
39 return 'json'
40 } catch {
41 // not JSON after all
42 }
43 }
44 if (/^diff --git /m.test(text) || (/^--- \S/m.test(text) && /^\+\+\+ \S/m.test(text) && /^@@ /m.test(text))) return 'diff'
45 const lines = text.split('\n').filter(line => line.trim() !== '')
46 if (lines.length < 20) return 'log'
47 const grepLike = lines.filter(line => /^[^\s:]+[:-]\d+[:-]/.test(line) || line === '--').length
48 if (tool === 'Grep' ? grepLike > lines.length * 0.5 : grepLike > lines.length * 0.7) return 'grep'
49 const pathLike = lines.filter(line => isPathLike(line.trim())).length
50 if (tool === 'Glob' || pathLike > lines.length * 0.85) return 'paths'
51 if (isTable(lines)) return 'table'
52 if (command && /^\s*(?:rg|grep|ag|ack)\b/.test(command) && grepLike > lines.length * 0.4) return 'grep'
53 return 'log'
54}
55
56// ---------- logs ----------
57
58const isWordy = (shape: string) => (shape.match(/[a-z]/gi)?.length ?? 0) >= 6
59
60type Line = { text: string; level: 0 | 1 | 2 }
61
62/** Folds runs of identical lines and of lines that differ only in numbers. */
63export const collapseRuns = (lines: string[]): Line[] => {
64 const shape = (line: string) => line.replace(/0x[0-9a-f]+|\d+(?:\.\d+)?/gi, '#').replace(/\s+/g, ' ').slice(0, 60)
65 const out: Line[] = []
66 let i = 0
67 while (i < lines.length) {
68 const line = lines[i] ?? ''
69 let j = i + 1
70 while (j < lines.length && lines[j] === line) j += 1
71 const same = j - i
72 if (same >= 3) {
73 out.push({ text: `${clip(line)} [×${same}]`, level: levelOf(line) })
74 i = j
75 continue
76 }
77 const s = shape(line)
78 j = i + 1
79 // only lines with words fold as "similar": rows that differ in numbers alone are data
80 while (j < lines.length && isWordy(s) && shape(lines[j] ?? '') === s) j += 1
81 const similar = j - i
82 if (similar >= 5) {
83 const last = lines[j - 1] ?? ''
84 const hit = Math.max(...lines.slice(i, j).map(levelOf)) as 0 | 1 | 2
85 out.push({ text: clip(line), level: hit })
86 out.push({ text: ` … ${plural(similar - 2, 'similar line')} …`, level: 0 })
87 out.push({ text: clip(last), level: hit })
88 i = j
89 continue
90 }
91 out.push({ text: clip(line), level: levelOf(line) })
92 i += 1
93 }
94 return out
95}
96
97const size = (lines: Line[]) => lines.reduce((n, l) => n + l.text.length + 1, 0)
98
99export const dietLog = (text: string, target: number): DietOutput => {
100 const raw = normalize(text)
101 // drop blank-line runs
102 const lines = raw.filter((line, i) => line !== '' || (raw[i - 1] ?? '') !== '')
103 const folded = collapseRuns(lines)
104 if (size(folded) <= target) return { text: folded.map(l => l.text).join('\n'), kind: 'log', kept: 'every line; repeats folded' }
105
106 const errorHits = folded.map((l, i) => (l.level === 2 ? i : -1)).filter(i => i >= 0)
107 const warnHits = folded.map((l, i) => (l.level === 1 ? i : -1)).filter(i => i >= 0)
108 // with errors to show, spend less on the start and end of the run
109 const headBudget = target * (errorHits.length ? 0.1 : 0.2)
110 const tailBudget = target * (errorHits.length ? 0.25 : 0.35)
111 const keep = new Set<number>()
112 let used = 0
113 for (let i = 0; i < folded.length && used < headBudget; i += 1) {
114 keep.add(i)
115 used += (folded[i]?.text.length ?? 0) + 1
116 }
117 let tailUsed = 0
118 for (let i = folded.length - 1; i >= 0 && tailUsed < tailBudget; i -= 1) {
119 if (!keep.has(i)) {
120 keep.add(i)
121 tailUsed += (folded[i]?.text.length ?? 0) + 1
122 }
123 }
124 used += tailUsed
125 // latest first: the last error is usually the one that matters
126 const take = (hits: number[], before: number, after: number, isDeduped: boolean) => {
127 const seen = new Set<string>()
128 let shown = 0
129 for (let h = hits.length - 1; h >= 0; h -= 1) {
130 const at = hits[h] ?? 0
131 const key = (folded[at]?.text ?? '').replace(/\d+/g, '#').trim()
132 if (isDeduped && seen.has(key)) continue
133 seen.add(key)
134 const window: number[] = []
135 for (let i = at - before; i <= at + after; i += 1) if (i >= 0 && i < folded.length && !keep.has(i)) window.push(i)
136 const cost = window.reduce((n, i) => n + (folded[i]?.text.length ?? 0) + 1, 0)
137 if (used + cost > target) continue
138 window.forEach(i => keep.add(i))
139 used += cost
140 shown += 1
141 }
142 return { shown, kinds: seen.size }
143 }
144 const errors = take(errorHits, 2, 3, false)
145 const warnings = take(warnHits, 0, 0, true)
146
147 const out: string[] = []
148 let gap = 0
149 for (let i = 0; i < folded.length; i += 1) {
150 if (keep.has(i)) {
151 if (gap) out.push(`… [${plural(gap, 'line')} cut] …`)
152 gap = 0
153 out.push(folded[i]?.text ?? '')
154 } else gap += 1
155 }
156 if (gap) out.push(`… [${plural(gap, 'line')} cut] …`)
157 const parts = ['the first and last lines']
158 if (errorHits.length) parts.push(`${errors.shown} of ${plural(errorHits.length, 'error line')} with context`)
159 if (warnHits.length) parts.push(`${warnings.shown} of ${plural(warnings.kinds, 'distinct warning')}`)
160 return { text: out.join('\n'), kind: 'log', kept: `${parts.join(', ')}; repeats folded` }
161}
162
163// ---------- JSON ----------
164
165type Shape = { depth: number; items: number; keys: number; str: number }
166
167const SHAPES: Shape[] = [
168 { depth: 6, items: 3, keys: 40, str: 160 },
169 { depth: 5, items: 2, keys: 25, str: 100 },
170 { depth: 4, items: 1, keys: 15, str: 60 },
171 { depth: 3, items: 1, keys: 10, str: 40 },
172 { depth: 2, items: 1, keys: 8, str: 30 },
173]
174
175const skeleton = (value: unknown, shape: Shape, depth = 0): unknown => {
176 if (typeof value === 'string') return value.length > shape.str ? `${value.slice(0, shape.str)}…(+${value.length - shape.str} chars)` : value
177 if (value === null || typeof value !== 'object') return value
178 if (Array.isArray(value)) {
179 if (depth >= shape.depth) return `[… ${plural(value.length, 'item')}]`
180 if (value.length <= shape.items + 1) return value.map(v => skeleton(v, shape, depth + 1))
181 const head = value.slice(0, shape.items).map(v => skeleton(v, shape, depth + 1))
182 const objects = value.filter(v => v !== null && typeof v === 'object' && !Array.isArray(v)) as Record<string, unknown>[]
183 const keys = [...new Set(objects.flatMap(o => Object.keys(o)))]
184 const more = `… ${plural(value.length - shape.items, 'more item')}${keys.length && objects.length === value.length ? ` (keys: ${keys.slice(0, 20).join(', ')}${keys.length > 20 ? ', …' : ''})` : ''}`
185 return [...head, more]
186 }
187 const entries = Object.entries(value as Record<string, unknown>)
188 if (depth >= shape.depth) return `{… ${plural(entries.length, 'key')}: ${entries.slice(0, 8).map(([k]) => k).join(', ')}${entries.length > 8 ? ', …' : ''}}`
189 const out: Record<string, unknown> = {}
190 for (const [k, v] of entries.slice(0, shape.keys)) out[k] = skeleton(v, shape, depth + 1)
191 if (entries.length > shape.keys) out['…'] = `${entries.length - shape.keys} more keys: ${entries.slice(shape.keys, shape.keys + 12).map(([k]) => k).join(', ')}${entries.length > shape.keys + 12 ? ', …' : ''}`
192 return out
193}
194
195export const dietJson = (text: string, target: number): DietOutput => {
196 const value = JSON.parse(text.trim()) as unknown
197 let out = ''
198 for (const shape of SHAPES) {
199 out = JSON.stringify(skeleton(value, shape), null, 1)
200 if (out.length <= target) break
201 }
202 const what = Array.isArray(value) ? `an array of ${plural(value.length, 'item')}` : value && typeof value === 'object' ? `an object with ${plural(Object.keys(value).length, 'key')}` : 'a value'
203 return { text: out, kind: 'json', kept: `the structure of ${what}: every key, the first items of each array, strings shortened` }
204}
205
206// ---------- diffs ----------
207
208export const dietDiff = (text: string, target: number): DietOutput => {
209 const lines = normalize(text)
210 const files: { name: string; lines: string[] }[] = []
211 let cur: { name: string; lines: string[] } = { name: '', lines: [] }
212 for (const line of lines) {
213 const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line)
214 if (m) {
215 if (cur.lines.length) files.push(cur)
216 cur = { name: m[2] ?? m[1] ?? '', lines: [line] }
217 continue
218 }
219 if (!cur.name) {
220 const p = /^\+\+\+ (?:b\/)?(.+)$/.exec(line)
221 if (p && p[1] !== '/dev/null') cur.name = p[1] ?? ''
222 }
223 cur.lines.push(line)
224 }
225 if (cur.lines.length) files.push(cur)
226
227 const counts = (body: string[]) => ({
228 add: body.filter(l => l.startsWith('+') && !l.startsWith('+++')).length,
229 del: body.filter(l => l.startsWith('-') && !l.startsWith('---')).length,
230 })
231 const generated = files.filter(f => LOCKFILE.test(f.name))
232 const real = files.filter(f => !LOCKFILE.test(f.name))
233 const perFile = Math.max(600, Math.floor((target - generated.length * 90) / Math.max(1, real.length)))
234 const out: string[] = []
235 let cutFiles = 0
236 for (const f of files) {
237 const { add, del } = counts(f.lines)
238 if (LOCKFILE.test(f.name)) {
239 out.push(`${f.lines[0] ?? f.name}\n [lockfile/generated: +${add} −${del} lines cut]`)
240 continue
241 }
242 let used = 0
243 const kept: string[] = []
244 let i = 0
245 for (; i < f.lines.length; i += 1) {
246 const line = clip(f.lines[i] ?? '', 300)
247 if (used + line.length > perFile && kept.length > 4) break
248 kept.push(line)
249 used += line.length + 1
250 }
251 if (i < f.lines.length && f.lines.length - i <= 3) {
252 for (; i < f.lines.length; i += 1) kept.push(clip(f.lines[i] ?? '', 300))
253 }
254 if (i < f.lines.length) {
255 const rest = counts(f.lines.slice(i))
256 kept.push(` … [${plural(f.lines.length - i, 'more diff line')} in ${f.name} cut: +${rest.add} −${rest.del}] …`)
257 cutFiles += 1
258 }
259 out.push(kept.join('\n'))
260 }
261 const totals = counts(lines)
262 const head = `${plural(files.length, 'file')} changed, +${totals.add} −${totals.del}`
263 return {
264 text: `${head}\n${out.join('\n')}`,
265 kind: 'diff',
266 kept: `every file's header and counts${generated.length ? `, ${plural(generated.length, 'lockfile/generated file')} reduced to counts` : ''}${cutFiles ? `, the start of the hunks in ${plural(cutFiles, 'long file')}` : ', every hunk'}`,
267 }
268}
269
270// ---------- grep results ----------
271
272export const dietGrep = (text: string, target: number): DietOutput => {
273 const lines = normalize(text).filter(l => l !== '' && l !== '--')
274 const byFile = new Map<string, string[]>()
275 const other: string[] = []
276 for (const line of lines) {
277 const m = /^([^\s:]+?)[:-](\d+)[:-](.*)$/.exec(line)
278 if (!m) {
279 other.push(line)
280 continue
281 }
282 const file = m[1] ?? ''
283 const list = byFile.get(file) ?? []
284 list.push(` ${m[2]}: ${clip((m[3] ?? '').trim(), 200)}`)
285 byFile.set(file, list)
286 }
287 const total = [...byFile.values()].reduce((n, l) => n + l.length, 0)
288 for (const sample of [50, 25, 12, 6, 3, 1, 0]) {
289 const out = [...other.slice(0, 3), `${plural(total, 'match')} in ${plural(byFile.size, 'file')}`]
290 let shown = 0
291 let used = out.join('\n').length
292 for (const [file, hits] of byFile) {
293 const block = [`${file} (${hits.length})`, ...hits.slice(0, sample), ...(hits.length > sample && sample ? [` … ${hits.length - sample} more`] : [])].join('\n')
294 if (used + block.length > target && shown > 0) break
295 out.push(block)
296 used += block.length + 1
297 shown += 1
298 }
299 if (shown < byFile.size) out.push(`… and ${plural(byFile.size - shown, 'more file')}`)
300 if (used <= target || sample === 0) {
301 return {
302 text: out.join('\n'),
303 kind: 'grep',
304 kept: `match counts for ${shown === byFile.size ? 'every file' : `${shown} of ${plural(byFile.size, 'file')}`}${sample ? `, the first ${sample} match${sample > 1 ? 'es' : ''} in each` : ''}`,
305 }
306 }
307 }
308 return { text: lines.slice(0, 50).join('\n'), kind: 'grep', kept: 'the first matches' }
309}
310
311// ---------- tables and data ----------
312
313const DELIMITERS = [',', '\t', '|', ';']
314const NUMERIC_ROW = /^[\s\d.,:;|+\-eE%$€/()]+$/
315
316const cellsOf = (line: string, delim: string) => line.split(delim).length - 1
317
318/** CSV/TSV/pipe tables, and rows of numbers: data an agent may need exactly. */
319export const isTable = (lines: string[]) => {
320 if (lines.filter(l => NUMERIC_ROW.test(l)).length >= lines.length * 0.85) return true
321 for (const delim of DELIMITERS) {
322 const counts = new Map<number, number>()
323 for (const l of lines) counts.set(cellsOf(l, delim), (counts.get(cellsOf(l, delim)) ?? 0) + 1)
324 const [cells, rows] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] ?? [0, 0]
325 if (cells >= (delim === '|' ? 2 : delim === ',' ? 2 : 1) && rows >= lines.length * 0.85) return true
326 }
327 return false
328}
329
330export const dietTable = (text: string, target: number): DietOutput => {
331 const lines = normalize(text).filter(l => l !== '')
332 const delim = DELIMITERS.find(d => cellsOf(lines[1] ?? '', d) >= 1 && cellsOf(lines[1] ?? '', d) === cellsOf(lines[2] ?? '', d))
333 const first = lines[0] ?? ''
334 const hasHeader = delim !== undefined && !NUMERIC_ROW.test(first) && cellsOf(first, delim) === cellsOf(lines[1] ?? '', delim)
335 const columns = hasHeader && delim ? first.split(delim).map(c => c.trim()) : []
336 const rows = hasHeader ? lines.slice(1) : lines
337 const head: string[] = []
338 const tail: string[] = []
339 let used = first.length + 120
340 for (let i = 0; i < rows.length && used < target * 0.55; i += 1) {
341 head.push(clip(rows[i] ?? '', 300))
342 used += (head.at(-1)?.length ?? 0) + 1
343 }
344 for (let i = rows.length - 1; i >= head.length && used < target; i -= 1) {
345 tail.unshift(clip(rows[i] ?? '', 300))
346 used += (tail[0]?.length ?? 0) + 1
347 }
348 const cut = rows.length - head.length - tail.length
349 const offset = hasHeader ? 2 : 1
350 const summary = `Table: ${plural(rows.length, 'row')}${columns.length ? ` × ${plural(columns.length, 'column')} (${columns.slice(0, 20).join(', ')}${columns.length > 20 ? ', …' : ''})` : ''}`
351 const out = [summary, ...(hasHeader ? [first] : []), ...head]
352 if (cut > 0) out.push(`… [${plural(cut, 'row')} cut: output lines ${head.length + offset}–${head.length + offset + cut - 1}; nothing in between is shown or summarized] …`)
353 out.push(...tail)
354 return {
355 text: out.join('\n'),
356 kind: 'table',
357 kept: `${hasHeader ? 'the header, ' : ''}the first ${head.length} and last ${tail.length} rows exactly; the ${plural(cut, 'row')} between them are not shown`,
358 }
359}
360
361// ---------- path lists ----------
362
363export const dietPaths = (text: string, target: number): DietOutput => {
364 const lines = normalize(text).map(l => l.trim()).filter(l => l !== '')
365 const header = lines[0] && !isPathLike(lines[0]) ? [lines[0]] : []
366 const paths = lines.filter(l => isPathLike(l))
367 const byDir = new Map<string, string[]>()
368 for (const p of paths) {
369 const slash = p.replace(/\/$/, '').lastIndexOf('/')
370 const dir = slash >= 0 ? p.slice(0, slash + 1) : './'
371 const list = byDir.get(dir) ?? []
372 list.push(slash >= 0 ? p.slice(slash + 1) : p)
373 byDir.set(dir, list)
374 }
375 const dirs = [...byDir.keys()]
376 let prefix = dirs[0] ?? ''
377 for (const d of dirs) while (prefix && !d.startsWith(prefix)) prefix = prefix.slice(0, prefix.slice(0, -1).lastIndexOf('/') + 1)
378 if (prefix.length < 8) prefix = ''
379 const show = (dir: string) => (prefix && dir !== prefix ? dir.slice(prefix.length) : dir)
380 const all = [...byDir.entries()]
381 for (const names of [8, 4, 2, 0]) {
382 const out = [...header, `${plural(paths.length, 'path')} in ${plural(byDir.size, 'directory')}${prefix ? `, all under ${prefix}` : ''}`]
383 const full = all.map(([dir, list]) => (names ? `${show(dir)} (${list.length}): ${list.slice(0, names).join(', ')}${list.length > names ? `, … +${list.length - names}` : ''}` : `${show(dir)} (${list.length})`))
384 const fits = full.reduce((n, l) => n + l.length + 1, out.join('\n').length) <= target
385 // when not every directory fits, list the biggest ones
386 const order = fits ? all.map((_, i) => i) : all.map((e, i) => [e[1].length, i] as const).sort((a, b) => b[0] - a[0]).map(([, i]) => i)
387 let used = out.join('\n').length
388 let shown = 0
389 for (const i of order) {
390 const line = clip(full[i] ?? '', 500)
391 if (used + line.length > target && shown > 0) break
392 out.push(line)
393 used += line.length + 1
394 shown += 1
395 }
396 if (shown < byDir.size) out.push(`… and ${plural(byDir.size - shown, 'more directory')} (${(paths.length - order.slice(0, shown).reduce((n, i) => n + (all[i]?.[1].length ?? 0), 0)).toLocaleString('en-US')} paths)`)
397 if (used <= target || names === 0) {
398 return { text: out.join('\n'), kind: 'paths', kept: `${shown < byDir.size ? `the ${shown} biggest of ${byDir.size} directories` : 'every directory'} with path counts${names ? ` and the first ${names} names in each` : ''}` }
399 }
400 }
401 return { text: paths.slice(0, 50).join('\n'), kind: 'paths', kept: 'the first paths' }
402}
403
404// ---------- entry ----------
405
406export const diet = (input: DietInput): DietOutput => {
407 const kind = detectKind(input.text, input.tool, input.command)
408 switch (kind) {
409 case 'json':
410 return dietJson(input.text, input.target)
411 case 'diff':
412 return dietDiff(input.text, input.target)
413 case 'grep':
414 return dietGrep(input.text, input.target)
415 case 'table':
416 return dietTable(input.text, input.target)
417 case 'paths':
418 return dietPaths(input.text, input.target)
419 default: {
420 const raw = normalize(input.text)
421 const lines = raw.filter((line, i) => line !== '' || (raw[i - 1] ?? '') !== '')
422 return digestTool(lines, input.command ?? '', input.target) ?? dietLog(input.text, input.target)
423 }
424 }
425}
426
427/** Roughly how many tokens a text costs: about four characters each. */
428export const tokensOf = (chars: number) => Math.round(chars / 4)
429
430export const fmtTokens = (n: number) =>
431 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
432
433// ---------- repeated commands ----------
434
435// what changes between two runs of the same command without meaning anything
436const VOLATILE =
437 /\b\d+(?:\.\d+)?\s?(?:ms|s|sec|secs|m|min|h|µs|us|ns|kB|KiB|MB|MiB|GB|GiB|KB|B|%)\b|\b\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\b|\b\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?\b|\b[0-9a-f]{7,40}\b|\bpid \d+|\bport \d+/gi
438
439const steady = (line: string) => line.replace(VOLATILE, '#').trim()
440
441/**
442 * A digest of what changed since the previous run of the same command, or
443 * undefined when the runs differ too much for a comparison to be shorter.
444 */
445export const dietDelta = (previous: string, text: string, target: number, since: string): DietOutput | undefined => {
446 const before = normalize(previous).filter(l => l.trim() !== '')
447 const now = normalize(text).filter(l => l.trim() !== '')
448 if (before.length < 10 || now.length < 10) return undefined
449 const pool = new Map<string, number>()
450 for (const l of before) pool.set(steady(l), (pool.get(steady(l)) ?? 0) + 1)
451 const added: number[] = []
452 now.forEach((l, i) => {
453 const key = steady(l)
454 const left = pool.get(key) ?? 0
455 if (left > 0) pool.set(key, left - 1)
456 else added.push(i)
457 })
458 const back = new Map<string, number>()
459 for (const l of now) back.set(steady(l), (back.get(steady(l)) ?? 0) + 1)
460 const removed: string[] = []
461 for (const l of before) {
462 const key = steady(l)
463 const left = back.get(key) ?? 0
464 if (left > 0) back.set(key, left - 1)
465 else removed.push(l)
466 }
467 const similarity = 1 - (added.length + removed.length) / (before.length + now.length)
468 if (similarity < 0.6) return undefined
469
470 // the end of the run is its summary; then the important changes first
471 const tail = now.slice(-6)
472 const pick = (items: string[], budget: number) => {
473 const ranked = items.map((l, i) => ({ l, i, level: levelOf(l) })).sort((a, b) => b.level - a.level || a.i - b.i)
474 const chosen = new Set<number>()
475 let used = 0
476 for (const r of ranked) {
477 const cost = clip(r.l, 300).length + 3
478 if (used + cost > budget) continue
479 chosen.add(r.i)
480 used += cost
481 }
482 const shown = items.filter((_, i) => chosen.has(i)).map(l => ` ${clip(l, 300)}`)
483 if (chosen.size < items.length) shown.push(` … and ${plural(items.length - chosen.size, 'more line')} …`)
484 return shown
485 }
486 const out = [
487 added.length || removed.length
488 ? `Same command as at ${since}. Compared with that run: ${plural(added.length, 'new line')}, ${plural(removed.length, 'line')} gone, ${plural(now.length - added.length, 'line')} unchanged (timings, sizes and hashes ignored).`
489 : `Same command as at ${since}, and the same output: all ${plural(now.length, 'line')} unchanged (timings, sizes and hashes ignored).`,
490 ]
491 if (added.length) out.push('New in this run:', ...pick(added.map(i => now[i] ?? ''), target * 0.55))
492 if (removed.length) out.push('Gone since then:', ...pick(removed, target * 0.25))
493 out.push('End of this run:', ...tail.map(l => ` ${clip(l, 300)}`))
494 return { text: out.join('\n'), kind: 'delta', kept: `only what changed since the same command at ${since}, and the end of this run` }
495}
496hooks/text.ts 36 lines1// Text helpers shared by the compressors.
2
3export const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Z0-9]/g
4export const LONG_LINE = 400
5export const IMPORTANT =
6 /\b(?:error|errors|err|failed|failure|failing|fail|fatal|panic|panicked|exception|traceback|warning|warn|denied|refused|forbidden|not found|no such file|cannot|can't|unable|undefined|segmentation fault|assert(?:ion)?|expected|received|timeout|timed out|abort(?:ed)?|killed|exit code|exited with)\b|ERR!|✗|✘|×|FAIL|^\s*at \S+ \(|^\s*File ".*", line \d+|^\s*-->\s|^\s*\^+\s*$|^\s*\d+ (?:passed|failed|tests?)\b|^(?:Tests?|Test Suites|Ran \d+)/i
7// errors outrank warnings: a run's failure must never lose its place to deprecation noise
8export const CRITICAL =
9 /\b(?:error|errors|failed|failure|failing|fatal|panic|panicked|exception|traceback|denied|refused|forbidden|not found|no such file|cannot|can't|unable|segmentation fault|assert(?:ion)?|expected|received|timeout|timed out|abort(?:ed)?|killed|exit code|exited with)\b|ERR!|✗|✘|✕|FAIL|●|^\s*at \S+ \(|^\s*File ".*", line \d+|^\s*-->\s|^\s*\^+\s*$|^\s*>\s*\d+\s*\|/i
10// a passing line is never an error, whatever its name says (errorHandler.test.ts)
11const PASSING = /^\s*(?:PASS\s|✓|✔|ok\s|PASSED\b)|\sPASSED\b|\.\.\. ok$/
12
13// \b does not see Cyrillic letters, so Russian logs get their own words
14const CRITICAL_RU = /ошибк|не удалось|невозможно|исключени|сбой|аварийн|отказано|не найден/i
15const WARNING_RU = /предупрежд|устарел/i
16
17export const levelOf = (line: string): 0 | 1 | 2 =>
18 PASSING.test(line) ? 0 : CRITICAL.test(line) || CRITICAL_RU.test(line) ? 2 : IMPORTANT.test(line) || WARNING_RU.test(line) ? 1 : 0
19/** Strips colors and cursor codes, keeps the last frame of `\r` progress lines. */
20export const normalize = (text: string): string[] =>
21 text
22 .replace(ANSI, '')
23 .replace(/\r\n/g, '\n')
24 .split('\n')
25 .map(line => {
26 const cr = line.lastIndexOf('\r')
27 return (cr >= 0 ? line.slice(cr + 1) : line).trimEnd()
28 })
29
30export const clip = (line: string, max = LONG_LINE) =>
31 line.length > max ? `${line.slice(0, Math.round(max * 0.75))} … [+${line.length - Math.round(max * 0.75)} chars]` : line
32
33export const plural = (n: number, word: string) =>
34 `${n.toLocaleString('en-US')} ${n === 1 ? word : /(?:ch|sh|s|x)$/.test(word) ? `${word}es` : /[^aeiou]y$/.test(word) ? `${word.slice(0, -1)}ies` : `${word}s`}`
35
36hooks/tools.ts 296 lines1// Digests that know one tool's output: where its failures, errors and summary
2// are, and what is noise. Each parser marks line ranges by rank; renderMarks
3// keeps the highest ranks that fit (failures and summaries first, the latest
4// first within a rank) and cuts the rest.
5
6import { clip, plural } from './text'
7
8export type ToolKind = 'jest' | 'node-test' | 'pytest' | 'go-test' | 'cargo' | 'tsc' | 'eslint' | 'install' | 'docker'
9
10/** A range of lines, `to` exclusive. Rank 3: failures, errors, summaries; 2: context; 1: noise worth one example. */
11export type Mark = { from: number; to: number; rank: 1 | 2 | 3 }
12
13type Parsed = { kind: ToolKind; header: string; marks: Mark[]; kept: string }
14type Parser = (lines: string[], command: string) => Parsed | undefined
15
16const count = (lines: string[], re: RegExp) => lines.reduce((n, l) => n + (re.test(l) ? 1 : 0), 0)
17
18/** The index after a block that starts at `from`, ends before `stop` matches, and is at most `cap` long. */
19const until = (lines: string[], from: number, stop: (line: string) => boolean, cap: number) => {
20 let k = from
21 while (k < lines.length && k - from < cap && !stop(lines[k] ?? '')) k += 1
22 return k
23}
24
25/** Marks the first `limit` lines of each kind of noise once, and counts every kind. */
26const once = () => {
27 const seen = new Map<string, number>()
28 return {
29 isFirst: (key: string) => {
30 const n = (seen.get(key) ?? 0) + 1
31 seen.set(key, n)
32 return n === 1
33 },
34 summary: (word: string) => {
35 const total = [...seen.values()].reduce((a, b) => a + b, 0)
36 return total ? `${plural(total, word)} of ${plural(seen.size, 'kind')}` : ''
37 },
38 }
39}
40
41const TEST_SUMMARY = /^\s*(?:Test Suites:|Tests:|Snapshots:|Time:|Ran all test suites|Test Files\s|Tests\s+\d|Duration\s|Start at\s)/
42
43const jest: Parser = (lines, command) => {
44 const passFail = count(lines, /^\s*(?:PASS|FAIL)\s+\S/)
45 const vitest = count(lines, /^\s*(?:✓|❯|×)\s+\S/)
46 if (passFail < 3 && vitest < 3 && !(/\b(?:jest|vitest)\b/.test(command) && lines.some(l => TEST_SUMMARY.test(l)))) return undefined
47 const marks: Mark[] = [{ from: 0, to: Math.min(2, lines.length), rank: 2 }]
48 const noise = once()
49 let passed = 0
50 let failed = 0
51 const isBoundary = (l: string) => /^\s*(?:PASS|FAIL)\s+\S/.test(l) || /^\s*●\s/.test(l) || /^\s*✓\s/.test(l) || TEST_SUMMARY.test(l) || /^Summary of all failing/.test(l.trim())
52 for (let k = 0; k < lines.length; k += 1) {
53 const l = lines[k] ?? ''
54 if (/^\s*PASS\s/.test(l) || /^\s*✓\s/.test(l)) passed += 1
55 if (/^\s*FAIL\s/.test(l) || /^\s*●\s/.test(l) || /^\s*(?:×|✕|❯)\s/.test(l)) {
56 if (/^\s*FAIL\s/.test(l) || /^\s*❯\s/.test(l)) failed += 1
57 marks.push({ from: k, to: until(lines, k + 1, isBoundary, 60), rank: 3 })
58 } else if (TEST_SUMMARY.test(l)) {
59 marks.push({ from: k, to: k + 1, rank: 3 })
60 } else if (/^Summary of all failing tests/.test(l.trim())) {
61 marks.push({ from: k, to: until(lines, k + 1, x => /^\s*Test Suites:/.test(x), 200), rank: 3 })
62 } else if (/^\s*console\.(?:warn|error|log|info|debug)\b/.test(l)) {
63 const message = /console\.\w+\s*$/.test(l) ? (lines[k + 1] ?? '') : l
64 if (noise.isFirst(message.replace(/\d+/g, '#').trim())) marks.push({ from: k, to: Math.min(lines.length, k + (/console\.\w+\s*$/.test(l) ? 2 : 1)), rank: 1 })
65 }
66 }
67 const consoleLines = noise.summary('console line')
68 return {
69 kind: 'jest',
70 header: `[${vitest >= 3 ? 'vitest' : 'jest'}] ${plural(passed, 'passing file')} not listed, ${failed} failing${consoleLines ? `; ${consoleLines}, one of each shown` : ''}`,
71 marks,
72 kept: 'every failure with its message and code frame, the summary',
73 }
74}
75
76const nodeTest: Parser = lines => {
77 if (count(lines, /^\s*[✔✖] /) < 3 || !lines.some(l => /^ℹ (?:tests|pass|fail) \d+/.test(l))) return undefined
78 const marks: Mark[] = []
79 let passed = 0
80 for (let k = 0; k < lines.length; k += 1) {
81 const l = lines[k] ?? ''
82 if (/^\s*✔ /.test(l)) passed += 1
83 if (/^\s*✖ /.test(l)) marks.push({ from: k, to: until(lines, k + 1, x => /^\s*[✔✖] |^ℹ /.test(x), 40), rank: 3 })
84 if (/^ℹ /.test(l)) marks.push({ from: k, to: k + 1, rank: 3 })
85 if (/^✖ failing tests:/.test(l)) marks.push({ from: k, to: until(lines, k + 1, x => /^ℹ /.test(x), 200), rank: 3 })
86 }
87 return { kind: 'node-test', header: `[node --test] ${plural(passed, 'passing test')} not listed`, marks, kept: 'every failing test with its error, the summary' }
88}
89
90const pytest: Parser = (lines, command) => {
91 const isPytest = lines.some(l => /^=+ test session starts =+$/.test(l)) || count(lines, /::\S+ (?:PASSED|FAILED|ERROR)/) >= 3 || (/\bpytest\b/.test(command) && lines.some(l => /\d+ (?:passed|failed)/.test(l)))
92 if (!isPytest) return undefined
93 const marks: Mark[] = [{ from: 0, to: Math.min(4, lines.length), rank: 2 }]
94 const sections = lines.map((l, k) => (/^=+ .+ =+$/.test(l) ? k : -1)).filter(k => k >= 0)
95 for (let s = 0; s < sections.length; s += 1) {
96 const start = sections[s] ?? 0
97 const end = sections[s + 1] ?? lines.length
98 const title = lines[start] ?? ''
99 if (/FAILURES|ERRORS/.test(title)) {
100 marks.push({ from: start, to: start + 1, rank: 3 })
101 const blocks = [...Array(end - start).keys()].map(i => start + i).filter(k => /^_+ .+ _+$/.test(lines[k] ?? ''))
102 for (let b = 0; b < blocks.length; b += 1) {
103 const from = blocks[b] ?? start
104 const to = blocks[b + 1] ?? end
105 if (to - from <= 52) marks.push({ from, to, rank: 3 })
106 else marks.push({ from, to: from + 40, rank: 3 }, { from: to - 12, to, rank: 3 })
107 }
108 } else if (/short test summary/.test(title)) {
109 marks.push({ from: start, to: end, rank: 3 })
110 } else if (/warnings summary/.test(title)) {
111 marks.push({ from: start, to: Math.min(end, start + 15), rank: 1 })
112 } else if (/\d+ (?:passed|failed|error|skipped|deselected)/.test(title)) {
113 marks.push({ from: start, to: start + 1, rank: 3 })
114 }
115 }
116 lines.forEach((l, k) => {
117 if (/ (?:FAILED|ERROR)\b/.test(l) && !/^=+/.test(l)) marks.push({ from: k, to: k + 1, rank: 2 })
118 })
119 const passed = count(lines, / PASSED\b/)
120 return { kind: 'pytest', header: `[pytest]${passed ? ` ${plural(passed, 'PASSED line')} not listed;` : ''} failures, errors and the summary below`, marks, kept: 'the session header, every failure (long ones by their start and end), the short summary' }
121}
122
123const goTest: Parser = (lines, command) => {
124 if (count(lines, /^(?:=== RUN|--- (?:PASS|FAIL|SKIP)|ok\s+\S+|FAIL\s+\S+|PASS$)/) < 3 && !/\bgo test\b/.test(command)) return undefined
125 if (!lines.some(l => /^(?:--- |ok\s|FAIL|PASS$|panic:)/.test(l))) return undefined
126 const marks: Mark[] = []
127 let ok = 0
128 const runAt = new Map<string, number>()
129 for (let k = 0; k < lines.length; k += 1) {
130 const l = lines[k] ?? ''
131 const run = /^=== RUN\s+(\S+)/.exec(l)
132 if (run?.[1]) runAt.set(run[1], k)
133 if (/^ok\s/.test(l)) ok += 1
134 const fail = /^\s*--- FAIL: (\S+)/.exec(l)
135 if (fail) {
136 const started = fail[1] ? runAt.get(fail[1]) : undefined
137 if (started !== undefined && k - started < 40) marks.push({ from: started, to: k, rank: 3 })
138 marks.push({ from: k, to: until(lines, k + 1, x => /^(?:=== RUN|\s*--- |ok\s|FAIL\s|FAIL$|PASS$)/.test(x), 60), rank: 3 })
139 }
140 if (/^panic:|^fatal error:/.test(l)) marks.push({ from: k, to: Math.min(lines.length, k + 40), rank: 3 })
141 if (/^FAIL(?:\s|$)/.test(l) || /^\S+\.go:\d+:\d+: /.test(l)) marks.push({ from: k, to: k + 1, rank: 3 })
142 }
143 const passed = count(lines, /^\s*--- PASS/)
144 return { kind: 'go-test', header: `[go test] ${plural(ok, 'package')} ok${passed ? `, ${plural(passed, 'passing test')}` : ''} not listed`, marks, kept: 'every failing test with its log, panics, build errors, FAIL lines' }
145}
146
147const cargo: Parser = (lines, command) => {
148 const steps = count(lines, /^\s*(?:Compiling|Checking|Downloaded|Downloading|Fresh|Documenting)\s/)
149 if (steps < 3 && !(/\bcargo\b/.test(command) && lines.some(l => /^(?:error|warning)(?:\[\w+\])?: |^test result: /.test(l)))) return undefined
150 const marks: Mark[] = []
151 const warnings = once()
152 for (let k = 0; k < lines.length; k += 1) {
153 const l = lines[k] ?? ''
154 if (/^error(?:\[\w+\])?: /.test(l)) marks.push({ from: k, to: until(lines, k + 1, x => x.trim() === '' || /^(?:error|warning)/.test(x), 30), rank: 3 })
155 const w = /^warning: (.+)$/.exec(l)
156 if (w?.[1] && warnings.isFirst(w[1].replace(/`[^`]*`/g, '`…`').replace(/\d+/g, '#'))) marks.push({ from: k, to: until(lines, k + 1, x => x.trim() === '' || /^(?:error|warning)/.test(x), 10), rank: 1 })
157 if (/^test result: |^test .* \.\.\. FAILED$|^error: test failed/.test(l)) marks.push({ from: k, to: k + 1, rank: 3 })
158 if (/^failures:$/.test(l)) marks.push({ from: k, to: until(lines, k + 1, x => /^test result: /.test(x), 80), rank: 3 })
159 if (/^---- .+ stdout ----$/.test(l)) marks.push({ from: k, to: until(lines, k + 1, x => /^---- |^failures:$|^test result/.test(x), 30), rank: 3 })
160 }
161 marks.push({ from: Math.max(0, lines.length - 2), to: lines.length, rank: 2 })
162 const w = warnings.summary('warning')
163 return { kind: 'cargo', header: `[cargo] ${plural(steps, 'compile/download line')} not listed${w ? `; ${w}, one of each shown` : ''}`, marks, kept: 'every error with its source snippet, one example of each warning, test failures and results' }
164}
165
166const TSC = /^(.+?)\((\d+),(\d+)\): error (TS\d+): |^(.+?):(\d+):(\d+) - error (TS\d+): /
167
168const tsc: Parser = (lines, command) => {
169 const errors = count(lines, TSC)
170 if (errors === 0 || (errors < 2 && !/\btsc\b|type-?check/.test(command))) return undefined
171 const marks: Mark[] = []
172 const files = new Set<string>()
173 for (let k = 0; k < lines.length; k += 1) {
174 const m = TSC.exec(lines[k] ?? '')
175 if (m) {
176 const file = m[1] ?? m[5] ?? ''
177 const isPretty = Boolean(m[5])
178 marks.push({ from: k, to: isPretty ? until(lines, k + 1, x => x.trim() === '' || TSC.test(x), 6) : k + 1, rank: files.has(file) ? 2 : 3 })
179 files.add(file)
180 }
181 if (/^Found \d+ errors?/.test(lines[k] ?? '')) marks.push({ from: k, to: k + 1, rank: 3 })
182 }
183 return { kind: 'tsc', header: `[tsc] ${plural(errors, 'error')} in ${plural(files.size, 'file')}`, marks, kept: 'the first error in every file, then the rest as they fit, the total' }
184}
185
186const eslint: Parser = lines => {
187 const problem = /^\s+\d+:\d+\s+(error|warning)\s+.*?\s{2,}(\S+)\s*$/
188 if (count(lines, problem) < 2) return undefined
189 const marks: Mark[] = []
190 const rules = new Map<string, number>()
191 let file = -1
192 let errors = 0
193 let warnings = 0
194 for (let k = 0; k < lines.length; k += 1) {
195 const l = lines[k] ?? ''
196 if (/^\S/.test(l) && !/^✖|^\d+ problems?/.test(l)) file = k
197 const m = problem.exec(l)
198 if (m) {
199 const rule = m[2] ?? ''
200 rules.set(rule, (rules.get(rule) ?? 0) + 1)
201 if (m[1] === 'error') errors += 1
202 else warnings += 1
203 const rank = m[1] === 'error' ? 3 : rules.get(rule) === 1 ? 1 : 0
204 if (rank) {
205 if (file >= 0) marks.push({ from: file, to: file + 1, rank })
206 marks.push({ from: k, to: k + 1, rank })
207 }
208 }
209 if (/^✖ \d+ problems?|potentially fixable/.test(l)) marks.push({ from: k, to: k + 1, rank: 3 })
210 }
211 const top = [...rules.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([r, n]) => `${r} ×${n}`).join(', ')
212 return { kind: 'eslint', header: `[eslint] ${plural(errors, 'error')}, ${plural(warnings, 'warning')}; most frequent rules: ${top}`, marks, kept: 'every error, one example of each warning rule, the totals' }
213}
214
215const install: Parser = (lines, command) => {
216 const signs = count(lines, /^npm (?:WARN|ERR!|notice|warn|error)|^(?:added|removed|changed|audited) \d+ packages?|^\s*(?:Collecting|Downloading|Installing collected packages|Successfully installed|Requirement already satisfied|Using cached|Building wheel)|^Progress: resolved|^Packages: |^warning .*: |^\[\d\/\d\] /)
217 if (signs < 3 && !/\b(?:npm|yarn|pnpm|bun)\s+(?:i|install|ci|add)\b|\bpip3?\s+install\b|\buv\s+(?:pip|sync|add)\b/.test(command)) return undefined
218 if (signs < 1) return undefined
219 const marks: Mark[] = []
220 const warnings = once()
221 let downloads = 0
222 for (let k = 0; k < lines.length; k += 1) {
223 const l = lines[k] ?? ''
224 if (/ERR!|^npm error|^ERROR:|^error\b|^\s*×\s|failed with exit code|ResolutionImpossible/i.test(l)) marks.push({ from: k, to: Math.min(lines.length, k + 4), rank: 3 })
225 const w = /^(?:npm (?:WARN|warn)|warning|WARNING:?)\s+(\S+)/.exec(l)
226 if (w?.[1] && warnings.isFirst(w[1])) marks.push({ from: k, to: k + 1, rank: 1 })
227 if (/^\s*(?:Collecting|Downloading|Using cached|Requirement already satisfied)/.test(l)) downloads += 1
228 if (/^(?:added|removed|changed|audited|up to date)|vulnerabilit|^Successfully installed|^Done in|^Packages: |^run `npm fund`|^\d+ packages? are looking for funding/.test(l)) marks.push({ from: k, to: k + 1, rank: 3 })
229 }
230 const w = warnings.summary('warning')
231 return { kind: 'install', header: `[install]${downloads ? ` ${plural(downloads, 'download line')} not listed;` : ''}${w ? ` ${w}, one of each shown` : ''}`, marks, kept: 'every error, one warning of each kind, the summary' }
232}
233
234const docker: Parser = (lines, command) => {
235 if (count(lines, /^#\d+ /) < 5 && !(/\bdocker\b.*\bbuild\b/.test(command) && lines.some(l => /^#\d+ /.test(l)))) return undefined
236 const marks: Mark[] = []
237 const steps = new Set<string>()
238 for (let k = 0; k < lines.length; k += 1) {
239 const l = lines[k] ?? ''
240 const step = /^#(\d+) \[/.exec(l)
241 if (step?.[1] && !steps.has(step[1])) {
242 steps.add(step[1])
243 marks.push({ from: k, to: k + 1, rank: 2 })
244 }
245 if (/^#\d+ ERROR|^ERROR[: ]|failed to solve|did not complete successfully/.test(l)) marks.push({ from: k, to: Math.min(lines.length, k + 4), rank: 3 })
246 if (/^------\s*$/.test(l)) {
247 const end = until(lines, k + 1, x => /^------\s*$/.test(x), 40)
248 marks.push({ from: k, to: Math.min(lines.length, end + 1), rank: 3 })
249 k = end
250 }
251 }
252 marks.push({ from: Math.max(0, lines.length - 4), to: lines.length, rank: 3 })
253 return { kind: 'docker', header: `[docker build] ${plural(steps.size, 'step')}: headers kept, their output not shown`, marks, kept: 'every step header, the failing step with its output, the end' }
254}
255
256const PARSERS: Parser[] = [jest, nodeTest, pytest, goTest, cargo, tsc, eslint, install, docker]
257
258/** Keeps the marked ranges by rank (latest first within a rank) within `target` and cuts the rest. */
259export const renderMarks = (lines: string[], marks: Mark[], target: number, header: string) => {
260 const keep = new Set<number>()
261 let used = header.length + 1
262 const order = [...marks].sort((a, b) => b.rank - a.rank || b.from - a.from)
263 for (const m of order) {
264 const limit = m.rank === 3 ? target * 1.5 : target
265 for (let k = Math.max(0, m.from); k < Math.min(lines.length, m.to); k += 1) {
266 if (keep.has(k)) continue
267 const cost = clip(lines[k] ?? '').length + 1
268 if (used + cost > limit) break
269 keep.add(k)
270 used += cost
271 }
272 }
273 const out = header ? [header] : []
274 let gap = 0
275 for (let k = 0; k < lines.length; k += 1) {
276 if (keep.has(k)) {
277 if (gap) out.push(`… [${plural(gap, 'line')} cut] …`)
278 gap = 0
279 out.push(clip(lines[k] ?? ''))
280 } else gap += 1
281 }
282 if (gap) out.push(`… [${plural(gap, 'line')} cut] …`)
283 return out.join('\n')
284}
285
286/** A digest by the first parser that recognises the output, or undefined. */
287export const digestTool = (lines: string[], command: string, target: number) => {
288 for (const parse of PARSERS) {
289 const parsed = parse(lines, command)
290 if (parsed && parsed.marks.some(m => m.rank === 3)) {
291 return { text: renderMarks(lines, parsed.marks, target, parsed.header), kind: parsed.kind, kept: parsed.kept }
292 }
293 }
294 return undefined
295}
296types/index.d.ts 76 lines1export type DietCutKind = 'log' | 'json' | 'diff' | 'grep' | 'paths' | 'table' | 'delta' | 'jest' | 'node-test' | 'pytest' | 'go-test' | 'cargo' | 'tsc' | 'eslint' | 'install' | 'docker'
2
3export type DietCut = {
4 /** when it was cut, ms since epoch; also the cut's id in the panel */
5 at: number
6 tool: string
7 /** the command, pattern or tool the output came from, shortened */
8 label: string
9 kind: DietCutKind
10 /** characters the tool returned */
11 raw: number
12 /** characters the model read instead */
13 kept: number
14 /** where the full output was saved, absolute */
15 path: string
16 /** the start of what the model read */
17 preview: string
18 /** what the cut kept, in words */
19 keptWhat: string
20 /** set when the output came from a subagent */
21 agentId?: string
22 /** Claude Code had already saved this output to a file; the digest replaced its preview */
23 isUpgrade?: true
24 /** a dry run: what would have been cut; nothing was changed or saved */
25 isDry?: true
26 /** the command's first words (or the tool), whose cuts and re-reads are counted together */
27 sig?: string
28 /** how many times Claude opened the full output afterwards */
29 rereads?: number
30 /** Claude Code would have shown only this many characters of it: the digest covers the whole output instead */
31 recoveredFrom?: number
32}
33
34export type DietToolTotals = { cuts: number; raw: number; kept: number }
35
36export type DietStats = {
37 startedAt: number
38 cuts: number
39 rawChars: number
40 keptChars: number
41 /** large outputs seen but left whole (cut would not save enough, or excluded) */
42 passed: number
43 /** previews of outputs Claude Code saved to a file, replaced by a digest */
44 upgrades: number
45 /** times Claude opened a saved full output after a cut: a sign the digest was not enough */
46 rereads: number
47 /** failing commands' outputs Claude Code would have shortened, digested whole instead */
48 recovered: number
49 byTool: Record<string, DietToolTotals>
50 log: DietCut[]
51 lifetimeSaved: number
52 lifetimeCuts: number
53}
54
55export type DietPrefs = {
56 isOn: boolean
57 /** outputs longer than this many characters get cut */
58 threshold: number
59 isStatusOn: boolean
60 isToastOn: boolean
61 /** measure only: compute digests and show the savings, change nothing */
62 isDry: boolean
63 /** save the whole output of test and build commands before Claude Code can shorten it */
64 isCapture: boolean
65 /** threshold multipliers by command signature, raised when Claude keeps opening the full output */
66 boost: Record<string, number>
67}
68
69export type DietView = { openCut: number; isPlaced: boolean; reason: string }
70
71declare module 'claude-code' {
72 interface PluginState {
73 'context-diet': { stats: DietStats; prefs: DietPrefs; view: DietView }
74 }
75}
76