neuralmind.uk / NeuralMind — tool-output compression benchmark Edit this page on GitHub ↗

NeuralMind — tool-output compression benchmark

What this is: a measurement of what neuralmind install-hooks’ PostToolUse hooks do to the tokens Claude actually sees for Read, Bash and Grep calls in Claude Code, by default and with the opt-in replacement of noisy logs (NEURALMIND_BASH_REPLACE=1). It drives the real hook with the payloads Claude Code sends and applies the hook’s response the way Claude Code’s documented hook protocol applies it. It does not stop at what the compressor functions return in isolation. Raw per-call data:

Reproduce it:

git clone https://github.com/dfrostar/neuralmind && cd neuralmind
pip install -e ".[dev]" tiktoken
python -m evals.compression.run --out bench/compression

The Bash inputs are committed. The Read and Grep inputs are the four public-benchmark repos at their pinned commits, cloned on first run. Runs from two different checkout directories, each rebuilding its indexes, agreed on every one of the 251 calls. On the 248 calls the corpus had before its pip install logs were added, a run on macOS (Apple Silicon, Python 3.11) and CI’s on Linux (x86-64, Python 3.12) agreed too, so a run on either can be committed.

What we found in v4.3.4

As shipped in v4.3.4, the hooks did not reduce what Claude sees. On Bash and Grep they added to it.

Tool call Calls Hook responded Tokens, no hook Tokens, v4.3.4 hooks Change
Read (whole file) 136 0 597,002 597,002 +0.0%
Bash 16 12 32,581 38,296 +17.5%
Grep, content mode 48 28 55,800 68,113 +22.1%
Grep, files_with_matches (the default) 48 0 2,062 2,062 +0.0%

Three documented facts about Claude Code’s hook protocol explain the table.

  1. A PostToolUse hook’s additionalContext is added next to the tool result. It does not replace it. The hooks reference describes it as a “String added to Claude’s context alongside the tool result”. NeuralMind returned its compressed text in that field. So Claude received the full output and the compressed copy. When the hook compressed nothing, as with any successful Bash output of 3,000 characters or less, the copy was the whole output again: pip list, next build, a file listing and a short pytest -q each doubled (+100%). Replacing a result takes a different field, updatedToolOutput, which NeuralMind didn’t use.
  2. The Read hook never found the text. PostToolUse receives the Read tool’s structured output, with the file under file.content. The hook looked for top-level content, output or text keys, found none, and exited. It responded on 0 of 136 reads. Given a top-level content key instead, it did respond. In our run it then printed an index-build line to stdout ahead of its JSON, which Claude Code would reject, because hook stdout must be only the JSON object.
  3. Failed commands never reach a PostToolUse hook. A command that exits non-zero (other than exit 1 from grep, find, diff and a few others) fires PostToolUseFailure, not PostToolUse. NeuralMind registers only PostToolUse, and PostToolUseFailure can add context but can never replace the result. The failing test run, the lint errors, the type errors and the crash traceback in the corpus all reached Claude untouched. Those are the outputs the compressor’s “errors + summary” design was built for.

On macOS, Linux and WSL, Claude Code leaves the Grep tool out of the default tool set, and searches reach hooks as Bash calls (the grep -rn entry below). Where the Grep tool was used, its hook did nothing in the default files_with_matches mode. On output_mode: "content" results longer than 25 lines it added the first 25 lines again (+10.4% to +96.2% per call).

What the hooks do now

From v4.5.0 they inject nothing by default, so Claude sees exactly the tool result: +0.0% for Read, Bash and Grep alike. The Read, Bash and Grep hooks stay registered. The Bash hook still caches the latest successful command’s output, credentials redacted, so neuralmind last can show it again without re-running the command. None of them returns context anymore. That removes the overhead above with no reinstall. Shrinking a tool result for real takes updatedToolOutput, and the retention figures further down are why that isn’t a drop-in switch. The one replacement there is, opt-in, is narrow on purpose.

The opt-in replacement: noisy logs only

Off by default. With NEURALMIND_BASH_REPLACE=1 in Claude Code’s environment (for example "env": {"NEURALMIND_BASH_REPLACE": "1"} in .claude/settings.json), the Bash hook returns updatedToolOutput, which replaces a result rather than adding to it, for one kind of output: the progress reporting of an install or an index build.

Bash calls Calls Replaced Tokens, no hook Tokens, opt-in Change
Noisy logs (installs, builds, index runs) 5 4 8,253 1,490 -81.9%
Content (test runs, diagnostics, diffs, listings, file dumps, searches) 14 0 31,473 31,473 +0.0%

Per noisy-log call the change averages -54.8%, from -96.5% to +0.0%:

Command Tokens, no hook Tokens, opt-in Must-keep lines reaching Claude
pip install -e ".[dev]", dependencies already present 4,720 164 (-96.5%) 3 of 3
pip install -r requirements.txt, fresh environment 2,225 351 (-84.2%) 1 of 1
neuralmind build src/requests --force 461 202 (-56.2%) 2 of 2
pip install pytest, already installed 200 126 (-37.0%) 1 of 1
npm run build (Next.js) 647 647 (+0.0%), not replaced —

next build isn’t on the allowlist: npm run build can run anything, and the page-generation progress lines in Next.js’s log are among its pre-registered must-keep lines. Read and Grep results are never replaced: +0.0% on all 232 of them.

How it decides what to replace:

Gates. The benchmark exits non-zero if any of these fails, and tests/test_compression_benchmark.py checks them on every CI run, recomputing the Bash calls from the committed corpus:

Checked in Claude Code itself. In a headless Claude Code 2.1.287 session, with the hook registered and the variable set only in the project’s .claude/settings.json, the model received the trimmed pip install result, 5 lines instead of 77, and answered which version of requests was installed from it. Asked instead how many lines started with Collecting, it opened the kept full output and counted them. Those were single runs, a check of the mechanism rather than a measurement.

What the opt-in figures don’t show:

What the compressors would deliver if they replaced the result

This part is hypothetical: it measures the compressors’ own output, as a hook returning updatedToolOutput would deliver it, next to what survives.

Tool call Calls a replacing hook could reach Tokens, no hook Tokens, compressor output Change What survives
Read (whole file) 136 (110 compressed) 597,002 79,308 -86.7% 93% of definitions named, 0% of source lines
Bash 15 of 19 32,003 9,093 -71.6% 60% of must-keep lines
Grep, content mode 48 55,800 16,610 -70.2% 63% of matches

The reductions are large, and so is what they cost:

The -71.6% Bash figure is over the 15 calls a replacing hook could reach. “Must-keep” lines are pre-registered per output type in evals/compression/capture_bash.py: the assertion lines of a failing test, a linter’s diagnostics, a diff’s changed lines, every hit of a search, an install’s outcome lines. They are not tuned to the compressor, and the install-log lines were committed before the code that trims those logs.

Method

Every sample is one tool call, measured four ways:

Sources:

Inputs:

What this does not capture, and which way each gap leans:

CI fails when results.json stops describing the code, in two places: