Tennis

Cards

Every conversation and every file add reads also lands as a markdown card in ~/tennis — a readable copy, summarized when you have an API key.

Search answers a question. A card is for the other thing you do with what you have indexed: read it. Every conversation and every file add reads is written out as one markdown file, so a year of sessions and a folder of documents become a folder of short summaries you can open in any editor, grep, sync, or paste somewhere else.

$ tennis add ~/.claude
tennis: summarizing cards with anthropic:claude-opus-5
tennis: /Users/you/.claude: reading Claude Code session transcripts
imported 5, skipped 0 unchanged, 5 chunks in "context", 2 cards in /Users/you/tennis

$ tennis add ~/Documents/notes
tennis: summarizing cards with anthropic:claude-opus-5
tennis: /Users/you/Documents/notes: reading plain files
imported 3, skipped 0 unchanged, 3 chunks in "context", 3 cards in /Users/you/tennis

$ ls ~/tennis
2026-08-01-100000-claude-code-flaky-auth-test-token-refresh-window.md
2026-08-03-093000-claude-code-toml-config-parser-with-defaults.md
auth-md-3f9e328a.md
config-md-d1305502.md
retry-md-fc40188a.md

A conversation's card

---
session: "claude-code:7f3a2c10-1b2e-4d5f-9a8b-0c1d2e3f4a5b"
source: claude-code
title: "Flaky auth test: token refresh window"
created: 2026-08-01T10:00:00Z
turns: 3
project: app
cwd: /Users/you/git/app
branch: main
summarizer: "anthropic:claude-opus-5"
digest: 6bbfd864159bd8a3
---

# Flaky auth test: token refresh window

The auth test was failing about one run in five because the token refresh
window was shorter than the test's sleep, so the second request ran with an
expired token. The window was widened and a regression test added.

Full conversation: `tennis search --where 'session=7f3a2c10-1b2e-4d5f-9a8b-0c1d2e3f4a5b'`

The frontmatter carries what the import knew: the document-id prefix as session, the source, the title, when it was created, how many turns it ran, and for local agent sessions the project, cwd, and branch when the transcript recorded them. project is the name of the repo the session ran in; a session run in a .claude/worktrees checkout is filed under the repo it was cut from, and its card carries the worktree's name as worktree, between project and cwd. summarizer names the model that wrote the prose, and is absent when the card carries the opening message instead. digest is how the next add tells whether the card needs writing again — see Adding again. The last line is how to get the rest: run it and search prints the whole conversation, turn by turn (see Reading, not ranking). The card's whole job is to be readable on its own and to say where the full record lives.

The filename is the UTC timestamp, the source, and a slug of the title: 2026-08-01-100000-claude-code-flaky-auth-test-token-refresh-window.md. Claude Code names a session several turns in, so importing twice can produce two titles for one conversation; a re-import replaces that conversation's older card rather than leaving both, and never touches another conversation's card. Two conversations that would get the same name — they started in the same second with the same title — are told apart by a short hash of the session's id on the second. A conversation with no timestamp at all is named undated-<source>-<title>.

A Claude Code session's subagents get no card of their own: their work is part of the session, which the session's card describes.

When the namespace is not the default, the last line names it, so it runs as pasted: tennis search --ns work --where 'session=…'. A path with a comma or a quote in it is quoted so that --where reads it whole.

A file's card

---
file: /Users/you/Documents/notes/auth.md
source: file
title: auth.md
modified: 2026-10-05T14:02:11Z
size: 163
digest: 67094aa163f55aba
---

# auth.md

```
# Session handling
Make the login flow remember the user between sessions. The session cookie is
set with an expiry so the browser keeps it after the tab closes.
```

Full text: `tennis search --where 'path=/Users/you/Documents/notes/auth.md'`

That one was written without an API key, so in place of a summary it carries the file's first lines, fenced so a spreadsheet's rows stay rows. With a key, it is a few sentences of prose like a conversation's.

The frontmatter is the file's: its absolute path as file, source: file, its name as the title, when it was last modified, and its size in bytes, then the same summarizer and digest a conversation's card has. The last line prints the text Tennis indexed for it, exactly as it was read.

The filename is a slug of the file's name and the first eight hex digits of a SHA-256 of its path: auth-md-3f9e328a.md. Nothing in it depends on the file's contents or dates, so re-adding a folder after editing a file updates that file's card in place, and the hash keeps two notes.md in different folders apart. A file that moves or is renamed gets a new card; the old one stays until you delete it.

Adding again

Running add on the same source again leaves a card alone unless there is a reason to write it:

  • what it describes changed — a conversation gained turns, a file was edited;
  • the card is gone, because you deleted it;
  • it carries the opening message or lines, and a key is now set to summarize it.

So a re-add makes no model calls for anything already summarized, and with nothing changed it writes no cards at all:

$ tennis add ~/Documents/notes 2>/dev/null
imported 0, skipped 3 unchanged, 0 chunks in "context"

The test is the digest in the card's frontmatter: a hash of the text the summarizer reads and every field the frontmatter shows, but not the layout, so a change to how cards look does not re-summarize the folder. An add with no key never replaces a summary with an opening, and edits you make to a card's body survive until what it describes changes. With --json the counts come back as cards, cards_unchanged, and cards_unsummarized.

Where they go

--cards <dir>write cards here instead
$TENNIS_CARDSthe same, from the environment
--no-cardswrite none

The default is ~/tennis. --ndjson documents never get a card, because they are neither conversations nor files.

Summaries

Writing the prose on a card is the one part of Tennis that needs a network and a key. Search does not: ranking runs entirely on the built-in embedder, so a machine with no key still finds everything it has indexed.

Tennis looks for ANTHROPIC_API_KEY first, then OPENAI_API_KEY, and uses whichever it finds:

Key presentModel used
ANTHROPIC_API_KEYclaude-opus-5
OPENAI_API_KEYgpt-4o-mini
neitherno summary; a conversation's card carries its opening message, a file's its first lines

TENNIS_SUMMARY_MODEL overrides the model for either provider — the lever for anyone summarizing a large backlog who would rather trade some quality for cost:

TENNIS_SUMMARY_MODEL=claude-haiku-4-5-20251001 tennis add ~/.claude

Without a key, cards are still written. A conversation's card carries the first thing the person typed, which is almost always the clearest statement of what they wanted; a file's carries its opening lines, up to a dozen of them, since the start of a document is usually what it is about. The import says so once up front rather than failing:

$ tennis add ~/.claude
tennis: no ANTHROPIC_API_KEY or OPENAI_API_KEY; cards will carry the opening message instead of a summary

The model is asked a different question for each. For a conversation it writes two to four sentences on what the person was trying to do, what was decided or produced, and what was left unresolved. For a file, "what was decided" is the wrong question — a spreadsheet decided nothing — so it is asked what the file is, what it holds, and what someone would open it to find, and is given the file's name and path alongside its text.

Four summaries are in flight at a time, so a first import of several hundred conversations or files is bounded by the provider's rate limit rather than by one round trip after another. A summary the model cut off at its length limit is kept up to its last whole sentence; one with no whole sentence counts as failed. A summary that fails — a refusal, a timeout, a rate limit on item 300 — costs that card its prose and nothing else: the card is written with its opening message or opening lines, the failure is reported on stderr and counted as cards_unsummarized, the import carries on, and the next add tries that card again.

The summarizer sees a conversation's text, not its tool calls and results, with consecutive turns from one speaker merged. A very long session or file is cut in the middle: the first 24,000 characters and the last 8,000 are kept, because the opening says what it set out to do and the end says how it finished.

Why they are not indexed

Tennis never reads a card back into the index; the one thing it reads from a card is the digest in its frontmatter. The documents in SQLite are the record, and a card is a rendering of one. That one-way rule is what makes cards safe to edit, move, delete, or hand to another tool without touching the archive.

It is also why tennis add ~/tennis is not something to do. Indexing the cards would rank every conversation and file twice — once as a short, on-topic summary and once as the full text that actually holds the answer — and the summary would usually win.