Tennis

CLI

add, search, ls, rm, ns, serve, and version — every flag, with real output.

Two commands cover almost everything: add puts things in, search gets them out. Neither needs a namespace, because both default to context. ls shows what is there.

tennis add ~/Documents/notes
tennis search "keep me signed in"
tennis ls

tennis add

add is the one way in. Point it at an export archive, a directory of agent transcripts, or a pile of files, and it works out what it was handed:

tennis add ~/Downloads/chatgpt-export.zip     # a ChatGPT data export
tennis add ~/Downloads/claude-export.zip      # a Claude data export
tennis add ~/.claude                          # local Claude Code sessions
tennis add ~/.codex                           # local Codex sessions
tennis add ./docs                             # a directory tree of files
tennis add ./a.md ./b.md                      # specific files

Detection reads the source, not the filename. When the guess is wrong, or you would rather be explicit, name the source:

tennis add --chatgpt ~/Downloads/export.zip
tennis add --claude ~/Downloads/export.zip
tennis add --claude-code ~/.claude
tennis add --codex ~/.codex
tennis add --files ~/Documents/notes

A .zip, an already-unzipped directory, and a single transcript are all acceptable. Importing sessions covers what Tennis reads out of each format and what it deliberately skips.

A folder add reads every file that holds text, each by whatever reads its kind:

  • Plain text of any kind, whatever the extension: notes, code, JSON, CSV, a Makefile.
  • Spreadsheets, Word, PowerPoint, HTML and EPUB (.xlsx, .docx, .pptx, .html, .epub) are read by Tennis itself: a workbook sheet by sheet with its dates as dates, a deck slide by slide, a book chapter by chapter in reading order.
  • PDFs are read with pdftotext when it is installed, and otherwise, on a Mac, through Spotlight.
  • RTF, .doc, .xls, .ppt, Pages, Numbers, Keynote and email are read through macOS. A Pages, Numbers or Keynote document saved as a package — a folder macOS shows as one file — and an .rtfd are each read as the one document they are, not walked into.

The rest is passed over:

  • Photos, audio, video, archives, dotfiles, and anything under a dot directory such as .git are skipped silently. A folder of photos is not a folder of mistakes. So are empty files and files holding nothing but whitespace.
  • Dependency and build folders — node_modules, vendor, target, dist, build, __pycache__ and site-packages, wherever they appear — and whatever a .gitignore inside the folder names are skipped silently too, but counted: the summary line ends (N folders passed over). The folder you name is read whatever it is called, so tennis add ./vendor reads it. On a Mac, .gitignore patterns match without regard to case, as git does there by default. Application bundles (.app, .framework) are passed over and counted the same way, and so is a symbolic link to a folder, which is not followed; a link to a file is read as that file.
  • Other binary files, and text over 10MB, are skipped with a note on stderr. Binary detection is a NUL-byte check in the leading 8KB, the same heuristic git uses.
  • Formats with no reader on this machine — a PDF on Linux without pdftotext, or a Pages file anywhere but a Mac — are skipped with a "no reader on this machine" note that says what is missing.
tennis add ./src --files --ext .go,.ts,.rs    # only these extensions (default: everything with text)
tennis add ./docs --chunk 2000                # bigger chunks for a new namespace
tennis add ~/.codex --ns work                 # a namespace other than the default
tennis add ~/.claude --cards ~/notes/sessions # write the cards somewhere else
tennis add ~/.claude --no-cards               # or not at all
tennis add ./docs --json                      # machine-readable
FlagMeaning
--ns <name>namespace (default context, or $TENNIS_NS)
--format <fmt>auto (default), chatgpt, claude, claude-code, codex, files
--chatgpt / --claude / --claude-code / --codex / --filesshorthand for --format
--ndjsonread documents from stdin instead of a path; see below
--per <unit>one document per turn (default) or conversation
--ext <list>only these extensions, when the source is plain files (default: everything with text in it)
--chunk <n>chunk size in characters, for a new namespace
--model <name>built-in model for a new namespace (default potion-retrieval-32M)
--openai <model>use an OpenAI model instead (requires OPENAI_API_KEY)
--cards <dir>where cards are written (default ~/tennis, or $TENNIS_CARDS)
--no-cardsskip writing cards
--db <path>database file (default ~/.tennis/db.sqlite, or $TENNIS_DB)
--jsonmachine-readable output on stdout; progress goes to stderr

Every conversation and every file add reads is also written out as a card: one markdown file each in ~/tennis, with its details in frontmatter and a summary when you have an API key. Without a key, a conversation's card carries its opening message and a file's its first lines. Adding the same source again rewrites only the cards whose conversation or file changed, so a re-add makes no model calls for what is already summarized. --ndjson documents get no card, because they are neither conversations nor files. Cards has the whole story.

$ tennis add ~/.claude
tennis: no ANTHROPIC_API_KEY or OPENAI_API_KEY; cards will carry the opening message instead of a summary
tennis: /Users/you/.claude: reading Claude Code session transcripts
imported 5, skipped 0 unchanged, 5 chunks in "context", 2 cards in /Users/you/tennis

Re-running add is an incremental update. Files are keyed by path and skipped when contents and metadata are byte-identical to what is stored; session documents are keyed by the export's own conversation and message IDs, so a fresh export months later writes only what is new.

Given several paths, a path that fails partway ends the run but not retroactively: what the earlier paths read is written and their cards are finished, the usual summary is printed, and the error is what the command exits on. With --json the report lists the sources that went in and carries the error beside them. A path that does not exist at all is refused before anything is read, so a typo never leaves an empty namespace behind.

tennis search "exponential backoff"
tennis search "keep me signed in" -k 5             # top 5, not just the best
tennis search "retry" --mode keyword               # BM25 only
tennis search "retry" --mode semantic              # vectors only
tennis search "auth" --where 'status=merged'       # filter by attribute
tennis search "auth" --where 'cost>5,status!=failed'
tennis search "auth" --ns work
tennis search "auth" --json
tennis search --where 'session=<id>'               # read a whole conversation, no query
FlagMeaning
-k <n>how many results (default 1; -n is an alias)
--mode <mode>hybrid (default), keyword, semantic
--where <filter>attribute filter: key=value, key>value, key!=value, comma-separated; with no query, prints every match in full
--ns <name>namespace (default context, or $TENNIS_NS)
--db <path>database file
--jsonfull results with scores and attributes

One result by default, printed whole: the words, then where they came from, when, and how strongly it matched. You asked a question, so you get an answer you can read, wrapped to your terminal.

$ tennis search "keep me signed in"
> # 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.

  auth.md [2026-10-05] 0.0328

-k is how you ask for more. Then every result is numbered, the best one included, because at that point you are reading a ranking rather than an answer, and each carries a tag saying which ranker found it and where:

$ tennis search "keep me signed in" -k 3
 1. auth.md [2026-10-05] 0.0328  [kw#1 sem#1]
    # 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.

 2. retry.md [2026-10-05] 0.0161  [sem#2]
    # Retries

    Failed requests back off exponentially, doubling the delay each attempt up
    to a cap of thirty seconds.

 3. config.md [2026-10-05] 0.0159  [sem#3]
    # Configuration

    Write a parser for TOML configuration files. Values from the file are merged
    over the defaults.

[kw#1 sem#1] means BM25 and the vectors both ranked it first. [sem#2] means only the vectors found it; a hit one ranker surfaced is a different kind of answer than one both agreed on. Text is wrapped to the terminal's width, capped at 84 columns; when stdout is a pipe it is 80 columns and no color.

When nothing matches, search says so rather than padding the list:

$ tennis search "signed in" --mode keyword
no matches

That is --mode keyword doing its job: no document contains the word signed, and in is a stop word. The default hybrid mode would still return the semantic hits.

--json includes each hit's stored attributes, not just its text and score:

[
  {
    "id": "/Users/you/Documents/notes/auth.md",
    "score": 0.01639344262295082,
    "text": "# Session handling\n\nMake the login flow remember the user between sessions…",
    "attributes": {
      "kind": "file",
      "modified": "2026-08-17T19:15:37Z",
      "name": "auth.md",
      "path": "/Users/you/Documents/notes/auth.md",
      "size": 227
    },
    "keyword_rank": 0,
    "semantic_rank": 1
  }
]

keyword_rank: 0 means BM25 did not return this document at all — the ranks are 1-based, and 0 is "absent", not "first".

Reading, not ranking

Given a --where filter and no query, search stops ranking and prints every match in full. This is the command at the foot of every card: --where 'session=<id>' prints the whole conversation, --where 'path=<file>' the file's text exactly as it was indexed.

$ tennis search --where 'session=7f3a2c10-1b2e-4d5f-9a8b-0c1d2e3f4a5b'
# Flaky auth test: token refresh window

## summary

Flaky auth test: token refresh window

## user

The auth test fails about one run in five. Can you find out why?

## assistant

The token refresh window is shorter than the test's sleep, so the second request runs with an expired token.

I widened the window and added a regression test.

A conversation is printed in turn order under its title, with back-to-back turns from one speaker merged — the same rendering the summarizer reads. A Claude Code session that ran subagents prints its own thread first, then each subagent's turns under a heading of their own:

$ tennis search --where session=7f3a2c10
# Flaky auth test

## user

The auth test fails about one run in five. Can you find out why?

## assistant

The token refresh window is shorter than the test's sleep. I widened it and added a regression test.

## subagent agent-search

## user/subagent

Find every caller of refreshToken.

## assistant/subagent

Three callers: auth/session.go, auth/middleware.go, and the test helper.

A filter that matches several sessions or files prints each one whole, oldest first, never interleaved, and heads each so you can tell where one ends: a file with ==> <path> <==, the way head heads one of several files, and a session with its title, or its source and id when it has none.

$ tennis search --where kind=file
==> /Users/you/Documents/notes/auth.md <==
# Session handling

The session cookie is set with an expiry so the browser keeps it after the tab closes.

==> /Users/you/Documents/notes/config.md <==
# Configuration

Values from the TOML file are merged over the defaults.

One match prints with no such heading, which is what a card's command asks for. --json returns the matching documents as [{"id", "text", "attributes"}] in the same order. -k caps how many documents print, but only when you pass it — the default of 1 is for ranked results — and -k 0 prints them all. --mode keyword or --mode semantic with no query is an error, since there is nothing to rank. A filter that matches nothing prints no matches, and search with neither a query nor a filter is still a usage error.

tennis ls

ls answers "what does tennis actually have" without running a search. It reads metadata only, so it stays fast at a size where search has started to cost real time.

$ tennis ls
DATE             SOURCE         DOCS  TITLE
2026-10-05 09:02 file              1  auth.md
2026-10-05 09:02 file              1  config.md
2026-10-05 09:02 file              1  retry.md
2026-08-03 04:30 claude-code       2  TOML config parser with defaults
2026-08-01 05:01 claude-code       3  Flaky auth test: token refresh window

5 sessions and files

It groups by session rather than listing documents, because add stores one document per turn by default and a flat listing of those is thirty thousand rows nobody asked for. A conversation is one row however many turns it was stored as, and DOCS is how many. A file has no session, so it is a row of its own, dated by when it was last modified and listed among the conversations by date. Newest first; dates are shown in your local time. A bare word filters by source, because tennis ls chatgpt is what people type before they read the flags:

tennis ls chatgpt                       # only ChatGPT sessions
tennis ls claude-code                   # only Claude Code sessions
tennis ls --where branch=main           # any attribute filter, same syntax as search
tennis ls --docs                        # documents, not sessions
tennis ls -n 50                         # more than the default 25
tennis ls -n 0                          # everything
tennis ls --offset 25                   # the next page
tennis ls --asc                         # oldest first
tennis ls --docs --sort name            # by another attribute
tennis ls --json
FlagMeaning
--docslist documents instead of grouping by session
-n <count>how many to list (default 25; 0 for all)
--offset <count>skip this many
--where <filter>attribute filter, same syntax as search
--sort <attr>attribute to sort by (default created, or modified for a file)
--ascoldest first
--ns <name>namespace (default context, or $TENNIS_NS)
--db <path>database file
--jsonmachine-readable output

--docs is the flat view: one row per turn and per file, with a file's path as its id:

$ tennis ls --docs -n 4
DATE             SOURCE       KIND     CHUNKS  ID
2026-10-05 09:02              file          1  /Users/you/Documents/notes/auth.md
2026-10-05 09:02              file          1  /Users/you/Documents/notes/config.md
2026-10-05 09:02              file          1  /Users/you/Documents/notes/retry.md
2026-08-03 04:30 claude-code  message       1  claude-code:b81e9d44-6c7a-4e2b-8f1d-3a4b5c6d7e8…

4 of 8 documents (--offset 4 for more, -n 0 for all)

The tally under the table goes to stderr, so tennis ls --json | jq sees only the rows. In the session view --json gives each row's key, document and chunk counts, and the attributes shared across the group; a file's row is marked "ungrouped": true and carries the file's own attributes. Two of the rows above, as JSON:

[
  {
    "key": "/Users/you/Documents/notes/retry.md",
    "ungrouped": true,
    "documents": 1,
    "chunks": 1,
    "attributes": {
      "kind": "file",
      "modified": "2026-10-05T14:02:11Z",
      "name": "retry.md",
      "path": "/Users/you/Documents/notes/retry.md",
      "size": 114
    }
  },
  {
    "key": "b81e9d44-6c7a-4e2b-8f1d-3a4b5c6d7e8f",
    "documents": 2,
    "chunks": 2,
    "attributes": {
      "created": "2026-08-03T09:30:08Z",
      "source": "claude-code",
      "title": "TOML config parser with defaults"
    }
  }
]

tennis add --ndjson

Every other source names a path; --ndjson is for content that never touched disk — event lines, chat turns, agent claims, anything a program produces rather than a file. It reads newline-delimited JSON from stdin, one document per line:

echo '{"id": "e1", "text": "deploy failed with a connection timeout", "attributes": {"kind": "event"}}' \
  | tennis add --ndjson --ns agents

tennis add --ndjson --ns agents < events.ndjson
tennis add --ndjson --ns agents --openai text-embedding-3-small < events.ndjson
tennis add --ndjson --ns agents --json < events.ndjson

Each line is {"id": "...", "text": "...", "attributes": {...}}. attributes is optional; everything else follows the rest of add, including creating the namespace on first use and skipping documents that are byte-identical to what is stored. Cards are not written for these documents — they are neither conversations nor files.

A line that isn't valid JSON, or is missing id or text, is reported to stderr with its line number and does not stop the batch. The command exits nonzero if any line failed:

$ tennis add --ndjson --ns agents --json < events.ndjson
tennis: ndjson: line 3: unexpected end of JSON input
{
  "chunks": 2,
  "failed": 1,
  "skipped": 0,
  "written": 2
}
error: 1 line(s) failed to parse

tennis ns

tennis ns list
tennis ns create agents                               # built-in model
tennis ns create agents --openai text-embedding-3-small
tennis ns rm agents
$ tennis ns list
NAMESPACE            EMBEDDER                             DIMS     DOCS   CHUNKS
agents               builtin:potion-retrieval-32M          512        2        2
context              builtin:potion-retrieval-32M          512        3        3

The embedder column is not decoration — it is the binding a namespace is stuck with for life. See Embedders.

ns rm removes the namespace and every document written to it. It names the cost before paying it:

$ tennis ns rm agents
tennis: removing "agents" takes its 2 documents (2 chunks) with it
removed "agents"

tennis rm --ns agents does the same thing and costs the same warning; see tennis rm.

tennis rm, serve, version

tennis rm /abs/path/to/auth.md                 # delete from the default namespace
tennis rm e1 e2 --ns agents                    # ...or from a named one
tennis rm --ns agents                         # no ids: remove the namespace itself
tennis serve                                   # local HTTP API on 127.0.0.1:8817
tennis serve --addr 127.0.0.1:9000
tennis version                                 # tag and the commit it was built from

rm takes document ids and reads --ns like add and search do. With no ids it removes the whole namespace instead, which is the same request as tennis ns rm — see tennis ns for what that costs. The flag has to be typed: an unflagged tennis rm resolves the namespace from $TENNIS_NS, and if that counted, a bare Enter against an exported TENNIS_NS would drop a namespace nobody named.

$ tennis rm --ns agents
error: usage: tennis rm <id...> [--ns name]

to remove a namespace instead: tennis rm --ns <name>

rm used to take the namespace first, and that spelling would still run — against the wrong namespace, deleting a document named after the right one — so it is refused rather than obeyed:

$ tennis rm agents e1
error: rm takes ids, not a namespace: try `tennis rm e1 --ns agents`

version prints the release tag and the commit the binary was actually built from. A build from a working tree has no tag, so it says dev and leans on the commit, with dirty appended when the tree had uncommitted changes; a go install ...@version binary carries no commit and reports the module version instead.

$ tennis version
tennis v0.1.0 (ee10c3ed7123)

serve is documented in full under HTTP API.

Common flags

FlagMeaning
--db <path>database file (default ~/.tennis/db.sqlite, or $TENNIS_DB)
--ns <name>namespace for add, ls, search and rm (default context, or $TENNIS_NS)
--cards <dir>where add writes cards (default ~/tennis, or $TENNIS_CARDS)
--jsonmachine-readable output on stdout; progress goes to stderr

Flags may come before, between, or after positional arguments: tennis search --ns work "retry" and tennis search "retry" --ns work are the same command.

The full surface

tennis --help lists the six commands a person reaches for and stops. The rest of the surface is still supported and tested, just not advertised; tennis --agents prints all of it, as plain text without the logo, because the reader is usually a program:

$ tennis --agents
tennis — local hybrid search. Keyword + semantic, one file, no server.

USAGE
  tennis <command> [flags]

COMMANDS
  add <path...>                index sessions or files
  search <query>               search
  ls [source]                  list what tennis has, newest first
  rm <id...>                   delete documents, or a whole namespace with --ns
  ns [list|create|rm]          manage namespaces
  version

ALSO AVAILABLE
  serve                        start the local HTTP API on 127.0.0.1:8817
  seed <namespace> <path...>   like add --files, namespace first
  import <namespace> <path...> like add, namespace first
  match <namespace> <query>    like search, namespace first

SOURCES
  tennis add --chatgpt <export.zip>       a ChatGPT export
  tennis add --claude <export.zip>        a Claude export
  tennis add --claude-code ~/.claude      Claude Code transcripts
  tennis add --codex ~/.codex             Codex transcripts
  tennis add --files ~/Documents/notes    plain files
  tennis add --ndjson < docs.ndjson       documents from a program, on stdin
  Without a source flag, add reads the path and works out which it is.

COMMON FLAGS
  --db <path>    database file (default ~/.tennis/db.sqlite, or $TENNIS_DB)
  --cards <dir>  where summary cards are written (default ~/tennis, or $TENNIS_CARDS)
  --ns <name>    namespace for add, ls, search and rm (default context, or $TENNIS_NS)
  --json         machine-readable output

Run 'tennis <command> --help' for command flags.

tennis help --agents prints the same thing. Both screens are assembled from the same pieces, so the two cannot drift apart.

The three commands under "also available" are add and search with the namespace spelled first, from before --ns existed:

tennis seed notes ./docs                       # like tennis add --files ./docs --ns notes
tennis import history ~/Downloads/export.zip   # like tennis add ~/Downloads/export.zip --ns history
tennis match notes "keep me signed in"         # like tennis search "keep me signed in" --ns notes