SLOPSHOPPER

output-diet

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

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