SEQ 00 hash a1f0c2d4 prev 00000000

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.

$ memvault --data-dir ./my-memory search --namespace project --query "deploy script" --max-tokens 20 retrieval_id: f96a229c-4d63-4db8-834d-1b98877611dd
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.
SEQ 01 hash 6b39e07c prev a1f0c2d4

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.

The write path and the read path memory_write appends to the hash-chained ledger, and the ledger builds both the vector index and the keyword index. memory_search queries the two indexes; their ranks are fused, weighted by decay and packed to a token budget before the context reaches the agent. That decision is appended to the ledger as a retrieval record, which memory_explain replays by retrieval_id. memory_write hash-chained ledger redb, append-only memory_search vector index usearch keyword index tantivy RRF fusion decay + budget context handed to the agent memory_explain retrieval record replay by retrieval_id
SEQ 02 hash d84c1fa2 prev 6b39e07c

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.

SEQ 03 hash 2e75b9c1 prev d84c1fa2

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.

A ledger record before and after forgetting The same ledger record, before and after a forget. The record keeps its sequence number, its fact id and its content hash; only the plaintext goes, because the key that decrypted it was destroyed. Verify still reports the chain intact from seq 0. as written seq 0 kind Assert fact_id 34e2001b content_hash 76d44f5d ciphertext_len 56 the deploy script lives in ops/deploy.sh forget --reason "customer deletion request" after: the key is destroyed seq 0 kind Assert fact_id 34e2001b content_hash 76d44f5d ciphertext_len 56 undecryptable (key destroyed or never existed) memvault verify chain verified from seq 0

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.

SEQ 04 hash 90fa3d68 prev 2e75b9c1

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.

Valid time against belief time Two axes. The horizontal one is valid time, when the fact held in the world. The vertical one is belief time, from the moment MemVault was told. The shaded block is one fact on both axes, and a query fixes a point on each axis: asking about moment T as the engine understood things at moment U. staging runs postgres 16 T U valid time: when the fact held belief time: when we were told as_of(valid T, belief U)

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.

SEQ 05 hash 47c8e215 prev 90fa3d68

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.

SEQ 06 hash bb1d6094 prev 47c8e215

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