Tennis

Troubleshooting

The errors Tennis raises, what each one is protecting you from, and how to clear it.

Tennis prefers a loud refusal to a quiet wrong answer. Most of what follows is a deliberate stop, not a bug.

"was indexed with … but the loaded embedder is …"

namespace "notes" was indexed with builtin:potion-retrieval-32M (512 dims) but the
loaded embedder is openai:text-embedding-3-small (1536 dims); reindex the
namespace or restore the original model

The namespace is bound to one embedder for life. You are asking it to compare vectors from two different models, and a cosine between those is not a worse score — it is a meaningless one.

Either restore the original model (drop --openai, or set OPENAI_API_KEY again if the namespace really is an OpenAI one), or build a new namespace:

tennis ns create notes-v2 --openai text-embedding-3-small
tennis add ~/Documents/notes --ns notes-v2

See Embedders.

"openai embedder requested but OPENAI_API_KEY is not set"

You passed --openai, or opened a namespace bound to an OpenAI model, without a key in the environment:

export OPENAI_API_KEY=sk-...

Tennis never reads that variable to decide whether to use OpenAI — only to use it once you have asked. That is why a namespace bound to OpenAI fails here instead of quietly falling back to the built-in model.

"no matches"

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

In --mode keyword this is literal: no indexed document contains those words. Keyword search matches any of the query's terms, with stop words such as in and the dropped first, so this means signed appears nowhere. Drop --mode to let the vectors answer.

In hybrid mode, no matches means the namespace is empty or your --where filter excluded everything. Check what is there:

tennis ls                     # sessions, newest first
tennis ls --where source=codex
tennis ns list                # every namespace and its counts

"no indexable files in … (looking for any file with text, N skipped)"

add reads every file with text in it by default, so this means the folder held none it could read: photos, archives, formats with no reader on this machine, or files that are all inside folders add passes over. With --ext, only the named extensions count:

tennis add ./src --files --ext .go,.ts,.rs

The N skipped count is the files passed over: photos, audio, video and archives (quietly), files a .gitignore names (quietly), unknown binaries and text over 10MB (with a note on stderr), and formats whose reader is missing on this machine — a PDF with neither pdftotext nor macOS, say — which are noted along with what to install.

When the error ends M folders passed over, those are folders add did not enter: node_modules, vendor, target, dist, build, __pycache__ and site-packages wherever they appear, and folders a .gitignore inside the source names. To read one anyway, name it directly — the folder you name is always read:

tennis add ./vendor --files

"does not look like a chat export" / "nothing to import from …"

Detection reads the source rather than the filename, and it can be wrong about an unusual archive. Name the format:

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

nothing to import from X (read as Y) means Y found no documents in it. When detection picked an agent format and the folder held no transcripts after all, add reads it as plain files instead, so you see this when you named the format yourself, or when the source is genuinely empty. See Importing sessions.

"--format X contradicts --Y" / "--X and --Y name different sources"

You passed two source flags that disagree, e.g. --claude --codex, or --format files --chatgpt. Pick one.

"checksum mismatch for …"

checksum mismatch for potion-retrieval-32M/model.safetensors:
  got  a1b2…
  want c3d4…
the upstream file changed or the download was corrupted; refusing to use it

Model downloads are verified against SHA-256 checksums pinned in the source. A corrupted download is the common cause — clear the cache and retry:

rm -rf ~/.cache/tennis && tennis search "anything"

If it persists, the upstream file genuinely changed, which is a bug to report rather than work around: a silently different model would change every vector the install ever produces.

"rm takes ids, not a namespace"

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

rm used to take the namespace first. The old spelling would still run — against the default namespace, deleting a document that happens to be named after the one you meant — so when the first argument is a namespace that exists, Tennis refuses and shows the spelling it wants. tennis ns rm <name> is the command that removes a whole namespace.

"no ANTHROPIC_API_KEY or OPENAI_API_KEY; cards will carry the opening message"

Not an error. Every conversation and file add reads is written to ~/tennis as a markdown card, and the summary on the card needs a model. Without a key a conversation's card carries the first thing you typed instead, and a file's carries its first lines; set either key and re-run add to fill in the prose, or pass --no-cards if you do not want them. Search is unaffected either way. See Cards.

"cannot parse filter … (want key=value, key>value, …)"

--where takes comma-separated comparisons, no spaces around the operator:

tennis search "auth" --where 'status=merged'
tennis search "auth" --where 'cost>5,status!=failed'
tennis search "flaky test" --where project=tennis,branch=main

Supported operators are =, !=, >, >=, <, <=. A value with a comma in it goes in double quotes, inside the shell's single quotes, so the comma doesn't end the clause; a quoted value is always compared as text:

tennis search --where 'path="/Users/you/Budget, 2026.md"'

"invalid namespace name" / "invalid attribute name"

Namespaces take letters, digits, dash, and underscore, up to 64 characters, and must start with a letter or digit. Attribute names additionally allow dots.

"N line(s) failed to parse" from add --ndjson

$ 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

Bad lines are reported with their line number and skipped; the good ones are still written. The command exits nonzero so a script notices. Each line needs id and text.

Results are there, but bad

Tennis has no relevance threshold — semantic search ranks everything, so a query always returns up to -k results even when nothing is a good match. Read the evidence rather than the position:

  • A hit tagged with only one ranker ([sem#3]) at a low score is usually noise.
  • A hit both rankers put first scores about 0.0328; one ranker alone tops out near 0.0164. See why fuse by rank.

If keyword search finds nothing for Chinese, Japanese, or Korean text, that is a known limitation: the FTS5 tokenizer does not segment CJK. Semantic search still works.

Starting over

The database is one ordinary SQLite file, and the model cache is a directory:

tennis ns rm notes            # one namespace
rm ~/.tennis/db.sqlite        # everything indexed
rm -rf ~/.cache/tennis        # the downloaded model

The cards in ~/tennis (or $TENNIS_CARDS) are ordinary markdown files that Tennis writes and never reads back into the index; delete any you do not want, and the next add of their source writes them again. Nothing else on your machine is state.