Memvara
Bitemporal memory for AI agents.
Know what was true. Know when it was true. Know why you believe it.
90-second demo · Quickstart · Documentation · PyPI · memvara.dev · Issues
mem.remember("Alice", "lives_in", "Berlin", valid_from=jan)
mem.remember("Alice", "lives_in", "London", valid_from=mar)
mem.remember("Alice", "lives_in", "New York", valid_from=jun)
[c.object for c in mem.get_all()] # ['New York'] where now?
[c.object for c in mem.get_all(as_of=mar_20)] # ['London'] and on 20 March?
[c.object for c in mem.get_all(as_of=jan_20)] # ['Berlin'] and on 20 January?
Three questions, three different correct answers, no model call. A store that keeps one value per fact answers the first and gets the other two wrong — not by hallucinating, but because overwriting Berlin destroyed the only record that Berlin was ever the answer.
pip install memvara
numpy and nothing else. Runs offline, no API key, no Docker, no vector database.
| Bitemporal | Two time axes that move independently: when a fact was true, and when this store was told. |
| Deterministic | A contradiction in a predicate declared single-valued is resolved on write by a keyed lookup, not by a model's judgement. |
| Auditable | A claim carries the episodes cited for it and the claim it superseded, and why() returns both. |
| Historical | search, get_all, history and five more reads take as_of=, valid_at= or known_at=, so what was true then is a query rather than a reconstruction. |
| LLM-light | remember() never calls a model. add() filters with model-free tiers first and sends whatever survives in one extraction call. |
The problem
AI agents don't just forget. They remember the wrong thing.
A customer says in March that invoices go to Bramble Cottage, not Coldharbour Road. In August they write again, annoyed, because an invoice went to Coldharbour Road. Ask a store that ranks by similarity where invoices go, and the August message wins: it is the more recent one and it says the wrong address twice.
That is not the model hallucinating. The memory layer held both answers and marked
neither one current. Nothing in "an embedding and an updated_at column" can represent
this value replaced that one in March.
Values ranked by similarity Values with the interval each held
Berlin Berlin Jan 10 → Mar 15 ended
London London Mar 15 → Jun 02 ended
New York New York Jun 02 → now live
↓ ↓
whichever embeds closest whichever was true at the
to the question instant you asked about
Retrieval asks one question — what is relevant? Memory has to answer five:
What do I know? · When was it true? · When did I learn it? · What replaced it? · Why do I believe it?
Memvara answers all five structurally, which means none of them costs a model call. A contradiction is an indexed lookup. A historical query is a range condition on two columns. Provenance is a join.
The long version of that story, question by question, is Why Memvara?. Memvara is neither a vector database nor a replacement for RAG — where it fits is further down.
The 90-second demo
Temporal memory, contradiction resolution and provenance, in ninety seconds.
pip install memvara
git clone https://github.com/memvara/memvara && cd memvara
python3 examples/temporal_memory_demo/demo.py
Six beats: the problem, three writes, what is true now, what was true then, the record behind both answers, and the close. Ninety seconds, held to a published schedule rather than to whatever its pauses happen to add up to. It runs against a real store with no key and no network — every value on screen is read back out of it.
--fast removes the pauses and changes nothing else, which is why the transcript is a
golden file the test suite asserts
on. The GIF above is not checked in either: it is a build product, recorded by CI from
this repository and attached to each release, so the URL never changes and the image
never goes stale. How it is recorded, including
the deterministic replay that makes the same source produce the same bytes.
Quickstart
Two ways to run it, and the API is the same either way. Memvara("memory.db") and
Memvara(api_key="mv_…") return objects with the same methods, so a function that takes a
Memvara and calls search() cannot tell which it was handed.
Run it yourselfpip install memvara
mem = Memvara("memory.db", user="alice")
A SQLite file you own. No API key, no network on the write path, no Docker, no vector database — numpy is the only hard dependency. Apache-2.0, and everything in this repository is in it. As an MCP server on the same machine: pip install 'memvara[encrypt]'
MEMVARA_DB=~/memory.db memvara-mcp
memvara-mcp init --agent claude
The server encrypts the store it creates, database and vectors both, which is what the
JSON-RPC 2.0 over stdio, twenty-two tools, no SDK dependency. Claude Code, Claude Desktop, Cursor, VS Code, Windsurf and Zed each have their own one-liner at memvara.dev/docs/self-hosted. MCP · Deploying |
Hosted, at memvara.devNothing to install and no key to paste. Give any MCP client this address: https://app.memvara.dev/mcp
Approve it once in a browser. That is OAuth, so the client holds a grant you can revoke rather than a secret it has to store. In Claude Code the plugin wires the same URL and the skill in one step:
Claude, Claude Code, ChatGPT, Codex, Cursor, Grok, VS Code, OpenCode and OpenClaw each have their own page at memvara.dev/docs/cloud. From your own code instead — the same client, against the hosted pip install 'memvara[cloud]'
memvara login # approve in the browser and pick a project; memvara whoami checks it
mem = Memvara(api_key="mv_…", user="alice") # or Memvara.connect()
A bare Not a Python shop? Free: 12,000 memories and one project, held rather than granted monthly, plus 2,000 recalls a month that do refill. Past either, the next call is refused and names the plan that carries more — no card, and no way to be billed by surprise. Pricing |
The walkthrough below runs against either. It is written for the local store because that is the one you can start in ten seconds.
from datetime import datetime, timezone
from memvara import Memvara, NullLLM
UTC = timezone.utc
def at(m, d): return datetime(2026, m, d, tzinfo=UTC)
mem = Memvara("memory.db", user="alice", llm=NullLLM())
mem.remember("Alice", "lives_in", "Berlin",
valid_from=at(1, 10), recorded_at=at(1, 10))
mem.remember("Alice", "lives_in", "London",
valid_from=at(3, 15), recorded_at=at(3, 15))
mem.remember("Alice", "lives_in", "New York",
valid_from=at(6, 2), recorded_at=at(6, 2))
[c.object for c in mem.get_all()] # ['New York']
[c.object for c in mem.get_all(as_of=at(3, 20))] # ['London']
[c.object for c in mem.get_all(as_of=at(1, 20))] # ['Berlin']
[r.text for r in mem.search("where does Alice live?")]
# ['Alice lives in New York']
Two dates per fact, because they answer different questions. valid_from is when it
became true in the world; recorded_at is when this store was told. They are equal here
because Alice told us on the day she moved — a fact that arrives late about the past is
where they differ, and that is the case a single updated_at column cannot represent.
That is examples/temporal_memory.py, which the test suite
runs and asserts on. Full walkthrough:
Quickstart.
Or hand it the conversation
mem.add("I live in Berlin and work at Acme")
mem.add("Actually, I moved to Lisbon last month")
[r.text for r in mem.search("where do they live?")][:1]
# ['user lives in Lisbon']
[(c.object, c.state) for c in mem.history("user", "lives_in")]
# [('Berlin', 'ended'), ('Lisbon', 'live')]
add() takes a string, a list of strings, pre-built Episodes, or OpenAI/mem0-style
{"role": ..., "content": ...} transcripts, so an existing agent loop can pass its
messages straight through. It runs three model-free tiers first — hash dedupe,
near-duplicate detection, a salience gate and a rule-based extractor — and batches
whatever survives into a single extraction call. With write_agentic_extraction=True and
an Anthropic or OpenAI backend, that call becomes a short tool loop instead: the model
searches what is already stored and proposes new facts, ends, replacements and links, and
the same deterministic reconciler decides what is written. It is off by default until it
has been measured against the single call.
With no llm= configured there is no model tier at all, so the two sentences above
work (they are recognised forms) and an employer mentioned in passing does not. Dropped
turns are counted on WriteReceipt.unextracted and the constructor warns once. That is the
qualifier on the offline claim: the library runs with no API key; extraction from arbitrary
prose does not. remember() is the offline way to get the full machine, and it is what a
real integration does.
Use cases
Coding agents are the case this repository leans on most. Two weeks after a
migration, examples/coding_agent.py
answers the question git cannot:
Q. What is checkout-service's auth strategy?
OAuth 2.0 client credentials
Q. What was the old strategy, and when did it change?
API keys 2026-02-03 -> 2026-06-12 [ended]
OAuth 2.0 client credentials 2026-06-12 -> now [live]
changed on 2026-06-12
why() then returns the June transcript turn the decision came from, and the claim it
replaced. That is a record rather than a note, and no model composes any of it.
Guide: coding agents
The other four shapes this store is built for:
| Support agents | The customer corrected the address in March; the agent must not quote the old one in August. This is the corpus behind demo/. |
| Personal assistants | The built-in vocabulary is this one: where somebody lives, works, what they are allergic to, and how they want to be spoken to (memory_standing). |
| Research agents | A finding that arrives late about the past is a valid_from in the past and a recorded_at of today, which is exactly what the two clocks are for. |
| Multi-agent systems | tenant > user > project > agent > session, with inheritance and fail-closed filters: a session sees that user's durable memory but never a sibling session's scratch space. |
| Agents working across repositories | Pass Memvara(project="github.com/you/repo"), or mem.scope(project=...) per request, and a fact learned in one repository stays there. A preference does not: predicates the vocabulary declares global are stored with no project, so they follow the user everywhere. The MCP server works the project out from the git remote of the directory it starts in, so every clone and worktree of one repository shares it; memvara.project.canonical_project() is the same rule for your own code. |
bob = mem.scope(user="bob") # the whole API, with the scope bound
bob.add("I live in Oslo")
To open a session, mem.profile("what this session is about") returns the user's standing
preferences, what arrived in the last seven days, the memories most relevant to the query,
and memories grouped into buckets (by default decisions, engineering and events), in
one call. mem.standing() is the first of those on its own. Over MCP the same call is
memory_profile.
Why Memvara
| 🕰️ Two clocks, not one | When it was true, and when you learned it — independently. Ask what you believed in March about June and get an answer, not a guess. |
| ⚖️ Contradictions resolve without a model | Cardinality is a schema property, so a conflict is an indexed lookup. Same two facts, same result, every run. |
| 🧾 Nothing is silently lost | A superseded fact is ended, never deleted, and every write returns a receipt saying what it did — including what it could not extract. |
| 🔍 Retrieval that explains itself | Vector and BM25 fused by rank, decayed per predicate, and every score inspectable rather than a ranking you have to trust. |
| 🗣️ It answers the audit question in words | ask() gives what is true now, what was true then, and what this store would have told you then — plus the day the record changed. No model composes it. |
| 🛡️ A guess cannot quietly overwrite a statement | A value worth less than half of what it would replace is kept beside it, and the receipt names both. Overwriting would record that the world changed, when nothing had. |
| 🧬 Claims are a graph | Walk relationships at a point in time — and optionally fuse that walk into search as a third retrieval leg. |
| 🔌 Offline by default | numpy and nothing else. No API key, no Docker, no vector database, no network on the write path. |
Longer form: Why Memvara?
Temporal memory
Two axes means two clocks, and they move independently:
mem.get_all(valid_at=T) # what we believe TODAY about how the world was at T
mem.get_all(known_at=T) # what we believed at T, about the world as it is now
mem.get_all(as_of=T) # both clocks at T — what we believed at T, about T
The middle two are the ones a single instant cannot ask. A correction that arrives in
August about June is invisible to as_of=June, because that call rewinds the belief clock
past the correction; valid_at=June is how you see it. as_of is exact sugar for
valid_at=known_at=T, and passing it alongside either axis raises rather than quietly
picking one.
Eight reads take all three — search, get_all, count, history, why, produced,
neighborhood, paths_between. recall() takes valid_at= alone, because its output is
a prompt and the world clock is the one that reaches no retired record. get() and
since() take none of them, and ask() spells it at=.
ask() composes the difference into an answer, which is the question the two clocks exist
for:
mem.ask("where do they live?", at=datetime(2026, 3, 15, tzinfo=utc)).text
# user lives_in: Berlin.
# On 2026-03-15 this store would have said Rome, and that is what anyone acting
# on it then acted on. The difference was recorded 2026-03-22, 7 days after the
# instant you asked about.
Three readings of every fact it touches — what is true now, what we believe today was true then, and what this store would have answered then. The last two differing means the record was corrected after the moment you asked about, so the answer somebody acted on is not the answer they would get today. No model is consulted; every sentence is rendered from a stored column.
Bitemporal memory · Temporal retrieval
Contradiction resolution
mem.remember("Alice", "lives_in", "Berlin")
mem.remember("Alice", "lives_in", "Lisbon")
[c.object for c in mem.get_all()]
# ['Lisbon']
[(c.object, c.state) for c in mem.history("Alice", "lives_in")]
# [('Berlin', 'ended'), ('Lisbon', 'live')]
Three steps, none of which involves a model. The predicate is normalised — lives_in,
resides_in, based_in and moved_to are one slot. The entity is folded —
from memvara import entity_key
entity_key("Acme Corp.") == entity_key("ACME, Inc.") == entity_key("acme") # True
— which is what makes the keyed lookup fire at all. A name that means two things can say
which one it means, by writing a type: namespace in front of it. company:apple and
fruit:apple are then two entities, and neither is the bare apple. Then cardinality decides:
lives_in is declared single-valued, so the new value closes the old one's interval.
The alternative design — embed, retrieve the nearest existing memories, ask a model whether they conflict — fails two ways that have nothing to do with model quality. It can miss (the conflicting value need not fall in top-k, and then both stay live), and it is not repeatable (the same two facts can resolve differently on two runs, with nothing downstream able to tell).
Teach it your vocabulary
The built-in predicates are a personal-assistant vocabulary — where someone lives, where they work. A store of engineering facts matches none of them, and an unknown predicate takes the safe default twice over: multi-valued, so nothing supersedes, and slow-decaying, so this morning's deploy still ranks as fresh in two years.
MEMVARA_PREDICATES=engineering memvara-mcp # or: engineering,events,decisions,./ours.toml
Three packs ship: engineering, events and decisions. Naming several is normal and
they do not collide — loading all three declares 48 predicates against a 64-name ceiling
on what each extraction prompt carries.
[[predicate]]
name = "git_state"
cardinality = "one" # supersedes; "many" accumulates
volatility = "fast" # static | slow | fast -> 36500 | 730 | 7 day half-life
A declaration outranks a guess, so a pack corrects a store that already classified something wrongly rather than only shaping a fresh one.
Tell the extractor what this project cares about
The extraction instructions are written for everyone. Guidance adds one project's own rules to them: a short description, what to extract, and what to leave out. It is added after the shipped instructions, never in place of them.
# guidance.toml, then MEMVARA_EXTRACT_GUIDANCE=./guidance.toml memvara-mcp
context = "A payments service. The team cares about incidents and why they happened."
include = ["decisions about retries and timeouts", "who owns which service"]
exclude = ["stack traces", "anything about the weekend"]
In Python, Memvara(llm=..., write_guidance=Guidance(context=..., include=[...])) does
the same. The description is at most 1,500 characters, and each list at most 20 rules of
200 characters; longer guidance is refused rather than cut. Reading the file needs Python
3.11 or later.
Merge the spellings a store already has
Two sessions that store the same fact under known_bug and known_defect get two slots,
so the newer value never supersedes the older one. merge_predicate repairs that: it
teaches the registry that one name is another, re-files every claim already stored under
the old name, and replays the slot so duplicates fold and older values close.
mem.merge_predicate("known_bug", "known_defect") # dry run: what would move
mem.merge_predicate("known_bug", "known_defect", dry_run=False)
Ids survive, nothing is deleted, and each moved claim carries a dated note that why()
shows. For a fact stored under a spelling nobody has merged yet, a server with a model can
say so at write time: MEMVARA_ADVISE_REPLACEMENTS=1 makes remember() ask the model
whether the new fact is a newer version of one of its nearest neighbours, and the receipt
names the matches without closing anything.
Provenance
turn = mem.add("Decision: migrate auth from API keys to OAuth 2.0.",
role="system", ts=jun)
mem.remember("checkout-service", "auth_strategy", "OAuth 2.0",
sources=turn.episode_ids, valid_from=jun, recorded_at=jun)
p = mem.why(claim_id)
[e.text for e in p.episodes] # ['Decision: migrate auth from API keys to OAuth 2.0.']
[c.text for c in p.superseded] # ['checkout-service auth strategy API keys']
p.derivation, p.extractor # (<Derivation.USER: 'user'>, 'api')
[c.text for c in mem.produced(turn.episode_ids[0])] # the same link, backwards
The turn, not a paraphrase of it — and superseded, which is the field that turns a note
into a record. "We decided X" becomes "we decided X on 12 June, replacing Y, on this
evidence", which is the sentence an incident review actually needs.
Three words, three different events, and using the wrong one is undetectable afterwards:
| Word | What happened | Written by |
|---|---|---|
| ended | The world changed. It was true, and then it wasn't. | a superseding write, or forget(close="ended") |
| retired | The record was wrong. It was never true. | forget(), delete() — the default |
| erased | The text itself is gone. Not recoverable. | erase(), purge(), delete_document() for a document's own text, and the store itself once a fact's expires_at has passed |
Served a value that expired and served a value that was never true are one column apart and are not the same finding. Only the third deletes anything.
A fact can be written to be erased later. remember(..., expires_at=...) stores it as
usual. Once that instant passes, no read returns it, and the store erases it with a proof
when it next opens and hourly in the MCP server. This is not valid_to: a fact whose valid_to has passed
is ended and kept, while a fact whose expires_at has passed is gone. Ending and
superseding never delete anything.
mem.remember("user", "door_code", "4411", expires_at=friday,
expire_reason="rental ends Friday")
mem.erase_expired() # runs on its own; returns what it erased, each with a proof
Each closure can also say why. delete(), forget() and remember(replaces=...) take a
reason, remember(valid_to=..., until_reason=...) records why a fact will end, and
history() and why() return the claim with the reason on it:
tea = mem.remember("user", "likes", "tea").added[0]
mem.remember("user", "likes", "coffee", replaces=tea.id, reason="switched to coffee")
# `likes` holds many values; naming the one replaced ends it, and only it.
To close many memories at once, forget_matching(query, close="ended") first returns the
matches and a token and changes nothing; passing the token back closes exactly those
matches. Memories can also point at each other: link(a, b, "extends") says a adds
detail to b, link(a, b, "derives") says a was inferred from b, and why() lists
both directions.
Architecture
flowchart TD
A["Your agent<br/><i>Python, MCP client, or a framework adapter</i>"]
B["<b>Memvara</b> — memvara/core.py<br/><i>add · remember · search · recall · ask ·<br/>history · why · forget · erase</i>"]
W["Write path — memvara/write/<br/><i>dedupe → salience gate → rule extractor →<br/>(model, only if needed) → reconcile</i>"]
R["Read path — memvara/retrieve/<br/><i>vector + BM25 + graph, fused by rank,<br/>decayed per predicate</i>"]
S["Store — memvara/store/<br/><i>SQLite + FTS5 + an mmap vector sidecar</i>"]
O["<b>Current state</b> + <b>history</b> + <b>provenance</b>"]
A --> B
B --> W
B --> R
W --> S
S --> R
R --> O
B -.-> O
The write path and the read path meet only at the store. With the default NullLLM,
nothing on the read path calls a model — not even the optional reranker, which is a
cross-encoder rather than a generative model. Scoring, contradiction handling, decay and
time travel never call one in any configuration. A model is called on a read only through
three named stages, each on your own chat backend, each with a 10-second deadline, and each
reporting on the result whether it ran, so a failure serves the ordinary read instead:
- Query rewrite, on by default when
llm=is a backend that can chat (OpenAILLM,AnthropicLLM). One call before retrieval asks for up to three other phrasings of the query and the dates it names. Every phrasing is searched and the lists are fused, and the dates becomevalid_atunless you passedvalid_atyourself. Passquery_rewrite=Falseto a read, or to the constructor, to turn it off. - Synthesis, when you call
recall(synthesize=True). One call writes a short summary of the recalled notes and puts it above them; every note is still returned. - Model ranking, when you call
search(ranked=True)against a retriever configured with aread_selector. One call names which of the reranked turns actually bear on the question. That call sees the user's turns unless the question asks what the assistant said; a store whose two roles are two people setsread_route_roles=Falseso neither person's turns are cut before the model sees them.
See memvara.select. History and provenance are
not a separate subsystem: they fall out of the store keeping intervals and supersession
pointers instead of overwriting rows.
Everything replaceable is a protocol — Store, Embedder, LLM, Redactor, Recorder —
and each has a real second implementation in this repository rather than being a
hypothetical extension point.
Architecture, in four diagrams · How it works · Internals
At a glance
| Unit of memory | a claim: (subject, predicate, object) |
| Temporal model | valid time and recorded time, queried independently |
| Conflict handling | predicate-aware and deterministic, decided on write |
| Provenance | the source episodes, the derivation, and the claim superseded |
| Retrieval | vector and BM25 fused by rank, optionally a graph leg, decayed per predicate |
| Storage | SQLite with FTS5, plus an mmap vector sidecar; optionally encrypted at rest, vectors included |
| Dependencies | numpy. Everything else is an extra |
| Model dependency | none for remember(); add() reaches for one only where no rule matches the prose |
| Python | 3.10 and later |
Integrations
| Keeps | Loses | |
|---|---|---|
| MCP — Claude Code, Cursor, Codex, Grok, VS Code, OpenCode, Claude Desktop, ChatGPT | everything | — |
LangChain MemvaraRetriever |
everything, including as_of= |
— |
LlamaIndex MemvaraRetriever, MemvaraMemoryBlock |
everything; the memory block keeps the write path too | — |
LangGraph MemvaraStore |
contradiction resolution, per-field claims | the predicate registry |
LangChain MemvaraChatMessageHistory |
the write path | supersession, intervals, source ids |
CrewAI MemvaraStorage |
storage and scope | the keyed lookup — its unit of memory has no subject or predicate |
The rule that decides it: an interface that hands over the query text keeps everything; one that hands over a pre-computed embedding, or a list of messages, cannot. Each adapter says which it is, out loud. Frameworks
Coming from somewhere else
from memvara.compat import import_mem0, import_supermemory
import_mem0(mem, history_db="~/.mem0/history.db") # replays mem0's own mutation log
import_supermemory(mem) # reads the Supermemory export API
mem0 records what changed and when, so that import rebuilds supersession and answers
as_of afterwards. Supermemory records current state, so its documents arrive as episodes
on their original timestamps and nothing invents a history it was never told. There is
also a method-level mem0 shim if you want its call surface on this store.
Documentation
Start here → docs/ — every page links to the next one.
| Getting started | Installation · Quickstart · Your first memory |
| Concepts | Why Memvara? · Bitemporal memory · Contradiction resolution · Provenance · Temporal retrieval · RAG and memory |
| Guides and integrations | Coding agents · MCP · Frameworks |
| Reference | API · Architecture · How it works · Internals · Deploying |
| Also | FAQ · Benchmarks · Limitations · Upgrading · Roadmap · Open core |
| Examples | Three runnable programs, asserted on by the suite |
RAG, vector stores, and where this fits
Memvara is not a replacement for RAG and it is not a vector database. It uses vector search internally, and swapping in pgvector is a protocol away.
RAG Memory
↓ ↓
"Which documents are relevant?" "What persistent state do I know about
this entity, how has it changed, and
what was true at a point in time?"
| RAG | Memvara | |
|---|---|---|
| Unit | a chunk of a document | a claim: (subject, predicate, object) |
| Addressed by | similarity to a query | the slot (subject, predicate) |
| Corpus | mostly static; documents are added | mostly mutable; values are replaced |
| A contradiction is | two chunks that both rank | a slot with two live values, resolved on write |
| "When" means | the document's date | two dates: when it was true, when you learned it |
| Good at | what does the manual say about TLS errors | what is this customer's billing address, and what was it in March |
They compose: RAG answers from the corpus, memory supplies the state the corpus does not know. "What is our refund window for this customer's plan?" is two questions — which plan they are on (one slot, one current value, with a history) and what the policy says for that plan (a passage from a document).
It can also hold the documents themselves. add_document() stores a policy, a README
or a set of meeting notes whole and splits it into passages of about 1,000 characters,
which recall(include_episodes=True) returns beside the facts. Give it a custom_id and
adding the same document again updates it, keeping every passage that did not change.
Deleting it erases its text and retires, rather than erases, any memory that came only
from it. That is a home for the documents one agent works with, not a retrieval system
for a large corpus: the vector index is exact and in-process.
mem.add_document(open("policies/refunds.md").read(), custom_id="policies/refunds.md",
filepath="policies/refunds.md", mime="text/markdown",
meta={"team": "support"})
print(mem.recall("how long do refunds take?", include_episodes=True))
A read can be narrowed to part of memory. filters={"team": "support"} keeps only
memories whose metadata, or whose source document's metadata, has that value, and
filepath_prefix="policies/" keeps only memories that came from a document in that
folder. Both work on search() and recall(), and both are applied before the result is
cut to k, so asking for five matches returns five when five exist.
print(mem.recall("how long do refunds take?", include_episodes=True,
filepath_prefix="policies/"))
Memvara ships retriever adapters for LangChain and LlamaIndex, so in an existing pipeline it can be one more retriever rather than a separate call. RAG and memory
Against mem0 specifically
mem0 and its descendants store a memory as an opaque string with an embedding, and every
add() costs a model call on the critical path. Retrieval is vector top-k.
Corrected against mem0 2.0.17. An earlier version of this section said
add()costs two LLM calls — extract, then adjudicate ADD/UPDATE/DELETE. That described mem0 1.x. 2.x makes one call, with existing memories passed into a single additive extraction prompt;DEFAULT_UPDATE_MEMORY_PROMPTis still in the source and no longer reached from the add path. The correction cuts against us, so it is stated rather than quietly dropped — but the contradiction problem it was cited for got larger, not smaller: 2.x's add path emits onlyADDevents, and its prompt says "Your sole operation is ADD". Conflicting values are linked, never retired.update()anddelete()are calls your application has to know to make.
Four consequences that show up in production:
- Contradictions accumulate. In 2.x this is explicit: nothing on the write path retires anything. Six months in, the store holds three cities for one person and returns whichever embeds closest to the question.
- Writes are slow and expensive. A model call per turn, on the critical path, including for "ok, thanks."
- There is no time. One
updated_atcolumn can't answer "where did she live in March?" or absorb a fact that arrives late about the past. - Nothing explains itself. When the agent says something wrong, you cannot ask which memory caused it, where that memory came from, or why it ranked first.
Memvara is built around the observation that most of this doesn't need a model at all.
Agent Memory Benchmark
A public, reproducible benchmark for memory systems in general, not for memvara. It measures what happens to a fact that changes: current state, historical state, contradiction resolution, provenance, knowledge time, retrieval among distractors, cost and latency. 262 events, 100 questions, 16 scenarios, no API key, about a second a system.
python -m benchmarks.agent_memory --system memvara --system naive --system vector-rag --compare
Any memory system can implement the adapter interface and be scored on the same dataset by the same rules — including from another repository, without forking this one. Memvara does not win every category, and the table reports the ones it loses.
| Run it, and the methodology | benchmarks/agent_memory/README.md |
| The report, with results and limitations | docs/benchmarks/agent-memory-benchmark.md |
| Add your memory system | benchmarks/agent_memory/CONTRIBUTING.md |
Measured
| Agent Memory Benchmark: changing facts, across systems | docs/benchmarks/agent-memory-benchmark.md |
Against the real mem0ai package |
docs/BENCHMARKS.md |
| The two clocks, six question families | docs/BENCHMARKS.md |
| LOCOMO and LongMemEval, retrieval | docs/BENCHMARKS.md |
| LongMemEval, judged answer accuracy (0.11.0's ranked recall) | docs/BENCHMARKS.md |
| Answer quality, end to end | docs/BENCHMARKS.md |
The harnesses are in bench/ and demo/, and every number is
reproducible from this repository. Where a result is synthetic or self-authored, its own
heading says so.
What these numbers do not cover, and every other limit this project knows about, is in Limitations — including why the LOCOMO and LongMemEval retrieval figures are not answer accuracy, which is the one most often quoted wrongly. The judged accuracy figure above is one run on one sample, with no same-harness comparison against another vendor's hosted service.
Development
python3 -m pytest -q # 4,172 passing, 10 skipped, no API key
python3 -m coverage run -m pytest && python3 -m coverage report # gated at 100%
python3 -m benchmarks.agent_memory --system memvara --compare # the agent memory benchmark
PYTHONPATH=. python3 bench/temporal.py # the two clocks, six families
PYTHONPATH=. python3 bench/compare.py # architecture comparison
PYTHONPATH=. python3 bench/perf.py # throughput and scaling
100% statement coverage, enforced (fail_under = 100), and mypy -p memvara is
clean in CI. The suite runs in about 21
seconds with no network, no API key, and almost no sleeping — time is controlled by
passing explicit datetime values rather than patching the clock, and the handful of
tests that do sleep are measuring concurrency, where the wall clock is the thing under
test.
Coverage of the lines is the floor, not the goal. What the suite actually pins down:
- Behavior — contradictions resolve, history survives, users are isolated in all three directions (sibling session, sibling agent, other tenant), and the LLM stays idle. Fakes count their own calls, and the tests assert on those counts — the design claim is that the model is rarely consulted, so a test that doesn't count calls doesn't test it.
- Failure paths — dimension mismatches, transaction rollback (including nested), a classifier that raises, a store that loses rows mid-query, and model output that violates every field contract at once. These only run during an incident, which is exactly why they can't ship unexercised.
- Adversarial input — a fuzz corpus (SQL and FTS5 injection, path traversal, template injection, control characters, astral-plane codepoints, 5KB strings, combining marks) driven through every public method and a persistence round trip, plus randomized transcripts asserting the store never ends up internally inconsistent.
- Executable docs — the
Memvaradocstring runs as a doctest, the README walkthrough is mirrored intests/test_integration.py, andtests/test_examples.pyruns every program underexamples/in a subprocess and asserts on what it prints. So the code a developer copies cannot drift from the code that ships.
The twelve remaining branch partials are verified-unreachable defensive guards — mostly
if valid_to is None or valid_to > t, where a live claim always satisfies the first
disjunct, so the second can never decide the branch. They are kept as guards rather than
deleted, and documented as such.
Design notes and the module-by-module contract live in docs/INTERNALS.md.
docs/UPGRADING.md is the short list of changes that do not announce
themselves — read it before upgrading, starting with the one where invalidated_at is None stopped meaning "live" without breaking anything.
Contributing
Issues and pull requests are welcome. Two things to read first:
- CONTRIBUTING.md — the bar a patch has to clear, and what will and will not be accepted. It is specific about scope: some things belong here, some belong in the commercial half, and it says how to tell which is which before you write the code.
- docs/ROADMAP.md — what is done, what is still missing, and a Deliberately deferred list, which exists so that considered-and-declined stops reading as not-yet-done.
git clone https://github.com/memvara/memvara && cd memvara
python3 -m pip install -e ".[dev,cloud,ingest,encrypt]"
python3 -m pytest -q
The gate is 100% statement coverage and a clean mypy -p memvara, both enforced in CI.
Documentation ships in the same commit as the code it describes — including the tool
descriptions in memvara/server/tools.py, which a model reads at runtime.
SECURITY.md covers private vulnerability reporting. Do not open a public issue for a vulnerability.
License
Apache-2.0, for everything in this repository. See Open core for what is and is not in it.
Release files for memvara 0.15.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| memvara-0.15.0.tar.gz | 3.2 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| memvara-0.15.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 4.1 MB
Release files / memvara-0.15.0.tar.gz
| Download URL | memvara-0.15.0.tar.gz |
|---|---|
| Size | 3.2 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1ff8edf4ece97c087d2b1324123f40bfa679eb399cecc913a55d3322d467ab47
|
|
BLAKE2b-256 checksum How to use checksums |
c92729d792eab8874c169314b31442f9cb32296efd4e0034d1d0e8f1def3be02
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / memvara-0.15.0-py3-none-any.whl
| Download URL | memvara-0.15.0-py3-none-any.whl |
|---|---|
| Size | 945.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
561423632288d7a750f0716abeb6b80c3cc6f1cc33e04363a2f895a2b8cfbddc
|
|
BLAKE2b-256 checksum How to use checksums |
adca09255b2fe92b9a6f95c28e490f83ce5f079912291cd05c6362af7b4c3fd1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log