kuro-lean
Health Pass
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Community trust — 15 GitHub stars
Code Fail
- process.env — Environment variable access in src/bench.ts
- spawnSync — Synchronous process spawning in src/cli.ts
- process.env — Environment variable access in src/cli.ts
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Cuts the token bill of AI coding agents: compresses shell output, blocks token-hungry calls before they run, prices your session live, and rescues sessions whose prompt cache expired. Bun + Claude Code.
Squash the noise, keep the signal
A CLI that cuts the token bill of AI coding agents — it compresses shell output, blocks
token-hungry calls before they run, prices the session you are in, and rescues sessions whose
prompt cache has expired.
Install · Commands · Where the money goes · Benchmarks · Configuration
$ kt run -- git diff HEAD~3 HEAD
.github/workflows/ci.yml +1 -1
src/hooks/prompt.ts +12 -13
src/sessions.ts +22 -13
src/statusline.ts +7 -10
src/transcript.ts +69 -0
test/sessions.test.ts +31 -0
test/statusline.test.ts +4 -1
test/transcript.test.ts +76 -0
↳ 405 lines compressed · full: kt show 2026-08-10T03-06-51-601Z
16,564 chars → 298 chars. 98% saved, and the full log is one command away. Run the same thing on
a failing test suite and you get the whole failure back, uncut — that is the trade kt makes everywhere.
Every number in this README is measured, not estimated — including the ones that say kt does
not help. See How much this actually saves.
The 30-second version
| What it does | Why it matters | |
|---|---|---|
| 🗜️ Compress | kt run -- <cmd> prints a squashed version of noisy output and stores the full log |
A 21k-char diff becomes 500 chars. Test failures are never compressed away |
| 🚧 Guard | Denies find /, cat on a 5 MB file, whole lock files, git log -p… |
The cheapest token is the one that never enters the context |
| 💸 Price | kt cost shows the real bill from your transcripts; the status line prices the turn you are about to send |
87% of the bill is cache read + cache write, not output. Most people optimise the wrong thing |
| 🛟 Rescue | kt handoff --recover rebuilds a dead session from the on-disk transcript for ~2.5k tokens |
The prompt cache expires after 1 hour. The transcript does not |
It works in two layers:
- Core CLI (
kt run,kt show,kt stats,kt cost,kt handoff) — a standalone Bun CLI.
Runs anywhere Bun is installed, with any agent, or by hand. - Auto-integration (
kt init) — registers hooks that rewrite commands, block noise and render
a status line. This uses Claude Code's settings schema, so full automation is Claude Code–only.
Other agents use the core CLI manually — see Compatibility.
Install
Requires Bun ≥ 1.3 (the entry point is src/cli.ts, run directly by Bun).
bun add -g kuro-lean # installs the `kt` binary
kt init # (Claude Code) register the hooks + status line
kt doctor # verify the setup
No build step — kt runs the TypeScript directly under Bun.
# straight from GitHub, no npm involved
bun add -g github:kurovu146/kuro-lean
# from source, for hacking on it
git clone https://github.com/kurovu146/kuro-lean.git && cd kuro-lean
bun install
bun link # puts your working copy on PATH as `kt`
To remove it: bun remove -g kuro-lean. That leaves your Claude Code settings alone — the hook
entries kt init added stay in ~/.claude/settings.json until you delete them, and a .bak from
before the install is sitting next to it.
$ kt doctor
settings: ✓ /Users/you/.claude/settings.json
statusLine kt: ✓
hook-guard: ✓
hook-compress: ✓
hook-prompt: ✓
permission kt run: ✓
skill concise-output: ✓
skill lean-code: ✓
bun: 1.3.9
What exactly does kt init touch?
It edits ~/.claude/settings.json and is idempotent — it writes a .bak first and never
creates a duplicate entry.
| Key | Change | Overwrite policy |
|---|---|---|
hooks.PreToolUse |
kt hook-guard + kt hook-compress on Bash, kt hook-guard on Read |
appended, never replaced |
hooks.UserPromptSubmit |
kt hook-prompt |
appended, never replaced |
statusLine |
kt status |
only if you have none — a custom status line is left alone |
permissions.allow |
Bash(kt run:*) |
appended |
~/.claude/skills/ |
installs concise-output and lean-code |
never overwrites an existing skill |
Trade-off worth knowing: Bash(kt run:*) is a wildcard, so anything invoked through kt run
is auto-approved. Its output still passes the compressor and the char cap, so the token risk stays
neutralised — but if you want per-command prompting, remove that entry from permissions.allow.
The two installed skills trim the model's own output, which is the most expensive kind of token.lean-code makes the model write less code via an efficiency ladder inspired by the
ponytail project (reuse → stdlib → existing deps → minimum).
Commands
kt <run|status|stats|cost|weekly|handoff|init|hook-compress|hook-guard|hook-prompt|show|doctor|bench>
| Command | What it does |
|---|---|
kt run -- <cmd> |
Run a command, print compressed output, save the full log |
kt show [id] |
View the full log (latest run if no id) |
kt stats |
Savings report from your real usage |
kt cost [dir] |
The actual bill, from usage in this project's transcripts |
kt weekly |
Print the cached weekly-spend line (--refresh rescans now) |
kt handoff |
Distil a session so tomorrow starts light |
kt handoff --recover |
Rebuild a session whose cache is already dead |
kt handoff --list |
Every abandoned session on the machine, ranked |
kt status |
Render the 3-line status line (reads JSON on stdin) |
kt bench |
A/B benchmark against real headless Claude sessions |
kt init / kt doctor |
Install into / inspect Claude Code settings |
kt hook-compress / kt hook-guard / kt hook-prompt |
The hooks — called by Claude Code, not by you |
kt run
Runs the command, prints the compressed output, saves the full log under .kt/runs/.
Small outputs pass through untouched. If raw output is under run.rawUnderChars
(default 4,000 chars ≈ 1k tokens), kt run prints it verbatim — no compression, no log, no meta.kt bench measured that compressing tiny outputs makes the model spend extra verification turns,
and each turn re-reads the whole context — costing more than the compression saves. Set"run": { "rawUnderChars": 0 } to disable the pass-through.
kt stats
$ kt stats
6 runs · raw 70k ch → 9k ch left · saved 87% (~15k tokens)
Top commands still occupying context (chars after compression):
6k ch · 1 runs · compressed 0% · sed -n 85,155p README.md
2k ch · 1 runs · compressed 0% · git log --oneline -20
771 ch · 1 runs · compressed 99% · git diff HEAD~10 HEAD
The bottom list is the useful half: those are the commands still eating context after compression,
i.e. candidates for a new pattern or guard. Data comes from .kt/runs/index.jsonl, one line per
run, auto-trimmed to store.keepRuns.
kt cost
The counterpart to kt stats. stats measures the shell output kt compressed; cost measures
where the money actually goes — usually not the same place.
$ kt cost
Cost derived from real usage · total ~$331.35
cache read $162.47 49% · 342.5M tok · 0.1× input · the context re-read on EVERY turn
cache write $103.75 31% · 11.3M tok · 2× input · once per token loaded
output $65.07 20% · 2.7M tok · what the model writes
fresh input $0.07 0% · 19k tok · not cached yet
By model:
$300.46 · 310.6M tok · claude-opus-5
$30.73 · 45.4M tok · claude-sonnet-5
$0.17 · 575k tok · claude-haiku-4-5-20251001
(A real run on this repo, not an illustration — the 49/31/20 split is what it printed. Run it on
your own project for your own numbers.)
Read from the Claude Code transcripts for this project (main session and subagents). Prices
come from pricing in kt.json, matched by model-id prefix; a model with no entry is skipped
rather than guessed at.
How compression works
Commands are classified into profiles. Small or already-quiet output is left alone.
| Profile | Behaviour |
|---|---|
| test | pass → the summary line only. fail → everything from the first failure marker onward (Expected/Received + stack), exit code preserved. Runs of ≥2 consecutive node_modules/node:* frames collapse to … (N lib frames hidden); your own frames stay intact |
| build | OK → ✓ build OK; otherwise only error/warning lines |
| lint | eslint, golangci-lint, <pm> run lint — clean → 1 line, otherwise error/warning lines only |
| install | keep added/removed/audited/vulnerabilit… lines; on failure keep everything |
| git | git diff ≤ 40 lines kept verbatim (the agent usually wants to read it); larger → path +adds -dels per file. git status/log fall through to generic |
| generic | everything else (grep, sed, ls, cat, go run…) — kept verbatim; the char cap is its only compression |
Package-manager scripts are recognised too: npm/pnpm/yarn/bun run test|build|lint and the
no-run variants map to their profile.
Absolute char cap (limits.maxChars, default 16,000 ≈ 4k tokens) applies after every profile,
including fallbacks. It catches what line-counting misses — one giant minified line, a huge failing
suite — keeping 65% head + 35% tail with a … [cut ~N KB — kt show] … marker. Set to 0 to disable.
Which commands the hook rewritesWhy is
genericverbatim? Middle-of-output matters too often forgrep/sed/ls, and
cutting it costs the agent akt showturn. Setgeneric.thresholdLines> 0 to re-enable the old
head/tail cut (firstheadLines+ lasttailLines).
kt hook-compress rewrites a Bash command into kt run -- … unless one of these applies:
- Pipes / redirects / subshells / newlines (
|,;,>,`,$(…),&) — left to bash.2>&1is the exception (kt merges both streams anyway) and routes throughbash -c. &&chains are rewritten as a whole throughbash -c, except a leadingcd X &&: thecd
stays outside the wrapper, because Claude Code keeps the shell's working directory between Bash
calls — burying it in a subshell would leave every later command in the wrong directory. Acd
(orexport/source/nvm…) anywhere later in the chain disables the rewrite entirely.- Long-running commands (
--watch,nodemon,<pm> run dev|start|serve,next dev,tail -f,… logs -f,ping) — wrapping them buffers until exit, i.e. hangs until the timeout. - Commands needing a tty (
sudo,ssh,vim,less,psql,docker … -it,gh auth login,git rebase -i, a barenode/python3REPL…) —kt runruns with stdin ignored.
- Watch / dev commands are never wrapped, so they cannot hang the agent.
kt runalso enforcesrun.timeoutMs(default 120s — raise it for slow e2e suites); a timed-out command is killed and
whatever output it produced is still returned. - Env-prefixed commands (
GIT_PAGER=cat git diff) and2>&1are compressed, wrapped askt run -- bash -lc '<cmd>'with single quotes escaped, so bash keeps the correct semantics. - Test and build failures are never compressed away — the full error block and the exit code
survive.
Guard — block token-hungry calls before they run
A PreToolUse hook denies calls that would dump noise into the context, with a reason the agent
can act on.
| Tool | Denied |
|---|---|
| Bash | find / (whole-disk scan) · npm ls without --depth · tree without -L · git log -p/--patch (use git log --oneline + git show <sha> -- <file>) · cat <file> over guard.maxCatKb (100 KB) |
| Read | lock files (package-lock.json, yarn.lock, bun.lockb, go.sum, Cargo.lock…) · minified/generated (*.min.js, *.min.css, *.map) · anything under node_modules/, dist/, build/, .next/, vendor/, coverage/ · any file over guard.maxReadKb (500 KB) |
Escape hatch: reading with an offset, or a small limit (≤ 400), is always allowed — so you
can still inspect a slice on purpose. Read is already capped at 2000 lines by Claude Code, so the
guard targets noise, not the size of real code files.
Where the money goes
This is the part most token-saving tools get wrong, so here is the measurement. Same transcripts as
below: 93,124 assistant messages with usage, priced at list rates (cache write at the
1-hour-TTL 2× multiplier).
| Token kind | Multiplier | Share of the bill |
|---|---|---|
| cache read | 0.1× input | 45% |
| cache write | 2× input | 44% |
| output | 5× input | 10% |
| fresh input | 1× | 0.3% |
Output is the priciest token per token and still only a tenth of the bill. The 89% is the cost of
putting things into the context and carrying them, because a token you load is billed twice over:
cost of 1 loaded token = 2× input (cache write, once)
+ 0.1× input × turns remaining (re-read every turn)
On Opus 5 with 30 turns left that is $10 + $15 per million — more than a token the model writes
($25/M). A whole-file Read early in a long session outcosts the code generated from it.
Two consequences, both of which kt can only show you, not fix:
- The lever is not compressing what arrives — it is not loading it, and not carrying it
(/clearbetween unrelated tasks; subagents, whose context dies with them instead of being
re-read every turn). - Filtering individual operations cannot reach this. Measured on the same data, a guard that
blocked un-limitedReads of ≥20k chars nets ~0.3% — the extra turns it forces eat most of
what it saves. That is why no such rule ships here.
Cost is extremely concentrated in long sessions
Across 1,342 sessions, the top 1% (13 sessions) account for 43.7% of the bill — all the same
shape: 600–3,000 turns with the context near the 1M ceiling. Cache-read cost scales with the
integral of context over turns, so splitting such a session along task boundaries divides that part
by roughly the number of pieces, while the only new cost — re-writing the prefix per session —
measured at $19–45 total. Cutting them into 4 is worth ~14% of the whole bill.
That is what kt handoff is for.
The 1-hour cache TTL, measured
Percentage of context that had to be re-written, by how long the session had been silent beforehand:
| Silence before the turn | Context re-written |
|---|---|
| < 1 min | 3.8% |
| 30–50 min | 24.5% |
| 50–60 min | 46.6% |
| 60–90 min | 87.6% |
| > 90 min | 97.2% |
The cliff sits exactly at 60 minutes, measured from the last use, not from creation — otherwise
a long session would lose its cache every hour even while actively working, which the < 1 min row
rules out. Note what expiry does not do: the conversation is still there and still billed in full.
You lose the discount, not the history — which makes resuming an old session strictly worse than
starting a fresh one, unless you actually need that history.
How much this actually saves
Measured over 12,220 real Bash calls from ~2 months of Claude Code transcripts (9.8M chars of
tool output), the honest answer is: less than you'd hope, and it depends entirely on your output shape.
| Output size per command | Calls | Share of all chars |
|---|---|---|
| < 4,000 ch → passed through untouched | 11,827 | 72% |
| 4,000–16,000 ch | 381 | 25% |
| > 16,000 ch → the char cap bites | 12 | 2% |
Context there is eaten by the number of small commands, not by a few giant ones — and compressing
small outputs is exactly what kt bench measured as a net loss. So on that workload the default
config saves ~0–1%. Turning the aggressive head/tail cut back on (generic.thresholdLines: 40)
simulates to ~7%, but that is the setting the benchmark found costs extra agent turns. Measure withkt bench before trusting it.
Where kt still earns its place: capping the rare huge dump, the guard (blocking find /, acat on a 5 MB file, whole lock files) before those tokens ever exist, and the two skills it
installs, which trim the model's own output. Treat ♻️ saved as "disasters averted", not as a
running discount.
kt handoff — rescuing a session
Three commands for three moments.
Before you stop for the day
kt handoff [file] # default: .kt/handoff.md
Prints a prompt asking the model to distil the session into a file: what's in flight, decisions and
why, next step, files touched, traps hit — explicitly no pasted code or diffs. Start the next
session by reading that file instead of resuming a 500k-token history. On the measured workload
that is ~$0.10 instead of ~$5.00, and every later turn stays cheap because the context starts small.
When you didn't get to run it
kt handoff --recover [N] # N = messages to keep, default 60
kt handoff --recover --copy # straight to the clipboard
kt handoff --recover --from <#|path>
Laptop shut, left in a hurry, remembered three days later. The prompt cache lives on Anthropic's
servers and expires; the transcript lives on your disk and does not. This reads the transcript,
keeps the last N messages, drops thinking blocks, clips tool output, and prints the result to
paste into a fresh session.
On a real 7.6 MB / 1.83M-token session that tail is ~2.5k tokens — 0.1% of the file, and still
carries the working context. No model call, no cost, nothing in the background: the extract is
what the new session needs, so there is no point paying another model to summarise it first.
--copy puts it on the clipboard (pbcopy / clip / xclip -selection clipboard), where this
output was always headed, and prints the size to stderr so > rescue.md still works unpolluted.
If it doesn't know your platform's clipboard command it says so instead of failing quietly.
--from takes a row number from --list, or a path outright, so the repo you happen to be standing
in stops mattering. Plain --recover takes the newest transcript of the current directory — which
is very often the session you just opened to run a command, burying the one you actually wanted.
A number past the last row is an error naming how many rows there are, never a guess at some other
session.
kt handoff --list
Which session, in which repo?
$ kt handoff --list
# idle context reload session
1 0m 68k tok $0.68 ~/Dev/kuro-lean (main)
2 3m 240k tok $2.40 ~/Dev/storefront (HEAD)
3 8m 283k tok $2.83 ~/Dev/storefront/api (main)
4 13m 359k tok $3.59 ~/Dev/storefront (feat/checkout)
5 25m 301k tok $3.01 ~/Dev/analytics (main)
→ kt handoff --recover --from <#> > rescue.md
Abandoned sessions across the whole machine, newest first, with repo, branch, silence, context
size and what a reload would cost — so you can see at a glance which one is worth rescuing.
It reads the repo from inside the transcript, not from the directory name — that name is encoded
with dashes and cannot be decoded (kuro-lean and kuro/lean collapse to the same thing).
Teammates are left out. One orchestrator run writes a full transcript per spawned agent into the
same project directory — 34% of the transcripts on this machine — all large and all touched minutes
apart, so they crowded out the real sessions. A teammate isn't rescuable anyway; its brief came from
the parent, which is listed. The marker is teamName, never agentName — Claude Code also
parks an auto-generated session title in agentName, so filtering on that hides real work.--from <path> still reaches a teammate directly if you want one.
N defaults to 20, the same number --from <#> resolves against — kept as one constant so a row
number can never mean two different things, and high enough that a live session doesn't fall off the
end (at 10, a 585k-token session still open in another pane sat at row 13 and looked deleted). Ask--list for more and --from follows: it rebuilds the table big enough to hold the row you read,
which shows the same session because the ranking is the exact top-N either way.
It is fast. On a real machine (913 transcripts, 885 above the size floor) it runs in 0.02 s.stat() filters first, then the ranking walks newest-mtime first and stops as soon as the remaining
files cannot beat the rows it already holds — the last message can never be newer than the last
write, so mtime gives a lower bound on idle for free. That reads 75 files instead of 885;
teammates are dropped on the head read before they cost a tail read, and only the rows actually
printed pay for both (16 KB head for repo/branch, 64 KB tail for usage).
kt hook-prompt — stop the reload before it is paid for
A UserPromptSubmit hook that blocks the first turn sent after the cache has expired, before
the request leaves your machine.
Warning about a dead cache from the status line is too late by construction: the status line only
re-renders after the turn has been sent, so by the time the snowflake appears you have already
paid the reload. This hook runs first and returns {"decision":"block"}, so that turn costs
nothing, and shows what it would have cost plus the two ways out (resume anyway with ↑ Enter, or/clear + kt handoff --recover).
- It blocks exactly once per expiry — the marker is keyed to the transcript's mtime, so
re-sending goes straight through and you never get stuck in a loop. - Silent below
promptGuard.idleMinminutes of silence and belowpromptGuard.minTokensof
context, where a reload is only worth cents. - On a live session it costs one
stat()(~25 ms) and reads nothing. - It only ever looks at the transcript of the session it was called from, never the project's
most recent one. A brand-new session has no transcript yet (Claude Code writes it after the turn
starts), which means an empty context with nothing to reload — so the hook stays quiet. That is
what keeps a fresh panel opened next to a session you abandoned last night from being blocked
over that session's bill.
Drop a kt.json with {"promptGuard": {"idleMin": 1, "minTokens": 1000}} into a scratch directory,
start a session there, send one message (so the session has a transcript), idle for a minute,
then send another.
Status line
kt status reads Claude Code's JSON on stdin and renders three lines:
🟢 Opus 5 (1M context) · ▰▰▰▱▱▱▱▱▱▱ 32% · ~320k tok · $12.40 · $1.60/turn · ❄️ 2h15 · reload ~$5.00
📁 ~/Dev/kuro-lean · 🌿 main ↑2 · 📋 refactor sessions
📝 +142 -37 · ✅ 3/7 · 🔧 18 tools · ♻️ ~15k saved · ⏳ 2h 15m left · 📅 3d 14h left · 💵 wk $1.7k 2.7B
| Segment | Meaning |
|---|---|
| 🟢 / 🟡 / 🔴 | Context fill against statusline.warnPct / dangerPct |
$1.60/turn |
What it costs to re-read the current context on every following turn (tokens × 0.1 × input price). It grows as the context fills, and is the cheapest reminder that /clear between unrelated tasks is worth money. Needs model.id in the JSON and a matching pricing entry |
🕐 42m |
The session has been silent that long (measured from the last message, not the file's mtime). Shown past 10 minutes |
❄️ 2h15 · reload ~$5.00 |
The 1-hour cache TTL has expired: the next turn re-writes the whole context at 2× input, and that is the bill. This is a receipt, not a warning — it can only appear once a turn has been sent. kt hook-prompt is the part that gets there in time |
♻️ ~15k saved |
Tokens saved by kt for this project (same data as kt stats, ≈ chars/4). Hidden until the first compressed run |
⏳ 3h 12m left · 📅 3d 14h left |
Time left on your Claude usage windows — the 5-hour one and the week — read from ~/.claude.json (cachedUsageUtilization). A window that has already reset is dropped. Countdowns only, deliberately: the utilization percentages in that file are a snapshot Claude Code refetches on its own schedule and can sit hours behind /usage, while resets_at is absolute and stays exact |
💵 wk $1.7k 2.7B |
What the current quota week has cost so far, across every project on this machine, priced with the machine-wide pricing table (defaults + ~/.claude/kt.json, never one project's). The week is anchored to Claude Code's real resets_at, not a guessed weekday, and falls back to a rolling seven days when that reset has already passed. Refreshed in the background every 10 minutes and cached in your temp dir — the scan runs detached, never on the line: measured over 2,016 transcripts (1.12 GB), ~0.5–0.9s and ~290 MB peak RSS for the files inside the window. If every file looks fresh — after a backup restore, an rsync, or copying ~/.claude to a new machine — nothing can be skipped and it costs ~3.4–4.1s and ~400–520 MB instead. A model with no entry in pricing still contributes its tokens, and the money reads $1.7k+? so the gap is visible. Turn it off with "statusline": { "weekly": false } |
| 📋 plan · ✅ todo | From the CK-stack cache / transcript when available; auto-hidden otherwise |
Compatibility with other AI tools
| Tool | Output compression (kt run) |
Auto command-rewrite | Guard | Status line |
|---|---|---|---|---|
| Claude Code | ✅ | ✅ kt init |
✅ kt init |
✅ kt init |
| Cursor | ✅ manual | ⚠️ nudge via rules | ❌ | ❌ |
| OpenAI Codex CLI | ✅ manual | ⚠️ nudge via AGENTS.md |
❌ | ❌ |
| GitHub Copilot | ✅ manual | ⚠️ nudge via instructions | ❌ | ❌ |
| Gemini CLI / others | ✅ manual | ⚠️ nudge via GEMINI.md/AGENTS.md |
❌ | ❌ |
Why the difference: Claude Code exposes a PreToolUse hook that can rewrite a shell command
(updatedInput) and block it (permissionDecision: deny) before it runs — that is the mechanismkt init wires up. It also exposes UserPromptSubmit, which can cancel a turn before it is sent,
the only place a cache-expiry warning can still save money. Other agents expose no equivalent
"intercept and rewrite the shell command" hook, and the status-line protocol is Claude Code–specific.
The compression itself works everywhere — you just invoke kt run yourself.
Install the CLI as above (
bun link), soktis on PATH.Either call it directly on noisy commands:
kt run -- npm testOr add an instruction so the agent prefers
kt runon its own —.cursor/rules/kt.mdcfor
Cursor,AGENTS.mdfor Codex CLI,.github/copilot-instructions.mdfor Copilot,GEMINI.mdfor
Gemini CLI:When running shell commands that produce long output (tests, builds,
git diff, install logs),
invoke them askt run -- <command>to compress the output and save tokens. Failures are kept
in full, and small outputs are printed verbatim without compression. When output WAS compressed
(akt show <id>hint is appended), usekt showto see the full log.This is a nudge, not enforcement — the agent decides whether to follow it. Only Claude Code
enforces it via the hook.
Benchmarks
Per-command compression (this repo, 2026-08-10)
Token estimate ≈ chars / 4. Reproduce with bash scripts/measure.sh.
| Command | Before | After | Saved |
|---|---|---|---|
git diff HEAD~10 HEAD |
223,278 ch (~56k tok) | 1,590 ch (~398 tok) | 99% |
bun test (pass) |
108 ch | 107 ch | 1% |
git log --oneline -20 |
1,840 ch | 1,840 ch | 0% (kept — correct) |
git status |
865 ch | 865 ch | 0% (kept — correct) |
Already-compact output is left untouched on purpose. bun test saves little because Bun's
output is already terse; jest/vitest/go test save far more.
End-to-end (kt bench)
kt bench [--runs N] [--model M] [--max-turns T] [--keep]
Real headless Claude Code sessions on a seeded bug-fix task (~50 tests, 1 planted bug), baseline
arm (KT_DISABLE=1) vs kt arm (hook wired into workspace settings). Correctness gate: a run only
counts if the fixture suite is green afterwards. ⚠ Spends real API quota (default 3 runs × 2 arms
on Haiku) and needs claude + kt on PATH.
2026-07-05 — median of 3 runs/arm, Haiku 4.5, 6/6 valid:
| Metric (median) | baseline | kt | Δ |
|---|---|---|---|
| Context tokens (in+cache) | 254,205 | 340,371 | +34% |
| Output tokens | 2,934 | 2,916 | −1% |
| Cost (USD) | $0.0750 | $0.0855 | +14% |
| Turns | 8 | 11 | +38% |
| Duration | 40.3s | 43.1s | +7% |
On this small fixture the kt arm cost more end-to-end: it took more agent turns, and each extra
turn re-reads the whole conversation — which swamped the few KB of tool-output compression per run.
Follow-up shipped: this result is what produced the small-output pass-through
(run.rawUnderChars). Re-measured after it — same task, 2026-07-09, median of 3 runs/arm, 6/6 valid:
| Metric (median) | baseline | kt | Δ |
|---|---|---|---|
| Context tokens (in+cache) | 238,612 | 239,830 | +1% |
| Output tokens | 2,924 | 3,256 | +11% |
| Cost (USD) | $0.0739 | $0.0781 | +6% |
| Turns | 8 | 8 | 0% |
| Duration | 38.3s | 37.3s | −3% |
The extra-turns penalty is gone; the remaining deltas are run-to-run noise. On small-output tasks kt
is now neutral instead of a net loss — the wins stay where outputs are big. Measure your own
workload: kt bench --runs 3.
Configuration
Config is entirely optional — nothing generates a kt.json, and the defaults below are compiled
into the binary. You only write one to override something. Three layers, lowest first:
| Layer | File | For |
|---|---|---|
| 1 | built-in defaults | works everywhere with no file at all |
| 2 | ~/.claude/kt.json |
your preferences, once, for every project |
| 3 | <project>/kt.json |
facts about that repo — a slow e2e suite's run.timeoutMs |
Each layer is merged section by section, so overriding one key never drops its siblings. A global{"promptGuard": {"idleMin": 30}} keeps the default minTokens, and a project file that sets onlyminTokens keeps your global idleMin. A malformed file drops only its own layer.
The full set of keys, with their default values:
{
"profiles": { "test": true, "build": true, "install": true, "git": true, "lint": true, "generic": true },
"generic": { "thresholdLines": 0, "headLines": 15, "tailLines": 10 },
"limits": { "maxChars": 16000 },
"run": { "timeoutMs": 120000, "rawUnderChars": 4000 },
"store": { "keepRuns": 50 },
"statusline": { "warnPct": 60, "dangerPct": 85, "weekly": true },
"guard": {
"maxCatKb": 100,
"maxReadKb": 500,
"rules": { "findRoot": true, "npmLs": true, "treeNoDepth": true, "gitLogP": true, "catBig": true, "readNoise": true }
},
"promptGuard": { "idleMin": 60, "minTokens": 50000 },
"pricing": { "claude-opus-5": { "input": 5, "output": 25 }, "claude-sonnet-5": { "input": 3, "output": 15 } }
}
promptGuard.idleMin— silence, in minutes, after whichkt hook-promptblocks one turn.
Defaults to 60 because that is the cache TTL; lower it only on the 5-minute TTL.0turns the hook off.promptGuard.minTokens— context size below which it stays quiet regardless (50k ≈ $0.50 on
Opus). Interrupting you to save pocket change is a worse trade than the reload.pricing— USD per 1M tokens, keyed by model-id prefix, longest match wins (soclaude-haiku-4-5-20251001resolves viaclaude-haiku-4-5). Merged over the built-in table
rather than replacing it — override only what changed. This is the one knob to correct whenkt costlooks off.
Full logs live under .kt/runs/ (last keepRuns kept) — already in .gitignore.
Bypass / disable
| Effect | |
|---|---|
KT_RAW=1 kt run -- <cmd> |
Run this one command without compression |
KT_DISABLE=1 |
Kill switch: the hook stops rewriting, the guard stops blocking, and hook-prompt stops holding turns back |
Development
bun install # dev deps (typescript + @types/bun)
bun test # 261 tests across 26 files
bun run typecheck # tsc --noEmit
CI (GitHub Actions) runs typecheck + tests on every push and PR to main.
Credits
The core mechanism — intercept the shell command, compress its output before it ever reaches the
model's context — comes from rtk, a single Rust binary that
does this across 100+ commands. If you want the fast, language-agnostic version of this idea, use
rtk; it is the more mature tool and it is not Claude Code–specific.
kt started as a Bun/TypeScript reimplementation of that idea and then went somewhere else: deeper
Claude Code integration (PreToolUse and UserPromptSubmit hooks, a status line, the noisy-Read
guard), cost accounting from the transcripts, session rescue after cache expiry — and a different
default, since measuring showed that compressing small outputs costs more in extra agent turns
than it saves.
Contributing
See CONTRIBUTING.md. The short version: measure your claims, and bun test has
to be green. Security issues go through SECURITY.md, not a public issue.
License
MIT © Vu Duc Tuan
Built for people who have looked at their Claude bill and wondered where it went.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found