A periodically rebuilt, age-encrypted snapshot of message text, served over HTTP to one machine on the local network that holds the decryption key. Built for n-gram tables: the consumer needs a corpus of real dialogue, not a copy of the database.
The database is ~600 MB on prod, but almost none of it is dialogue:
| Part | Size |
|---|---|
session_messages.content, all roles |
68 MB |
session_messages.thinking |
40 MB |
session_messages.tool_calls |
16 MB |
| attachments (images/files/metadata) | 95 MB |
everything else (sessions JSON, store, vectors) |
~370 MB |
The default corpus — user + assistant content across both message tables — is 14 MB of UTF-8, 23,672 rows on prod. That gzips to a few megabytes.
A dump would also carry mcp_user_keys (Fernet-encrypted MCP credentials), users (PII, password hashes) and api_tokens (token hashes). Those must not leave the server in any form, under any key — so the export is a fixed, versioned SELECT, not pg_dump.
EXPORT_VERSION (navi/export/builder.py) plus the fields list in the manifest is the contract. Adding a field means bumping the version, because the consumer validates the list.
gzip-JSONL, one object per line:
{"v":1,"session":"…","seq":42,"role":"assistant","content":"…","ts":"2026-11-04T10:22:31+00:00"}
Default corpus: role = user | assistant, content only. NAVI_EXPORT_ROLES adds or removes roles:
| Role | Default | Why |
|---|---|---|
user, assistant |
on | the dialogue itself — what an n-gram table should predict |
tool |
off | 52 MB of scraped pages, command output and file contents; a different distribution that would dominate the counts |
system |
off | near-identical boilerplate (867 copies of the same prompts) |
thinking |
never | model reasoning is a separate style; not part of the schema |
Summary and compression artefacts (is_summary, is_compression) are always excluded — they duplicate dialogue in a summarised form.
Whole sessions, newest activity first, until the cumulative text reaches NAVI_EXPORT_MAX_BYTES (150 MB). Today the entire corpus is 14 MB, so nothing is cut (window.truncated: false); the cap exists so the snapshot stays bounded as history grows. The cut is always on a session boundary — a conversation never arrives half a story.
Both session_messages and session_messages_archive feed the window, so compressed-away history stays in the corpus.
A background loop (navi/export/loop.py) starts at boot and repeats every NAVI_EXPORT_INTERVAL_HOURS. Inside:
REPEATABLE READ transaction, so window and rows agree;mtime, sorted keys) — the same corpus always yields the same digest;age to NAVI_EXPORT_AGE_RECIPIENT and the plaintext is deleted; only messages-v1.jsonl.gz.age remains.content_id is the SHA-256 of the plaintext JSONL. When a rebuild produces the same content_id, the ciphertext is left untouched and only built_at moves — so an idle server does not invalidate a consumer's cache.
If a build fails, the previous file and manifest stay in place. built_at keeps telling the truth, so a consumer sees the snapshot ageing instead of silently reading stale data as fresh.
/health carries an informational export object when this feature is configured — built, built_at, rows, age_seconds, stale (> two missed rebuilds). It never moves the aggregate status: a slow snapshot is not the service being unhealthy.
Both require Authorization: Bearer <NAVI_EXPORT_TOKEN>. This is a machine credential: no cookies, no user, no role. A missing token and a wrong token get the same 401.
GET /export/v1/manifest{
"export_version": 1,
"built_at": "2026-11-04T12:00:00+00:00",
"content_id": "9f2c…",
"file": {"name": "messages-v1.jsonl.gz.age", "bytes": 5242880, "sha256": "…"},
"rows": 23672,
"sessions": 1177,
"sessions_total": 1177,
"text_bytes": 14680064,
"fields": ["v", "session", "seq", "role", "content", "ts"],
"roles": ["user", "assistant"],
"window": {"from": "…", "to": "…", "truncated": false, "max_bytes": 157286400},
"age_recipient": "age1…"
}
503 when nothing has been built yet, or when the export is not configured.
GET /export/v1/messagesThe ciphertext, streamed from disk. Headers: ETag (derived from content_id), X-Export-Content-Id, X-Export-Built-At. A conditional request with the matching If-None-Match answers 304 with an empty body — the normal case on a 4-hour schedule.
scripts/navi_export.py — one file, one dependency (pip install pyrage), Python 3.11+.
# once: make a keypair here, put the public half into Navi's .env
./navi_export.py keygen --out ~/.config/navi-export
# -> ~/.config/navi-export/key.txt (secret, 0600; never leaves this machine)
# -> age1… (NAVI_EXPORT_AGE_RECIPIENT on Navi)
# every run
./navi_export.py pull \
--url https://navi.example \
--token-file ~/.config/navi-export/token \
--identity ~/.config/navi-export/key.txt \
--out ~/ngram/messages.jsonl.gz
The pull reads the manifest first and exits 3 with no download when content_id matches the local state file. Otherwise it downloads to .part, verifies the manifest's SHA-256, decrypts, fully decompresses once to prove the gzip is intact, and atomically renames into place.
Exit codes: 0 updated, 3 unchanged, 1 error.
Cron: polling every 30 minutes is cheap — the steady state is one manifest GET.
.env and restarting; rotate the key by generating a new pair and updating NAVI_EXPORT_AGE_RECIPIENT (the old ciphertext becomes unreadable, which is the point).SELECT.