Local-first memory for agents
Every fact it holds, and every fact it decided not to hand over.
MemVault stores what you give it and explains what it hands back. One binary, no service to operate, no network unless you ask for one.
| fact_id | ann_rank | ann_dist | bm25_rk | bm25_score | rrf | decay_wt | final | outcome | tokens |
|---|---|---|---|---|---|---|---|---|---|
| 34e2001b-1603-45c5-abbe-f5fbcf31d4a8 | 0 | 0.3518 | 0 | 2.2232 | 0.0328 | 1.0000 | 0.0328 | Injected | 15 |
| fdaa5149-440e-4a42-b2e6-d67653f2871d | 1 | 0.6311 | - | - | 0.0161 | 1.0000 | 0.0161 | CutByBudget | 17 |
| b5aa16a8-aa14-48cb-917d-1bc5d048f184 | 2 | 1.0000 | - | - | 0.0159 | 1.0000 | 0.0159 | CutByBudget | 11 |
- ann_rank / ann_dist
- Where the fact placed on the vector axis, and how far out. A dash means it never surfaced there at all.
- bm25_rk / bm25_score
- The same, for the keyword axis. A row ranked on both axes was found two different ways.
- rrf
- The two ranks fused. Ranks only, never raw scores, since a cosine distance and a BM25 score are not on the same scale.
- decay_wt
- How much age discounted it. 1.0000 is no discount, and a pinned fact never decays.
- final
- rrf multiplied by decay_wt. This is what the ordering is by.
- outcome
- Injected reached the agent. CutByBudget lost to the token budget, CutByK to the k limit, FilteredByTime was no longer valid at query time.
- tokens
- What that fact costs you in context.
Every candidate considered appears here, including the two this
budget cut. That is what makes a bad retrieval debuggable instead of
mysterious, and it is a ledger read rather than a re-run: pass that
retrieval_id back to explain any time later and the
same table comes out.
How it works
Two indexes, fused by rank, packed to a budget.
A vector index and a keyword index answer every query independently. Their ranks are fused, weighted by decay, and packed to fit the token budget you set. What the fusion cut is written down next to what it kept.
Solid brass is the write path, and it only ever appends. The read path runs both indexes, fuses their ranks, and writes its own decision back into the ledger, which is why an old retrieval can be replayed instead of re-run.
Run it
Four ways in, one engine behind them.
The CLI, an MCP server over stdio, an optional gRPC surface, and Python in-process. Every one of them returns the same provenance.
CLI
memvault --data-dir ./my-memory write --namespace project \ --content "the deploy script lives in ops/deploy.sh" memvault --data-dir ./my-memory search --namespace project \ --query "deploy script" --max-tokens 20 memvault --data-dir ./my-memory explain <retrieval_id> memvault --data-dir ./my-memory verify memvault --data-dir ./my-memory forget <fact_id> --reason "customer deletion request"
No server and no config. search prints the
provenance table above and a retrieval_id;
explain reprints that table later from the ledger,
and verify answers
chain verified from seq 0. The binary builds to
target/release/memvault; the commands above assume
you put it on your PATH.
MCP
Point the client at memvault-server, with a data
directory as its one argument. Save this as
.mcp.json in your project root for Claude Code, or
merge it into claude_desktop_config.json for Claude
Desktop. Any other MCP client takes the same shape: it is a
plain stdio server.
{
"mcpServers": {
"memvault": {
"command": "/absolute/path/to/memvault/target/release/memvault-server",
"args": ["/absolute/path/to/my-memory"]
}
}
}
Use absolute paths for both. The server inherits whatever working directory the client happened to launch it from, which is rarely the one you expect.
Restart the client and the agent has seven tools:
| memory_write |
Assert a fact. Pass an existing fact_id to
supersede that fact instead; it has to be one of this
namespace’s own.
|
|---|---|
| memory_search | Hybrid retrieval. Returns the injected facts with their content, best first, plus the full provenance table as rows. Every tool answers in structured JSON. |
| memory_get | One fact’s current version by id. |
| memory_as_of | What was true, or what the engine believed, at a given moment. |
| memory_supersede | Close a fact’s interval without asserting a replacement. |
| memory_forget | Cryptographic erase. |
| memory_explain |
Reconstruct any past retrieval from its
retrieval_id.
|
On startup the server runs recovery and verifies the chain, logging the result to stderr. Stdout is the protocol channel and carries nothing else.
gRPC
cargo build -p memvault-server --features grpc MEMVAULT_GRPC_ADDR=127.0.0.1:50051 \ ./target/debug/memvault-server ./my-memory
Off by default, for multi-process deployments where stdio is not
available. The same seven operations, as protobuf messages
with the same shapes as the MCP tools’ JSON; the schema is
crates/memvault-server/proto/memvault.proto. A
default build rejects MEMVAULT_GRPC_ADDR
rather than ignoring it, so a half-configured deployment fails
at startup instead of quietly serving the wrong thing.
Python
# a wheel for each platform is attached to every release
pip install https://github.com/swap-mitra/memvault/releases/download/v0.2.0/memvault-0.2.0-cp39-abi3-manylinux_2_28_x86_64.whl
import memvault
mv = memvault.MemVault("./my-memory")
fact_id = mv.write("project", "the deploy script lives in ops/deploy.sh")
result = mv.search("project", "deploy script")
for f in result.injected: # what goes in context, best first
print(f.fact_id, f.content)
for e in result.candidates: # why: every candidate considered
print(e.fact_id, e.outcome, e.final_score, e.token_cost)
mv.verify() # raises if the chain is broken
In-process, with no subprocess and no event loop. The API is
synchronous and every call releases the GIL while the engine
works. Explanation carries the same fields as the
table above, so provenance does not get thinner just because you
came in this way.
Provable forgetting
Erase a fact and the chain still verifies.
Cryptographic erase: the key dies, the record stays, the chain holds. The fact leaves search, its content hash survives, and nobody can recover what it said.
The record does not move and the hash does not change, so the history stays honest. Anyone holding the original plaintext can still prove what this record said; nobody can recover it from here.
Bitemporal
What was true, and what the engine believed.
Two axes, both queryable. Ask what held at a moment, or ask what MemVault thought held at that moment. They are not the same question, and a memory that conflates them cannot explain itself.
Move T and you ask a different question of the world. Move U and you ask the same question of an earlier MemVault. A memory that collapses the two axes cannot tell you which one changed.
Install
Hand it to your agent.
Paste one prompt into the agent you already use and let it clone, build, wire up the MCP config, and prove the round trip works.
Install MemVault as an MCP server for this project.
Prerequisites, check both before you start and stop if either is missing:
Rust 1.85 or newer (the workspace is edition 2024), and a C++ toolchain,
because the vector index builds from source. (If either is missing, the
release page at https://github.com/swap-mitra/memvault/releases has a
prebuilt archive per platform; unpack it and skip step 1.)
1. git clone https://github.com/swap-mitra/memvault
cd memvault
cargo build --release
2. If the build fails, stop and report the error to me verbatim. Do not
improvise a fix, and do not substitute another memory tool.
3. Write .mcp.json in my project root. Use absolute paths for both the
binary and the data directory, because the server inherits whatever
working directory the client launched it from:
{
"mcpServers": {
"memvault": {
"command": "<abs path>/target/release/memvault-server",
"args": ["<abs path>/my-memory"]
}
}
}
4. Tell me to restart the client, then prove the round trip: call
memory_write with namespace "project" and some fact about this repo,
call memory_search for it, and report the retrieval_id and each
candidate's outcome you got back.
It clones, builds, writes the config with absolute paths, and then proves the round trip rather than declaring victory. If the build fails it is told to stop and hand you the error, which is the one place an agent otherwise starts inventing.
Known limits
Stated plainly, because each one otherwise looks like a bug.
| Embeddings come from outside |
MemVault runs no model. The MCP and Python surfaces accept an
embedding; the server can fetch one from an
OpenAI-compatible provider you configure, and without either
it falls back to keyword-only retrieval.
|
|---|---|
| Namespaces share a candidate pool | A search never returns another namespace’s facts, but a namespace holding far more facts than its neighbours can crowd them out of the pool and cost them recall. |
| Token counts are estimates by default |
Ciphertext bytes / 4, not a tokenizer. Close enough for
English prose, drifting on code. Build with
--features tokenizer for a real cl100k_base
count; the vocabulary is compiled in and no model runs.
|
| Decay measures from a fact’s own start | Not from last access, so retrieval does not yet reinforce a fact against decay. |
| No retrieval-quality numbers yet | The LongMemEval and LOCOMO harnesses run with a real embedding model if you give them one, but no scored run has been published. The figures here are latency and cost. |
chain verified from seq 0 MIT OR Apache-2.0 · github.com/swap-mitra/memvault