Developer guide
Mnemo is a persistent-memory service for AI agents. Everything below is taken from the repository: how it works, how to run it, the exact REST contracts, and the trade-offs behind it.
Getting started
Run Mnemo locally: a one-command Docker stack, or a Python environment against your own Postgres.
Run it locally
#Two supported paths: a one-command Docker stack, or a local Python environment against your own Postgres. Both end with the API answering on port 8000.
Docker
Recommended- 1Clone the repository
- 2Start the stack with Docker Compose
- 3Migrations run automatically on boot
- 4The API answers on port 8000
Local Python
Postgres 16+- 1Create a virtual environment and install the requirements
- 2Copy the example env file and set the database URL
- 3Apply migrations with Alembic
- 4Serve the app with uvicorn in reload mode
Base URL
localhost:8000
Health
/health, no key needed
OpenAPI
Interactive docs at /docs
Concepts
How the service is put together, how recall is ranked, and how conflicts are handled.
How it's built
#One stateless FastAPI service in front of a single PostgreSQL database with pgvector. Embeddings are computed in-process, so there is no embedding API and no second vector store to keep in sync. Select a stage to see what it does.
Client
Your service or agent calls the REST API over JSON/HTTP. Everything under v1 is authenticated; the health probe is not.
- Interface
- JSON over HTTP
- Auth
- X-API-Key header
- Schema
- OpenAPI at /docs
Retrieval & ranking
#A search runs in two steps: pgvector returns approximate nearest neighbours by cosine distance, then Python re-orders them so recency and importance decide between comparable matches.
Every candidate is scored by
relevance = similarity × decay(age) × importance
similarity
Raw cosine from pgvector.
decay
Exponential, 180-day half-life, floored at 0.7.
importance
A 1 to 5 rating mapped to a narrow 0.90 to 1.10 band.
Embeddings
all-MiniLM-L6-v2, 384 dimensions, computed in-process. Vectors are L2-normalised, so a dot product equals cosine similarity.
Index
HNSW over cosine distance: fast and approximate, not an exhaustive scan. Search over-fetches limit × 4 before re-ranking.
Why the multiplier is small
It spans under 2× on purpose, narrower than the spread of cosine similarity, so decay reorders, never overrides.
Try the function live
Contradictions
#Mnemo never guesses which statement is true. It finds candidate conflicts with a deterministic, two-stage check (a similarity gate, then a negation-marker test), and hands the decision to a person.
Active
Every memory lands here. It is embedded, stored, and immediately retrievable from its namespace.
- is_active
- true
- Searchable
- yes
- Listed
- yes
The check is cheap and explainable: no model call by default, and a pluggable slot if you want an LLM verdict later. Try the exact logic in the contradiction checker below.
Reference
The exact tables, routes and access model: the parts you look up rather than read.
Data model
#Two tables. memories holds each fact and its 384-dimensional vector; contradictions records a flagged pair and, once resolved, which side won.
memories
- iduuid
Primary key
- namespacevarchar(128)
Retrieval bucket; default "default"
- contenttext
The fact itself
- importanceinteger
1 to 5, default 3
- embeddingvector(384)
all-MiniLM-L6-v2, L2-normalised
- is_activeboolean
Soft-delete flag; default true
- created_at / updated_attimestamptz
created_at drives decay
contradictions
- iduuid
Primary key
- namespacevarchar(128)
Same bucket as the pair
- new_memory_id / existing_memory_iduuid
Both sides of the flagged pair
- similaritydouble precision
Cosine similarity that cleared the gate
- reasontext
Human-readable explanation of the flag
- statusvarchar(32)
open | resolved
- resolved_winner_id / resolved_atuuid / timestamptz
Set when a human resolves it
Soft delete
Rows are never removed. An inactive memory is hidden from lists and search but stays fetchable by id.
Namespaces
Both tables carry a 128-character namespace, and every query filters on it.
Indexes
A b-tree on namespace and active state, plus an HNSW index for the vector column.
REST API
#Twenty-six routes over a JSON interface. Everything under v1 requires an API key; the two probes do not. Keys, events and webhooks additionally need the admin scope. Pick a route to read exactly what it accepts and returns.
Embeds the text locally, writes the row, then checks it against its five nearest active neighbours before the transaction commits.
Request
- contentstringrequired
The fact to store
- namespacestring
Retrieval bucket, 1 to 128 chars. Default “default”
- importanceinteger
1 to 5. Default 3
- tagsarray
Up to 10 short labels; filterable with ANY-match
- metadataobject
Free-form JSON side data, returned on read
- expires_attimestamp
Optional TTL; expired rows drop out of reads
Response
- memoryobject
The stored row
- contradictionsarray
Flags raised by this write; usually empty
- deduplicated_ofuuid | null
Id of the near-identical existing memory when dedup hit
Returns 201 with the memory and any flags. A missing or wrong key returns 401 with “Missing or invalid API key.”; a duplicate within the dedup window returns 409.
Authentication & namespaces
#v1 auth is deliberately minimal. It is worth being precise about what that buys you.
A single shared key
One key, compared in constant time, guards every v1 route. Requests without it are rejected with 401.
Namespaces
A namespace is the memory bucket. Every read and write filters on it, so two namespaces never see each other's memories.
What this is, and is not
Guides
What the benchmark measures, what this version does not do, and how to get unstuck.
Evaluation
#A retrieval benchmark ships with the repository and runs against the real search path. These are its numbers: 60 memories, 50 queries, plus a raw-cosine baseline for comparison.
| Ranking | Top-1 | Top-3 | MRR |
|---|---|---|---|
| similarity × decay × importance | 80.0% | 94.0% | 0.875 |
| raw cosine similarity (baseline) | 78.0% | 94.0% | 0.864 |
top-1 accuracy
How it is measured
- 01Seed a dedicated 'benchmark' namespace with the 60 memories from backend/benchmark/dataset.json, backdating created_at from each row's age_days so decay is genuinely exercised.
- 02Run every one of the 50 hand-labelled queries through the real search path (app.services.memory_service.search): same embedder, same pgvector query, same re-ranking.
- 03A query counts as correct at rank k if any of its gold memory ids appears in the top k. Several questions have more than one defensible answer, so gold lists can hold multiple ids.
- 04Report top-1, top-3 and MRR. The --no-decay mode re-ranks by raw cosine only, so decay's contribution is measured rather than asserted.
What the benchmark caught
The first scoring used a 30-day half-life with a 0.05 floor and a 0.6 to 1.4 importance band. Together those spanned about 28×, far wider than the <3× spread of cosine similarity, so decay didn't nudge the ranking, it dictated it, degenerating search into “newest first”. That configuration scored 36% top-3. Widening the half-life to 180 days, raising the floor to 0.7 and narrowing importance to 0.9 to 1.1 brought it to 94%. backend/tests/test_core.py pins the invariant so it cannot regress.
An honest caveat
On this benchmark decay is worth about +2 points of top-1 over raw cosine and nothing at top-3. Its value shows on the six recency-sensitive queries, where a fact was superseded, taking top-3 from 83% to 100%. The parameters were tuned against these same 50 queries, so treat the headline as an in-sample result. These numbers describe this dataset only and are not comparable to unrelated vendor benchmarks.
Limitations
#What this version does not do, stated plainly.
Contradiction detection is a heuristic
The marker list is small and English-only. Paraphrased contradictions can be missed, and a shared negation can produce a false positive. A flag is a candidate for review, not a verdict.
Search is approximate
HNSW trades exactness for speed, and re-ranking only sees the over-fetched candidates.
One shared key
No per-tenant keys, no rate limiting, no roles. Namespaces are a logical filter, not an auth boundary.
Long input is truncated
MiniLM truncates at its maximum sequence length, so very long memories lose detail in the vector.
Relevance is not clamped
It can edge slightly above raw similarity when importance is high.
No pagination on contradictions
The list returns every matching row for the namespace.
Troubleshooting
#The failures worth knowing about before they cost you an afternoon.
- 401 Missing or invalid API keySend the X-API-Key header matching MNEMO_API_KEY. The default dev key is dev-local-key-change-me.
- Embedding dimension mismatchThe model, EMBEDDING_DIM and the migration's vector size must change together.
- The memories relation is missingMigrations haven't run. Apply them with Alembic after setting DATABASE_URL.
- The vector extension is unavailableInstall pgvector, or use the pgvector/pgvector:pg16 image. The migration needs extension privileges.
- First request is slowThe MiniLM model loads once per process. Docker pre-downloads it to /models.
- 400 on resolveThe contradiction is unknown, already resolved, or the winner isn't one of the two memories in the pair.
The source is the authority for anything not covered here: read it on GitHub.
Interactive
Browser-side simulations of the real logic. Nothing here touches the API. The same tools also stand alone on the playground page.
Memory playground
#Store a fact, search for it, watch the ranking, then try to contradict it. Add and search run against a simulated store in your browser.
The live API authenticates every request with a private X-API-Key, which a public page must not embed, so this demo mirrors the request and response shapes and reuses the real ranking formula. Similarity is an illustrative score; the service itself matches on all-MiniLM-L6-v2 embeddings.
1 · Add a sample memory
weight = 1.05 (band 0.90 to 1.10)
2 · Search for a memory
- 4Pranav lives in Bengaluru260d
- 4Pranav is lactose intolerant and avoids milk-based desserts380d
- 5Pranav is allergic to peanuts400d
- 2Pranav is learning Rust on weekends45d
- 3Pranav runs 5 kilometres three mornings a week110d
- 2Pranav's favourite cuisine is Thai290d
3 · Recall results
similarity is illustrativeRun a search to see returned memories, their raw similarity, and the ranking breakdown.
Nothing has happened yet
Add a memory or run a search. Everything below runs in your browser. See the note above.
no action yet
Ranking calculator
#Search does not order by vector distance. Each candidate is scored by the function below, evaluated in Python, then sorted. Move the sliders to see why a memory won.
Memory A
Memory B
The fresher memory edges ahead because the two similarities are close. Decay reorders comparable matches. It does not decide alone.
The calculator evaluates the exact function in backend/app/core/decay.py. None of these numbers come from a search. Real results also show the raw cosine similarity and age returned by the API.
Decay of Memory A
age 400 d → decay 0.700
Contradiction checker
#The stages below are the exact logic from backend/app/core/contradiction.py, ported to the browser. Similarity would normally come from the vector search; set it manually here.
negation markers
similarity 0.93 ≥ 0.82
Similar content (sim=0.93) with opposing polarity; negation marker(s) on one side only: no longer.
verdict.reason
Similar content (sim=0.93) with opposing polarity; negation marker(s) on one side only: no longer.
A flag only means “these two look like opposites about the same subject”. Resolution is an explicit, human action: POST /v1/contradictions/resolve names the winner, and the loser is soft-deleted (is_active = false) so the audit trail survives. Nothing is ever hard-deleted.
A flag does not establish which statement is true. The marker list is small and English-only, so paraphrased contradictions can be missed and unrelated statements that share one negation can be flagged. It is a high-precision default, not an oracle.
The resolution flow
Adding a memory compares it to its 5 nearest neighbours and writes a contradiction row with status: open.
GET /v1/contradictions lists open conflicts so a person can read both sides.
POST /v1/contradictions/resolve names the winner. The loser becomes is_active = false.
Both rows and the contradiction itself remain in the database, resolved, never erased.