Find a past Claude Code session by describing it, then resume it in the same terminal tab.
claude --resume gives you a picker, but it only lists sessions from the current directory and only matches the auto-generated title. That title is set from your opening prompt and never updated, so a session titled "Check GitHub identity" can contain 3400 mentions of CODEOWNERS and stay unfindable. claude-find searches what was actually said, across every project.
claude-find "conversation about adding CODEOWNERS to several repos"
claude-find codeowners,repos
Pick a result, press Enter, and you are back in that session.
claude-findbuilds a search index at~/.claude/session-index.json. Delete it any time. It is derived entirely from your existing transcripts and rebuilds itself in a couple of seconds on the next run.rm ~/.claude/session-index.json # totally safe claude-find --reindex # or rebuild it explicitlyNothing lives only in the index, and your transcripts are never modified. Apart from the index and its staging file, the only other thing written is a short-lived preview file per result under
mkdtemp, removed when the picker exits.
Three stages, so the expensive one only ever sees a shortlist.
1. Index. Walks ~/.claude/projects/**/*.jsonl and caches the searchable parts of each transcript to ~/.claude/session-index.json. Only user turns are indexed: assistant prose and tool output are most of the bytes on disk and are mostly noise for "which conversation was that".
The index refreshes on every run, not only when you remember a flag, otherwise this morning's session would be invisible. Rebuilds are incremental by mtime, so a warm run costs about 200ms (190-260ms measured over a 259-session index) against a re-rank call that takes 10-15s. If ~/.claude/projects is missing but a cached index exists, the cache is searched rather than failing.
Long sessions are sampled across their whole span rather than truncated at the front. A prefix would reproduce the exact weakness that makes the built-in title useless: on the largest session here a prefix reached only turn 29 of 219, so a topic that started at turn 50 had no recall path. Sampling keeps the first and last turns and strides through the middle, then water-fills the remaining budget so sessions made of many short turns get more turns instead of unused space. Every session tested now reaches its final turn and uses the full 6000-char budget.
2. Lexical prefilter. Stopwords are cut from your query, then sessions are scored on term frequency across title, project path, and message body. Body counts are log-scaled so one enormous session cannot drown everything else, and matching more distinct terms is rewarded superlinearly. Top 25 go forward.
3. Semantic re-rank. One headless claude -p call scores those 25 on what the conversation was about, not keyword density, and returns a score plus a short reason per session. If this step fails for any reason the lexical order is kept and a note goes to stderr, so the tool never dies on you.
Lexical scores are peak-normalised to 100 while model scores are absolute, so the two cannot share an ordering. Any candidate the model omits is marked n/a and listed last, rather than keeping a 100 that would outrank a model-assigned 95. Unscored candidates keep their relative lexical order among themselves.
A reply that scores nothing usable counts as a failure, not a result. A bare [] or an array of ids is valid JSON, so it parses cleanly, but scoring nothing would leave every candidate tied and reshuffled. That path keeps the lexical ranking and says so on stderr.
Results land in fzf with a preview pane. Enter runs os.execvp, replacing the process with claude --resume, which is why the session opens in the tab you are already looking at rather than a nested shell.
| Python 3.8+ | stdlib only, no packages to install |
claude CLI |
on PATH, logged in |
fzf |
optional. Without it you get a numbered prompt |
The script is a single file. Keep the source wherever you like and symlink it onto your PATH so edits take effect with no reinstall step.
git clone <this-repo> ~/Documents/claude-find # or copy the folder
chmod +x ~/Documents/claude-find/claude-find
mkdir -p ~/.local/bin
ln -sfn ~/Documents/claude-find/claude-find ~/.local/bin/claude-find-s makes it a symlink, -f replaces an existing one, -n stops it nesting inside a directory symlink if the target already exists.
Then make sure ~/.local/bin is on your PATH. Check first:
echo "$PATH" | tr ':' '\n' | grep -q "$HOME/.local/bin" && echo "already on PATH" || echo "needs adding"If it needs adding, append to ~/.zshrc (or ~/.bashrc):
export PATH="$HOME/.local/bin:$PATH"Open a new shell, then verify:
which claude-find # -> /Users/you/.local/bin/claude-find
claude-find --reindex # first index buildPrefer a different directory? Any directory on your PATH works. ~/bin is the usual alternative, but it is not on PATH by default on macOS. Avoid /usr/local/bin for a symlink into your home directory: it needs sudo and breaks for other users on the machine.
To uninstall, delete the symlink and the cache:
rm ~/.local/bin/claude-find ~/.claude/session-index.jsonclaude-find [options] <query>
The query is either plain English or comma-separated keys. Key mode switches off stopword removal and matches each entry literally, which is what you want for codeowners,repos.
It only triggers when no comma-separated segment contains a space, since commas are ordinary punctuation in English. "we set up grafana, then alertmanager" stays prose and tokenizes to set, grafana, alertmanager, not two literal phrases.
Both modes strip a trailing . or - from each term. Leading dots and internal punctuation survive, so .env, claude-find and v1.2 still match literally, but codeowners,repos. does not go looking for a literal repos. in your transcripts.
| Option | Effect |
|---|---|
--no-llm |
Lexical scoring only. Instant, no claude call |
--print |
List results and exit, no picker, no resume |
--fork |
Resume with --fork-session, branching instead of writing into the original |
--recreate-dir |
Recreate the project directory if it is gone, so the session can resume |
--reindex |
Rebuild the index from scratch |
--limit N |
Candidates to consider (default 25) |
--model M |
Model for re-ranking (default sonnet) |
--auth MODE |
auto (default), subscription, or api-key. See below |
-q, --quiet |
Suppress progress output |
Run claude-find --reindex on its own to refresh the cache without searching.
# the everyday case
claude-find "when we set up the Grafana alert manager"
# fast, offline, no model call
claude-find --no-llm "azure cost"
# see the ranking without opening anything
claude-find --print "pr review feedback on the indexer"
# reopen without touching the original transcript
claude-find --fork "codeowners"Worth knowing before you run it, since this tool reads your conversation history.
What stays local. The index at ~/.claude/session-index.json is written 0600, matching the permissions Claude Code gives its own transcripts, so it is not readable by other users on the machine. It is written atomically via a temp file and rename, so an interrupted run cannot leave a truncated index behind.
What leaves the machine. The re-rank stage sends the shortlist to Claude: for each of up to 25 candidates, its session id, date, project path, title, and up to three short excerpts. Full transcripts are never sent. If you would rather nothing leave the machine at all, --no-llm does the whole search locally and never invokes claude.
What is never touched. Your transcripts are only ever read, never modified or moved. Resuming a session is a normal claude --resume, and --fork avoids writing into the original at all.
Both a claude.ai subscription and an API key are supported. The subscription is preferred and the API key is a fallback, never the default.
--auth |
Behaviour |
|---|---|
auto (default) |
Try the subscription. Only if that fails, and a key is set, retry with the key |
subscription |
Subscription only. Never touches the key, even if the call fails |
api-key |
Force ANTHROPIC_API_KEY. Falls back to the subscription if no key is set |
This has to be explicit because Claude Code does the opposite by default. If ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN is exported in your shell, it silently takes precedence over your claude.ai login, so a script that passes its environment straight through bills the API without ever saying so. claude-find strips both variables from the subscription attempt to stop that.
Preferring the subscription is also the more reliable order in practice. On a machine with a stale key exported, the API attempt burned the full timeout retrying before giving up, while the subscription answered in under 10 seconds.
Each run prints which one it used:
ranking 6 candidates with claude (sonnet, claude.ai subscription)...
When auto has a fallback queued, the time budget is split between the two attempts so a hang on the first still leaves time for the second. A failure names the cause and what it is doing next:
rerank via claude.ai subscription failed: timed out after 90s; falling back to ANTHROPIC_API_KEY
If every attempt fails, the lexical ranking stands and the tool still works. Use --no-llm to skip the model call entirely.
Both auth methods work, subscription is preferred. See Authentication.
MCP servers are skipped. The re-rank call passes --strict-mcp-config --mcp-config '{"mcpServers":{}}'. Loading a full MCP roster dominates startup for a one-shot ranking call and none of those tools are needed to score text.
Renaming a project folder orphans its sessions, and finding them is not the same as resuming them. Transcript directories are keyed by the path at session start and do not follow a rename, so claude --resume in the renamed folder will not list them. claude-find reads the cwd field recorded inside each transcript, so it still finds them.
Resuming is a separate problem. claude --resume <id> resolves the id against the project slug of the current directory rather than searching globally, so it only works from the original path. If that path is gone, the session cannot be resumed until it exists again. Verified: resuming a /tmp/cf-probe session from $HOME fails with No conversation found with session ID, and recreating the directory makes the same command succeed.
So claude-find refuses rather than pretending. On a terminal it offers to create the directory there and then, since the re-rank call has already been paid for and re-running with a flag would pay for it again. --recreate-dir skips the prompt, which is what you want when piping:
claude-find --recreate-dir "the session in that folder I renamed"An empty directory is enough. Nothing is restored into it, it just gives the slug something to resolve against.
Duplicate session IDs are expected. The same session can appear under several project directories. The index keeps one copy per ID, preferring the one whose recorded cwd still exists.
Subagent transcripts are excluded. Files under subagents/ are not resumable sessions. On a machine with 1393 transcript files, 1138 were subagent files and only 256 were real sessions.
Run it from a normal shell. It replaces itself with claude via exec. Launching it from inside an existing Claude Code session will not do what you want.
python3 -m unittest discover -v57 tests, stdlib unittest, no fixtures or network. They pass with or without claude on PATH. They cover the pure logic: query tokenizing, turn sampling and budget fill, scoring and word-boundary matching, tie-break ordering, excerpt selection, the re-rank envelope parsing (with subprocess stubbed), malformed and unusable model output driven through rerank itself, unicode and hyphenated query handling, the unscored sentinel, and API-key stripping. The interactive fzf picker and the exec into claude --resume need a real TTY and are not covered.
claude-find/
claude-find the executable (single file, stdlib only)
test_claude_find.py unittest suite
README.md
LICENSE
The index lives outside the repo at ~/.claude/session-index.json. Delete it any time; it rebuilds in a couple of seconds.
MIT. See LICENSE.