A Dockerized HTTP memory service for an AI agent. It ingests conversation turns, extracts structured knowledge from them, evolves facts as they change, and answers recall queries that decide what context the agent sees on its next turn.
git clone <repo> memory-service
cd memory-service
docker compose up -d --build
until curl -sf http://localhost:8080/health; do sleep 1; done
# service is live on http://localhost:8080
The service boots with no keys, but then degrades to rule-based extraction
- BM25-only retrieval. To exercise the real pipeline, set keys first:
cp .env.example .env
# edit .env and set OPENAI_API_KEY (this single key powers BOTH stages:
# extraction via OpenAI AND embeddings via OpenAI)
# Anthropic users: set ANTHROPIC_API_KEY for extraction + OPENAI_API_KEY (or
# VOYAGE_API_KEY) for embeddings — there is no Anthropic embeddings API.
docker compose up -d --buildThe service needs a key for two stages — extraction (ANTHROPIC_API_KEY
or OPENAI_API_KEY) and embeddings (OPENAI_API_KEY or VOYAGE_API_KEY).
An OPENAI_API_KEY alone covers both. Confirm you're not in degraded mode:
docker compose logs memory | grep "ready:"
# want: ready: extraction=openai embeddings=openai (NOT rule_based / none)- Architecture
- Backing store choice
- Extraction pipeline
- Recall strategy
- Fact evolution
- Tradeoffs
- Failure modes
- Running the tests
- Configuration reference
- Endpoint reference
- Code map (for maintainers / agents)
flowchart TD
A[POST /turns] --> EX[Extraction<br/>LLM or rule-based]
EX --> EV[Fact evolution<br/>supersede / reinforce]
A --> EMB1[Embed turn + memories]
EV --> DB[(SQLite<br/>turns • memories<br/>FTS5 • float32 blobs)]
EMB1 --> DB
Q[POST /recall / /search] --> QX[Query expansion]
QX --> BM25[BM25 ranker - FTS5]
QX --> VEC[Vector ranker - cosine]
BM25 --> RRF[Reciprocal Rank Fusion]
VEC --> RRF
DB --> BM25
DB --> VEC
RRF --> ASM[Budget-aware assembly<br/>facts → opinions → episodes]
ASM --> OUT[context + citations]
┌─────────────────────────────────────────────┐
POST /turns ───► │ extract → evolve(supersede/reinforce) → │
│ embed → persist (one SQLite transaction) │
└─────────────────────────────────────────────┘
│
┌──────▼───────┐
│ SQLite │ one file on a named
│ WAL + FTS5 │ Docker volume (/data)
└──────▲───────┘
│
POST /recall ──► expand query ─► [BM25 ⊕ vector] ─► RRF ─► budget assembly ─► context
In two paragraphs. The write path (POST /turns) is fully synchronous: a turn
is run through extraction, each candidate memory is reconciled against existing
memories on the same topic key (supersede or reinforce), everything is embedded
in a single round-trip, and turn + memories + FTS index rows are committed in one
SQLite transaction. When /turns returns 201, the data is already queryable —
no background workers, no eventual consistency.
The read path (POST /recall) expands the query with light synonyms, runs two
independent rankers over the user's memories and turns — BM25 for lexical
precision and cosine for semantics — and fuses them with Reciprocal Rank
Fusion. The fused results are assembled into prompt-ready prose under a token
budget, prioritizing stable user facts, then opinions, then query-relevant
episodes. It's a monolith (one process, one store) because the spec scopes this
to a single service with a few concurrent sessions; that keeps the synchronous
correctness guarantee trivial to reason about.
SQLite (one file) with FTS5 for lexical search and float32 embedding blobs scored in-process with cosine. Persistence is a single file on a named Docker volume; restart is invisible to clients.
Why SQLite over Postgres+pgvector / Qdrant / a vector DB:
| Need | How SQLite covers it |
|---|---|
| Persistence across restart | One file on a named volume. Nothing to orchestrate. |
Real BM25 (not just ts_rank) |
FTS5 ships a true BM25 scorer. Postgres FTS does not. |
| Hybrid retrieval in one store | FTS5 table + embedding blob columns live next to the data. No cross-store joins, no second container. |
| Synchronous correctness | Single writer + WAL: a committed transaction is immediately visible to the next read. Trivial to guarantee. |
docker compose up, no setup |
No server to provision, no migrations to run, no init scripts. |
| The actual scale | One service, single user, a few sessions = hundreds–thousands of vectors. Brute-force cosine is sub-millisecond here. |
What I gave up: approximate-nearest-neighbor indexing (HNSW/IVF) and
multi-writer concurrency. Neither matters at this scale. Scaling path: when
the per-user vector count grows past ~10⁴, swap the in-process cosine loop for the
sqlite-vec extension (drop-in, same
file) or move embeddings to Postgres+pgvector — the recall ranker interface
isolates that change to one module.
Concurrency model: a single shared connection guarded by a re-entrant lock, with
PRAGMA journal_mode=WAL. FastAPI runs sync endpoints in a threadpool; the lock
serializes writes (cheap at this scale) while WAL lets reads proceed.
Goal: turn raw turns into typed, keyed, provenance-stamped memories — never store message chunks and call them memories.
Primary path — LLM (provider-agnostic). A single structured-output call
(src/memory_service/extraction.py, Anthropic or OpenAI via raw HTTP in
llm.py) returns:
{ "memories": [
{ "subject": "user", // who the fact is about (v1.1)
"type": "fact|preference|opinion|event",
"key": "employment", // canonical snake_case topic id
"value": "Works at Notion as a PM",
"confidence": 0.9 }
]}The prompt is engineered around four ideas:
- Subject attribution (v1.1). Each memory is tagged with who it is about —
"user", or a named third party ("sister maya","friend dave"). Facts the user states about other people are never folded into the user's own facts. This fixed a class of failures where a friend's job leaked into the user's profile, or a sister's city was mis-stored as the user's. See the v1.1 CHANGELOG entry. - Canonical keys. The model is told to emit a stable snake_case key per
real-world topic (
employment,location,pet_name,opinion_typescript) so that a later statement on the same topic collides on the same key. This key is the hook the fact-evolution layer keys off. - Type discipline.
fact(stable),preference(likes/working style),opinion(evolvable stance),event(a moment in time). Types drive recall priority and whether a memory can be superseded. - Implicit facts & corrections. The prompt explicitly extracts implicit facts ("walking Biscuit" → pet named Biscuit) and resolves in-turn corrections ("actually, not X — Y") to the corrected value, with calibrated confidence (explicit ≈ 0.9, inferred ≈ 0.6).
Fallback path — rule-based. When no LLM key is configured (or the call fails), a small high-precision regex bank extracts the most common explicit facts (employment, location, pet, name, diet, allergy, response-style). It is deliberately narrow — better to miss than to fabricate.
What it misses (and why): the rule-based fallback only catches explicit, common phrasings and won't do implicit inference or opinion arcs — that's the cost of running without an LLM, and it's documented rather than hidden. Even the LLM path is per-turn (no cross-turn co-reference resolution beyond what's in the turn), so a fact spread across many turns in subtle ways can be partially missed.
End-to-end for POST /recall (src/memory_service/recall.py):
- Query expansion. Strip stopwords, then add a small synonym set so question
phrasing reaches stored keys (
"where do they live"→location, city, lives). Cheap and deterministic — no extra LLM call on the hot path. - Two rankers.
- BM25 over the FTS5 index of memories and turns — exact/keyword matches.
- Cosine over embedding blobs — semantic matches. Skipped if embeddings are disabled (BM25-only fallback).
- Fusion — Reciprocal Rank Fusion (
score = Σ 1/(k + rank),k=60). RRF is scale-free, so I never have to normalize BM25 against cosine; it fuses on rank position. This is what rescues keyword-dependent queries that pure vector search misses. - Budget-aware assembly. Results are split into facts/preferences, opinions,
and episodes (events + raw turns), then emitted as prose under
max_tokens.
When the budget is tight, the order is deliberate:
- The user's own stable facts first (
fact/preferencewithsubject="user"), ranked by relevance then confidence. The highest-value, longest-lived context — who the user is. Facts get up to 70% of the budget. - The user's opinions next — useful stance context, but mutable.
- Facts about people they know (
subject != "user") in a separate "## About people they know" section, so a friend's or relative's facts inform multi-hop questions without ever masquerading as the user's own. - Query-relevant episodes last (events, recent turns), ranked by relevance then recency, filling whatever budget remains.
Rationale: a frozen LLM reading this context benefits most from durable identity facts; episodic detail is supporting color. Facts are also short, so capping them at 70% almost never starves episodes in practice — it's a guard against a fact-heavy user crowding out the query-relevant turn. Token cost is estimated at ~4 chars/token and lines are added greedily until the budget is reached, so we stay within the budget (never 2× over).
Output format is prompt-ready Markdown with dated episodes and a stable-facts
header (matches the contract example), plus citations carrying turn_id, fused
score, and a snippet for each included line.
Latency: one embedding call for the query (skipped in BM25-only mode), one FTS5 query, and an in-process cosine loop over the user's vectors (hundreds → sub-ms). No reranker model on the hot path. Typical recall is well under the "reasonable time" bar; the dominant cost when embeddings are on is the single embedding API round-trip.
Memories are keyed by (scope, subject, key), scope = user_id (or
session:<id> when no user), subject = who the fact is about (v1.1). Scoping by
subject means a fact about the user's sister never supersedes the same-topic fact
about the user. On ingest, each extracted memory is reconciled (memory_store.py):
- Reinforce — if the new value is near-identical to an active memory on the same key (case-insensitive string match, or embedding cosine ≥ 0.93): bump confidence, refresh wording and timestamp. No new row, no churn.
- Supersede — if the value differs: the old row is set
active=0and a new active row is inserted withsupersedespointing at the old id. History is preserved; only the active row is recalled. - Events never supersede — they describe a moment, so they accumulate.
This satisfies the contract's worked example directly (verified live):
session 1: "I work at Stripe" → memory{key:employment, value:"...Stripe", active:1}
session 3: "I joined Notion" → old Stripe row active:0;
new Notion row active:1, supersedes:<stripe-id>
/recall "where do they work now" → "Started working at Notion"
/users/{id}/memories → both rows; Stripe inactive, chain visible
Opinion arcs (partial, documented). Opinions use the same key, so the
supersession chain is the arc: "loves TypeScript" → "generics are annoying" →
"fine for big projects" each supersede the prior, and the full chain is walkable
via supersedes in /users/{id}/memories. What's not yet implemented is a
synthesized arc summary in /recall (e.g. "warmed then cooled on TypeScript") —
today recall surfaces the current stance. Synthesizing the arc from the chain is
the natural next step and is isolated to the assembly stage.
Known limitation — key-based reconciliation (found via the eval harness; see
eval/FINDINGS.md). Supersession matches on the exact (subject, key) the LLM
assigns, which produces two opposite failure modes when the key is ambiguous:
- Over-merge: several coexisting facts that share a key clobber each other —
tell the service about a cat, a dog, and a parrot and it can keep only one
petrow. (Reproduces reliably for 3+ same-slot items.) - Under-merge: the same single-valued slot phrased with different keys
(
carvsvehicle) leaves two contradictory rows active. (Intermittent — depends on the LLM's key choice.)
Single-valued slots with a consistent key (location, employment) supersede
correctly, and the recall layer often masks the gap because raw turns still carry
the history. The robust fix (planned v1.2) is LLM reconciliation: on ingest,
show the model the user's existing facts and have it emit ADD / UPDATE / DUPLICATE
per new fact — judging contradiction vs. coexistence instead of trusting an
exact key match.
Optimized for:
- Recall + extraction quality and contract correctness — the primary eval signals.
- Operational simplicity & reproducibility — one process, one file,
docker compose up, no migrations. - Synchronous correctness — committed = immediately recallable, by construction.
- Graceful degradation — never dead on a cold machine; missing keys downgrade capability, not availability.
Gave up:
- ANN vector indexing — brute-force cosine instead (fine at this scale; swap path documented).
- Horizontal scale / multi-writer concurrency — single SQLite writer. Out of scope per the brief.
- A reranker model — RRF + recency/confidence heuristics instead of a cross-encoder. Cheaper, no model to host; precision ceiling is lower on long histories.
- Cross-turn co-reference & multi-turn extraction — extraction is per-turn.
| Situation | Behavior |
|---|---|
| No data / cold session | /recall returns {"context":"", "citations":[]} with 200. Never errors. |
| No API keys at all | Boots fine. Rule-based extraction + BM25-only retrieval. Logged at startup (extraction=rule_based embeddings=none). |
| LLM key but no embedding key | LLM extraction + BM25-only retrieval. (This is the validated host setup: extraction=anthropic embeddings=none.) |
| LLM/embedding call fails mid-request | Caught and logged; extraction falls back to rules, retrieval falls back to BM25. Request still succeeds. |
| Malformed JSON / missing fields | 422 (or 400), never a crash. |
| Oversized payload | 413 above REQUEST_MAX_BYTES (default 2 MB). |
| Unicode / emoji / weird roles / extra fields | Tolerated; stored and recalled correctly. |
| Unexpected internal error | Global handler returns 500 with a generic body; worker stays up. |
| Slow disk | WAL keeps reads non-blocking; writes serialize on the lock. Degrades in latency, not correctness. |
Bad/expired auth (when MEMORY_AUTH_TOKEN set) |
401. If unset, auth is skipped entirely. |
The suite runs hermetically in degraded mode (no keys) so it's deterministic and fast. Two ways:
A. In Docker (matches the deploy target, Python 3.12):
docker run --rm -v "$PWD:/app" -w /app python:3.12-slim \
bash -c "pip install -q -r requirements.txt -r tests/requirements-dev.txt && pytest -q tests"B. Locally (needs Python with prebuilt pydantic-core wheels):
pip install -r requirements.txt -r tests/requirements-dev.txt
pytest -q testsWhat's covered:
| Test file | Covers |
|---|---|
test_contract.py |
Roundtrip, every endpoint's shape & status code, cold-session empty recall. |
test_persistence.py |
Write → tear down app → re-open same DB → data survives (restart sim). |
test_concurrent.py |
Two users don't bleed; same-user cross-session sharing (the documented design). |
test_malformed.py |
Bad JSON, missing fields, unicode, oversized body, weird roles → 4xx/handled, never crash. |
test_recall_quality.py |
Self-eval fixture: ingest fixtures/, run probes, assert X of Y passed; plus a supersession-chain assertion. |
Latest run: 17 passed; recall self-eval 6/6 probes in degraded mode. Run
the fixture with -s to see the per-probe report:
pytest -q -s tests/test_recall_quality.py::test_recall_quality_fixtureeval/run_eval.py drives the live service through harder scenarios over the
full LLM + embedding pipeline and grades /recall two ways: deterministic
substring checks and an independent LLM-as-judge (correctness +
hallucination). This is the loop that drives the CHANGELOG — the hermetic unit
fixture saturates, so honest iteration needs probes that break the real pipeline.
# service must be running with provider keys (docker compose up -d)
python eval/run_eval.py --scenarios eval/scenarios_hard.json --tag my-runIt loads keys from .env, prints a per-category report, and appends a summary to
eval/results.jsonl. eval/scenarios_hard.json is 35 probes across implicit
facts, corrections, negation/deactivation, long-term memory (facts recalled
across ~6 months of sessions), noise resistance, attribution, fact evolution,
multi-hop, opinion arcs, temporal ordering, budget triage, and numeric
aggregation.
Baselines (full OpenAI pipeline):
- v1.0 (9-probe set): 7/9 det · 8/9 judge · 1 halluc — exposed two subject- attribution failures.
- v1.1 (35-probe set): 34/35 det · 35/35 judge · 0 halluc. The single deterministic miss is a check artifact — the correct superseding value ("traded in the Civic for a Tesla") contains the keyword the substring check forbids; the judge scores it correct. This is exactly why the harness grades with both a dumb substring check and an LLM judge.
All optional — see .env.example. The service auto-detects providers from
whichever keys are present.
| Variable | Default | Meaning |
|---|---|---|
DB_PATH |
/data/memory.db |
SQLite file (on the named volume). |
MEMORY_AUTH_TOKEN |
(unset) | If set, all endpoints require Authorization: Bearer <token>. |
EXTRACTION_PROVIDER |
auto | anthropic / openai. Auto-detected from keys; falls back to rule_based. |
EXTRACTION_MODEL |
per-provider | claude-haiku-4-5 (anthropic) / gpt-4o-mini (openai). |
EMBEDDING_PROVIDER |
auto | openai / voyage. Auto-detected; falls back to none (BM25-only). |
EMBEDDING_MODEL |
per-provider | text-embedding-3-small / voyage-3-lite. |
ANTHROPIC_API_KEY / OPENAI_API_KEY / VOYAGE_API_KEY |
(unset) | Provider credentials. |
REQUEST_MAX_BYTES |
2097152 |
Max request body before 413. |
LLM_TIMEOUT |
45 |
Per-call HTTP timeout (seconds), within the eval's 60s /turns budget. |
| Method | Path | Purpose | Success |
|---|---|---|---|
| GET | /health |
Liveness/readiness | 200 {"status":"ok"} |
| POST | /turns |
Ingest a turn (extract + persist, synchronous) | 201 {"id":...} |
| POST | /recall |
Prose context for the next agent turn | 200 {context, citations} |
| POST | /search |
Structured search results (tool-call shape) | 200 {results[]} |
| GET | /users/{user_id}/memories |
Inspect all memories (incl. superseded) | 200 {memories[]} |
| DELETE | /sessions/{session_id} |
Purge a session's data | 204 |
| DELETE | /users/{user_id} |
Purge a user's data | 204 |
Cross-session scoping (documented design decision): memories are
user-scoped and intentionally shared across that user's sessions — that's the
point of cross-session memory and fact evolution. Raw-turn recall is likewise
scoped to the user. Different user_ids never share. When user_id is null,
scope falls back to the session_id so anonymous sessions stay isolated.
Single Python package, src/memory_service/. Each module has a top-of-file
docstring explaining its role; this is the 30-second tour:
| Module | Responsibility | Start here if… |
|---|---|---|
main.py |
FastAPI app: routes, auth dependency, body-size middleware, global error handler, lifespan that wires the store/recall objects. | …you're changing the HTTP surface or status codes. |
config.py |
Env → Settings. Provider auto-detection, model defaults, enable flags. |
…you're adding a provider or a knob. |
models.py |
Pydantic request/response models. Lenient inputs (extra ignored, content coerced) so odd-but-valid payloads don't 422. | …you're touching the contract shapes. |
db.py |
SQLite: schema (turns, memories, two FTS5 tables), WAL, lock, read()/write() transaction context managers. |
…you're changing storage or indexes. |
extraction.py |
Turn → candidate memories. LLM prompt + JSON normalization, rule-based regex fallback. | …extraction quality work. |
llm.py |
Provider-agnostic chat client (Anthropic/OpenAI over httpx) + robust JSON parsing. | …adding an LLM provider. |
embeddings.py |
Provider-agnostic embeddings + float32 pack/unpack. Returns None when disabled. |
…adding an embedding provider. |
memory_store.py |
Write path: persist turn, reconcile memories (supersede/reinforce), append events, deletes/inspection. | …fact-evolution logic. |
recall.py |
Read path: query expansion, BM25 + vector ranking, RRF, budget-aware assembly, /search. |
…retrieval/ranking work. |
Design invariants to preserve when extending:
/turnsstays synchronous — a committed transaction must be immediately recallable. Don't introduce background indexing.- Every external call (LLM, embeddings) must degrade, not throw, on failure.
- Only active memories are recalled; superseded rows are kept for inspection.
- Scope isolation by
user_id(orsession_idwhen null) is a hard boundary — don't widen a query's scope without updating the README's scoping section. subjectdistinguishes the user from third parties; onlysubject="user"facts belong under "Known facts about this user". Don't collapse the distinction.