We decided it three weeks ago and I could not find it
Claude Code writes every session to disk as JSONL and nothing reads them back. So a decision, a number, or the reason you rejected something is gone in practice the moment it scrolls out of context, even though the bytes are sitting in your home directory. Here is what searching them actually takes, including the three things that make the naive version fail.
TL;DR · THE FIX
Your Claude Code history is already on disk at ~/.claude/projects/<slug>/*.jsonl and there is no search over it. Grepping it directly does not work: a single session can be 145MB of base64 and tool payloads, the slug is derived from your working directory and often does not match what you would guess, and subagent transcripts live in sibling files that never appear in the session you are reading. Index the metadata, pick sessions by title and date, and extract text-only from just those.
The symptom
I went looking for a decision. We had settled something about a pricing approach a few weeks earlier, I remembered settling it, and I could not remember what we settled or why. The outcome I could reconstruct from the code. What I wanted was the reasoning, the argument against the option we dropped, so I would not spend another afternoon re-deriving it.
It was not in the commit messages, and it was not in my notes, because at the time it had felt like a conversation rather than a decision. So it was in a conversation, and the conversation was gone.
Except it was not. Claude Code writes every session to disk as JSONL, one file per session, under ~/.claude/projects/. Mine was 581MB across 72 sessions. Every word was right there, and nothing reads them back.
What I tried first
Grep it:
grep -ril "pricing" ~/.claude/projects/my-project/
This returns almost every session, which is as useful as returning none. When I opened one to read around a match, the largest file in that corpus was 144.9MB, most of it tool call payloads, file contents that were read into context, and inline base64 images. The sentence I wanted was in there at a ratio of roughly one part in a thousand.
Throwing it all at the model is worse, and expensive. A 145MB transcript does not fit in a context window, and the parts that would have to be dropped to make it fit are the parts nobody can identify in advance.
Both attempts treated a structured file as an unstructured one. The JSONL has a shape: every line is a turn, every turn declares its type, text lives in text blocks and commands in tool blocks. Reading it as a text blob throws all of that away and then asks a search engine to recover it.
Three things that break the obvious version
The slug is derived, and it is not what you would guess. The directory under ~/.claude/projects/ is built from your working directory path, with separators replaced and the case not necessarily preserved, so C:\Dev\Base becomes C--Dev-Base. Hardcode a guess, or derive it slightly differently than Claude Code did, and you get zero sessions and no error, because an empty directory is not a failure. This is the most common way the whole thing silently returns nothing, so any tool doing this should print the directory and slug it resolved before it prints a count.
Subagent transcripts are not in the session file. If you dispatched subagents, their conversations are in sibling files at <dir>/<sessionId>/subagents/agent-*.jsonl, and reading the session you were pointed at will not show you a word of them. Plenty of real reasoning happens in there.
Tool calls sit in a different block than conversation. So “did I ever run that migration” and “what did we say about the migration” are two different searches over two different parts of the same file. A text-only extract answers the second and returns a confident miss on the first, every time, which is worse than an error.
What works
Three tiers, each paying only for what the question needs.
Index the metadata only. Walk the .jsonl files and pull title, session id, and first and last timestamp into one flat JSON cache. On that 581MB corpus this is a ~15KB file that rebuilds in under two seconds, incrementally afterwards keyed on mtime and size.
Pick sessions by judgement. Read the 15KB index and choose one to three plausible sessions by title and date range. The index is small enough to read in full, and titles plus dates are a good filter for “which conversation was that in”.
Extract text-only from those. Pull text blocks and nothing else, so tool payloads and inline base64 are dropped by construction rather than by a filter that might miss one. The numbers on that corpus:
| Largest session, 144.9MB | to 194KB, 0.132%, 446 turns, 0.4s |
| Same session with a search term | 446 turns down to 18 |
| 68.8MB session | to 80KB, 0.111%, 0.2s |
| Same, including tool calls | 273KB, 0.378%, no base64 |
The Windows bug that broke it for half its users
This one cost me a release and applies to any Python CLI on Windows.
The first thing I did after publishing was clone my own repo somewhere clean and run it. It crashed immediately:
UnicodeEncodeError: 'charmap' codec can't encode character '\u2192'
That is an arrow. Python on Windows defaults sys.stdout to the ANSI codepage, cp1252 here, and a Claude Code transcript is full of characters cp1252 cannot represent: arrows, dashes, box drawing, emoji. So printing a transcript to stdout, the tool’s main path, died for every Windows user on any session containing one of them.
It had gone unnoticed because the write-to-a-file path was fine, and that path opens with an explicit encoding:
with open(out_path, "w", encoding="utf-8") as f:
The stdout path had no such line, because nobody writes one. The fix:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already-wrapped or redirected stream: leave it alone
errors="replace" is deliberate. The content is being read for its meaning, and losing one glyph to a placeholder beats losing the entire dump to an exception.
If you set an encoding when you open a file, you have already admitted the default is wrong, and stdout has the same default. I only found this because I cloned the published artifact and ran it, instead of testing the copy on my own machine that had never been packaged.
The tool
It is a Claude Code skill, MIT, free, stdlib-only Python with no database, embeddings, API key, network or daemon.
npx skills add Kaboomadin/claude-recall --skill recall --agent claude-code
Or clone it straight in:
git clone https://github.com/Kaboomadin/claude-recall ~/.claude/skills/recall
Then ask, in the project whose history you want searched:
didn’t we discuss why we dropped the Postgres approach?
It answers with a verbatim quote, its timestamp, and a claude --resume pointer back to that session, or it tells you plainly that it did not find it. There is no third option, because a vague recollection assembled from nothing is worse than a miss.
Two limits. It only knows what is still on disk, and Claude Code prunes transcripts, so check the oldest timestamp in the index before reading a miss as proof something never happened. And without the tool-call flag it cannot see anything you did, only what was said.
Discussion
Powered by GitHub. Sign in to leave a comment.