Importing sessions
Index ChatGPT and Claude exports, and Claude Code and Codex transcripts, then search them by attribute.
The fastest way to have something worth searching is to index the history you already have. Tennis reads four session formats plus plain files, and add works out which is which.
tennis add ~/Downloads/chatgpt-export.zip
tennis search "that thing about session cookies"Why import rather than capture
Capturing conversations live — pointing a client at a proxy or a custom base URL — only ever sees traffic from the moment it is configured. Everything before that is in the export archive and nowhere else, so the archive is the thing Tennis reads.
Claude Code and Codex are kinder: they keep their transcripts on your disk already, under ~/.claude and ~/.codex, and Tennis reads them where they sit.
How detection works
Detection reads the source rather than the filename:
- ChatGPT vs Claude exports are told apart by the shape of their
conversations.json. - Claude Code vs Codex transcripts are told apart by the fields on each line of their
.jsonl— Claude Code writes turns with auuid, asessionIdand amessage, Codex wraps everything in a{timestamp, type, payload}envelope.
A folder that turns out to hold no transcripts after all — a project with an event log in .jsonl, say — is read as plain files instead, with a note on stderr. A source you name with a flag is taken at its word.
A .zip, an already-unzipped directory, and a single transcript are all acceptable. When the guess is wrong, name the source:
tennis add --chatgpt ~/Downloads/export.zip
tennis add --claude ~/Downloads/export.zip
tennis add --claude-code ~/.claude
tennis add --codex ~/.codexWhat a run looks like
$ tennis add ~/Downloads/claude-export.zip
tennis: created namespace "context" bound to builtin:potion-retrieval-32M
tennis: summarizing cards with anthropic:claude-opus-5
tennis: ~/Downloads/claude-export.zip: reading a Claude export (conversations.json, projects.json)
tennis: 5000 documents in…
imported 11423, skipped 0 unchanged, 14106 chunks in "context", 1209 cards in /Users/you/tennis
$ tennis search "that thing about session cookies"
> a session cookie with a refresh token is the usual shape…
Claude [2025-03-15] 0.0328Two things came out of that. The documents went into the database, which is what search reads. Each conversation was also written as a markdown card to ~/tennis, summarized because an API key was set — see Cards. Pass --no-cards to skip them.
To see what landed without searching for it:
$ tennis ls -n 3
DATE SOURCE DOCS TITLE
2025-03-15 11:20 claude 2 Session cookie expiry
2025-03-11 04:07 claude 4 Postgres connection pooling
2025-02-27 13:40 claude 2 Trip planning: Lisbon
3 of 1214 sessions (--offset 3 for more, -n 0 for all)ls groups by session, newest first, with any files you have added listed among them by date; tennis ls chatgpt narrows to one source, and --docs lists documents instead. The CLI page has every flag.
Turns or conversations
By default each message becomes its own document, which is the unit you tend to remember. --per conversation makes each thread one document instead, for when the thread matters more than the line:
tennis add ~/Downloads/export.zip --per conversationA Claude Code session that ran subagents keeps them in the session: their turns carry the session's id, with roles user/subagent and assistant/subagent. Under --per conversation each subagent's run is a document of its own, beside the session's.
Whole threads are long, and a vector is the average of its tokens — see why chunking matters. --per turn usually retrieves better; --per conversation is for when you want to find the discussion, not the sentence.
Searching by attribute
Every imported document carries the attributes needed to find its way home — source, session, role, title, created, and for local sessions project, cwd, and branch. project is the name of the repo a session ran in, taken from the working directory it started in, for Claude Code and Codex alike. The desktop app runs most sessions in a worktree under .claude/worktrees, where the last element of the working directory is the worktree's name rather than the repo's, so a session there is filed under the repo it was cut from and the worktree's name is kept as worktree. The attributes work on search and on ls alike:
tennis search "retry logic" --where role=user
tennis search "flaky test" --where project=tennis,branch=main
tennis search "the timeout" --where source=codex
tennis ls --where branch=main
tennis ls --where worktree=brave-turing-1a2b3c
tennis search "callers" --where 'session=7f3a2c10,role=assistant/subagent'A subagent's turns also carry subagent, the name of its transcript, and the session's title, project and cwd, so a filter on any of those finds the whole session.
A card's last line is the filter that pulls up its whole conversation: tennis search --where 'session=<id>'. With a filter and no query, search prints every match in full and in order rather than ranking anything — see Reading, not ranking.
Filters run before ranking, so a filtered query is faster than an unfiltered one.
Re-importing is free
Session documents are keyed by the export's own conversation and message IDs. A fresh export months later writes only what is new:
$ tennis add ~/Downloads/claude-export-2.zip
imported 412, skipped 11423 unchanged, 502 chunks in "context", 37 cards in /Users/you/tennisCards follow the same rule: only the conversations that gained messages, or are new, get their card written again, so the summaries already on disk cost nothing the second time.
What is deliberately left out
Three omissions, each for the same reason — they would make every future ranking worse:
- Tool calls and tool results in local transcripts. Those are mostly whole file reads and command output; letting them in would mean every search ranked file contents above what was said about them. Tennis indexes text and thinking.
- The raw model traffic in a Codex rollout. Codex records each exchange twice, once as raw traffic carrying the harness preamble and once as the events the interface showed. Tennis reads the second, because what you remember saying is what you typed.
- ChatGPT messages the exporter marked as hidden. They were never on screen.
Namespaces are bound to one embedder
An import creates the namespace on first use and binds it to an embedder forever. If you plan to use OpenAI embeddings for this corpus, pass --openai on the first import — switching later means a new namespace and a full re-index. See Embedders.