Shrinks each Bash result before the model reads it: per-command filters for git, test runners, linters, package managers, containers and file tools, the full…

Most of what a Bash command prints is noise to the model: progress bars, a hundred passing test lines, the same warning twice, colour codes. All of it lands in the context and gets paid for on every later request. This mod trims each Bash result before the model reads it. Known commands (git, test runners, linters, compilers, package managers, containers, file listings and searches) go through a filter of their own, and everything else gets a generic cleanup. Whenever a filter leaves something out, the full output waits in a file the model can open.
stdout and stderr of a success, the error text of a failed exit. Subagent calls go through the same hook.FOO=1, timeout 60, nice, env, sudo) are peeled off. In a chain like cd app && cargo test, the one command that prints is the one filtered. A pipeline is filtered when its last stage is grep or rg, or when only cat, head or a non-following tail come after the command that produces the output.git log -10 when no count, range or format is given, and pytest --tb=short -q. It never switches a tool to a bigger format such as JSON, because a failed command's text reaches the hook cut at 10,000 characters and a JSON report passes that limit long before the plain text does. When the model asks for JSON itself (go test -json, jest --json, eslint -f json, rspec --format json, rubocop --format json, phpstan analyse --error-format=json, ruff check --output-format=json), the filter reads that report. The flag is added only when the permission check reads the new command the same way as the one the model wrote. It is never added when the arguments already pick a format, in a pipeline or a chain, after sudo, or with a redirect.env or printenv listing with a credential value masked. No full output file is kept for it, because that file would hold the masked values; BASH_DIET_RAW=1 env gives them back.Claude Code writes an output over 30,000 characters to a file and hands the model only a 2KB preview and the file's path. The mod filters that whole file, but the filtered result wins only when it is shorter than the preview, and the saving is counted against the preview too.
[full output: /var/folders/.../bash-diet/3fa9c1b2d4e5.log]
The file is the engine's own copy when the engine already cut the result, otherwise one under $TMPDIR/bash-diet/. That directory keeps at most 200 files, for 30 days. A failed command's text reaches the hook already cut by Claude Code at 10,000 characters, and its middle is written nowhere. In that case the file holds only what arrived, and the line says so:
[output cut by Claude Code at 10000 characters; the middle is lost: /var/folders/.../bash-diet/3fa9c1b2d4e5.log]
Exit code 1 and the filtered text as a tool error./clear and after a compaction, the model reads one note: a condensed result is complete, the full output sits at the named path, and BASH_DIET_RAW=1 <command> returns the exact bytes.### Ran Playwright code: the code the model wrote for browser_run_code_unsafe and browser_evaluate, and the code of each click or navigation. The mod takes that section out of every Playwright browser tool's result, always, with no setting. The page, the snapshot link, the console events and any error stay. In 30 days of this machine's transcripts that section was more than half of all Playwright result text, about 950,000 of 1.8 million characters. Setting PLAYWRIGHT_MCP_CODEGEN from the mod does not help, because the MCP server starts before the session start runs (measured on 2.1.283).These are all the commands the mod has a filter of its own for. A command marked * gets the flag of item 4.
| Family | Commands |
|---|---|
| git | git status, git diff, git show, git log\*, git push, git fetch, git pull, git commit, git branch, git stash, git checkout, git switch, git restore, git add, git worktree, git tag (a list keeps ten tags at each end and the count), git remote -v; yadm status, yadm diff, yadm log\* |
| GitHub, GitLab | gh pr, gh issue, gh run, gh release; glab mr, glab issue |
| Rust | cargo build, cargo check, cargo clippy, cargo doc, cargo run, cargo test, cargo nextest, cargo install; cargo fmt and rustfmt (a check reads as each file with the lines it would add and remove) |
| Go | go test, go build, go vet, go get, go mod, go install; golangci-lint, golangci-lint run; gofmt -l and -d, go fmt |
| Python | pytest\*; ruff, ruff check, ruff format; mypy; flake8 and pylint (grouped by rule); black; pip and pip3: list, install, uninstall, sync, download and every other subcommand; uv pip, uv sync, uv add, uv lock; poetry install, poetry add, poetry update |
| JavaScript | npm install, npm i, npm ci, npm ls, npm list, npm outdated, npm test, npm run, npm run-script, npm exec and every other npm subcommand; pnpm install, pnpm i, pnpm add, pnpm remove, pnpm rm, pnpm update, pnpm up, pnpm list, pnpm ls, pnpm outdated, pnpm why and every other pnpm subcommand; yarn install, yarn add; bun install, bun add, bun remove, bun test; deno test, deno lint, deno check; jest, vitest, mocha, cypress run, playwright, tsc, eslint, prettier, next build, prisma; webpack, webpack-cli, vite, rollup, esbuild (the emitted files as their count and the largest three) |
| JVM | mvn, mvnd, gradle, gradlew, sbt |
| Ruby | rake test, rails test, ruby (a minitest file), rspec, rubocop, bundle install, bundle update |
| PHP | php -l, phpunit, pest, paratest, artisan test, phpstan analyse, phpstan analyze |
| .NET | dotnet build, dotnet test, dotnet format, dotnet publish, dotnet pack, dotnet restore |
| Apple | swift build, swift test, xcodebuild |
| Files and system | ls, and ls -R as one line per directory; cp, mv, rm, ln with -v (every error, the first five paths and the count), also as gcp, gmv, grm, gln; find, grep, egrep, rg, ast-grep, tree, env and printenv (credential values masked), ps |
| Containers | docker ps, docker images, docker image ls, docker logs, docker build, docker pull, docker inspect, docker compose (ps, logs and the rest); kubectl get, kubectl logs, kubectl describe; oc get, oc logs; helm list |
| Clouds and network | aws (aws s3 ls as a capped list, the rest as JSON), gcloud; terraform plan, terraform apply, tofu plan, tofu apply; pulumi; curl, wget |
| make | make, gmake: make's directory lines and the compiler source excerpts go, and the line each runner writes for a passing test (go test -v, cargo test, pytest -v, vitest --reporter=verbose, claude plugin test) reads as one count; every failure, summary and other line stays |
| Built-in rules | gcc, g++, cc, c++, clang, clang++ (also with a version suffix such as gcc-14); cmake, cmake --build; brew install, upgrade, reinstall, update, tap, bundle; rsync; df; du; ping, ping6; shellcheck |
npx, bunx, pnpx, pnpm exec and dlx, npm exec and x, uv run, poetry run, pipenv run, bundle exec, python -m, python3 -m, php artisan. An absolute path (/usr/bin/git) counts as its base name, and git -C <dir> as git.cat, head and tail of a file are never filtered, because the model asked for exactly those lines.Measured on Claude Code 2.1.282 with Claude Opus 5.5 and bash-diet 0.1.2. The sample repository holds Go, Rust, Node, Python, Gradle, .NET, Swift, Ruby, PHP and C projects, each with one failing test or build error. A headless session ran the same 35 commands in order: git, builds, tests, linters, package lists, file listings and searches, docker ps and images, env, ps, df, du. It ran three times with the mod and three times without it, and the figures are the medians of the three runs.
| Without the mod | With the mod | Saving | |
|---|---|---|---|
| Characters of the 35 Bash results | 79,555 | 33,366 | 58% |
| Context tokens the 35 results added | 38,277 | 20,653 | 46% |
| Context at the session's end | 107,946 | 90,557 | 16% |
| Input tokens over all requests | 2,856,172 | 2,512,370 | 12% |
| Session cost | $1.00 | $0.78 | 21% |
The context tokens of the 35 results, by family. Each figure is the sum of the family's per-command medians.
| Family | Commands run | Without the mod | With the mod | Saving |
|---|---|---|---|---|
| git | git status, git diff, git log, git branch -a, git show --stat | 1,598 | 890 | 44% |
| Go | go build, go vet, go test | 308 | 226 | 27% |
| Rust | cargo build, cargo clippy, cargo test | 1,682 | 923 | 45% |
| Node | npm install, npx tsc, npx vitest run, npm ls | 1,191 | 1,012 | 15% |
| Python | pytest, python3 -m pip list | 1,635 | 1,287 | 21% |
| Gradle | gradle build, gradle test | 757 | 540 | 29% |
| .NET | dotnet build, dotnet test | 1,221 | 697 | 43% |
| Swift | swift build, swift test | 1,443 | 915 | 37% |
| Ruby | rake test | 1,092 | 206 | 81% |
| PHP | php -l | 112 | 103 | 8% |
| C | make | 281 | 273 | 3% |
| Files | ls -la, find -name, grep -rn | 6,974 | 2,384 | 66% |
| Containers | docker ps -a, docker images | 10,423 | 2,270 | 78% |
| System | env, ps aux, df -h, du -sh | 9,560 | 8,927 | 7% |
| Total | 35 commands | 38,277 | 20,653 | 46% |
env (the filter only masks credential values), php -l, make and go vet: their output is already a few lines, and most of their count is the call's own 100 tokens.The filters added after that session were measured with one run each in a scratch project, in characters of the result:
| Command | Without the mod | With the mod | Saving |
|---|---|---|---|
flake8 | 2,091 | 775 | 63% |
pylint | 2,898 | 1,049 | 64% |
gofmt -d | 328 | 56 | 83% |
black --check --diff | 1,104 | 286 | 74% |
webpack | 779 | 117 | 85% |
vite build (a parse error) | 1,562 | 291 | 81% |
esbuild (a parse error) | 1,044 | 109 | 90% |
rollup (a parse error) | 1,554 | 178 | 89% |
mocha | 897 | 455 | 49% |
cypress run | 5,517 | 327 | 94% |
make check of this mod (lint, typecheck, validate, 129 tests) | 15,131 | 1,684 | 89% |
make test running go test -v | 563 | 307 | 45% |
make test running pytest -v | 1,382 | 928 | 33% |
A command no filter knows can get a rule of yours. Rules live in two files:
~/.claude/bash-diet/filters.json, for every project. It runs as it is.<repository>/.bash-diet/filters.json, for one project. It runs only after /bash-diet trust, and it stops again when its content changes, because a file that came with a cloned repository could hide output from the model.Your rule comes before the mod's own filter for the same command. The steps run in this order, and each one is optional:
{
"filters": {
"deploy": {
"description": "the deploy script: only the steps and the result",
"match_command": "^\\./scripts/deploy\\.sh( |$)",
"strip_ansi": true,
"replace": [{ "pattern": "\\d+ms", "replacement": "Nms" }],
"match_output": [{ "pattern": "nothing to deploy", "message": "deploy: nothing to do", "unless": "(?i)error" }],
"keep_lines_matching": ["^(step|error|done)"],
"truncate_lines_at": 200,
"head_lines": 20,
"tail_lines": 10,
"max_lines": 40,
"on_empty": "deploy: done"
}
}
}
match_command is a JavaScript regex over the command's words, after variables and wrappers. A leading (?i) ignores case.strip_lines_matching drops the lines it matches, keep_lines_matching keeps only those. A rule takes one of the two.match_output answers with message alone when the whole output matches pattern and does not match unless.head_lines and tail_lines keep both ends with a count between them. max_lines then caps the lines.A file with a mistake still keeps its good rules, and one transcript line names every mistake. /bash-diet filters lists both files, their rules and the built-in rules.
/bash-diet on or off, the excludes, and this session's saving /bash-diet on | off on by default /bash-diet exclude <prefix | ^regex> that command runs unfiltered; excludes lists them, include takes one back /bash-diet filters the rule files, their rules, the built-in rules /bash-diet trust | untrust lets this repository's .bash-diet/filters.json run, or stops it /bash-diet gain the saving of the last 90 days, the families that saved most /bash-diet gain project | daily | graph | history /bash-diet cost this session's spend, and what the tokens kept out would have cost /bash-diet discover [days] [all] the output the model read in earlier sessions, by filter, and the commands no filter reads /bash-diet learn [days] [write] commands that failed on a CLI mistake and the form that worked after them
gain reads the records in ~/.claude/bash-diet/gain/, one file per session and day, kept for 90 days. Each report gives the measured characters before and after. The token figure is an estimate at four characters per token; history and graph give characters only.cost prices the tokens kept out of the context at the model's list prices of September 2026: once at the cache write rate, and again at the cache read rate for each later request.discover and learn read this project's transcripts of the last 30 days by default. discover all reads every project's; it answers at once, and its report follows as a transcript line.learn counts only a single command that failed on an unknown flag, a missing command, a missing argument or a syntax error, followed within three calls by a similar command that worked. learn write writes the pairs to .claude/rules/cli-corrections.md in the repository, which the model reads in later sessions. A command that may carry a credential is never written.claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install bash-diet@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
.bash-diet/filters.json and run /bash-diet trust in that project.Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, classic.SessionStart, command.run{command=bash-diet}, tool.call{tool=Bash}, tool.call{tool=?} ❯ ./register.ts calls: $.clock.now (via gainCommand, pruneGain, pruneRecall, recordGain, transcriptsOf), $.command.register, $.env.get (via locate, recallDir), $.fs.exists (via gainFiles, refreshFile, transcriptDirs), $.fs.list (via gainFiles, pruneRecall, transcriptDirs, transcriptsOf), $.fs.read (via gainCommand, refreshFile, seedGain, wholeText), $.fs.stat (via pruneRecall, refreshFile, transcriptsOf), $.fs.write (via keepFull, recordGain, writeLearned), $.process.run (via locate, pruneGain, pruneRecall, recallDir, recordGain, writeLearned), $.process.spawn (via callsIn), $.session.id, $.session.model (via costCommand), $.session.root (via locate), $.session.usage (via costCommand), $.sidebar.set (via showGain), $.store.get (via readSettings), $.store.set (via setEnabled, setExcludes, setTrusted), $.tool.check (via withPlanFlags), $.ui.log (via activeRules, discoverAll, refreshFile, report), $.ui.status (via showGain)
Reach L2: it writes files and runs processes.
BASH_DIET_RAW=1 are your way back.run_in_background) is not filtered, because its result is a task id.$(...), a heredoc or a process substitution, or one whose output is redirected to a file, is not filtered.output-flood 0.3.0 and later measure the filtered result, whichever order the two mods load in. An older output-flood loaded after bash-diet measures the output before the filter, and its note names a size the model never read.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 555 lines1import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
2import { BUILTIN_RULES } from './builtin-rules.ts'
3import { withFlags } from './command.ts'
4import { rulesOf, type Rule } from './dsl.ts'
5import { correctionsOf, discoverText, finish, learnFile, learnText, scan, scannerOf, type BashCall } from './history.ts'
6import type { FilterResult } from './filters/common.ts'
7import { dayOf, gainFileName, gainReport, linesOfRecords, recordsOf, staleGainFiles, type GainRecord } from './gain.ts'
8import { failureOf, joined, persistedPathOf, planFor, replaces, runFilter, type Plan } from './pipeline.ts'
9import { PLAYWRIGHT_TOOL, resultWithoutEcho } from './playwright.ts'
10import { costText } from './pricing.ts'
11import { fullOutputLine, hashOf, isCut, needsFile, sha256Of, staleFiles } from './recall.ts'
12import { AWARENESS, USAGE, filtersText, isExcluded, patternError, sessionLine, sessionText, statusText, tokensOf } from './text.ts'
13
14const ENABLED_KEY = 'enabled'
15const EXCLUDES_KEY = 'excludes'
16/** The SHA-256 each trusted project rule file had when the person trusted it, by path. */
17const TRUSTED_KEY = 'trusted'
18
19/** Keys of a Bash record that point at the engine's own copy of the unfiltered output. */
20const PERSISTED_KEYS = ['persistedOutputPath', 'persistedOutputSize', 'rawOutputPath'] as const
21
22/** The session's gain, the settings, and the directory the full-output files go in. */
23type State = {
24 enabled: boolean
25 excludes: string[]
26 calls: number
27 rawChars: number
28 shownChars: number
29 dir?: string
30 lastError?: string
31 /** The person's rule files: the project's first, then the global one. */
32 files: RuleFile[]
33 trusted: Record<string, string>
34 /** Where the saving records go, this session's id and project, and its records so far. */
35 gain?: { dir: string; sessionId: string; project: string; ready: boolean }
36 records: GainRecord[]
37 places?: Places
38}
39
40/** The places the mod reads and writes: the repository the session runs in, and the config directory. */
41type Places = { repo: string; config: string; home: string }
42
43/** One `filters.json` file as last read: its rules, and the hash trust is checked against. */
44type RuleFile = {
45 path: string
46 shown: string
47 source: 'project' | 'global'
48 mtimeMs?: number
49 hash?: string
50 rules: Rule[]
51 /** The hash of the content the untrusted notice was last written for. */
52 told?: string
53}
54
55/** The output of one call as the model would read it, and where the engine kept it whole. */
56type Output = { text: string; exitCode: number; isError: boolean; persisted?: string }
57
58type BashRecord = { stdout: string; stderr: string; interrupted: boolean; isImage?: boolean; backgroundTaskId?: string; persistedOutputPath?: string }
59
60function errorText(err: unknown): string {
61 return err instanceof Error ? err.message : String(err)
62}
63
64/** Logs a failure once until a different one comes; the call's own result is never lost to it. */
65function report($: EngineInterface, state: State, what: string, err: unknown): void {
66 const text = `${what}: ${errorText(err)}`
67 if (text !== state.lastError) $.ui.log(text)
68 state.lastError = text
69}
70
71/** The session's gain in the shared sidebar, or on the status line while the sidebar is closed. */
72async function showGain($: EngineInterface, state: State): Promise<void> {
73 const text = sessionText(state.calls, state.rawChars, state.shownChars)
74 try {
75 const taken = await $.sidebar.set({
76 consumer: 'bash-diet',
77 key: 'session',
78 title: 'Bash output',
79 lines: [sessionLine(state.calls, state.rawChars, state.shownChars)],
80 until: 'session',
81 order: 22,
82 })
83 if (taken) { $.ui.status(undefined); return }
84 } catch {
85 // The sidebar mod is not installed: the status line carries the gain.
86 }
87 $.ui.status(state.calls === 0 ? undefined : text)
88}
89
90/** The directory the full-output files go in, made when it is missing. */
91async function recallDir($: EngineInterface, state: State): Promise<string> {
92 if (state.dir !== undefined) return state.dir
93 const dir = `${((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')}/bash-diet`
94 const made = await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5_000 })
95 if (made.exitCode !== 0) throw new Error(`mkdir ${dir} failed: ${made.stderr.trim()}`)
96 state.dir = dir
97 return dir
98}
99
100/** Deletes the full-output files past the age and count limits; a failure is logged once. */
101async function pruneRecall($: EngineInterface, state: State): Promise<void> {
102 try {
103 const dir = await recallDir($, state)
104 const names = (await $.fs.list(dir)).filter(f => f.kind === 'file').map(f => f.name)
105 const files = await Promise.all(names.map(async name => ({ name, mtimeMs: (await $.fs.stat(`${dir}/${name}`)).mtimeMs })))
106 const stale = staleFiles(files, await $.clock.now())
107 if (stale.length > 0) await $.process.run(['rm', '-f', ...stale.map(n => `${dir}/${n}`)], { timeoutMs: 10_000 })
108 } catch (err) {
109 report($, state, 'old full-output files were not pruned', err)
110 }
111}
112
113/** The path of the call's full output: the engine's own file, else a new one; undefined when writing failed. */
114async function keepFull($: EngineInterface, state: State, command: string, out: Output): Promise<string | undefined> {
115 if (out.persisted !== undefined) return out.persisted
116 try {
117 const path = `${await recallDir($, state)}/${await hashOf(command, out.text)}.log`
118 await $.fs.write(path, out.text)
119 return path
120 } catch (err) {
121 report($, state, 'the full output was not kept', err)
122 return undefined
123 }
124}
125
126/** Reads the engine's full-output file when it cut the result, else the text as it came. */
127async function wholeText($: EngineInterface, text: string, persisted: string | undefined): Promise<string | undefined> {
128 if (persisted === undefined) return text
129 try {
130 return await $.fs.read(persisted)
131 } catch {
132 // Over the read limit or gone: the engine's own preview stays, unfiltered.
133 return undefined
134 }
135}
136
137/**
138 * The call's output, or undefined for one a filter must not touch: denied, backgrounded, interrupted,
139 * an image, or an error that is not a command's exit (a timeout, a refused input).
140 */
141async function outputOf($: EngineInterface, r: ToolCallResult<'Bash'>): Promise<Output | undefined> {
142 if (r.deny !== undefined) return undefined
143 if (r.isError === true) return failedOutput($, r.text ?? '')
144 const rec = r.result as BashRecord | undefined
145 if (rec === undefined || rec.interrupted || rec.isImage === true || rec.backgroundTaskId !== undefined) return undefined
146 const text = await wholeText($, joined(rec.stdout, rec.stderr), rec.persistedOutputPath)
147 return text === undefined ? undefined : { text, exitCode: 0, isError: false, persisted: rec.persistedOutputPath }
148}
149
150/** A failed call's output from its error text, `Exit code N` and the output; undefined for another error. */
151async function failedOutput($: EngineInterface, errorText: string): Promise<Output | undefined> {
152 const failure = failureOf(errorText)
153 if (failure === undefined) return undefined
154 const persisted = persistedPathOf(failure.output)
155 const text = await wholeText($, failure.output, persisted)
156 return text === undefined ? undefined : { text, exitCode: failure.exitCode, isError: true, persisted }
157}
158
159/** The result the model reads: the filtered text in the tool's own shape, or its exit as an error. */
160function shaped(r: ToolCallResult<'Bash'>, out: Output, text: string): ToolCallResult<'Bash'> {
161 if (out.isError) return { deny: `Exit code ${out.exitCode}\n${text}` }
162 const rec = { ...(r.result as BashRecord) } as Record<string, unknown>
163 for (const key of PERSISTED_KEYS) delete rec[key]
164 return { result: { ...rec, stdout: text, stderr: '' } as never, context: r.context }
165}
166
167/**
168 * What the model reads without the mod: the engine's preview and path when it kept the output in a file,
169 * else the output itself.
170 */
171function unfiltered(r: ToolCallResult<'Bash'>, out: Output): string {
172 return out.persisted !== undefined && r.text !== undefined ? r.text : out.text
173}
174
175/**
176 * The path of the call's full output when the filter left something out, else undefined. A masked
177 * result gets none, because that file would hold the credential values the filter masked.
178 */
179async function fullPathOf($: EngineInterface, state: State, command: string, out: Output, filtered: FilterResult): Promise<string | undefined> {
180 if (filtered.redacted === true || !needsFile(filtered.elided, out.exitCode, out.text.length)) return undefined
181 return keepFull($, state, command, out)
182}
183
184/**
185 * Whether the engine's preview stays: it kept the output in a file, and the filtered text is no shorter
186 * than that preview. A masked result stands anyway, because the preview holds the values it masked.
187 */
188const previewStays = (out: Output, filtered: FilterResult, text: string, before: string): boolean =>
189 out.persisted !== undefined && filtered.redacted !== true && text.length >= before.length
190
191/** Filters one call's result, keeps its full output when something was left out, and counts the gain. */
192async function shrink($: EngineInterface, state: State, command: string, plan: Plan, flagged: boolean, r: ToolCallResult<'Bash'>): Promise<ToolCallResult<'Bash'>> {
193 const out = await outputOf($, r)
194 if (out === undefined) return r
195 const filtered = runFilter(plan, out.text, out.exitCode, flagged)
196 if (!replaces(out.text, filtered.text, flagged || filtered.redacted === true)) return r
197 const full = await fullPathOf($, state, command, out, filtered)
198 const text = full === undefined ? filtered.text : `${filtered.text}\n${fullOutputLine(full, out.isError && isCut(out.text))}`
199 const before = unfiltered(r, out)
200 if (previewStays(out, filtered, text, before)) return r
201 state.calls += 1
202 state.rawChars += before.length
203 state.shownChars += text.length
204 await Promise.all([showGain($, state), recordGain($, state, plan.family, before.length, text.length)])
205 return shaped(r, out, text)
206}
207
208/**
209 * The command with the plan's flags, when the permission check reads it as it reads the command the
210 * model wrote; a flag that would raise a new prompt is left out, and the filter reads plain output.
211 */
212async function withPlanFlags($: EngineInterface, command: string, plan: Plan): Promise<string> {
213 if (plan.target === undefined || plan.flags.length === 0) return command
214 const next = withFlags(command, plan.target, plan.nameEnd, plan.flags)
215 const [before, after] = await Promise.all([
216 $.tool.check({ tool: 'Bash', input: { command } }),
217 $.tool.check({ tool: 'Bash', input: { command: next } }),
218 ])
219 return before.decision === after.decision ? next : command
220}
221
222/** The repository the session started in (its root where git does not answer) and the config directory. */
223async function locate($: EngineInterface): Promise<Places> {
224 const root = await $.session.root()
225 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root, timeoutMs: 5_000 })
226 const home = (await $.env.get('HOME')) ?? ''
227 const config = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`).replace(/\/+$/, '')
228 return { repo: top.exitCode === 0 ? top.stdout.trim() : root, config, home }
229}
230
231/** The two rule files: `<repo>/.bash-diet/filters.json` and `<config>/bash-diet/filters.json`. */
232function ruleFilesOf(p: Places): RuleFile[] {
233 const global = `${p.config}/bash-diet/filters.json`
234 return [
235 { path: `${p.repo}/.bash-diet/filters.json`, shown: '.bash-diet/filters.json', source: 'project', rules: [] },
236 { path: global, shown: p.home !== '' && global.startsWith(`${p.home}/`) ? `~${global.slice(p.home.length)}` : global, source: 'global', rules: [] },
237 ]
238}
239
240/** Writes this session's records of the day to its file; a failure is logged once. */
241async function recordGain($: EngineInterface, state: State, family: string, raw: number, shown: number): Promise<void> {
242 const g = state.gain
243 if (g === undefined) return
244 try {
245 const at = await $.clock.now()
246 state.records.push({ at, project: g.project, family, raw, shown })
247 if (!g.ready) {
248 const made = await $.process.run(['mkdir', '-p', g.dir], { timeoutMs: 5_000 })
249 if (made.exitCode !== 0) throw new Error(`mkdir ${g.dir} failed: ${made.stderr.trim()}`)
250 g.ready = true
251 }
252 await $.fs.write(`${g.dir}/${gainFileName(at, g.sessionId)}`, linesOfRecords(state.records.filter(r => dayOf(r.at) === dayOf(at))))
253 } catch (err) {
254 report($, state, 'the saving was not recorded', err)
255 }
256}
257
258/** The gain files in the directory; none when it does not exist yet. */
259async function gainFiles($: EngineInterface, dir: string): Promise<string[]> {
260 if (!(await $.fs.exists(dir))) return []
261 return (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.jsonl')).map(f => f.name)
262}
263
264/**
265 * Takes back this session's records and counts from its gain files, so a reloaded module (an edit, an
266 * update, `/reload-plugins`) neither overwrites the records written before it nor restarts the count.
267 */
268async function seedGain($: EngineInterface, state: State): Promise<void> {
269 const g = state.gain
270 if (g === undefined || state.records.length > 0) return
271 try {
272 const own = (await gainFiles($, g.dir)).filter(n => n.endsWith(`-${g.sessionId}.jsonl`))
273 for (const name of own) state.records.push(...recordsOf(await $.fs.read(`${g.dir}/${name}`)).records)
274 state.calls = state.records.length
275 state.rawChars = state.records.reduce((n, r) => n + r.raw, 0)
276 state.shownChars = state.records.reduce((n, r) => n + r.shown, 0)
277 } catch (err) {
278 report($, state, 'the saving records of this session were not read', err)
279 }
280}
281
282/** Deletes the gain files past the retention; a failure is logged once. */
283async function pruneGain($: EngineInterface, state: State): Promise<void> {
284 if (state.gain === undefined) return
285 const dir = state.gain.dir
286 try {
287 const stale = staleGainFiles(await gainFiles($, dir), await $.clock.now())
288 if (stale.length > 0) await $.process.run(['rm', '-f', ...stale.map(n => `${dir}/${n}`)], { timeoutMs: 10_000 })
289 } catch (err) {
290 report($, state, 'old saving records were not pruned', err)
291 }
292}
293
294/** `/bash-diet gain [project | daily | graph | history]` over every kept record. */
295async function gainCommand($: EngineInterface, state: State, view: string): Promise<string> {
296 if (state.gain === undefined) return 'the saving records are not known yet'
297 const dir = state.gain.dir
298 let bad = 0
299 const records: GainRecord[] = []
300 for (const name of await gainFiles($, dir)) {
301 const r = recordsOf(await $.fs.read(`${dir}/${name}`))
302 records.push(...r.records)
303 bad += r.bad
304 }
305 const text = gainReport(view, records, await $.clock.now())
306 if (text === undefined) return 'gain expects nothing, project, daily, graph or history'
307 return bad === 0 ? text : `${text}\n(${bad} unreadable line(s) in ${dir} left out)`
308}
309
310/** The transcript directories: the one holding this session's transcript, or every project's. */
311async function transcriptDirs($: EngineInterface, state: State, all: boolean): Promise<string[]> {
312 const root = `${state.places?.config ?? ''}/projects`
313 if (!(await $.fs.exists(root))) return []
314 const dirs = (await $.fs.list(root)).filter(f => f.kind === 'dir').map(f => `${root}/${f.name}`)
315 if (all) return dirs
316 for (const dir of dirs) if (await $.fs.exists(`${dir}/${state.gain?.sessionId ?? ''}.jsonl`)) return [dir]
317 return []
318}
319
320/** The transcripts in the directories written in the last `days` days. */
321async function transcriptsOf($: EngineInterface, dirs: string[], days: number): Promise<string[]> {
322 const since = (await $.clock.now()) - days * 24 * 60 * 60 * 1000
323 const files: string[] = []
324 for (const dir of dirs) {
325 for (const f of await $.fs.list(dir)) {
326 if (f.kind === 'file' && f.name.endsWith('.jsonl') && (await $.fs.stat(`${dir}/${f.name}`)).mtimeMs >= since) files.push(`${dir}/${f.name}`)
327 }
328 }
329 return files
330}
331
332/** Every Bash call of the transcripts, streamed, because a transcript can pass the file read limit. */
333async function callsIn($: EngineInterface, files: string[]): Promise<BashCall[]> {
334 const calls: BashCall[] = []
335 for (const path of files) {
336 const s = scannerOf(path)
337 for await (const chunk of $.process.spawn({ argv: ['cat', path] })) if (chunk.stream === 'stdout') scan(s, chunk.text)
338 calls.push(...finish(s))
339 }
340 return calls
341}
342
343/** `/bash-diet learn write`: the corrections as a rules file the model reads in later sessions. */
344async function writeLearned($: EngineInterface, state: State, calls: BashCall[]): Promise<string> {
345 const corrections = correctionsOf(calls)
346 if (corrections.length === 0) return 'no corrected command to write'
347 const dir = `${state.places?.repo ?? ''}/.claude/rules`
348 const made = await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5_000 })
349 if (made.exitCode !== 0) return `mkdir ${dir} failed: ${made.stderr.trim()}`
350 await $.fs.write(`${dir}/cli-corrections.md`, learnFile(corrections))
351 return `wrote ${corrections.length} correction(s) to .claude/rules/cli-corrections.md`
352}
353
354/** `/bash-diet discover [days] [all]` and `learn [days] [write]` over the transcripts. */
355async function historyCommand($: EngineInterface, state: State, word: string, rest: string[]): Promise<string> {
356 const days = Number(rest.find(w => /^\d+$/.test(w)) ?? 30)
357 if (days < 1 || days > 365) return `${word} expects a number of days from 1 to 365`
358 if (word === 'discover' && rest.includes('all')) return discoverAll($, state, days)
359 const files = await transcriptsOf($, await transcriptDirs($, state, false), days)
360 const calls = await callsIn($, files)
361 if (word === 'discover') return discoverText(calls, files.length, days)
362 if (rest.includes('write')) return writeLearned($, state, calls)
363 return learnText(correctionsOf(calls), files.length, days)
364}
365
366/**
367 * `/bash-diet discover all`: every project's transcripts are more than a command's own time allows (6.6 s
368 * of parsing for 110k calls, measured), so the reading runs on after the command answers, and its report
369 * comes as a log line.
370 */
371async function discoverAll($: EngineInterface, state: State, days: number): Promise<string> {
372 const files = await transcriptsOf($, await transcriptDirs($, state, true), days)
373 void (async () => {
374 try {
375 $.ui.log(discoverText(await callsIn($, files), files.length, days))
376 } catch (err) {
377 report($, state, 'discover all did not finish', err)
378 }
379 })()
380 return `reading ${files.length} transcript(s) of every project from the last ${days} days; the report follows as a log line`
381}
382
383/** `/bash-diet gain`, `discover` and `learn`: the reports over what earlier sessions left. */
384function reportCommand($: EngineInterface, state: State, word: string, rest: string[]): Promise<string> {
385 return word === 'gain' ? gainCommand($, state, rest.join(' ')) : historyCommand($, state, word, rest)
386}
387
388/** `/bash-diet cost`: the session's spend and what the tokens kept out would have cost. */
389async function costCommand($: EngineInterface, state: State): Promise<string> {
390 const [model, usage] = await Promise.all([$.session.model(), $.session.usage()])
391 return costText(model, tokensOf(Math.max(0, state.rawChars - state.shownChars)), usage.cost?.usd)
392}
393
394/** Reads a rule file again when it changed; a file with errors keeps its good rules and says what is wrong. */
395async function refreshFile($: EngineInterface, f: RuleFile): Promise<void> {
396 if (!(await $.fs.exists(f.path))) {
397 Object.assign(f, { mtimeMs: undefined, hash: undefined, rules: [] })
398 return
399 }
400 const { mtimeMs } = await $.fs.stat(f.path)
401 if (mtimeMs === f.mtimeMs) return
402 const text = await $.fs.read(f.path)
403 const compiled = rulesOf(text, f.source)
404 Object.assign(f, { mtimeMs, hash: await sha256Of(text), rules: compiled.rules })
405 if (compiled.errors.length > 0) $.ui.log(`${f.shown}: ${compiled.errors.join('; ')}`)
406}
407
408const isTrusted = (state: State, f: RuleFile): boolean => f.source === 'global' || (f.hash !== undefined && state.trusted[f.path] === f.hash)
409
410/** The person's rules that may run: a project file's only while its content is the trusted one. */
411async function activeRules($: EngineInterface, state: State): Promise<Rule[]> {
412 try {
413 for (const f of state.files) await refreshFile($, f)
414 } catch (err) {
415 report($, state, 'the filter rules were not read', err)
416 }
417 for (const f of state.files) {
418 if (isTrusted(state, f) || f.rules.length === 0 || f.told === f.hash) continue
419 f.told = f.hash
420 $.ui.log(`${f.shown}: ${f.rules.length} filter rule(s) are not trusted and do not run; /bash-diet trust runs them`)
421 }
422 return state.files.filter(f => isTrusted(state, f)).flatMap(f => f.rules)
423}
424
425/**
426 * Reads the on/off setting, the excludes and the trusted rule files from the store, which every window
427 * shares: a change made in another window applies here at the next Bash call, and a change made here
428 * starts from what the store holds, so it never writes this session's older copy over another window's.
429 */
430async function readSettings($: EngineInterface, state: State): Promise<void> {
431 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
432 const stored = await $.store.get(EXCLUDES_KEY)
433 state.excludes = Array.isArray(stored) ? stored.filter((p): p is string => typeof p === 'string') : []
434 const trusted = await $.store.get(TRUSTED_KEY)
435 state.trusted = typeof trusted === 'object' && trusted !== null ? (trusted as Record<string, string>) : {}
436}
437
438async function setTrusted($: EngineInterface, state: State, trusted: Record<string, string>): Promise<void> {
439 state.trusted = trusted
440 await $.store.set(TRUSTED_KEY, trusted)
441}
442
443/** `/bash-diet trust`, `untrust` and `filters`. */
444async function ruleCommand($: EngineInterface, state: State, word: string): Promise<string> {
445 const project = state.files.find(f => f.source === 'project')
446 if (project === undefined) return 'the rule files are not known yet'
447 await activeRules($, state)
448 if (word === 'filters') {
449 return filtersText(state.files.map(f => ({ shown: f.shown, source: f.source, exists: f.hash !== undefined, trusted: isTrusted(state, f), names: f.rules.map(r => r.name) })), BUILTIN_RULES.map(r => r.name))
450 }
451 const others = Object.fromEntries(Object.entries(state.trusted).filter(([path]) => path !== project.path))
452 if (word === 'untrust') {
453 await setTrusted($, state, others)
454 return `untrusted: ${project.shown} does not run`
455 }
456 if (project.hash === undefined) return `there is no ${project.shown} in this repository`
457 await setTrusted($, state, { ...others, [project.path]: project.hash })
458 return `trusted: ${project.rules.length} rule(s) of ${project.shown} run until the file changes (sha256 ${project.hash.slice(0, 12)})`
459}
460
461async function setExcludes($: EngineInterface, state: State, excludes: string[]): Promise<void> {
462 state.excludes = excludes
463 await $.store.set(EXCLUDES_KEY, excludes)
464}
465
466/** `/bash-diet exclude <p>`, `include <p>` and `excludes`. */
467async function exclude($: EngineInterface, state: State, word: string, pattern: string): Promise<string> {
468 if (word === 'excludes') return state.excludes.length === 0 ? 'no excludes' : state.excludes.join('\n')
469 const error = patternError(pattern)
470 if (error !== undefined) return `${word} ${error}`
471 if (word === 'exclude') {
472 if (!state.excludes.includes(pattern)) await setExcludes($, state, [...state.excludes, pattern])
473 return `excluded: ${pattern} runs unfiltered`
474 }
475 if (!state.excludes.includes(pattern)) return `${pattern} is not excluded`
476 await setExcludes($, state, state.excludes.filter(p => p !== pattern))
477 return `included: ${pattern} is filtered again`
478}
479
480async function setEnabled($: EngineInterface, state: State, enabled: boolean): Promise<string> {
481 state.enabled = enabled
482 await $.store.set(ENABLED_KEY, enabled)
483 return enabled ? 'on: Bash results are filtered' : 'off: Bash results reach the model as they are'
484}
485
486/** The answer to a subcommand, or undefined for one the command does not know. */
487function subcommand($: EngineInterface, state: State, word: string, rest: string[]): Promise<string> | undefined {
488 const bare = rest.length === 0
489 if (['on', 'off'].includes(word) && bare) return setEnabled($, state, word === 'on')
490 if (['exclude', 'include', 'excludes'].includes(word)) return exclude($, state, word, rest.join(' '))
491 if (['trust', 'untrust', 'filters'].includes(word) && bare) return ruleCommand($, state, word)
492 if (['gain', 'discover', 'learn'].includes(word)) return reportCommand($, state, word, rest)
493 if (word === 'cost' && bare) return costCommand($, state)
494 return undefined
495}
496
497async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
498 await readSettings($, state)
499 const text = args.trim()
500 if (text === '') return statusText(state.enabled, state.excludes, sessionText(state.calls, state.rawChars, state.shownChars))
501 const [word = '', ...rest] = text.split(/\s+/)
502 return (await subcommand($, state, word, rest)) ?? USAGE
503}
504
505/** Filters one Bash call; everything the plan leaves alone runs as the model wrote it. */
506async function filterCall($: EngineInterface, state: State, e: ToolCallInput & { tool: 'Bash' }, next: (e: ToolCallInput) => Promise<ToolCallResult<'Bash'>>): Promise<ToolCallResult<'Bash'>> {
507 if (e.run_in_background === true) return next(e)
508 await readSettings($, state)
509 const plan = state.enabled ? planFor(e.command, await activeRules($, state)) : undefined
510 if (plan === undefined || (plan.target !== undefined && isExcluded(state.excludes, plan.target.words))) return next(e)
511 const command = await withPlanFlags($, e.command, plan)
512 const r = await next(command === e.command ? e : { ...e, command })
513 return shrink($, state, e.command, plan, command !== e.command, r)
514}
515
516export const register: Register = on => {
517 const state: State = { enabled: true, excludes: [], calls: 0, rawChars: 0, shownChars: 0, files: [], trusted: {}, records: [] }
518
519 on('session.start', async ($, e, next) => {
520 const r = await next(e)
521 await readSettings($, state)
522 const places = await locate($)
523 state.places = places
524 state.files = ruleFilesOf(places)
525 state.gain = { dir: `${places.config}/bash-diet/gain`, sessionId: await $.session.id(), project: places.repo.split('/').pop() ?? places.repo, ready: false }
526 await $.command.register({
527 name: 'bash-diet',
528 description: 'Filters Bash results: status, on, off, exclude, include, filters, trust, gain, discover, learn, cost (bash-diet)',
529 argumentHint: '[on | off | exclude <p> | include <p> | excludes | filters | trust | untrust | gain [project | daily | graph | history] | discover [days] [all] | learn [days] [write] | cost]',
530 immediate: true,
531 })
532 await Promise.all([pruneRecall($, state), pruneGain($, state), seedGain($, state)])
533 await showGain($, state)
534 return r
535 })
536
537 // The note reaches the model at startup, resume, /clear and after a compaction, while the mod is on.
538 on('classic.SessionStart', async ($, e, next) => {
539 const r = await next(e)
540 await readSettings($, state)
541 return state.enabled ? { ...r, additionalContext: [...(r.additionalContext ?? []), AWARENESS] } : r
542 })
543
544 on('command.run', { command: 'bash-diet' }, async ($, e) => {
545 const text = await runCommand($, state, String(e.args ?? ''))
546 await showGain($, state)
547 return { text }
548 })
549
550 on('tool.call', { tool: 'Bash' }, async ($, e, next) => filterCall($, state, e, next as never))
551
552 // Playwright MCP repeats the code of each call in its result; the model already holds that code.
553 on('tool.call', { tool: PLAYWRIGHT_TOOL }, async (_$, e, next) => resultWithoutEcho(await next(e)))
554}
555hooks/builtin-rules.ts 87 lines1/**
2 * The mod's own data rules: commands whose output shrinks by dropping lines of a known shape, with no
3 * parsing. A command with a filter in `filters/` never reaches these; the person's rules come first.
4 */
5
6import { ruleOf, type Rule, type RuleSpec } from './dsl.ts'
7
8/** A clang or gcc source excerpt under a diagnostic: ` 12 | code` and the ` | ^` marker line. */
9const EXCERPT = ['^\\s*\\d+ \\| ', '^\\s+\\| ']
10
11export const BUILTIN_SPECS: Record<string, RuleSpec> = {
12 cc: {
13 description: 'C and C++ compilers: the source excerpts under each diagnostic go',
14 match_command: '^(gcc|g\\+\\+|cc|c\\+\\+|clang|clang\\+\\+)(-\\d+)?( |$)',
15 strip_ansi: true,
16 strip_lines_matching: EXCERPT,
17 },
18 'cmake-build': {
19 description: 'cmake --build: progress, compile and link steps go; errors stay',
20 match_command: '^cmake --build( |$)',
21 strip_ansi: true,
22 strip_lines_matching: ['^\\[\\s*\\d+%\\] (Building|Linking|Built target|Generating|Scanning)', '^(gmake|make)(\\[\\d+\\])?: (Entering|Leaving) directory', ...EXCERPT],
23 on_empty: 'cmake --build: done',
24 },
25 cmake: {
26 description: 'cmake configure: the compiler detection steps go',
27 match_command: '^cmake( |$)',
28 strip_ansi: true,
29 strip_lines_matching: ['^-- (The \\w+ compiler identification|Detecting |Check for working |Looking for |Performing Test |Found \\w+: )'],
30 },
31 brew: {
32 description: 'Homebrew: auto-update notes, new-formula lists, download and progress lines go',
33 match_command: '^brew (install|upgrade|reinstall|update|tap|bundle)( |$)',
34 strip_ansi: true,
35 strip_lines_matching: [
36 '^==> (Auto-updat|Downloading|Fetching|Pouring|Verifying|New Formulae|New Casks|Outdated Formulae|Outdated Casks|Updated Homebrew)',
37 '^(Adjust how often this is run|`\\$HOMEBREW_NO_AUTO_UPDATE|Updated \\d+ taps?|Already downloaded:|You have \\d+ outdated)',
38 '^✔︎? (Bottle|Formula|Cask|JSON API)',
39 '^#+ *[\\d.]+%$',
40 '^(?!Error|Warning)[\\w@.+-]+: .+',
41 '^[\\w@.+-]+$',
42 '^\\s*$',
43 ],
44 on_empty: 'brew: done',
45 },
46 rsync: {
47 description: 'rsync: the file list goes, the transfer summary stays',
48 match_command: '^rsync( |$)',
49 strip_lines_matching: ['^sending incremental file list$', '^receiving incremental file list$', '^(?!sent |total size |rsync|.*: |.*error).*[^:]$', '^\\s*$'],
50 },
51 df: {
52 description: 'df: virtual and system volumes go, column padding shrinks',
53 match_command: '^df( |$)',
54 replace: [{ pattern: ' {2,}', replacement: ' ' }],
55 strip_lines_matching: ['^(devfs|map |devices |tmpfs|devtmpfs|udev|overlay|shm|none|/dev/loop)', ' /System/Volumes/(VM|Preboot|Update|xarts|iSCPreboot|Hardware)$', ' /Volumes/Recovery$', ' /(dev|run|sys/fs/cgroup|snap/\\S+)$'],
56 },
57 du: {
58 description: 'du: the rows inside generated and vendored directories go, long listings are cut',
59 match_command: '^du( |$)',
60 strip_lines_matching: ['/(node_modules|\\.git|target|\\.venv|venv|__pycache__|\\.next|\\.nuxt|\\.gradle|DerivedData|Pods|\\.cache)/'],
61 head_lines: 40,
62 tail_lines: 10,
63 },
64 ping: {
65 description: 'ping: each reply line goes, the header and the statistics stay',
66 match_command: '^ping6?( |$)',
67 strip_lines_matching: ['^\\d+ bytes from ', '^\\s*$'],
68 },
69 shellcheck: {
70 description: 'shellcheck: the wiki link list and blank lines go',
71 match_command: '^shellcheck( |$)',
72 strip_ansi: true,
73 strip_lines_matching: ['^For more information:$', '^\\s+https://www\\.shellcheck\\.net/wiki/', '^\\s*$'],
74 },
75}
76
77/** Compiles the specs; a spec that does not compile is a fault of this file, so it throws. */
78function compiledAll(specs: Record<string, RuleSpec>): Rule[] {
79 return Object.entries(specs).map(([name, spec]) => {
80 const r = ruleOf(name, 'builtin', spec)
81 if (r.rule === undefined) throw new Error(`built-in rule ${r.errors.join('; ')}`)
82 return r.rule
83 })
84}
85
86export const BUILTIN_RULES: readonly Rule[] = compiledAll(BUILTIN_SPECS)
87hooks/command.ts 154 lines1import { asksVerbose } from './filters/common.ts'
2import { hidesStdout, isAssignment, parse, type Segment, type Stage, type Token } from './shell.ts'
3
4/** The variable that leaves one call's output as it is: `BASH_DIET_RAW=1 git diff`. */
5export const RAW_VARIABLE = 'BASH_DIET_RAW'
6
7/**
8 * The command whose output the model reads, as far as a filter needs it: its words after every
9 * variable and wrapper (`timeout 60`, `nice`, `command`), and the token each word came from, so a flag
10 * can be put into the original text at the right place.
11 */
12export type Target = {
13 words: string[]
14 tokens: Token[]
15 /** False when a flag must not be added: a pipeline, a chain, a redirect, `sudo`, or an opaque part. */
16 canAddFlags: boolean
17}
18
19/** What the reading found: the command to filter, or why there is none. */
20export type Reading =
21 | { kind: 'raw' }
22 | { kind: 'opaque' }
23 | { kind: 'mixed' }
24 | { kind: 'target'; target: Target }
25
26/** Commands that print nothing worth a filter; a chain around one command is still that command's output. */
27const QUIET = new Set([
28 'cd', 'pushd', 'popd', 'export', 'unset', 'set', 'source', '.', 'mkdir', 'rm', 'rmdir', 'mv', 'cp', 'ln',
29 'chmod', 'chown', 'touch', 'true', 'false', ':', 'sleep', 'wait', 'trap', 'alias', 'umask', 'shopt',
30])
31
32/** Wrappers that take no argument of their own. */
33const PLAIN_WRAPPERS = new Set(['command', 'builtin', 'exec', 'noglob', 'nocorrect', 'nohup', 'time', 'sudo'])
34
35/** Stages after a producer that only cut its output, so the producer's filter still reads it. */
36const CUTTERS = new Set(['cat', 'head', 'tail'])
37
38/** Where a flag may follow `sudo` no more than it may follow a pipe. */
39const NO_FLAG_WRAPPERS = new Set(['sudo'])
40
41const valueOf = (words: Token[], name: string): string | undefined =>
42 words.find(w => isAssignment(w.text) && w.text.startsWith(`${name}=`))?.text.slice(name.length + 1)
43
44/** Whether any stage sets the raw variable to a value other than empty or `0`. */
45function asksRaw(segments: Segment[]): boolean {
46 return segments.some(g => g.stages.some(st => {
47 const v = valueOf(st.words, RAW_VARIABLE)
48 return v !== undefined && v !== '' && v !== '0'
49 }))
50}
51
52/**
53 * How many words `timeout`, `nice` or `env` take before the command they wrap. `env` wraps only when a
54 * command follows its options and variables; alone it is the command, and it prints the environment.
55 */
56function wrapperLength(words: Token[], at: number): number {
57 const name = words[at]?.text ?? ''
58 if (PLAIN_WRAPPERS.has(name)) return 1
59 if (name === 'env') {
60 const n = optionsLength(words, at + 1, /^-/) + 1
61 return words.slice(at + n).some(w => !isAssignment(w.text)) ? n : 0
62 }
63 if (name === 'nice') return optionsLength(words, at + 1, /^-(n)?\d*$|^--adjustment=/) + 1
64 if (name === 'timeout') return optionsLength(words, at + 1, /^-/) + 2
65 return 0
66}
67
68/** The words from `from` that match `option`; `-n 5` and `-s KILL` take the word after them too. */
69function optionsLength(words: Token[], from: number, option: RegExp): number {
70 let i = from
71 while (i < words.length && option.test(words[i]?.text ?? '')) i += /^-[nsk]$/.test(words[i]?.text ?? '') ? 2 : 1
72 return i - from
73}
74
75/** The stage's words after its variables and wrappers, and whether a wrapper forbids a flag. */
76export function peel(words: Token[]): { words: Token[]; noFlags: boolean } {
77 let at = 0
78 let noFlags = false
79 for (let guard = 0; guard < 16 && at < words.length; guard += 1) {
80 const text = words[at]?.text ?? ''
81 if (isAssignment(text)) { at += 1; continue }
82 const n = wrapperLength(words, at)
83 if (n === 0) break
84 noFlags ||= NO_FLAG_WRAPPERS.has(text)
85 at += n
86 }
87 return { words: words.slice(at), noFlags }
88}
89
90const nameOf = (st: Stage): string => (peel(st.words).words[0]?.text ?? '').split('/').pop() ?? ''
91
92/** A `tail` that keeps following a file never ends; its output is not a producer's. */
93const isCutter = (st: Stage): boolean =>
94 CUTTERS.has(nameOf(st)) && !st.words.some(w => /^-[a-zA-Z]*[fF]/.test(w.text) || w.text === '--follow')
95
96/**
97 * The stage whose output a filter reads: the only one, a final `grep` or `rg` (its matches are what
98 * the model reads), or a producer followed only by `cat`, `head` or `tail`.
99 */
100function stageOf(g: Segment): { stage: Stage; alone: boolean } | undefined {
101 const [first, ...rest] = g.stages
102 if (first === undefined) return undefined
103 if (rest.length === 0) return { stage: first, alone: true }
104 const last = rest[rest.length - 1] as Stage
105 if (['grep', 'rg'].includes(nameOf(last))) return { stage: last, alone: false }
106 return rest.every(isCutter) ? { stage: first, alone: false } : undefined
107}
108
109/** Quiet commands that print one line per path with `-v`; with it they are the output. */
110const VERBOSE_WHEN_ASKED = new Set(['rm', 'mv', 'cp', 'ln'])
111
112/** A quiet command, unless it is one of `VERBOSE_WHEN_ASKED` given `-v` (also in `-rv`) or `--verbose`. */
113function isQuiet(st: Stage): boolean {
114 const name = nameOf(st)
115 if (!QUIET.has(name)) return false
116 return !VERBOSE_WHEN_ASKED.has(name) || !asksVerbose(st.words.map(w => w.text))
117}
118
119/** The segments that print something: a chain of `cd x && cargo test` is `cargo test`'s output. */
120const loud = (segments: Segment[]): Segment[] =>
121 segments.filter(g => g.background || !g.stages.every(isQuiet))
122
123/** Reads a command: raw, opaque, a chain of several commands, or the one command to filter. */
124export function read(command: string): Reading {
125 const { segments, opaque } = parse(command)
126 if (asksRaw(segments)) return { kind: 'raw' }
127 if (opaque) return { kind: 'opaque' }
128 const printing = loud(segments)
129 const only = printing.length === 1 ? printing[0] : undefined
130 if (only === undefined || only.background) return { kind: 'mixed' }
131 return readSegment(only, segments.length === 1)
132}
133
134/** Reads the one segment that prints: its stage, and whether a flag may be added to it. */
135function readSegment(only: Segment, single: boolean): Reading {
136 const picked = stageOf(only)
137 if (picked === undefined) return { kind: 'mixed' }
138 if (picked.stage.redirects.some(hidesStdout)) return { kind: 'opaque' }
139 const { words, noFlags } = peel(picked.stage.words)
140 if (words.length === 0) return { kind: 'mixed' }
141 const canAddFlags = !noFlags && picked.alone && single && picked.stage.redirects.length === 0
142 return { kind: 'target', target: { words: words.map(w => w.text), tokens: words, canAddFlags } }
143}
144
145/**
146 * The command with flags put right after the word at `after` (the subcommand), where they cannot land
147 * behind a `--` or a pathspec. Every flag is one plain word, so it needs no quoting.
148 */
149export function withFlags(command: string, target: Target, after: number, flags: string[]): string {
150 const token = target.tokens[after]
151 if (token === undefined || flags.length === 0) return command
152 return `${command.slice(0, token.end)} ${flags.join(' ')}${command.slice(token.end)}`
153}
154hooks/dsl.ts 195 lines1/**
2 * Filter rules written as data: a command pattern and the steps that shrink its output. The person's own
3 * rules live in `filters.json` files; the mod's built-in rules use the same shape.
4 *
5 * The steps run in a fixed order: `strip_ansi`, `replace`, `match_output`, `strip_lines_matching` or
6 * `keep_lines_matching`, `truncate_lines_at`, `head_lines` and `tail_lines`, `max_lines`, `on_empty`.
7 */
8
9import { cutLine, stripAnsi, type FilterResult } from './filters/common.ts'
10
11/** Where a rule came from; a project rule runs only while its file is trusted. */
12export type RuleSource = 'project' | 'global' | 'builtin'
13
14/** One rule as a `filters.json` file writes it. */
15export type RuleSpec = {
16 description?: string
17 /** A regex over the command's words (after variables and wrappers such as `timeout`). */
18 match_command: string
19 strip_ansi?: boolean
20 /** Regex substitutions, applied to every line in order. */
21 replace?: { pattern: string; replacement: string }[]
22 /** When the whole output matches `pattern` (and not `unless`), the result is `message` alone. */
23 match_output?: { pattern: string; message: string; unless?: string }[]
24 strip_lines_matching?: string[]
25 keep_lines_matching?: string[]
26 truncate_lines_at?: number
27 head_lines?: number
28 tail_lines?: number
29 max_lines?: number
30 /** The result when nothing is left. */
31 on_empty?: string
32}
33
34/** A checked rule with its patterns compiled. */
35export type Rule = {
36 name: string
37 source: RuleSource
38 match: RegExp
39 stripAnsi: boolean
40 replace: { pattern: RegExp; replacement: string }[]
41 matchOutput: { pattern: RegExp; message: string; unless?: RegExp }[]
42 lines?: { keep: boolean; patterns: RegExp[] }
43 truncateAt?: number
44 head?: number
45 tail?: number
46 max?: number
47 onEmpty?: string
48}
49
50// ---------------------------------------------------------------------------------------------- checks
51
52const isString = (v: unknown): boolean => typeof v === 'string'
53const isCount = (v: unknown): boolean => typeof v === 'number' && Number.isInteger(v) && v > 0
54const isStringList = (v: unknown): boolean => Array.isArray(v) && v.every(isString)
55
56function isObjectList(v: unknown, required: string[], optional: string[]): boolean {
57 if (!Array.isArray(v)) return false
58 return v.every(item => {
59 if (typeof item !== 'object' || item === null) return false
60 const keys = Object.keys(item)
61 const values = item as Record<string, unknown>
62 return required.every(k => isString(values[k])) && keys.every(k => [...required, ...optional].includes(k) && isString(values[k]))
63 })
64}
65
66/** Every field a rule may carry, and the check its value must pass. */
67const FIELDS: Record<keyof RuleSpec, { ok: (v: unknown) => boolean; expects: string }> = {
68 description: { ok: isString, expects: 'a string' },
69 match_command: { ok: isString, expects: 'a regex string' },
70 strip_ansi: { ok: v => typeof v === 'boolean', expects: 'true or false' },
71 replace: { ok: v => isObjectList(v, ['pattern', 'replacement'], []), expects: 'a list of { pattern, replacement }' },
72 match_output: { ok: v => isObjectList(v, ['pattern', 'message'], ['unless']), expects: 'a list of { pattern, message, unless? }' },
73 strip_lines_matching: { ok: isStringList, expects: 'a list of regex strings' },
74 keep_lines_matching: { ok: isStringList, expects: 'a list of regex strings' },
75 truncate_lines_at: { ok: isCount, expects: 'a whole number above 0' },
76 head_lines: { ok: isCount, expects: 'a whole number above 0' },
77 tail_lines: { ok: isCount, expects: 'a whole number above 0' },
78 max_lines: { ok: isCount, expects: 'a whole number above 0' },
79 on_empty: { ok: isString, expects: 'a string' },
80}
81
82/** What is wrong with one rule's fields, or an empty list. */
83function specErrors(spec: Record<string, unknown>): string[] {
84 const errors = Object.entries(spec).map(([key, value]) => {
85 const field = FIELDS[key as keyof RuleSpec] as (typeof FIELDS)[keyof RuleSpec] | undefined
86 if (field === undefined) return `unknown field ${key}`
87 return field.ok(value) ? '' : `${key} expects ${field.expects}`
88 })
89 if (spec.match_command === undefined) errors.push('match_command is missing')
90 if (spec.strip_lines_matching !== undefined && spec.keep_lines_matching !== undefined) errors.push('strip_lines_matching and keep_lines_matching exclude each other')
91 return errors.filter(e => e !== '')
92}
93
94/** A regex from a rule; a leading `(?i)` turns on case-insensitive matching. */
95export function regexOf(pattern: string, global = false): RegExp {
96 const insensitive = pattern.startsWith('(?i)')
97 return new RegExp(insensitive ? pattern.slice(4) : pattern, `${insensitive ? 'i' : ''}${global ? 'g' : ''}`)
98}
99
100/** Compiles a checked spec; a pattern that does not compile throws with its field. */
101function compiled(name: string, source: RuleSource, spec: RuleSpec): Rule {
102 const lines = spec.keep_lines_matching ?? spec.strip_lines_matching
103 return {
104 name,
105 source,
106 match: regexOf(spec.match_command),
107 stripAnsi: spec.strip_ansi === true,
108 replace: (spec.replace ?? []).map(r => ({ pattern: regexOf(r.pattern, true), replacement: r.replacement })),
109 matchOutput: (spec.match_output ?? []).map(m => ({ pattern: regexOf(m.pattern), message: m.message, unless: m.unless === undefined ? undefined : regexOf(m.unless) })),
110 lines: lines === undefined ? undefined : { keep: spec.keep_lines_matching !== undefined, patterns: lines.map(p => regexOf(p)) },
111 truncateAt: spec.truncate_lines_at,
112 head: spec.head_lines,
113 tail: spec.tail_lines,
114 max: spec.max_lines,
115 onEmpty: spec.on_empty,
116 }
117}
118
119/** One named rule compiled, or the errors that keep it out. */
120export function ruleOf(name: string, source: RuleSource, spec: unknown): { rule?: Rule; errors: string[] } {
121 if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) return { errors: [`${name}: expects an object`] }
122 const errors = specErrors(spec as Record<string, unknown>)
123 if (errors.length > 0) return { errors: errors.map(e => `${name}: ${e}`) }
124 try {
125 return { rule: compiled(name, source, spec as RuleSpec), errors: [] }
126 } catch (err) {
127 return { errors: [`${name}: ${err instanceof Error ? err.message : String(err)}`] }
128 }
129}
130
131/** Every rule of a `filters.json` text: `{ "filters": { "<name>": { ... } } }`, and what kept any out. */
132export function rulesOf(text: string, source: RuleSource): { rules: Rule[]; errors: string[] } {
133 let doc: unknown
134 try {
135 doc = JSON.parse(text)
136 } catch (err) {
137 return { rules: [], errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`] }
138 }
139 const filters = (doc as { filters?: unknown } | null)?.filters
140 if (typeof filters !== 'object' || filters === null || Array.isArray(filters)) return { rules: [], errors: ['expects { "filters": { "<name>": { ... } } }'] }
141 const results = Object.entries(filters).map(([name, spec]) => ruleOf(name, source, spec))
142 return { rules: results.flatMap(r => (r.rule === undefined ? [] : [r.rule])), errors: results.flatMap(r => r.errors) }
143}
144
145/** The first rule whose pattern matches the command's words. */
146export function ruleFor(rules: readonly Rule[], words: string[]): Rule | undefined {
147 const line = words.join(' ')
148 return rules.find(r => r.match.test(line))
149}
150
151// ----------------------------------------------------------------------------------------------- steps
152
153/** The message of the first `match_output` entry the output matches and its `unless` does not. */
154function shortCircuit(rule: Rule, text: string): string | undefined {
155 return rule.matchOutput.find(m => m.pattern.test(text) && !(m.unless?.test(text) ?? false))?.message
156}
157
158function selected(rule: Rule, lines: string[]): string[] {
159 const l = rule.lines
160 if (l === undefined) return lines
161 return lines.filter(line => l.patterns.some(p => p.test(line)) === l.keep)
162}
163
164/** Whether `head` and `tail` leave lines out, and the first `head` and last `tail` lines with a count between. */
165function headTail(head: number | undefined, tail: number | undefined, lines: string[]): string[] | undefined {
166 const h = head ?? 0
167 const t = tail ?? 0
168 if ((head === undefined && tail === undefined) || lines.length <= h + t) return undefined
169 return [...lines.slice(0, h), `… ${lines.length - h - t} lines left out`, ...lines.slice(lines.length - t)]
170}
171
172/** The line steps: cut long lines, keep the head and the tail, then cap the count. */
173function cut(rule: Rule, lines: string[]): { lines: string[]; elided: boolean } {
174 const at = rule.truncateAt
175 const short = at === undefined ? lines : lines.map(l => cutLine(l, at))
176 const ends = headTail(rule.head, rule.tail, short)
177 const kept = ends ?? short
178 const max = rule.max
179 const over = max !== undefined && kept.length > max
180 const capped = over ? [...kept.slice(0, max), `… +${kept.length - max} more lines`] : kept
181 return { lines: capped, elided: over || ends !== undefined || short.some((l, i) => l !== lines[i]) }
182}
183
184/** Runs a rule over a command's output. */
185export function applyRule(rule: Rule, text: string): FilterResult {
186 const raw = text.replace(/\n+$/, '').split('\n')
187 const clean = rule.stripAnsi ? raw.map(stripAnsi) : raw
188 const lines = clean.map(line => rule.replace.reduce((l, r) => l.replace(r.pattern, r.replacement), line))
189 const message = shortCircuit(rule, lines.join('\n'))
190 if (message !== undefined) return { text: message, elided: true }
191 const c = cut(rule, selected(rule, lines))
192 const out = c.lines.join('\n')
193 return { text: out.trim() === '' && rule.onEmpty !== undefined ? rule.onEmpty : out, elided: c.elided }
194}
195hooks/history.ts 219 lines1/**
2 * The Bash calls of earlier sessions, read from their transcripts: which commands cost the most output
3 * and have no filter (`discover`), and which failed commands the model corrected (`learn`).
4 */
5
6import { read } from './command.ts'
7import { planFor } from './pipeline.ts'
8import { classify } from './rules.ts'
9import { parse } from './shell.ts'
10import { fmtTokens, tokensOf } from './text.ts'
11
12/** One Bash call of a transcript: the command, the text the model read, and how it ended. */
13export type BashCall = { session: string; command: string; output: string; exitCode?: number }
14
15/** A transcript read piece by piece: the calls found so far, the calls waiting for their result. */
16export type Scanner = { session: string; rest: string; pending: Map<string, string>; calls: BashCall[]; bad: number }
17
18export const scannerOf = (session: string): Scanner => ({ session, rest: '', pending: new Map(), calls: [], bad: 0 })
19
20type Item = { type?: string; id?: string; name?: string; input?: { command?: unknown }; tool_use_id?: string; content?: unknown }
21
22/** The text of a tool result: a string, or the text parts of a list. */
23function textOf(content: unknown): string {
24 if (typeof content === 'string') return content
25 if (!Array.isArray(content)) return ''
26 return content.map(c => ((c as { type?: string }).type === 'text' ? String((c as { text?: unknown }).text ?? '') : '')).join('\n')
27}
28
29/** A Bash call's id and command, from a `tool_use` item. */
30function bashUse(item: Item): { id: string; command: string } | undefined {
31 const command = item.input?.command
32 if (item.type !== 'tool_use' || item.name !== 'Bash' || typeof command !== 'string' || item.id === undefined) return undefined
33 return { id: item.id, command }
34}
35
36function takeItem(s: Scanner, item: Item): void {
37 const use = bashUse(item)
38 if (use !== undefined) {
39 s.pending.set(use.id, use.command)
40 return
41 }
42 const id = item.type === 'tool_result' ? item.tool_use_id : undefined
43 const command = id === undefined ? undefined : s.pending.get(id)
44 if (id === undefined || command === undefined) return
45 s.pending.delete(id)
46 const output = textOf(item.content)
47 const exit = /^Exit code (\d+)\n/.exec(output)
48 s.calls.push({ session: s.session, command, output, exitCode: exit === null ? undefined : Number(exit[1]) })
49}
50
51/**
52 * Whether a line may hold a Bash call or the result of one: parsing every row of a long transcript
53 * costs more than the hook's own time allows, and most rows are other tools' results.
54 */
55function mayHold(s: Scanner, line: string): boolean {
56 if (line.includes('"tool_use"') && line.includes('"name":"Bash"')) return true
57 if (!line.includes('"tool_result"') || s.pending.size === 0) return false
58 return [...line.matchAll(/"tool_use_id":"([^"]+)"/g)].some(m => s.pending.has(m[1] ?? ''))
59}
60
61function takeLine(s: Scanner, line: string): void {
62 if (!mayHold(s, line)) return
63 let row: { message?: { content?: unknown } }
64 try {
65 row = JSON.parse(line) as typeof row
66 } catch {
67 s.bad += 1
68 return
69 }
70 const content = row.message?.content
71 if (Array.isArray(content)) for (const item of content) takeItem(s, item as Item)
72}
73
74/** Reads the next piece of a transcript; a line cut between pieces waits for the rest. */
75export function scan(s: Scanner, text: string): void {
76 const lines = (s.rest + text).split('\n')
77 s.rest = lines.pop() ?? ''
78 for (const line of lines) takeLine(s, line)
79}
80
81/** Reads what is left after the last piece. */
82export function finish(s: Scanner): BashCall[] {
83 if (s.rest.trim() !== '') takeLine(s, s.rest)
84 s.rest = ''
85 return s.calls
86}
87
88// ------------------------------------------------------------------------------------------- discover
89
90type Tally = { calls: number; chars: number }
91
92/** Where a call falls: its filter's family, or why no filter reads it. */
93function kindOf(command: string): { group: 'filtered' | 'unfiltered' | 'chain' | 'raw' | 'opaque'; key: string } {
94 const reading = read(command)
95 if (reading.kind === 'raw' || reading.kind === 'opaque') return { group: reading.kind, key: reading.kind }
96 const plan = planFor(command)
97 if (reading.kind === 'mixed' || plan === undefined) return { group: 'chain', key: 'chain' }
98 if (plan.family !== 'other') return { group: 'filtered', key: plan.family }
99 const c = classify(reading.target.words)
100 return { group: 'unfiltered', key: c === undefined || c.sub === '' ? (c?.tool ?? 'other') : `${c.tool} ${c.sub}` }
101}
102
103function add(map: Map<string, Tally>, key: string, chars: number): void {
104 const t = map.get(key) ?? { calls: 0, chars: 0 }
105 map.set(key, { calls: t.calls + 1, chars: t.chars + chars })
106}
107
108/** The top rows of a group, the most output first. */
109function rows(map: Map<string, Tally>, limit = 10): string[] {
110 const sorted = [...map].sort((a, b) => b[1].chars - a[1].chars).slice(0, limit)
111 const width = Math.max(0, ...sorted.map(([k]) => k.length))
112 return sorted.map(([k, t]) => ` ${k.padEnd(width)} ${t.calls} call${t.calls === 1 ? '' : 's'} ~${fmtTokens(tokensOf(t.chars))} tokens`)
113}
114
115/** `/bash-diet discover`: the output the model read, by command, and the commands no filter reads. */
116export function discoverText(calls: BashCall[], sessions: number, days: number): string {
117 if (calls.length === 0) return `no Bash call in ${sessions} session(s) of the last ${days} days`
118 const groups = new Map<string, Map<string, Tally>>()
119 for (const c of calls) {
120 const k = kindOf(c.command)
121 const map = groups.get(k.group) ?? new Map<string, Tally>()
122 add(map, k.key, c.output.length)
123 groups.set(k.group, map)
124 }
125 const total = calls.reduce((n, c) => n + c.output.length, 0)
126 const section = (group: string, title: string): string[] => (groups.has(group) ? [title, ...rows(groups.get(group) ?? new Map())] : [])
127 return [
128 `${calls.length} Bash calls in ${sessions} session(s) of the last ${days} days; the model read ~${fmtTokens(tokensOf(total))} tokens of their output`,
129 ...section('unfiltered', 'no filter reads these (a rule in .bash-diet/filters.json can):'),
130 ...section('filtered', 'a filter reads these:'),
131 ...section('chain', 'chains of several commands (the generic cleanup reads them):'),
132 ...section('opaque', 'left alone (substitution, heredoc or a redirect to a file):'),
133 ...section('raw', `left raw on purpose (BASH_DIET_RAW=1):`),
134 ].join('\n')
135}
136
137// ---------------------------------------------------------------------------------------------- learn
138
139/** A failed command and the form that worked after it. */
140export type Correction = { wrong: string; right: string; kind: string; count: number }
141
142/**
143 * The mistake kinds a correction is kept for, by the error text of the failed call. A wrong path is not
144 * one of them: its fix depends on the working directory of that moment, not on how the tool is used.
145 */
146const MISTAKES: [kind: string, pattern: RegExp][] = [
147 ['unknown flag', /unknown (option|flag|argument)|unrecognized (option|arguments?)|invalid (option|argument)|unexpected argument|no such option/i],
148 ['command not found', /command not found|: not found$/im],
149 ['missing argument', /missing (required )?(argument|operand)|requires (a|an) (argument|value)|arguments are required|^usage: /im],
150 ['wrong syntax', /syntax error|parse error|unexpected token/i],
151]
152
153/** A command that may carry a credential is never written down. */
154const SECRET = /token|secret|password|passwd|api[_-]?key|authorization|bearer /i
155
156/** How many calls after a failure the correction may come. */
157const LOOKAHEAD = 3
158
159const wordsOf = (command: string): string[] => command.trim().split(/\s+/)
160
161/** The share of words two commands have in common. */
162function likeness(a: string, b: string): number {
163 const wa = new Set(wordsOf(a))
164 const wb = new Set(wordsOf(b))
165 const shared = [...wa].filter(w => wb.has(w)).length
166 return shared / Math.max(wa.size, wb.size)
167}
168
169/** Whether a command is one command alone: an error of a chain or a pipeline cannot be tied to one part. */
170function alone(command: string): boolean {
171 const { segments, opaque } = parse(command)
172 return !opaque && segments.length === 1 && segments[0]?.stages.length === 1
173}
174
175const usable = (c: BashCall): boolean => !c.command.includes('\n') && c.command.length <= 200 && !SECRET.test(c.command) && alone(c.command)
176
177/** The call after a failure that fixed it: the same tool, most words shared, and it succeeded. */
178function fixOf(calls: BashCall[], at: number): BashCall | undefined {
179 const failed = calls[at] as BashCall
180 return calls.slice(at + 1, at + 1 + LOOKAHEAD).find(c =>
181 c.session === failed.session && c.exitCode === undefined && usable(c) && c.command !== failed.command &&
182 wordsOf(c.command)[0] === wordsOf(failed.command)[0] && likeness(c.command, failed.command) >= 0.5)
183}
184
185/** The corrections in the calls, the most frequent first. */
186export function correctionsOf(calls: BashCall[]): Correction[] {
187 const found = new Map<string, Correction>()
188 calls.forEach((c, i) => {
189 if (c.exitCode === undefined || !usable(c)) return
190 const kind = MISTAKES.find(([, p]) => p.test(c.output))?.[0]
191 const fix = kind === undefined ? undefined : fixOf(calls, i)
192 if (kind === undefined || fix === undefined) return
193 const key = `${c.command}\0${fix.command}`
194 const seen = found.get(key)
195 found.set(key, { wrong: c.command, right: fix.command, kind, count: (seen?.count ?? 0) + 1 })
196 })
197 return [...found.values()].sort((a, b) => b.count - a.count)
198}
199
200const line = (c: Correction): string => `- \`${c.wrong}\` failed (${c.kind}); \`${c.right}\` worked${c.count > 1 ? ` (${c.count} times)` : ''}.`
201
202/** `/bash-diet learn`: the corrections found, or that there are none. */
203export function learnText(corrections: Correction[], sessions: number, days: number): string {
204 if (corrections.length === 0) return `no corrected command in ${sessions} session(s) of the last ${days} days`
205 return [`${corrections.length} corrected command(s) in ${sessions} session(s) of the last ${days} days:`, ...corrections.slice(0, 20).map(line)].join('\n')
206}
207
208/** The rules file `/bash-diet learn write` writes for the model to read in later sessions. */
209export function learnFile(corrections: Correction[]): string {
210 return [
211 '# CLI corrections',
212 '',
213 'Commands that failed in earlier sessions of this project, and the form that worked after them. Use the form that worked.',
214 '',
215 ...corrections.slice(0, 50).map(line),
216 '',
217 ].join('\n')
218}
219hooks/filters/common.ts 138 lines1/**
2 * What every filter shares: its input and result, and the small line tools the ecosystem files build on.
3 * A filter is a pure function of the command's arguments and its output; it never runs anything.
4 */
5
6/** The output a filter reads: stdout and stderr as the model would have read them, and the exit code. */
7export type FilterInput = {
8 args: string[]
9 /** stdout, then stderr after a newline when there is any: the text the model would have read. */
10 text: string
11 exitCode: number
12}
13
14/**
15 * The filtered text, and whether it left out something the full output file still holds. `redacted`
16 * marks a text with credential values masked: it always replaces the output, whatever it saves, and no
17 * full output file is kept for it, because that file would hold the values the filter masked.
18 */
19export type FilterResult = { text: string; elided: boolean; redacted?: true }
20
21/**
22 * One entry of a filter table: the filter, and the flags it asks for (`--tb=short -q` for `pytest`), or
23 * undefined when the arguments already choose a format the filter cannot read. A flag never switches a
24 * tool to a larger format: a failed command's text reaches the hook cut at 10,000 characters.
25 */
26export type Filter = {
27 run: (input: FilterInput) => FilterResult
28 flags?: (args: string[]) => string[] | undefined
29}
30
31/** A table keyed by `tool sub` (`git status`) or by `tool` alone for every subcommand. */
32export type FilterTable = Record<string, Filter>
33
34/** How many list rows, errors and warnings a filter shows before it counts the rest. */
35export const CAP_LIST = 20
36export const CAP_INVENTORY = 50
37export const CAP_ERRORS = 20
38export const CAP_WARNINGS = 10
39
40const ESC = String.fromCharCode(27)
41const BEL = String.fromCharCode(7)
42
43/** The index after an escape sequence that starts at `at`: CSI (`ESC [ ... final`), OSC (`ESC ] ... BEL`), or two bytes. */
44function sequenceEnd(text: string, at: number): number {
45 const kind = text[at + 1]
46 if (kind === '[') {
47 let i = at + 2
48 while (i < text.length && !/[@-~]/.test(text[i] ?? '')) i += 1
49 return i + 1
50 }
51 if (kind === ']') {
52 const bel = text.indexOf(BEL, at)
53 const st = text.indexOf(`${ESC}\\`, at + 2)
54 const ends = [bel < 0 ? Infinity : bel + 1, st < 0 ? Infinity : st + 2]
55 return Math.min(text.length, ...ends)
56 }
57 return at + (kind === '(' || kind === ')' ? 3 : 2)
58}
59
60/** The text with every terminal escape sequence (colours, cursor moves, titles) removed. */
61export function stripAnsi(text: string): string {
62 if (!text.includes(ESC)) return text
63 let out = ''
64 let i = 0
65 while (i < text.length) {
66 const esc = text.indexOf(ESC, i)
67 if (esc < 0) { out += text.slice(i); break }
68 out += text.slice(i, esc)
69 i = sequenceEnd(text, esc)
70 }
71 return out
72}
73
74/**
75 * A progress line redrawn with carriage returns keeps only what the terminal showed last: `10%\r50%\rdone`
76 * is `done`.
77 */
78export function lastRedraw(line: string): string {
79 const parts = line.split('\r').filter(p => p !== '')
80 return parts[parts.length - 1] ?? ''
81}
82
83/** The text's lines, ANSI and carriage-return redraws gone, with no trailing empty line. */
84export function linesOf(text: string): string[] {
85 const lines = stripAnsi(text).split('\n').map(lastRedraw)
86 while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop()
87 return lines
88}
89
90/** A line cut to `max` characters, with an ellipsis where it was cut. */
91export function cutLine(line: string, max: number): string {
92 return line.length <= max ? line : `${line.slice(0, max - 1)}…`
93}
94
95/** The first `cap` items and a count of the rest, or all of them when they fit. */
96export function capped(items: string[], cap: number, noun = 'more'): { lines: string[]; elided: boolean } {
97 if (items.length <= cap) return { lines: items, elided: false }
98 return { lines: [...items.slice(0, cap), `… +${items.length - cap} ${noun}`], elided: true }
99}
100
101/** Consecutive equal lines as one, with the count: `retrying (×12)`. Blank lines are left to `collapseBlanks`. */
102export function collapseRepeats(lines: string[]): string[] {
103 const out: string[] = []
104 let count = 0
105 for (let i = 0; i < lines.length; i += 1) {
106 count += 1
107 if (lines[i] === lines[i + 1] && (lines[i] ?? '').trim() !== '') continue
108 out.push(count > 1 ? `${lines[i]} (×${count})` : (lines[i] ?? ''))
109 count = 0
110 }
111 return out
112}
113
114/** Runs of blank lines as one blank line. */
115export function collapseBlanks(lines: string[]): string[] {
116 return lines.filter((l, i) => l.trim() !== '' || (i > 0 && (lines[i - 1] ?? '').trim() !== ''))
117}
118
119/** Whether any argument is one of `names` or starts with one of them followed by `=`. */
120export function hasArg(args: string[], ...names: string[]): boolean {
121 return args.some(a => names.some(n => a === n || a.startsWith(`${n}=`)))
122}
123
124/** Whether the arguments hold `--verbose`, `-v`, or a short-option bundle with `v` in it (`-rv`, `-sfv`). */
125export function asksVerbose(args: readonly string[]): boolean {
126 return args.some(a => a === '--verbose' || /^-[a-zA-Z]*v[a-zA-Z]*$/.test(a))
127}
128
129/** A result that keeps every line, only joined. */
130export function whole(lines: string[]): FilterResult {
131 return { text: lines.join('\n'), elided: false }
132}
133
134/** The text for an output that is empty after filtering. */
135export function orOk(lines: string[], ok: string): string {
136 return lines.length === 0 ? ok : lines.join('\n')
137}
138hooks/gain.ts 132 lines1/**
2 * The saving over time: one record per shrunk result, kept in one file per session and day, and the
3 * reports `/bash-diet gain` draws from them.
4 */
5
6import { charsText, fmtTokens, savingText } from './text.ts'
7
8/** One shrunk result: when, in which project, which command family, and its size before and after. */
9export type GainRecord = { at: number; project: string; family: string; raw: number; shown: number }
10
11/** How long the records are kept. */
12export const RETENTION_DAYS = 90
13
14const DAY_MS = 24 * 60 * 60 * 1000
15
16const pad = (n: number): string => String(n).padStart(2, '0')
17
18/** The local calendar day of a time: `2026-09-25`. */
19export function dayOf(at: number): string {
20 const d = new Date(at)
21 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
22}
23
24/** The file one session's records of one day go in. */
25export const gainFileName = (at: number, sessionId: string): string => `${dayOf(at)}-${sessionId}.jsonl`
26
27/** The gain files older than the retention, by the day in their name. */
28export function staleGainFiles(names: string[], now: number): string[] {
29 const oldest = dayOf(now - RETENTION_DAYS * DAY_MS)
30 return names.filter(n => /^\d{4}-\d\d-\d\d-.+\.jsonl$/.test(n) && n.slice(0, 10) < oldest)
31}
32
33const isRecord = (v: unknown): v is GainRecord => {
34 const r = v as Partial<GainRecord> | null
35 return typeof r?.at === 'number' && typeof r.project === 'string' && typeof r.family === 'string' && typeof r.raw === 'number' && typeof r.shown === 'number'
36}
37
38/** The records of a file's lines, and how many lines were not a record. */
39export function recordsOf(text: string): { records: GainRecord[]; bad: number } {
40 const records: GainRecord[] = []
41 let bad = 0
42 for (const line of text.split('\n').filter(l => l.trim() !== '')) {
43 try {
44 const v: unknown = JSON.parse(line)
45 if (isRecord(v)) records.push(v)
46 else bad += 1
47 } catch {
48 bad += 1
49 }
50 }
51 return { records, bad }
52}
53
54export const linesOfRecords = (records: GainRecord[]): string => records.map(r => JSON.stringify(r)).join('\n')
55
56// -------------------------------------------------------------------------------------------- reports
57
58type Total = { calls: number; raw: number; shown: number }
59
60function totalOf(records: GainRecord[]): Total {
61 return records.reduce((t, r) => ({ calls: t.calls + 1, raw: t.raw + r.raw, shown: t.shown + r.shown }), { calls: 0, raw: 0, shown: 0 })
62}
63
64/** `42 results · 164k → 71k chars (−57%) · ~23k tokens estimated` */
65const totalText = (t: Total): string => `${t.calls} result${t.calls === 1 ? '' : 's'} · ${savingText(t.raw, t.shown)}`
66
67/** Records grouped by a key, the largest saving first. */
68function grouped(records: GainRecord[], key: (r: GainRecord) => string): [string, Total][] {
69 const groups = new Map<string, GainRecord[]>()
70 for (const r of records) groups.set(key(r), [...(groups.get(key(r)) ?? []), r])
71 return [...groups].map(([k, rs]): [string, Total] => [k, totalOf(rs)]).sort((a, b) => (b[1].raw - b[1].shown) - (a[1].raw - a[1].shown))
72}
73
74const NONE = 'no Bash result shrunk in the last 90 days'
75
76/** `/bash-diet gain`: the total, then the command families that saved most. */
77export function summaryText(records: GainRecord[]): string {
78 if (records.length === 0) return NONE
79 const first = dayOf(Math.min(...records.map(r => r.at)))
80 const top = grouped(records, r => r.family).slice(0, 10)
81 const width = Math.max(...top.map(([k]) => k.length))
82 return [`since ${first}: ${totalText(totalOf(records))}`, 'top commands:', ...top.map(([k, t]) => ` ${k.padEnd(width)} ${totalText(t)}`)].join('\n')
83}
84
85/** `/bash-diet gain project`: the saving per project. */
86export function projectText(records: GainRecord[]): string {
87 if (records.length === 0) return NONE
88 const rows = grouped(records, r => r.project)
89 const width = Math.max(...rows.map(([k]) => k.length))
90 return rows.map(([k, t]) => `${k.padEnd(width)} ${totalText(t)}`).join('\n')
91}
92
93/** The last `days` days, oldest first, each with its total. */
94function lastDays(records: GainRecord[], now: number, days: number): [string, Total][] {
95 const byDay = new Map(grouped(records, r => dayOf(r.at)))
96 return Array.from({ length: days }, (_, i) => dayOf(now - (days - 1 - i) * DAY_MS)).map(d => [d, byDay.get(d) ?? { calls: 0, raw: 0, shown: 0 }])
97}
98
99/** `/bash-diet gain daily`: one row per day of the last two weeks. */
100export function dailyText(records: GainRecord[], now: number, days = 14): string {
101 return lastDays(records, now, days).map(([d, t]) => (t.calls === 0 ? `${d} -` : `${d} ${totalText(t)}`)).join('\n')
102}
103
104/** `/bash-diet gain graph`: the characters saved per day as bars. */
105export function graphText(records: GainRecord[], now: number, days = 14, width = 40): string {
106 const rows = lastDays(records, now, days).map(([d, t]): [string, number] => [d.slice(5), t.raw - t.shown])
107 const max = Math.max(1, ...rows.map(([, n]) => n))
108 return rows.map(([d, n]) => `${d} ${'█'.repeat(Math.round((n / max) * width)).padEnd(width)} ${n === 0 ? '' : `${fmtTokens(n)} chars`}`.trimEnd()).join('\n')
109}
110
111/** `/bash-diet gain history`: the newest results, one line each. */
112export function historyText(records: GainRecord[], count = 20): string {
113 if (records.length === 0) return NONE
114 const newest = [...records].sort((a, b) => b.at - a.at).slice(0, count)
115 return newest.map(r => {
116 const d = new Date(r.at)
117 return `${dayOf(r.at).slice(5)} ${pad(d.getHours())}:${pad(d.getMinutes())} ${r.family} ${charsText(r.raw, r.shown)} ${r.project}`
118 }).join('\n')
119}
120
121/** The report a `gain` argument asks for, or undefined for an unknown one. */
122export function gainReport(view: string, records: GainRecord[], now: number): string | undefined {
123 const views: Record<string, () => string> = {
124 '': () => summaryText(records),
125 project: () => projectText(records),
126 daily: () => dailyText(records, now),
127 graph: () => graphText(records, now),
128 history: () => historyText(records),
129 }
130 return views[view]?.()
131}
132hooks/pipeline.ts 99 lines1import { BUILTIN_RULES } from './builtin-rules.ts'
2import { read, type Target } from './command.ts'
3import { applyRule, ruleFor, type Rule } from './dsl.ts'
4import type { Filter, FilterResult } from './filters/common.ts'
5import { GENERIC } from './filters/generic.ts'
6import { filterFor } from './filters/index.ts'
7import { classify, type Classified } from './rules.ts'
8
9/** What to do with one Bash call: which filter reads its output, and the flags it asks for. */
10export type Plan = {
11 /** The family a gain record is counted under: `git status`, `cargo test`, or `other`. */
12 family: string
13 filter: Filter
14 args: string[]
15 flags: string[]
16 target?: Target
17 /** The index, in the target's words, of the word the flags follow. */
18 nameEnd: number
19}
20
21const OTHER = (args: string[] = []): Plan => ({ family: 'other', filter: GENERIC, args, flags: [], nameEnd: -1 })
22
23/**
24 * The plan for a command: undefined for one that is left alone (raw, opaque), the generic cleanup for a
25 * chain, else the first of: the person's rules, the command's own filter, a built-in rule, the cleanup.
26 */
27export function planFor(command: string, rules: readonly Rule[] = []): Plan | undefined {
28 const reading = read(command)
29 if (reading.kind === 'raw' || reading.kind === 'opaque') return undefined
30 if (reading.kind === 'mixed') return OTHER()
31 return targetPlan(reading.target, rules)
32}
33
34/** A plan that runs a data rule; a rule asks for no flags. */
35function rulePlan(rule: Rule, target: Target): Plan {
36 return { family: `${rule.source} rule ${rule.name}`, filter: { run: input => applyRule(rule, input.text) }, args: target.words.slice(1), flags: [], target, nameEnd: -1 }
37}
38
39/** The plan for one command: the person's rule, its table's filter and flags, a built-in rule, or the cleanup. */
40function targetPlan(target: Target, rules: readonly Rule[]): Plan {
41 const own = ruleFor(rules, target.words)
42 if (own !== undefined) return rulePlan(own, target)
43 const c = classify(target.words)
44 const filter = c === undefined ? undefined : filterFor(c)
45 if (c !== undefined && filter !== undefined) return tablePlan(c, filter, target)
46 const builtin = ruleFor(BUILTIN_RULES, target.words)
47 return builtin === undefined ? { ...OTHER(target.words.slice(1)), target } : rulePlan(builtin, target)
48}
49
50/** A plan that runs a table's filter, with the flags it asks for when the command may take them. */
51function tablePlan(c: Classified, filter: Filter, target: Target): Plan {
52 const flags = target.canAddFlags ? (filter.flags?.(c.args) ?? []) : []
53 const family = c.sub === '' ? c.tool : `${c.tool} ${c.sub}`
54 return { family, filter, args: c.args, flags, target, nameEnd: c.nameEnd }
55}
56
57/** Runs a plan's filter; a filter that throws leaves the output to the generic cleanup. */
58export function runFilter(plan: Plan, text: string, exitCode: number, flagged: boolean): FilterResult {
59 try {
60 return plan.filter.run({ args: flagged ? [...plan.args, ...plan.flags] : plan.args, text, exitCode })
61 } catch {
62 // A filter that cannot read this output: the cleanup still drops escapes and repeats.
63 return GENERIC.run({ args: plan.args, text, exitCode })
64 }
65}
66
67/** The least a filter must save for its text to replace the output: a share of it, and characters. */
68export const MIN_SAVED_SHARE = 0.05
69export const MIN_SAVED_CHARS = 40
70
71/**
72 * Whether the filtered text replaces the output: only when it saves at least `MIN_SAVED_SHARE` of the
73 * output and `MIN_SAVED_CHARS` characters, trailing whitespace left out, because a smaller saving only
74 * changes what the model reads and counts a shrink nobody gains from. Flags that changed the command's
75 * format always replace it, because then the raw output is a format the model did not ask for.
76 */
77export function replaces(raw: string, filtered: string, flagged: boolean): boolean {
78 const before = raw.trimEnd().length
79 const saved = before - filtered.trimEnd().length
80 return flagged || (saved >= MIN_SAVED_CHARS && saved >= before * MIN_SAVED_SHARE)
81}
82
83/** A failed call's error text split into its exit code and output: `Exit code 3\n...`; else undefined. */
84export function failureOf(text: string): { exitCode: number; output: string } | undefined {
85 const m = /^Exit code (\d+)\n?/.exec(text)
86 return m === null ? undefined : { exitCode: Number(m[1]), output: text.slice(m[0].length) }
87}
88
89/** The path of the full output the engine kept when a result was too large, from its model text. */
90export function persistedPathOf(text: string): string | undefined {
91 return /<persisted-output>[\s\S]*?Full output saved to: (\S+)/.exec(text)?.[1]
92}
93
94/** The full output the model would have read: stdout, then stderr on its own line when there is any. */
95export function joined(stdout: string, stderr: string): string {
96 if (stderr === '') return stdout
97 return stdout === '' ? stderr : `${stdout.replace(/\n$/, '')}\n${stderr}`
98}
99hooks/playwright.ts 30 lines1/** The code echo in Playwright MCP results: the code of the call, which the model wrote or asked for itself. */
2
3/** A Playwright MCP browser tool, as a plugin or as a plain MCP server names it. */
4export const PLAYWRIGHT_TOOL = /^mcp__.*playwright.*__browser_/
5
6/** The `### Ran Playwright code` section, up to the next section or the end. */
7const ECHO = /^### Ran Playwright code\n[\s\S]*?(?=^### |(?![\s\S]))/gm
8
9/** The text without its code echo. */
10export function withoutEcho(text: string): string {
11 return text.replace(ECHO, '')
12}
13
14type Block = { type?: unknown; text?: unknown }
15
16/** An MCP tool result as it reaches a hook: the content blocks, and their text joined. */
17export type McpResult = { result?: unknown; text?: unknown }
18
19function blockWithoutEcho(block: unknown): unknown {
20 const b = block as Block
21 return b !== null && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string' ? { ...b, text: withoutEcho(b.text) } : block
22}
23
24/** The result with the code echo taken out of every text block and of the joined text. */
25export function resultWithoutEcho<R extends McpResult>(r: R): R {
26 const result = Array.isArray(r.result) ? r.result.map(blockWithoutEcho) : r.result
27 const text = typeof r.text === 'string' ? withoutEcho(r.text) : r.text
28 return { ...r, result, text }
29}
30hooks/pricing.ts 57 lines1/**
2 * What the tokens a filter kept out of the context would have cost. A result enters the context once,
3 * written to the prompt cache, and every later request of the session reads it back from the cache.
4 */
5
6import { fmtTokens } from './text.ts'
7
8/** List prices in dollars per million tokens: the cache read, and the 1-hour cache write. */
9export type Price = { read: number; write: number }
10
11/**
12 * Model pricing from platform.claude.com/docs/en/about-claude/pricing, read in September 2026. A model
13 * id takes the first row whose family it contains, so the specific families come before the general ones.
14 */
15const PRICES: ReadonlyArray<readonly [family: string, price: Price]> = [
16 ['fable-5-1', { read: 0.25, write: 20 }],
17 ['mythos-5-1', { read: 0.25, write: 20 }],
18 ['fable-5', { read: 1, write: 20 }],
19 ['mythos-5', { read: 1, write: 20 }],
20 ['opus-5-5', { read: 0.2, write: 8 }],
21 ['opus-5', { read: 0.5, write: 10 }],
22 ['opus-4-5', { read: 0.5, write: 10 }],
23 ['opus-4-6', { read: 0.5, write: 10 }],
24 ['opus-4-7', { read: 0.5, write: 10 }],
25 ['opus-4-8', { read: 0.5, write: 10 }],
26 ['opus-4', { read: 1.5, write: 30 }],
27 ['sonnet-5-5', { read: 0.2, write: 4 }],
28 ['sonnet-5', { read: 0.2, write: 4 }],
29 ['sonnet', { read: 0.3, write: 6 }],
30 ['haiku-4-5', { read: 0.1, write: 2 }],
31 ['haiku', { read: 0.08, write: 1.6 }],
32]
33
34const MTOK = 1e6
35
36export function priceOf(model: string): Price | undefined {
37 const id = model.toLowerCase().replace(/[\s.]+/g, '-')
38 return PRICES.find(([family]) => id.includes(family))?.[1]
39}
40
41const usd = (n: number): string => (n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`)
42
43/** `/bash-diet cost`: the session's spend, and what the tokens kept out would have added. */
44export function costText(model: string, savedTokens: number, sessionUsd: number | undefined): string {
45 const spent = sessionUsd === undefined ? 'no cost ledger in this host' : `${usd(sessionUsd)} so far`
46 const head = `this session (${model}): ${spent}`
47 if (savedTokens === 0) return `${head}\nno Bash result shrunk yet`
48 const price = priceOf(model)
49 const kept = `~${fmtTokens(savedTokens)} tokens kept out of the context`
50 if (price === undefined) return `${head}\n${kept}; no price is known for ${model}`
51 return [
52 head,
53 `${kept}: ${usd((savedTokens * price.write) / MTOK)} saved on the cache write when they would have entered,`,
54 `and ${usd((savedTokens * price.read) / MTOK)} on every later request that reads the context back from the cache`,
55 ].join('\n')
56}
57hooks/recall.ts 45 lines1/**
2 * The full output of a filtered call, kept in a file the model can open with Read: the engine's own file
3 * when it already wrote one, else one under `$TMPDIR/bash-diet`.
4 */
5
6/** A failed call's output shorter than this reads whole after filtering; it needs no file. */
7export const MIN_KEPT_BYTES = 500
8
9/** How many files the directory keeps, and for how long; the oldest go first. */
10export const MAX_FILES = 200
11export const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
12
13/** Whether a call's full output is worth a file: the filter left something out, or a long run failed. */
14export function needsFile(elided: boolean, exitCode: number, rawLength: number): boolean {
15 return elided || (exitCode !== 0 && rawLength >= MIN_KEPT_BYTES)
16}
17
18/** The SHA-256 of a text in hex. */
19export async function sha256Of(text: string): Promise<string> {
20 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
21 return Array.from(digest, b => b.toString(16).padStart(2, '0')).join('')
22}
23
24/** The first 12 hex digits of the SHA-256 of the command and its output, as the file's name. */
25export async function hashOf(command: string, output: string): Promise<string> {
26 return (await sha256Of(`${command}\0${output}`)).slice(0, 12)
27}
28
29/**
30 * Whether the engine cut a failed call's text before the hook read it: it keeps 10,000 characters around
31 * `... [N characters truncated] ...` and writes the middle nowhere.
32 */
33export const isCut = (text: string): boolean => /\.\.\. \[\d+ characters truncated\] \.\.\./.test(text)
34
35/** The line the model reads under the filtered text; a cut output's file holds only what reached the hook. */
36export function fullOutputLine(path: string, cut: boolean): string {
37 return cut ? `[output cut by Claude Code at 10000 characters; the middle is lost: ${path}]` : `[full output: ${path}]`
38}
39
40/** The files to delete from a listing: over the age limit, then the oldest past the count limit. */
41export function staleFiles(files: { name: string; mtimeMs: number }[], now: number): string[] {
42 const sorted = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs)
43 return sorted.filter((f, i) => i >= MAX_FILES || now - f.mtimeMs > MAX_AGE_MS).map(f => f.name)
44}
45hooks/text.ts 123 lines1import { RAW_VARIABLE } from './command.ts'
2
3/**
4 * The note the model reads at the session's start, after /clear and after a compaction, so it knows a
5 * condensed result is complete and how to reach every byte.
6 */
7export const AWARENESS = [
8 'Bash results in this session pass through a filter that condenses known commands (git, test runners, linters, compilers, package managers, containers, file listings and searches): passing tests collapse to a count, progress and noise lines drop, long lists end with a count of the rest.',
9 'Treat a condensed result as complete. A result that left something out ends with `[full output: <path>]`; open that file with the Read tool when you need what was left out.',
10 'Claude Code cuts a failed command\'s output at 10000 characters before any filter reads it; such a result ends with `[output cut by Claude Code ...]`, and its middle exists nowhere, so run the command again with narrower output when you need it.',
11 `When you need the exact bytes (a patch to apply, output to parse), prefix the command with \`${RAW_VARIABLE}=1\`, and the result comes back unfiltered.`,
12].join(' ')
13
14export const USAGE = 'expects nothing (the status), on, off, exclude <prefix | ^regex>, include <prefix | ^regex>, excludes, filters, trust, untrust, gain [project | daily | graph | history], discover [days] [all], learn [days] [write] or cost'
15
16/** A rule file as the person reads it, and the names of its rules. */
17export type RuleFileView = { shown: string; source: 'project' | 'global'; exists: boolean; trusted: boolean; names: string[] }
18
19/** `/bash-diet filters`: every rule file with its state and rules, then the built-in rules. */
20export function filtersText(files: RuleFileView[], builtin: string[]): string {
21 const rows = files.map(f => {
22 if (!f.exists) return `${f.source}: ${f.shown} (none)`
23 const state = f.source === 'project' && !f.trusted ? ' (not trusted: /bash-diet trust runs it)' : ''
24 return `${f.source}: ${f.shown}${state}: ${f.names.length === 0 ? 'no rules' : f.names.join(', ')}`
25 })
26 return [...rows, `built-in: ${builtin.join(', ')}`].join('\n')
27}
28
29/** Characters per token, as the gain is estimated: no tokenizer runs here. */
30export const CHARS_PER_TOKEN = 4
31
32export function tokensOf(chars: number): number {
33 return Math.ceil(chars / CHARS_PER_TOKEN)
34}
35
36/** A token or character count as `830`, `12.4k` or `1.2M`. */
37export function fmtTokens(n: number): string {
38 if (n < 1000) return String(n)
39 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
40 return `${(n / 1_000_000).toFixed(1)}M`
41}
42
43/** The share of the characters the filter took out, as `(−57%)`. */
44function pctText(rawChars: number, shownChars: number): string {
45 const pct = rawChars === 0 ? 0 : Math.round(((rawChars - shownChars) / rawChars) * 100)
46 return `(−${pct}%)`
47}
48
49/** The tokens saved as estimated from the characters: `~23k tokens estimated`. */
50function estimateText(rawChars: number, shownChars: number): string {
51 return `~${fmtTokens(tokensOf(rawChars - shownChars))} tokens estimated`
52}
53
54/** The measured characters before and after: `164k → 71k chars (−57%)`. */
55export function charsText(rawChars: number, shownChars: number): string {
56 return `${fmtTokens(rawChars)} → ${fmtTokens(shownChars)} chars ${pctText(rawChars, shownChars)}`
57}
58
59/** The measured characters, then the tokens saved as estimated from them. */
60export function savingText(rawChars: number, shownChars: number): string {
61 return `${charsText(rawChars, shownChars)} · ${estimateText(rawChars, shownChars)}`
62}
63
64/** The session's gain: how many results shrank and what they saved. */
65export function sessionText(calls: number, rawChars: number, shownChars: number): string {
66 if (calls === 0) return 'no Bash result shrunk yet'
67 return `${calls} result(s) shrunk · ${savingText(rawChars, shownChars)}`
68}
69
70/** How the sidebar colours a line or a part of one. */
71type Tone = 'ok' | 'warn' | 'error' | 'dim'
72export type Part = { text: string; kind?: Tone }
73/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
74export type Line = { text: string; kind?: Tone; parts?: Part[] }
75
76const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
77
78/** A line made of parts, its `text` their texts joined. */
79const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
80
81/**
82 * `sessionText` as the sidebar line: faint before any result shrank, then the share taken out green and
83 * the token estimate faint, the counts in the default colour.
84 */
85export function sessionLine(calls: number, rawChars: number, shownChars: number): Line {
86 if (calls === 0) return { text: sessionText(calls, rawChars, shownChars), kind: 'dim' }
87 return partsLine([
88 part(`${calls} result(s) shrunk · ${fmtTokens(rawChars)} → ${fmtTokens(shownChars)} chars `, undefined),
89 part(pctText(rawChars, shownChars), 'ok'),
90 part(' · ', undefined),
91 part(estimateText(rawChars, shownChars), 'dim'),
92 ])
93}
94
95export function statusText(enabled: boolean, excludes: string[], session: string): string {
96 const ex = excludes.length === 0 ? '' : ` · ${excludes.length} exclude(s)`
97 return `${enabled ? 'on' : 'off'}${ex} · ${session}`
98}
99
100/** Whether a pattern is a valid exclude: a plain prefix, or a `^` regex that compiles. */
101export function patternError(pattern: string): string | undefined {
102 if (pattern === '') return 'expects a command prefix or a ^regex'
103 if (!pattern.startsWith('^')) return undefined
104 try {
105 new RegExp(pattern)
106 return undefined
107 } catch (err) {
108 return `not a valid regex: ${err instanceof Error ? err.message : String(err)}`
109 }
110}
111
112/**
113 * Whether an exclude matches a command: a plain pattern matches the command's words from the start at a
114 * word boundary (`npm` matches `npm test`, not `npmx`), a `^` pattern is a regex over the same words.
115 */
116export function isExcluded(patterns: string[], words: string[]): boolean {
117 const text = words.join(' ')
118 return patterns.some(p => {
119 if (p.startsWith('^')) return new RegExp(p).test(text)
120 return text === p || text.startsWith(`${p} `)
121 })
122}
123