SLOPSHOPPER

bash-diet

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…

newguardcommandstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bash-diet
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /bash-diet ⎿ bash-diet: on · no Bash result shrunk yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

bash-diet

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.

What it does

  1. It hooks the Bash tool, runs the command, and reads the output before the model does: stdout and stderr of a success, the error text of a failed exit. Subagent calls go through the same hook.
  2. It reads the command the way a shell does. Variables and wrappers in front (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.
  3. A filter keeps what you would read the output for and drops the rest:
  4. A passing test run shrinks to its count line. A failing one keeps each failure with its message and the stack frames of your own code.
  5. A build keeps its diagnostics, each once, errors first, and the verdict.
  6. A listing, a search or a table keeps its rows up to a cap and ends with a count of the rest.
  7. Progress bars, download lines, spinners and colour codes go everywhere.
  8. For two commands it adds a flag that makes the output smaller: 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.
  9. A filtered result that saves less than 5% of the output, or fewer than 40 characters, is thrown away and the model reads the output as it was; your own rules follow the same bar. A smaller saving would only change what the model reads and count a shrink nobody gains from. Two cases always keep the filtered result:
  10. An 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.
  11. Output after an added flag, because the raw output is then in a format the model did not ask for.

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.

  1. When a filter left lines out, or a failed run printed 500 characters or more, the full output is kept and the result ends with its path:

[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]

  1. A failed command stays an error with its exit code: the model reads Exit code 1 and the filtered text as a tool error.
  2. At the session's start, after /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.
  3. Playwright MCP repeats the code of every browser call in its result, under ### 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).
  4. With the sidebar open, the session's saving stands there under "Bash output". Without it, the status line shows it.

Filters

These are all the commands the mod has a filter of its own for. A command marked * gets the flag of item 4.

FamilyCommands
gitgit 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, GitLabgh pr, gh issue, gh run, gh release; glab mr, glab issue
Rustcargo 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)
Gogo test, go build, go vet, go get, go mod, go install; golangci-lint, golangci-lint run; gofmt -l and -d, go fmt
Pythonpytest\*; 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
JavaScriptnpm 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)
JVMmvn, mvnd, gradle, gradlew, sbt
Rubyrake test, rails test, ruby (a minitest file), rspec, rubocop, bundle install, bundle update
PHPphp -l, phpunit, pest, paratest, artisan test, phpstan analyse, phpstan analyze
.NETdotnet build, dotnet test, dotnet format, dotnet publish, dotnet pack, dotnet restore
Appleswift build, swift test, xcodebuild
Files and systemls, 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
Containersdocker 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 networkaws (aws s3 ls as a capped list, the rest as JSON), gcloud; terraform plan, terraform apply, tofu plan, tofu apply; pulumi; curl, wget
makemake, 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 rulesgcc, 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
  • A command started through a runner counts as the command it starts: 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.
  • Every other command gets the generic cleanup: colour codes, carriage-return redraws and repeated lines go.
  • cat, head and tail of a file are never filtered, because the model asked for exactly those lines.

Measured saving

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 modWith the modSaving
Characters of the 35 Bash results79,55533,36658%
Context tokens the 35 results added38,27720,65346%
Context at the session's end107,94690,55716%
Input tokens over all requests2,856,1722,512,37012%
Session cost$1.00$0.7821%
  • A result's tokens are how much the context grew from the request that ran the command to the next one, minus that request's output tokens. That count includes about 100 tokens for the call itself, which no filter can shrink.
  • The session's own prompt, tools and instructions are the same in both runs, so the saving over the whole session is smaller than the saving on the results.

The context tokens of the 35 results, by family. Each figure is the sum of the family's per-command medians.

FamilyCommands runWithout the modWith the modSaving
gitgit status, git diff, git log, git branch -a, git show --stat1,59889044%
Gogo build, go vet, go test30822627%
Rustcargo build, cargo clippy, cargo test1,68292345%
Nodenpm install, npx tsc, npx vitest run, npm ls1,1911,01215%
Pythonpytest, python3 -m pip list1,6351,28721%
Gradlegradle build, gradle test75754029%
.NETdotnet build, dotnet test1,22169743%
Swiftswift build, swift test1,44391537%
Rubyrake test1,09220681%
PHPphp -l1121038%
Cmake2812733%
Filesls -la, find -name, grep -rn6,9742,38466%
Containersdocker ps -a, docker images10,4232,27078%
Systemenv, ps aux, df -h, du -sh9,5608,9277%
Total35 commands38,27720,65346%
  • The smallest savings are on 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:

CommandWithout the modWith the modSaving
flake82,09177563%
pylint2,8981,04964%
gofmt -d3285683%
black --check --diff1,10428674%
webpack77911785%
vite build (a parse error)1,56229181%
esbuild (a parse error)1,04410990%
rollup (a parse error)1,55417889%
mocha89745549%
cypress run5,51732794%
make check of this mod (lint, typecheck, validate, 129 tests)15,1311,68489%
make test running go test -v56330745%
make test running pytest -v1,38292833%

Your own rules

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.

Command

/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.

Install

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.

After installing

  1. Restart Claude Code.
  2. If you use another tool that rewrites Bash commands for the same purpose, turn it off, so each output is filtered once.
  3. To keep a project's own rules, write .bash-diet/filters.json and run /bash-diet trust in that project.

What it can reach

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.

  1. Reads: each Bash command and its output; the result of each Playwright MCP browser tool; the two filters.json files; this session's model, spend and id; the transcripts under ~/.claude/projects for discover and learn
  2. Runs: the model's own Bash command, with a flag that shortens its output added when the permission check allows it; git rev-parse, mkdir, rm (of its own files only) and cat (of transcripts)
  3. Sends: the filtered result to the model in place of the output, and each Playwright result without its code echo; nothing leaves the machine
  4. Persists: full outputs in $TMPDIR/bash-diet (200 files, 30 days); saving records in ~/.claude/bash-diet/gain (90 days); .claude/rules/cli-corrections.md on learn write; in $.store, on/off, the excludes and the trusted rule file hashes
  5. Hostile input: a command's output only passes through regexes and JSON.parse, and is never run; a project rule file runs only after /bash-diet trust and only while its SHA-256 matches; env values of credential-like names are masked

Limits

  • A filter reads the output's known shape. A tool that changes its output format can make a filter keep less than it should; the full output file and BASH_DIET_RAW=1 are your way back.
  • The middle of a failed command's output over 10,000 characters is dropped by Claude Code before the mod ever sees it. The mod cannot bring it back; it only tells you it was cut.
  • A backgrounded command (run_in_background) is not filtered, because its result is a task id.
  • A command inside $(...), a heredoc or a process substitution, or one whose output is redirected to a file, is not filtered.
  • A chain of several printing commands gets only the generic cleanup (colour codes, carriage-return redraws, repeated lines).
  • 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.

Development

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

Source 35 files
hooks/register.ts 555 lines
1import 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}
555
hooks/builtin-rules.ts 87 lines
1/**
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)
87
hooks/command.ts 154 lines
1import { 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}
154
hooks/dsl.ts 195 lines
1/**
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}
195
hooks/history.ts 219 lines
1/**
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}
219
hooks/filters/common.ts 138 lines
1/**
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}
138
hooks/gain.ts 132 lines
1/**
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}
132
hooks/pipeline.ts 99 lines
1import { 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}
99
hooks/playwright.ts 30 lines
1/** 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}
30
hooks/pricing.ts 57 lines
1/**
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}
57
hooks/recall.ts 45 lines
1/**
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}
45
hooks/text.ts 123 lines
1import { 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