Claude Code writes every session to disk as JSON Lines under ~/.claude/projects/. Weeks of debugging sit there in full: the commands that failed, the one that worked, and the reasoning in between. Nothing in the CLI searches it. I got tired of solving the same problem twice because I could not find the session where I solved it the first time, so I wrote ccfind: a plugin that lets you search Claude Code sessions full-text, locally, with no embeddings and no network.
This post covers the problem it solves, how to install it, how the ranking works and why plain BM25 is not enough, and the benchmark numbers with their caveats.

The Problem
The history is right there and it is not small:
du -sh ~/.claude/projects/
ls ~/.claude/projects/*/ | head--resume matches a session by id or name, which is no help when the id is exactly what you have forgotten. /insights writes a report. Neither answers the only question you actually have: which session was the one where we fixed that?
grep -rl comes closer, and it is what I used before this existed:
grep -rl "certs.d" ~/.claude/projects/A list of files, in no particular order, with no indication of which turn inside them matters and nothing resumable at the end of it. That gap is the whole project.
What I Built
Not “here are some related conversations”. The session, the turn you asked it in, the line that mattered, and one command to be back inside that context with the full history rather than a summary of it.
Inside Claude Code you can ask for it by name:
/ccfind why did the node take so long to rebootOr not. The skill fires on its own when you say things like where did we already fix this, which session was that, we did this before, search my history, in English or in whatever language you write in. Either way you get the matches in the chat and the /resume line for the session they came from, typed into that same window.
There is a terminal side as well. ccfind pick "<query>" gives you arrow keys or the mouse over every hit, / to narrow the query without leaving the list, and Enter hands the terminal to claude --resume on the session you picked.

Installation
Prerequisites
Node 18 or newer on PATH, and that is the only one. Claude Code’s native installer does not ship a Node runtime, so check before assuming:
node -vStep 1: add the marketplace and install
Two lines, typed inside Claude Code:
/plugin marketplace add epogonii/ccfind
/plugin install ccfind@ccfindOr from a shell, before you start Claude Code:
claude plugin marketplace add epogonii/ccfind
claude plugin install ccfind@ccfindStep 2: the terminal command, if you want it
Either install method gives you /ccfind inside Claude Code. What it does not give you is a ccfind command in your own shell:
$ ccfind pick "registry config"
zsh: command not found: ccfindThe plugin installs a skill, not a binary, which is why. The shortest fix is to ask Claude Code itself: run ccfind install. It puts the script somewhere on PATH once, with no alias to maintain.
Step 3: verify
Search Claude Code sessions for something you know you discussed. Expected result: a table of sessions with the matching line and a /resume command, plus a count of what was gated, like 31 matched, 6 relevant, 25 weak. The first query builds the index, so it is the slow one.
How It Ranks A Search Across Claude Code Sessions
Plain BM25 over a transcript directory does not work. The reasons are what most of the code is about, and they are the interesting part of the project.
Scoring per field, not per file
Every transcript is split into roughly 1 KB chunks, tokenised, and scored per field, because where a word appears says a lot about whether it answers your question:
| Field | Weight | What it is |
|---|---|---|
title | 3.5 | session name, renamed or model-written |
prompt | 3.0 | what you typed |
answer | 1.5 | the model’s replies |
tool | 1.0 | tool calls and their arguments |
thinking | 0.7 | reasoning blocks |
output | 0.5 | tool results, up to 16 KB each |
summary | 0.4 | compaction summaries |
A hit in a question you asked outranks the same word in scrollback from a grep that happened to print it. Identifiers are indexed whole and split, so a dotted path, a hyphenated flag and a camel-case setting name are all findable either way, but the compound you actually typed always outranks its pieces.
Stripping what is not conversation
System reminders, hook output, slash-command wrappers and the turns Claude Code marks as meta (a loaded skill’s own body, boilerplate caveats, an image-cache path) are not conversation. Indexed as prompts they rank boilerplate above real questions. In the test corpus that was 145 of 969 user turns, and 108 phantom exchanges. What is not stripped is a slash command’s arguments: the words after the command are the question.
Compaction summaries get their own field at the lowest weight. The model writes them, you did not ask them, and they restate every topic of the session they replace, so indexed as prompts they let a continuation outrank the session that did the work. Demoting them removed 46 more phantom turns.
Rolling scores up, and cutting the tail
Scores roll up twice: chunk to turn, turn to session. The best chunk dominates, corroborating chunks add a capped bonus, and coverage of the words you typed multiplies the result, so a long noisy session cannot out-sum a short precise one.
Then a relevance gate. BM25 scores every session holding a single word of the query, so a raw ranking is one answer and thirty sessions that once printed one common word. Hits scoring under 25% of the top one are dropped and reported as a count, and --all brings them back when a passing mention is what you want. The top hit is never gated.
Measured
A search engine that only claims to be good is not worth installing, so here are the numbers. Corpus: 78 transcripts, 90 MB, 657 exchanges, 20,729 chunks, 78,766 terms, giving a 3.9 MB gzipped index plus a 13 MB chunk store. Twelve queries, each a natural-language phrasing containing one distinctive identifier. Ground truth is a literal grep -F for that identifier across the corpus, so relevance is checkable rather than asserted.
| P@1 | P@3 | MRR | latency | identifier in results | resumable id | |
|---|---|---|---|---|---|---|
| ccfind | 0.92 | 0.89 | 0.96 | 133 ms | 12/12 | yes |
| claude-historian-mcp 1.0.3 | 0.00 | 0.00 | 0.00 | 9 ms | 0/12 | no |
claude-historian-mcp is the existing MCP server for this job, measured on the same corpus and the same queries and given a result limit of 8 against ccfind’s 5, so the comparison is not tilted by list length. It retrieves by recency rather than by the query, which is why the column of zeros is not a bug in the harness.
Two honest caveats. The table is not a claim of beating grep -rl, which scores 1.00 by construction: the ground truth is grep. What grep does not do is rank 78 transcripts, tell you which turn a match belongs to, or hand back a session to resume; it also reads 90 MB per query where ccfind reads a 3.9 MB index.
And the one miss is real. A query whose identifier appears in exactly one session returns it at rank 2, behind a longer session that matched three common words but not the identifier. BM25’s inverse document frequency is logarithmic, so one rare term and two medium-rare ones come out close. Tuning constants until that query passes would be fitting the benchmark, not fixing retrieval, so it stays as a miss.
What It Writes
~/.claude/ccfind/index.json.gz postings, lengths, offsets, session metadata
~/.claude/ccfind/docs.jsonl indexed chunks, for snippets
~/.claude/ccfind/state.json per-transcript size and mtimeDelete the directory to reset. Nothing else is written, anywhere.
Gotchas
- Any change to any transcript triggers a full rebuild at roughly 13 MB/s: a second or two for a small history, about 19 s for 250 MB. Incremental merge lands once the on-disk format settles.
- BM25 is lexical. A query sharing no words with the conversation will not match it. There are no embeddings and no API calls.
- Terms appearing in more than 40% of chunks are dropped from the index, so searching for a word you use in every session finds nothing.
- Snippets are verbatim transcript text, so whatever you pasted into a session (a password, a token, an internal hostname) can come back in a result. Nothing leaves the machine, but the output is as sensitive as the history it searches.
Takeaways
- Nothing in the CLI can search Claude Code sessions, even though the whole history is a searchable corpus sitting on disk.
- Field weights do most of the work. The difference between “a word appears here” and “you asked about this” is the whole difference between grep and retrieval.
- Injected context and compaction summaries poison a naive index. Roughly 15% of the user turns in my corpus were not questions at all.
- A ranking without a relevance gate is one answer plus thirty distractions. Cutting the tail mattered more than any constant I tuned.
Links
- Source and issues: github.com/epogonii/ccfind
- Licence: MIT. Zero dependencies, no network access.
- Free and staying free. If it saved you an afternoon: GitHub Sponsors or PayPal.
Status: the plugin is still in review. The marketplace commands above install it straight from the repository in the meantime.
