Skip to content

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.

01

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
  1. 1Clone the repository
  2. 2Start the stack with Docker Compose
  3. 3Migrations run automatically on boot
  4. 4The API answers on port 8000

Local Python

Postgres 16+
  1. 1Create a virtual environment and install the requirements
  2. 2Copy the example env file and set the database URL
  3. 3Apply migrations with Alembic
  4. 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.

02

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
03

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

The ranking calculator evaluates this exact score for any similarity, age and importance.
04

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.

05

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.

06

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.

POST/v1/memoriesStore a memoryAPI key required

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.

07

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

Namespaces are an application-level filter, not database row-level security, and the shared key can read and write any namespace. There are no per-tenant keys, no rate limiting and no roles. Treat Mnemo as a single-tenant service you run yourself.

Guides

What the benchmark measures, what this version does not do, and how to get unstuck.

08

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.

Top-1, top-3 and MRR for relevance ranking versus a raw-cosine baseline
RankingTop-1Top-3MRR
similarity × decay × importance80.0%94.0%0.875
raw cosine similarity (baseline)78.0%94.0%0.864

top-1 accuracy

relevance-ranked
80.0%
raw cosine
78.0%

How it is measured

  1. 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.
  2. 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.
  3. 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.
  4. 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.

09

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.

10

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.

11

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.

Simulation: runs entirely in your browser, not the live API

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

4 / 5 · high

weight = 1.05 (band 0.90 to 1.10)

2 · Search for a memory

In memory6 active
  • 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 illustrative

Run 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

12

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.

relevance = similarity × max(0.7, 0.5^(age / 180)) × (1 + (importance − 3) × 0.05)

Memory A

similarity0.85
age400 d
importance5 / 5
decay
0.700
weight
1.10
relevance
0.654

Memory B

similarity0.80
age0 d
importance1 / 5
decay
1.000
weight
0.90
relevance
0.720
rankingB ranks first: 0.720 vs 0.654

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

1.0floor 0.7180d
half-life
180 days
floor
0.7 (penalty capped at 30%)
importance band
0.90 to 1.10
clamped?
no, relevance may exceed similarity
13

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.

0.93 · gate 0.82

negation markers

no longernotnevern'tisn'twasn'tdoesn'tdidn'tstoppedquitleftformerformerlyex-used tono moreceasedresignedcancelledcanceled
flaggedgate passed
1
Similarity gate✓

similarity 0.93 ≥ 0.82

2
Negation check✓

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

Flag

Adding a memory compares it to its 5 nearest neighbours and writes a contradiction row with status: open.

Review

GET /v1/contradictions lists open conflicts so a person can read both sides.

Resolve

POST /v1/contradictions/resolve names the winner. The loser becomes is_active = false.

Audit

Both rows and the contradiction itself remain in the database, resolved, never erased.