promptwash
v0.1.0
Published
Redact secrets from, and mine insight out of, the Claude Code transcript archive in ~/.claude/projects
Maintainers
Readme
promptwash
Right now, on your laptop, there is a folder full of your API keys in plaintext. You didn't put them there. Claude Code did.
Every session Claude Code runs is written to ~/.claude/projects/ as JSONL and kept forever: your prompts verbatim, every reply, every tool call, and the full output of every bash command. Nothing is summarised or truncated.
So the first time a session ran cat .env, or printed a connection string, or echoed a token while debugging — that value became a permanent plaintext file, outside every .gitignore you have.
One month of ordinary use, measured:
694 files · 722 MB · ~2,100 prompts
800 secrets across 64 files
├─ 40 Supabase service_role JWTs (full RLS bypass)
├─ 20 distinct database passwords (production pooler hosts)
└─ OpenAI / Stripe / Google / GitHub keys, private key blocksNobody leaked anything. Nobody pasted a key into a chat. It accumulated from normal work.
This repo cleans that up — and then does something more interesting with what's left.
One command
npx promptwashThat scans your archive for secrets (dry run, with a full report), asks before rewriting anything (every touched file is backed up first), then builds the dashboard, the prompt export and the functional breakdown into ./claude-history-stats/ and opens the dashboard in your browser. Everything runs locally; nothing leaves your machine.
npx promptwash --help for the knobs: --yes, --redact-only, --skip-redact, --no-open, --out, --literals, --entropy.
The sections below explain what each step does — and how to run the pieces individually from a clone.
1. Get the credentials out
node redact-secrets.mjs # dry run: reports, writes nothing
node redact-secrets.mjs --apply # rewrites, after backing up every file it touchesStructure-preserving: each record is parsed, walked, re-serialised. Line counts, key order and JSON validity all survive, so claude --resume keeps working. Unparseable lines pass through byte-for-byte, writes are atomic, and files touched in the last 10 minutes are skipped so a live session is never rewritten mid-write.
It knows the usual shapes — API keys, JWTs, private keys, DB URLs with inline passwords, *_SECRET/*_TOKEN assignments — and ignores <your-key-here>, xxxxxxxx, ${VAR} and friends.
It also catches the ones regexes normally can't. Credentials typed as prose — "I reset the password, it's hunter2Xyz now" — have no assignment or URL to anchor on. Two answers: --literals for known strings, --entropy for shape.
16+ characters, no separators, ≥2 lower, ≥2 upper, ≥1 digit, class-switch rate ≥ 0.4.
The separator rule is what makes it usable: real credentials are unbroken runs, while UUIDs, kebab identifiers and mcp__tool__names all contain - or _.
Composition is checked in code, not as a lookahead — (?=\D*\d) is unbounded and will satisfy itself from a digit thousands of characters later in the file. An early version of this happily "verified" that sourceToolAssistantUUID contained a digit.
Naive version: 2.4M matches. This one: 243. Still dry-run it and read the report.
2. Then read what's left
Your transcripts are the only honest record of how you actually work — better than commit counts, better than LoC. Nobody writes prompts for the metrics.
npm run all # → output/You get a self-contained HTML dashboard (no server, no CDN, data inlined — a file:// page can't fetch() a sibling JSON), every prompt you've typed as readable markdown, and a functional breakdown of them.
The breakdown is the point. Prompts are classified by what they do to the conversation, not by topic:
| | |
| --- | --- |
| correction | pushing back on what the model produced |
| steering | constraining how, without rejecting |
| bug-report | the app is broken |
| question · task | seeking information · opening new work |
correction vs bug-report is what makes the number honest. "the archive button doesn't work" and "no, I said HYR2 not HYR3" both look like pushback to a regex — only the second is overhead you could design away. The classifier routes on whether the complaint is model-directed or app-surface.
On that same month: 22% of everything typed was steering or correcting. Most corrections turned out to be one-line environment facts the model never had — which is a fact-sheet in AGENTS.md, not a prompting habit.
It also counts what you didn't type. The user role carries tool results, subagent notifications, slash-command bodies and [Image:] markers; counting those as prompts overstates your input by ~40%.
Before you trust the analysis
Redaction generalises — secret shapes are secret shapes. The analysis was calibrated against one person and one codebase, and these will be wrong for you:
- Writing register (
lib-classify.mjs) — the patterns match lowercase, unpunctuated, typo-tolerant English (dont,nah,pls,u). Write in full sentences, or not in English, and they'll miss nearly everything. APP_SURFACEvocabulary — decides bug-report vs correction. It's one web app's nouns. Swap in yours.- The 400-token cutoff — from one prompt-length distribution. Run
count-tokens.mjsand read its cutoff table first. SYNTHETICslash-command bodies — matches one/reviewtemplate. Yours expand differently.
Then do the thing that actually works: read output/cat-correction.md end to end and check the categories match what you meant. That's how the numbers here got fixed — the first pass counted slash-command templates as steering and scored "good catch" as pushback.
Two warnings
The backups still hold everything. --apply copies each file to ~/.claude/projects-backup-<ts>/ before touching it. That's your undo, and it's a complete plaintext copy of every secret. Delete it once you're satisfied.
Redacting local disk doesn't un-leak anything. It doesn't reach server-side copies and it doesn't rotate a credential that was already exposed. If a service_role key shows up in the report, rotating it is step one. This is step two.
Layout
*.mjs the tools
lib-prompts.mjs extraction — what counts as a prompt you typed
lib-classify.mjs the functional categories
dashboard-template.html edit this, not the generated html
output/ everything generated — gitignored
secrets-literals.txt your literal secrets — gitignorednode <tool>.mjs --help for flags. Node ≥ 20, one dependency (js-tiktoken).
Never run the id-scrubber over
~/.claude/projects.uuid/parentUuidlink the conversation tree andtool_use_idpairs each call with its result — blanking them collides every pair and breaks--resumeirreversibly. Output files are derived and disposable; the archive is not.
MIT.
