Skip to main content

Memvara

Bitemporal memory for AI agents.
Know what was true. Know when it was true. Know why you believe it.

PyPI Python License CI Site

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.

Alice moves from Berlin to London to New York. get_all() answers New York, get_all(as_of=March) answers London, and history() shows every value with the interval it was true for.

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 yourself

pip 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 encrypt extra is for. The key goes in ~/.memvara/db.key unless the OS keychain or MEMVARA_DB_KEY has one; back it up with memvara encrypt --export-key, because a lost key is a lost store. MEMVARA_FEATURE_ENCRYPTION=0 creates it unencrypted instead.

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.dev

Nothing 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:

/plugin marketplace add memvara/claude-memvara
/plugin install memvara

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 /v1 API:

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()

Memvara.connect() uses the key memvara login wrote. The npm bridge installs a command called memvara too, so where both are on your PATH, python3 -m memvara login is the spelling that always reaches this one and npx memvara the one that always reaches the bridge.

A bare Memvara() never becomes remote. The dispatch reads the explicit api_key= or base_url= argument and never the environment, so a script that has always written to a local file cannot start posting to a hosted store because somebody ran memvara-mcp login on that machine. A hosted deployment

Not a Python shop? /v1 is a REST API and a bearer key. Cloud quickstart · REST · Python client

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.

Contradiction resolution


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.

Provenance


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 become valid_at unless you passed valid_at yourself. Pass query_rewrite=False to 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 a read_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 sets read_route_roles=False so 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, each dated and cut to the part that matches the question. 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_PROMPT is 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 only ADD events, and its prompt says "Your sole operation is ADD". Conflicting values are linked, never retired. update() and delete() are calls your application has to know to make.

Four consequences that show up in production:

  1. 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.
  2. Writes are slow and expensive. A model call per turn, on the critical path, including for "ok, thanks."
  3. There is no time. One updated_at column can't answer "where did she live in March?" or absorb a fact that arrives late about the past.
  4. 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 Memvara docstring runs as a doctest, the README walkthrough is mirrored in tests/test_integration.py, and tests/test_examples.py runs every program under examples/ 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.16.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for memvara 0.16.0
File Size Uploaded
memvara-0.16.0.tar.gz 3.3 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for memvara 0.16.0
File Interpreter ABI Platform
memvara-0.16.0-py3-none-any.whl Python 3 none any Details

Total release size: 4.2 MB

Release files / memvara-0.16.0.tar.gz

Download URL memvara-0.16.0.tar.gz
Size 3.3 MB
Tags Source
SHA-256 checksum
How to use checksums
0690debd4ba1efaac50ff1725654955885f708aaa16ec821f2775b044fb56b64
BLAKE2b-256 checksum
How to use checksums
de4115ce3bf16e2c69f6e1064372fc694e7cf6d3456fd8e3899cb28e809d56a9
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 25, 2026.

Transparency log

Release files / memvara-0.16.0-py3-none-any.whl

Download URL memvara-0.16.0-py3-none-any.whl
Size 970.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fb82cab25e80d4adb4680eebeb5dfe8266ccd2905e31022d37dabf7ff598a26e
BLAKE2b-256 checksum
How to use checksums
51781289aeb3d4c5cd35f3047680c58b7040604934075d59d71db7779586f17d
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 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.16.0 This release

2 release files

0.15.0

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.12.0

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page