SLOPSHOPPER

context-diet

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

newpaneguardcommandtoaststatus
★ 1v0.3.0MITupdated 2026-10-09drkokorev/context-diet/plugins/context-diet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-diet
│ ┃ Context Diet ✕ › fix the failing auth test and add an audit log call │ ┃ ◇ CONTEXT DIET on cuts outputs over ≈2.0k to │ ┃ ⏺ Read(src/auth.ts) │ ┃ saved ≈0 tokens nothing cut yet ⎿ Read 6 lines │ ┃ all-time ≈0 tokens · 0 cuts ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ LATEST CUTS ⏺ Bash(bun test) │ ┃ Outputs from Bash, Grep, Glob and MCP tools ⎿ 3 pass, 1 fail │ ┃ longer than 8,000 characters are trimmed │ ┃ here; the full text goes to .context-diet/. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ Turn off ] [ Cut over: ≈2.0k tok ] [ Dry r ✻ Worked for 42s · done 4:20 PM │ │ › /diet │ ⎿ context-diet: Context Diet panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context Diet
◇ CONTEXT DIET on cuts outputs over ≈2.0k tokens saved ≈0 tokens nothing cut yet all-time ≈0 tokens · 0 cuts LATEST CUTS Outputs from Bash, Grep, Glob and MCP tools longer than 8,000 characters are trimmed here; the full text goes to .context-diet/. [ Turn off ] [ Cut over: ≈2.0k tok ] [ Dry run: off ]
README

Context Diet

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
  • Add # diet:off to a command to keep its output whole

What it runs, reads, writes and sends

Context 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

Source 5 files
hooks/register.tsx 739 lines
1import { 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}
739
hooks/diet.ts 496 lines
1// 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}
496
hooks/text.ts 36 lines
1// 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
36
hooks/tools.ts 296 lines
1// 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}
296
types/index.d.ts 76 lines
1export 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