Documentation
memrust for developers
An agent-native memory engine in a single Rust binary. Store what happened, recall it across sessions, tools and agents — over four fused retrieval signals that each explain their contribution.
New here?
Read Memory vs. vectors first — it is the one concept that makes the rest obvious — then run the quickstart.
Just want it working in Claude?
Two commands in Claude Code & Desktop. No account, no keys, no embedding provider.
What memrust is
memrust answers “what do I know about this?” rather than “which vectors are nearest?”. It is a memory layer for AI agents: one binary that stores memories, indexes each one four different ways, and serves them over HTTP and the Model Context Protocol.
Concretely, it gives you:
- Three verbs —
remember,recall,forget— over HTTP, MCP, Python, TypeScript or Rust. - Hybrid retrieval fusing vector similarity, BM25 keywords, an entity graph and recency, with a per-signal breakdown on every hit.
- Shared memory across tools: Claude Code, Cursor, Codex, LangGraph and CrewAI can all read and write the same store.
- A lifecycle: TTLs, expiry sweeps, and consolidation of old episodic memories into durable semantic ones.
- Operational seriousness: write-ahead log with fsync, checkpoint recovery, namespaces, scoped API keys, Prometheus metrics.
No external services are required. No vector database, no Elasticsearch, no embedding API key — the default embedder runs offline. You add those when you want better quality, not to get started.
Memory vs. vectors — the mental model
This trips up almost everyone arriving from a vector database, so it is worth being precise.
A vector is not the memory. It is one of four indexes over the memory. The memory is the record — its text, kind, timestamp, importance, extracted entities, tags, session, owning agent, visibility, expiry and provenance. When you store one, memrust puts it into four places at once:
| Index | Built from | Answers |
|---|---|---|
| vector | an embedding of the text (HNSW, M=16) | “what means something similar?” |
| lexical | tokens of the text (BM25, k1=1.2, b=0.75) | “what contains this exact term?” |
| graph | entities extracted at write time, linked by co-occurrence | “what is related to this?” |
| recency | the timestamp (exponential decay, 1-week half-life) | “what happened lately?” |
A recall queries all four and fuses the rankings with reciprocal-rank fusion. So a memory can be found by any of them — and the API never returns the raw embedding, because it is an implementation detail you should not be handling.
Why this is not a detail
Store one incident note and ask for its ticket number. Here is a real response from a running engine, trimmed to the scores:
"score": 0.0401, "signals": { "vector": 0.0164, // found it, but so would a near-identical ticket "lexical": 0.0164, // exact token match — this is the one that is right "graph": 0.0164, // INC-90312 was extracted as an entity at write time "recency": 1.0, "importance": 0.004 }
Three signals contributed. A pure vector store has one of them, and for
identifiers that one is weak: INC-90312 and INC-90319
are near-identical strings, so they are near-identical vectors. Measured over
500 memories with identical embeddings, exact-identifier hit@5 is
27% for brute-force vector search, 27% for FAISS, Chroma and Qdrant,
and 75% for memrust. Exact search scoring the same as the approximate
indexes is the tell: this is a ceiling of the embedding, not an index-quality
problem a faster ANN fixes. See Benchmarks to
reproduce it.
If your data contains identifiers, error codes, ticket numbers, SKUs or proper names, the non-vector signals are doing most of the work — and you get them for free.
Install
cargo install memrust
memrust --version
# HTTP API and a built-in dashboard on :7700
memrust serve
docker run -p 7700:7700 -v memrust-data:/data aianytime/memrust # The named volume matters. memrust keeps a write-ahead log and a # checkpoint on disk; without a volume every memory dies with the # container.
# Clients only — they talk to a running server, they do not embed one. pip install memrust npm install memrust-client cargo add memrust # this one IS the engine, usable as a library
git clone https://github.com/AIAnytime/memrust cd memrust cargo build --release ./target/release/memrust serve --data-dir ./data cargo test # the test suite ./target/release/memrust demo # seeds 8 memories and runs three recalls
Prebuilt Linux and macOS binaries are attached to each GitHub release.
Quickstart
Start the server, store a memory, ask for it back. Everything below assumes
memrust serve is listening on 127.0.0.1:7700.
# 1. store something that happened curl -s localhost:7700/v1/remember \ -H 'content-type: application/json' \ -d '{ "text": "Dana Whitfield escalated INC-90312: checkout latency hit 900ms after the Redis upgrade.", "kind": "episodic", "tags": ["incident", "checkout"], "importance": 0.8 }' # 2. ask for it back — by ticket, by person, or by meaning curl -s localhost:7700/v1/recall \ -H 'content-type: application/json' \ -d '{"query": "why was checkout slow?", "top_k": 3}'
# pip install memrust from memrust import MemrustClient memory = MemrustClient("http://127.0.0.1:7700") memory.remember( "Dana Whitfield escalated INC-90312: checkout latency hit 900ms " "after the Redis upgrade.", kind="episodic", tags=["incident", "checkout"], importance=0.8, ) for hit in memory.recall("why was checkout slow?", top_k=3): print(round(hit["score"], 4), hit["record"]["text"]) print(" signals:", hit["signals"])
// npm install memrust-client import { MemrustClient } from "memrust-client"; const memory = new MemrustClient("http://127.0.0.1:7700"); await memory.remember( "Dana Whitfield escalated INC-90312: checkout latency hit 900ms after the Redis upgrade.", { kind: "episodic", tags: ["incident", "checkout"], importance: 0.8 }, ); const hits = await memory.recall("why was checkout slow?", { topK: 3 }); for (const h of hits) console.log(h.score.toFixed(4), h.record.text, h.signals);
// cargo add memrust — embeds the engine, no server process use memrust::{MemoryEngine, RememberRequest, RecallRequest, MemoryKind}; fn main() -> anyhow::Result<()> { let mut memory = MemoryEngine::open("./data".as_ref())?; memory.remember(RememberRequest { text: "Dana Whitfield escalated INC-90312: checkout latency hit 900ms.".into(), kind: MemoryKind::Episodic, tags: vec!["incident".into()], importance: Some(0.8), ..Default::default() })?; for hit in memory.recall(&RecallRequest { query: "why was checkout slow?".into(), top_k: Some(3), ..Default::default() }) { println!("{:.4} {}", hit.score, hit.record.text); } Ok(()) }
The engine extracted the entities on its own. You never asked it to:
"entities": ["incident", "checkout", "dana whitfield", "inc-90312", "900ms", "redis"]
Open http://127.0.0.1:7700 for the built-in dashboard: browse memories, run recalls, watch the signal breakdown change as you switch strategy.
The memory record
Everything memrust stores has this shape. Only text is required.
| Field | Type | Meaning |
|---|---|---|
id | uuid | Assigned by the engine. Use it to forget. |
text | string | The memory itself. Everything else is derived from or attached to it. |
kind | enum | See memory kinds. Default episodic. |
created_at | i64 | Unix milliseconds. Drives the recency signal. Defaults to now; pass it explicitly when importing history. |
importance | 0.0–1.0 | A retrieval boost. Default 0.5. Contributes a small, bounded amount — it nudges ranking, it does not override relevance. |
expires_at | i64? | Invisible to recall past this instant; deleted by the next lifecycle sweep. Set via ttl_seconds. |
entities | string[] | Extracted at write time. Lower-cased. Supply your own to skip the heuristic. |
tags | string[] | Yours. Also fed to the entity graph, so a tag is a retrievable relation, not just a filter. |
session_id | string? | Conversation or run. Filterable, and the unit of snapshot. |
agent_id | string? | Owner. Setting it flips the default visibility to private. |
visibility | enum | shared or private. See agents. |
metadata | json? | Opaque passthrough. Stored and returned; not indexed or filterable. |
sources | uuid[] | Provenance. Present on consolidated summaries: the memories folded into this one. |
metadata is a payload, not a query surface. If you need to
retrieve by an attribute, put it in tags — tags are filterable
and feed the entity graph.
Memory kinds
Six kinds, mirroring how agent frameworks reason about memory rather than how databases store rows. The kind changes lifecycle behaviour and gives you a cheap, meaningful filter.
| Kind | For | Lifecycle |
|---|---|---|
episodic (default) | Things that happened: events, turns, actions taken. | Consolidated into semantic after 7 days. |
semantic | Distilled facts, independent of when they were learned. | Never expires by default. |
working | Scratch state for the current task. | Expires after 1 day unless you set a TTL. |
reflection | The agent's conclusions about its own behaviour. | Never expires by default. |
tool_call | Tool invocations and their results. | Never expires by default. |
procedural | How-to knowledge: workflows, runbooks, few-shot patterns. | Never expires by default. |
procedural is the one people skip and shouldn't. An agent that
remembers how it did something is the one that gets better; an agent
that only remembers what happened just gets more context.
# only runbooks, ignore the incident chatter memory.recall("how do we roll back checkout?", kinds=["procedural"])
The four signals
Every recall runs all four over the same memories and fuses their rankings — not their raw scores — with reciprocal-rank fusion (K=60). Fusing ranks is what makes it safe to combine a cosine similarity, a BM25 score and a graph weight, which have no common scale.
vector — meaning
In-crate HNSW, M=16, ef_construction=200, ef_search=100. Catches paraphrase: “why was checkout slow” finds “latency hit 900ms”. SQ8 scalar quantization engages automatically at ≥1024 dims.
lexical — exactness
In-crate BM25, k1=1.2, b=0.75. Catches what embeddings blur: identifiers, error codes, ticket numbers, version strings, names.
graph — relatedness
Entities extracted at write time and linked by co-occurrence, traversed one hop and weighted by rarity. Finds what is connected to the query, not merely similar to it.
recency — time
Exponential decay with a one-week half-life. “What did we decide lately” is not a similarity question, and treating it as one is why agents cite stale decisions.
Reading the breakdown
Every hit carries its per-signal contribution. This is not decoration — it lets an agent (or you) tell a strong semantic match from a lucky keyword hit.
{
"record": { "id": "15a16ce6-…", "text": "Dana Whitfield escalated INC-90312…", … },
"score": 0.0401,
"signals": {
"vector": 0.0164, // reciprocal-rank contribution, 0 if unmatched
"lexical": 0.0164,
"graph": 0.0164,
"recency": 1.0, // decay factor in 0..1, not a rank
"importance": 0.004, // the importance boost actually applied
"rerank": 0.0 // 0 unless a reranker ran
}
}
The extractor is a deterministic heuristic: identifiers with digits
(INC-90312), short all-caps terms (API,
GPU), multi-word capitalised runs (Dana Whitfield),
plus your tags. A single capitalised word at the start of a
sentence is deliberately skipped, because otherwise every sentence
donates its first word as an entity.
In practice: querying "Redis" alone yields
graph: 0.0, while "the Redis upgrade" yields
graph: 0.0164. If you build queries programmatically and want
the graph signal, pass a phrase rather than a bare capitalised token — or
supply entities explicitly when writing.
Recall strategies
A strategy reweights the fusion. It never switches an index off
— an exact-identifier query still works under semantic, and a
conceptual query still works under lexical. You are tilting the
blend, not choosing an engine.
| Strategy | vector | lexical | graph | recency | Use when |
|---|---|---|---|---|---|
balanced (default) | 1.0 | 1.0 | 0.7 | 0.3 | You don't know. Genuinely the right default. |
semantic | 1.0 | 0.3 | 0.5 | 0.1 | Conceptual questions, paraphrase, “what do we think about X”. |
lexical | 0.3 | 1.0 | 0.5 | 0.1 | An identifier, error code or exact phrase you already know. |
recent | 0.5 | 0.5 | 0.3 | 1.2 | “What changed?”, “where did we leave off?”, session resumption. |
relational | 0.4 | 0.4 | 1.2 | 0.2 | “Everything connected to Acme” — entity-centred exploration. |
curl -s localhost:7700/v1/recall -H 'content-type: application/json' \ -d '{"query": "E1234", "strategy": "lexical"}' curl -s localhost:7700/v1/recall -H 'content-type: application/json' \ -d '{"query": "what does the user prefer?", "strategy": "semantic"}' curl -s localhost:7700/v1/recall -H 'content-type: application/json' \ -d '{"query": "the Acme renewal", "strategy": "relational"}'
Namespaces
A namespace is a separate engine: its own HNSW index, its own BM25 index, its own entity graph, its own WAL, its own checkpoint, its own directory, its own embedding dimension. It is not a filter over a shared index.
That has consequences worth knowing:
- One tenant's bulk ingest cannot slow another tenant's recall.
- Deleting a namespace is a directory delete, not a scan-and-delete.
- Two namespaces may use different embedding models.
- There is no cross-namespace query. By design — that is the isolation.
# Pick a namespace per request with a header. curl -s localhost:7700/v1/remember \ -H 'content-type: application/json' \ -H 'X-Memrust-Namespace: acme' \ -d '{"text": "Acme prefers monthly invoicing."}' curl -s localhost:7700/v1/namespaces # {"namespaces":["acme","default"]}
Namespaces are created on first use. In the SDKs, pass it once at construction:
MemrustClient(url, namespace="acme").
One namespace per customer or per project.
Not per user and not per conversation — use session_id for those,
which is a filter within one index. Thousands of namespaces are fine; millions
are not, because each one holds open its own indexes.
Agents & visibility
This is what makes one store safe to share between agents that should not see everything about each other.
- A memory written without an
agent_idis unowned and visible to everyone. - A memory written with an
agent_iddefaults toprivate— only that agent recalls it. - Set
"visibility": "shared"to publish it to the team. - Recall with
as_agentsees: shared memories, unowned memories, and its own private ones.
researcher = MemrustClient(URL, agent_id="researcher") writer = MemrustClient(URL, agent_id="writer") # private by default because agent_id is set researcher.remember("source paywalled; used the cached copy", kind="reflection") # deliberately published to the team researcher.remember( "Q3 infra spend was $412k, up 8% on Q2.", kind="semantic", visibility="shared", ) # the writer sees the finding, never the researcher's private note print([h["record"]["text"] for h in writer.recall("infra spend")])
The visibility rule runs in the pre-filter inside each index
search, not as a filter over the result list. A private memory cannot leak
through ranking, through a widened ef_search, or through the
graph's one-hop expansion — it is never a candidate in the first place.
as_agent and filter.agent_id are different things:
as_agent sets who is asking, while
filter.agent_id narrows to exactly one agent's records within
what the asker may already see. Omitting as_agent is an unscoped
operator view that sees everything.
HTTP API
All endpoints are under /v1, take and return JSON, and use
POST unless noted. Two headers matter:
| Header | Purpose |
|---|---|
X-Memrust-Namespace | Selects the namespace. Omitted means default. |
Authorization: Bearer <key> | Required when the server was started with --api-key. X-API-Key also works. |
| Field | Type | Default |
|---|---|---|
text required | string | — |
kind | enum | episodic |
tags | string[] | [] |
importance | 0.0–1.0 | 0.5 |
ttl_seconds | int | 1 day for working, none otherwise. Counts from created_at. |
created_at | i64 (Unix ms) | now — see importing history |
session_id | string | null |
agent_id | string | null |
visibility | shared|private | private if agent_id set, else shared |
metadata | any json | null |
embedding | float[] | engine embeds the text |
{
"record": {
"id": "15a16ce6-0bfa-4c12-b6a4-b1e106949991",
"kind": "episodic",
"text": "Dana Whitfield escalated INC-90312…",
"created_at": 1785304573485,
"importance": 0.8,
"entities": ["incident", "checkout", "dana whitfield", "inc-90312", "900ms", "redis"],
"tags": ["incident", "checkout"],
"visibility": "shared"
}
}
The embedding is never returned — it is an internal representation, and echoing a few hundred floats per record on every response is waste.
Takes {"items": [ …remember bodies… ]} and returns
{"records": [...]}. Use it for anything over a handful of writes:
the whole batch is one embedding round-trip and one fsync,
which is usually an order of magnitude faster than the same writes serially.
memory.remember_batch([ {"text": "The team capped the Redis connection pool at 64.", "kind": "semantic"}, {"text": "Rollback: scale checkout to 0, flip the flag, redeploy.", "kind": "procedural"}, {"text": "User prefers terse answers with code first.", "kind": "semantic"}, ])
| Field | Type | Notes |
|---|---|---|
query required | string | Natural language, an identifier, or both. |
top_k | int | Default 10. |
strategy | enum | See strategies. |
as_agent | string | Recall on behalf of this agent. Omit for the unscoped view. |
filter.kinds | enum[] | Any-of. |
filter.tags | string[] | All-of, not any-of. |
filter.session_id | string | Exact match. |
filter.agent_id | string | Exact match, within the visible set. |
filter.since / until | i64 | Unix ms, inclusive. |
ef_search | int | HNSW beam width for this query. Higher = more accurate, slower. Default 100. |
rerank | bool | Set false to skip a configured reranker for this call. |
query_embedding | float[] | Bring your own query vector. Must match the collection's dimension. |
curl -s localhost:7700/v1/recall -H 'content-type: application/json' -d '{ "query": "what broke in checkout?", "top_k": 5, "strategy": "recent", "as_agent": "triage-bot", "filter": { "kinds": ["episodic", "tool_call"], "tags": ["incident"], "since": 1785000000000 } }'
They run inside each index search, not over the results. So
top_k: 5 with a narrow filter returns the best 5 matching
memories — not “the best 5 overall, then filtered down to 2”.
{"id": "<uuid>"} → {"forgotten": true}. Durable
immediately: the deletion is appended to the WAL and fsynced before the
response, and the record is removed from all four indexes.
?offset=0&limit=50 → {"records": [...], "total": 128},
newest first. This is a listing, not a search — no ranking, no filters. Use it
for dashboards and exports.
{"entities": [
{"name": "redis", "count": 2},
{"name": "900ms", "count": 1},
{"name": "checkout", "count": 1},
{"name": "dana whitfield", "count": 1}
]}
Frequency-ordered. A fast way to see what a memory store has actually accumulated — and a good sanity check that your writes carry the identifiers you think they do.
{"report": {
"expired_swept": 3, // TTL'd memories durably deleted
"batches_consolidated": 1, // episodic groups folded into summaries
"summaries": ["9f0e…"], // ids of the new semantic memories
"checkpointed": false
}}
Runs automatically every 300s. Call it directly in tests, or after a bulk import, when you don't want to wait.
{"session_id": "…"} (optional) → a portable
{"snapshot": {created_at, session_id, records}}. Snapshot records
do include embeddings, so a restore does not re-embed and
does not need the original embedding provider.
{"records": [...]} → {"restored": 42}. Id-preserving
and idempotent — restoring the same snapshot twice does not duplicate. This is
how you move a conversation's memory between machines, or seed a new
namespace.
{
"namespace": "default",
"status": "ok",
"stats": {
"total_memories": 4,
"vector_indexed": 4,
"lexical_indexed": 4,
"entities": 6,
"embedding_dim": 256, // the engine's embedder
"vector_dim": 256, // the index — set by the first vector stored
"quantized": false,
"wal_tail_ops": 1 // ops a restart would replay
}
}
/v1/namespaces, /v1/namespaces/drop and
/metrics name every namespace on the server. When you use scoped
API keys they require an unscoped key. Never expose them through a
multi-tenant proxy.
Errors
| Status | Means | Usually |
|---|---|---|
401 | Missing or wrong key | Server started with --api-key; send Authorization: Bearer. |
403 | Key not scoped to this namespace | A scoped key reaching a namespace outside its list, or an admin route. |
413 | Body too large | Split the batch. Bring-your-own embeddings make bodies big fast. |
422 | Body did not deserialize | The message names the field: missing field `query`. |
500 | Engine error | Dimension mismatch, disk full, embedding provider unreachable. |
Errors are plain-text bodies with the reason, not a JSON error envelope. They are meant to be read in a terminal.
MCP — the Model Context Protocol
MCP is how memrust becomes memory for tools you did not write. memrust speaks it two ways.
stdio — local
memrust mcp --data-dir ~/.memrust --agent-id planner
The process owns the data directory directly — no server, no port, no network. Best for a single machine.
HTTP — hosted
The hosted control plane exposes the same JSON-RPC at POST /mcp,
so several machines and several people connect to one memory. That is in
private beta.
Tools
| Tool | Arguments |
|---|---|
memory_remember | text, kind, tags, importance |
memory_recall | query, top_k, strategy |
memory_forget | id |
memory_entities (HTTP transport) | limit |
Recall returns the per-signal breakdown in the text block, so the model can weigh a strong semantic match against a lucky keyword hit rather than trusting a rank order it cannot inspect.
Claude Code & Claude Desktop
claude mcp add memory -- memrust mcp --data-dir ~/.memrust --agent-id claude-code
# verify
claude mcp list
{
"mcpServers": {
"memrust": {
"command": "memrust",
"args": ["mcp", "--data-dir", "/Users/you/.memrust"]
}
}
}
claude mcp add --transport http memrust https://your-host/mcp \
--header "Authorization: Bearer mr_live_…"
Point the same --data-dir at several clients and
they share one memory. That is the whole trick: what Claude Code learns while
fixing a bug, Claude Desktop recalls the next morning.
Tools exist but models need a reason to reach for them. Put one line in
your CLAUDE.md or system prompt: “Call memory_recall before
answering anything that depends on earlier context, and memory_remember
whenever you learn a durable fact, decision or preference.” Recall
quality is not the bottleneck early on — recall frequency is.
Cursor, Windsurf, Codex
Any MCP client works; only the config file location differs. All of them take
the standard mcpServers block:
{
"mcpServers": {
"memrust": {
"command": "memrust",
"args": ["mcp", "--data-dir", "/absolute/path/.memrust",
"--agent-id", "cursor"]
}
}
}
Use an absolute path — MCP clients do not launch the process in your project
directory, and a relative path will quietly create an empty store somewhere
surprising. Give each client a distinct --agent-id if you want to
tell later who learned what; leave it off if you want everything pooled.
LangGraph
Memory is a node. Recall before the model call, remember after it — the graph state stays small and the memory store carries what persists across runs.
from langgraph.graph import StateGraph, START, END from memrust import MemrustClient memory = MemrustClient("http://127.0.0.1:7700", agent_id="support-agent") def load_memory(state): """Recall before the model sees the question.""" hits = memory.recall(state["question"], top_k=5) context = "\n".join(f"- {h['record']['text']}" for h in hits) return {"context": context} def answer(state): prompt = f"What we already know:\n{state['context']}\n\nQ: {state['question']}" return {"answer": llm.invoke(prompt).content} def save_memory(state): """Store the outcome, not the transcript.""" memory.remember( f"Q: {state['question']} → {state['answer']}", kind="episodic", session_id=state["thread_id"], ) return {} g = StateGraph(dict) g.add_node("load", load_memory) g.add_node("answer", answer) g.add_node("save", save_memory) g.add_edge(START, "load") g.add_edge("load", "answer") g.add_edge("answer", "save") g.add_edge("save", END) app = g.compile()
LangGraph's checkpointer persists graph state so a run can resume. memrust persists what was learned so the next run starts smarter. They are complementary; keep both.
CrewAI
Give each crew member its own client. Private notes stay private, findings get published — with no coordination code between the agents.
from crewai import Agent, Task, Crew from crewai.tools import tool from memrust import MemrustClient URL = "http://127.0.0.1:7700" researcher_mem = MemrustClient(URL, agent_id="researcher") writer_mem = MemrustClient(URL, agent_id="writer") @tool("remember") def remember(text: str, shared: bool = False) -> str: """Store a finding. shared=True publishes it to the whole crew.""" rec = researcher_mem.remember( text, kind="semantic", visibility="shared" if shared else "private", ) return rec["id"] @tool("recall") def recall(query: str) -> str: """Search the crew's shared memory.""" hits = writer_mem.recall(query, top_k=5) return "\n".join(h["record"]["text"] for h in hits) researcher = Agent(role="Researcher", goal="Find and record facts", tools=[remember], backstory="…") writer = Agent(role="Writer", goal="Write from what the crew knows", tools=[recall], backstory="…") crew = Crew(agents=[researcher, writer], tasks=[…])
The crew's memory outlives the crew. Run it again next week and the writer starts with everything the researcher established, without re-running the research.
A plain LLM loop
No framework. This is the whole pattern, and it is worth seeing how small it is:
from openai import OpenAI from memrust import MemrustClient llm = OpenAI() memory = MemrustClient("http://127.0.0.1:7700") def chat(question: str, session: str) -> str: # 1. recall — hybrid, so identifiers survive hits = memory.recall(question, top_k=6) known = "\n".join(f"- {h['record']['text']}" for h in hits) # 2. answer, grounded in it reply = llm.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": f"What you already know:\n{known}"}, {"role": "user", "content": question}, ], ).choices[0].message.content # 3. remember the outcome — not the transcript memory.remember(f"Q: {question} → {reply[:280]}", session_id=session) return reply
Note step 3. Storing every raw turn is the most common mistake — it fills the store with “ok”, “thanks” and “try again”, all of which compete for retrieval slots forever. Store conclusions.
Embeddings
memrust ships an offline embedder so nothing blocks the first run, and takes a real one whenever you want quality.
| Option | Flag | When |
|---|---|---|
| hash (default) | — | Feature hashing, 256 dims, no network, no key. Good enough to demo and to let the lexical + graph signals carry identifier queries. Not good enough for production paraphrase. |
| OpenAI | --embedder openai | Default model text-embedding-3-small. Needs OPENAI_API_KEY or MEMRUST_EMBED_API_KEY. |
| Gemini | --embedder gemini | Default gemini-embedding-001. Needs GEMINI_API_KEY. |
| Local / OpenAI-compatible | --embedder openai --embedding-url … | Ollama, LM Studio, HF TEI, Infinity, vLLM. No key needed. |
| Bring your own | — | Pass embedding on write and query_embedding on recall. The engine never calls out. |
# OpenAI export OPENAI_API_KEY=sk-… memrust serve --embedder openai --embedding-model text-embedding-3-small # Ollama, entirely local memrust serve --embedder openai \ --embedding-url http://localhost:11434/v1 \ --embedding-model nomic-embed-text # E5-family models want asymmetric prefixes memrust serve --embedder openai \ --embedding-url http://localhost:8080/v1 \ --embed-query-prefix "query: " \ --embed-passage-prefix "passage: "
The vector index pins its dimension to the first vector stored. Vectors
from different models are not comparable, so mixing them is meaningless rather
than merely inaccurate — memrust refuses a mismatched dimension and logs the
reason. Changing embedding model means re-ingesting into a fresh
namespace. Check the current dimension at
GET /health → stats.vector_dim.
Quantization
At ≥1024 dimensions memrust automatically stores SQ8 codes — one byte per
dimension instead of four. At that width it is both faster than f32 (better
cache behaviour) and a quarter of the memory. Below 1024 it keeps f32. Force
either way with --quantize / --no-quantize.
Capturing memories: manual, automatic, and in between
“Do I have to write every memory by hand?” No — but be clear about which layer is doing the work.
1. The model decides (MCP)
With MCP connected, Claude calls memory_remember when it judges
something durable, and memory_recall before answering. You write
no capture code at all. This is the highest-leverage setup and the one to
start with. Its quality depends entirely on your system prompt — see the note
in Claude Code.
2. Your code decides (a wrapper)
Deterministic, cheap, and what most production systems end up doing: wrap the agent turn and store the outcome, as in the plain loop above. Add a filter so trivia never lands:
SKIP = {"ok", "thanks", "yes", "no", "try again", "continue"}
def maybe_remember(text, **kw):
"""Store only what could matter tomorrow."""
if len(text) < 25 or text.strip().lower() in SKIP:
return None
return memory.remember(text, **kw)
3. An LLM decides (extraction pass)
Run a cheap model over each turn and ask it for durable facts only. Store the
output as semantic. This gives the best signal-to-noise ratio and
costs one small call per turn.
memrust ships this as the extraction layer — one endpoint, deterministic deduplication, provenance back to the raw turns, and off unless you configure it. Roll your own with the pattern below if you want control over the prompt.
EXTRACT = (
"Extract durable facts, decisions and preferences from this exchange. "
"One per line, third person, keep names, numbers and identifiers. "
"Output nothing if there are none."
)
def distil(exchange: str, session: str):
out = llm.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "system", "content": EXTRACT},
{"role": "user", "content": exchange}],
).choices[0].message.content.strip()
facts = [l.lstrip("- ") for l in out.splitlines() if l.strip()]
if facts:
memory.remember_batch(
[{"text": f, "kind": "semantic", "session_id": session} for f in facts]
)
What memrust automates for you
Regardless of which you choose, once text arrives the engine does the rest:
- Embeds it, indexes it for BM25, extracts entities and links them in the graph.
- Applies a default TTL to
workingmemories. - Every 300s: sweeps expired memories and folds 7-day-old episodic batches
into
semanticsummaries, recording provenance insources.
There is no “point memrust at your Slack and it learns.” Deciding what is worth remembering is a judgement call, made by a model or by your code. That is deliberate: automatic capture of everything is exactly how a memory store becomes landfill that retrieval then has to fight through.
The extraction layer
Level 3 above — having a model decide what is worth remembering — is built in.
Start the server with an extractor and POST /v1/ingest takes a
raw exchange instead of a finished memory.
memrust serve --extractor openai --extractor-model gpt-4o-mini
# or entirely local
memrust serve --extractor openai \
--extractor-url http://localhost:11434/v1 \
--extractor-model qwen2.5:7b
report = memory.ingest([ {"role": "user", "content": "Dana escalated INC-90312; we capped the Redis pool at 64."}, {"role": "assistant", "content": "Noted, I'll write that down."}, ], session_id="oncall") # {'raw': [2 ids], 'extracted': [2 ids], 'superseded': [], # 'proposed': 2, 'duplicates': 0, 'extraction_ran': True}
What makes this different
Extraction is not new; the design choices around it are, and each one follows from memrust's write path being cheap.
Raw turns are kept
Systems that discard the conversation after extracting do so because
their write path costs two model calls per memory, so storing everything
is prohibitive. memrust's costs nothing, so it keeps both — and the facts
carry sources back to the turns they came from. A bad
extraction is recoverable instead of permanent.
Deduplication is deterministic
Whether a fact is already known is a cosine comparison, not a second
model call. Free, repeatable, and it cannot invent a reason to drop
something. Tune with dedup_threshold (default 0.95).
One model call per exchange
Not one per candidate fact. The routing decision that usually costs a second call is the deterministic dedup above.
Superseding is opt-in
Deleting a memory a new fact contradicts needs judgement, and published
evaluations put LLM update-routing near 25% accuracy. Off by default:
keeping both memories is recoverable, deleting the correct one is not.
Pass "supersede": true when you want it.
| Field | Default | Effect |
|---|---|---|
turns required | — | [{role, content}] |
store_raw | true | Keep the turns as episodic memories. |
extract | true | Run the extractor. No extractor configured = verbatim write. |
supersede | false | Let the model delete contradicted memories. |
dedup_threshold | 0.95 | Cosine above which a fact counts as already known. |
session_id, agent_id, tags, created_at, visibility | — | Applied to everything the ingest stores. |
The model call runs while holding only a read lock; the write lock is taken afterwards, for the commit alone. Measured against a scripted model sleeping 2 seconds: the ingest took 2,011 ms while concurrent recalls averaged 1.0 ms.
remember is unchanged and still has no model in it. If you
need every write deterministic — for audit, reproducibility or data
residency — simply do not configure an extractor, and nothing about the
engine's behaviour changes.
Using memrust for plain RAG
Yes, it works. Map the concepts and go:
| Vector DB concept | memrust |
|---|---|
| Collection | Namespace |
| Upsert | POST /v1/remember_batch |
| Search | POST /v1/recall |
| Payload | metadata (returned, not queryable) + tags (queryable) |
| Delete by id | POST /v1/forget |
| Hybrid search | On by default. Not a separate index to configure. |
memory = MemrustClient(URL, namespace="handbook") # ingest chunks — tags are your filterable payload memory.remember_batch([ {"text": chunk, "kind": "semantic", "tags": ["handbook", f"page-{page}"], "metadata": {"source": path, "page": page}} for chunk, page in chunks ]) # retrieve — hybrid, so "clause 7.3" and "what is the refund policy" # both work against the same store hits = memory.recall(question, top_k=8, tags=["handbook"])
Where memrust is the better choice
- Your corpus is full of identifiers — SKUs, clause numbers, error codes, part numbers, ticket IDs. The lexical and graph signals are free and they are exactly what pure vector search misses.
- You want one binary instead of a vector database plus a keyword engine plus glue to fuse them.
- You want to see why a chunk was retrieved.
Where a dedicated vector database is still the better choice
- Distributed scale. memrust is single-node. Hundreds of millions of vectors across a cluster is Qdrant/Milvus territory.
- Rich metadata filtering. memrust filters on kinds, tags, session, agent and time — not arbitrary JSON predicates, ranges over custom fields, or geo.
- Sparse or multi-vector retrieval (SPLADE, ColBERT).
For pure document RAG, memrust is a reasonable single-binary option — not obviously better than Qdrant. Where nothing else fits is when your agent needs to remember across sessions: typed memories, ownership, visibility, expiry, consolidation. That is not RAG, and a vector store has no answer for it.
Lifecycle & consolidation
Memory that only grows becomes unusable. A background pass runs every 300 seconds and does two things.
Expiry
Memories past expires_at are invisible to recall immediately and
durably deleted on the next sweep. working memories get a 1-day
TTL automatically; set ttl_seconds on anything else you want to
be temporary.
Consolidation
Episodic memories older than 7 days are grouped and distilled into a single
semantic memory. The sources are forgotten in the same pass, and
the summary records their ids in sources — so provenance survives
even though the raw episodes do not. Batches are 4–12 memories: below 4 there
is not enough context to distil, above 12 the summary loses specifics.
| Summarizer | Flag | Behaviour |
|---|---|---|
| extractive (default) | --summarizer extract | Picks the most lexically-central memories within a character budget. Deterministic, free, cannot hallucinate — it only ever selects text you already stored. |
| LLM | --summarizer openai | Genuine 2–4 sentence abstraction preserving names, numbers and decisions. Better output; can hallucinate; costs money. |
memrust serve \ --lifecycle-interval-secs 300 \ # 0 disables the pass entirely --working-ttl-secs 86400 \ # 1 day --consolidate-after-secs 604800 \ # 7 days --summarizer openai --summarizer-model gpt-4o-mini
The source episodes are deleted. If you need the raw record permanently —
audit, compliance, legal — either store it as semantic (never
consolidated), keep periodic snapshots, or set
--consolidate-after-secs 0 to turn consolidation off.
Tuning recall
| Symptom | Try |
|---|---|
| Misses an exact identifier | strategy: "lexical". If it still misses, the identifier is probably being split by tokenization — check GET /v1/entities to see whether it was extracted at all. |
| Returns stale decisions | strategy: "recent", or filter.since. Consider raising importance on decisions. |
| Misses paraphrase | You are on the default hash embedder. Switch to a real one — this is the single biggest quality jump available. |
| Right topic, wrong specifics | Raise top_k and let the model choose; or enable a reranker. |
| Recall too slow | Lower ef_search (default 100). Check whether a remote embedding call dominates — that is usually the real latency. |
| Recall not accurate enough | Raise ef_search to 200–400 for that query only. It is per-request; you do not have to rebuild anything. |
| Everything scores about the same | Your memories are too long. One fact per memory retrieves far better than one paragraph per memory. |
Reranking
An optional LLM pass reorders the fused results by relevance. Hits come back
ordered by signals.rerank, while score still reports
the retrieval score — so you can see when the reranker disagreed with
retrieval.
memrust serve --reranker openai --reranker-model gpt-4o-mini # skip it for one latency-sensitive query curl -s localhost:7700/v1/recall -H 'content-type: application/json' \ -d '{"query": "INC-90312", "rerank": false}'
Deployment
services:
memrust:
image: aianytime/memrust
command: >
serve --addr 0.0.0.0:7700
--data-dir /data
--api-key ${MEMRUST_API_KEY}
--log-format json
ports: ["7700:7700"]
volumes: ["memrust-data:/data"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/healthz"]
interval: 30s
restart: unless-stopped
volumes:
memrust-data:
memrust keeps a write-ahead log and a checkpoint on disk. Any platform with ephemeral storage loses every memory on redeploy — including several free tiers. Fine for a demo you reseed; wrong for anyone real. Attach a real volume.
Sizing
Memory is dominated by vectors: roughly dims × 4 bytes per
memory (or dims × 1 when quantized), plus the HNSW graph and the
inverted index. A million 768-dim memories is a few GB of RAM. It is a
single-node engine — scale by splitting namespaces across instances, not by
clustering one.
Authentication
With no --api-key the server is unauthenticated. That is fine on
loopback and not fine on a public address — memrust prints a warning
when you do it anyway.
# one admin key, one tenant key pinned to its own namespaces memrust serve --addr 0.0.0.0:7700 \ --api-key "$ADMIN_KEY" \ --api-key "$ACME_KEY:acme,acme-staging"
- An unscoped key reaches every namespace and the admin routes.
- A scoped key (
KEY:ns1,ns2) reaches only those namespaces, and is refused on/v1/namespaces,/v1/namespaces/dropand/metrics— all three leak the names of namespaces the key has no business knowing about. - Keys are compared in constant time.
They protect you from mistakes, not from an adversary who has a key and
chooses the X-Memrust-Namespace header. Real multi-tenancy needs
a proxy that pins the namespace from the key and never trusts the
caller's header — which is what the hosted control plane does.
Durability
Writes are appended to a write-ahead log and fsynced before the
response returns. If remember returned 200, the memory
survives a power cut. Concurrent writes are group-committed, so throughput
does not collapse under load.
Recovery is checkpoint plus WAL tail: load the last checkpoint, replay
whatever came after. stats.wal_tail_ops tells you how many
operations a restart would replay right now.
Four concurrent writers racing a checkpoint loop, then kill -9:
1,000 acknowledged writes, 1,000 recovered. The harness is
benches/concurrency.py — run it yourself.
Observability
# HELP memrust_uptime_seconds Seconds since the server started.
# TYPE memrust_uptime_seconds gauge
memrust_uptime_seconds 127.413
# HELP memrust_build_info Build metadata; the value is always 1.
memrust_build_info{version="0.6.1"} 1
# HELP memrust_namespaces Namespaces currently open.
memrust_namespaces 2
# HELP memrust_memories Live memories.
memrust_memories{namespace="acme"} 1
Request counts, latency histograms and per-namespace memory counts are all
exported. Request logging is one line per request:
--log-format text|json|off — use json anywhere logs
get shipped.
Importing history
Migrating off another memory system, replaying a transcript, or ingesting
dated documents all have the same problem: the memory is older than the
write. Pass created_at — Unix milliseconds — and
memrust stamps the record with when it happened rather than when it arrived.
memory.remember_batch([ {"text": turn["content"], "kind": "episodic", "session_id": thread_id, "created_at": int(turn["timestamp"] * 1000)} # ms, not seconds for turn in transcript ])
Two behaviours worth knowing before you run a large import:
- TTLs count from
created_at, not from the write. A three-day-oldworkingmemory arrives already expired, because its default one-day TTL ran out two days before you imported it. That is deliberate — it makes re-importing the same records idempotent instead of resurrecting them and extending their life every time. - Recency is what you are protecting. Without
created_atevery imported memory is stamped "now", they all score identical recency, and the signal that answers "what did we decide lately" stops distinguishing anything.
Passing Unix seconds is the easy mistake, and left alone it fails silently: the write succeeds, the memory is dated 1970, and its recency sits at zero forever with nothing to point at. memrust rejects timestamps in that range with the corrected value in the error rather than accepting them.
For moving a whole store rather than individual memories, use
snapshot and restore — snapshot records already
carry their original created_at and their embeddings, so a
restore neither re-dates nor re-embeds anything.
Backup & migration
Two approaches, for two different jobs.
Snapshots — portable, logical
# export everything (or one session with {"session_id": "…"}) curl -s localhost:7700/v1/snapshot -d '{}' \ -H 'content-type: application/json' > backup.json # restore anywhere — ids preserved, embeddings included, idempotent curl -s localhost:7700/v1/restore \ -H 'content-type: application/json' \ -H 'X-Memrust-Namespace: restored' \ -d @<(jq '{records: .snapshot.records}' backup.json)
Directory copy — fast, exact
curl -s -X POST localhost:7700/v1/checkpoint # flush first
tar czf memrust-$(date +%F).tar.gz ./data
Snapshots survive version changes and move between machines with different embedding providers. A directory copy is faster and byte-exact but tied to the on-disk format. Use snapshots for migration, tarballs for backups.
CLI reference
memrust serve [--addr 127.0.0.1:7700] [--data-dir ./memrust-data] [options]
memrust mcp [--data-dir ./memrust-data] [--agent-id <name>] [options]
memrust demo # 8 seeded memories, three recalls
memrust bench [--n 20000] [--dim 256] [--engine]
| Flag | Default | Effect |
|---|---|---|
--addr | 127.0.0.1:7700 | Bind address. Use 0.0.0.0 in a container. |
--data-dir | ./memrust-data | WAL, checkpoint, per-namespace subdirectories. |
--api-key | none | Repeatable. KEY or KEY:ns1,ns2. |
--namespace | default | Namespace for mcp. |
--agent-id | none | mcp: stamp writes and scope recalls to this identity. |
--quantize / --no-quantize | auto at ≥1024 dims | Force SQ8 or f32. |
--embedder | hash | hash | openai | gemini |
--embedding-model / --embedding-url | provider defaults | Any OpenAI-compatible server. |
--embed-query-prefix / --embed-passage-prefix | none | For asymmetric models (E5, BGE). |
--reranker | off | openai, with --reranker-model / --reranker-url. |
--summarizer | extract | extract | openai |
--lifecycle-interval-secs | 300 | 0 disables the background pass. |
--working-ttl-secs | 86400 | Default TTL for working memories. |
--consolidate-after-secs | 604800 | Age at which episodic memories consolidate. |
--log-format | text | text | json | off |
API keys for embedding and reranking come from
MEMRUST_EMBED_API_KEY, OPENAI_API_KEY or
GEMINI_API_KEY. Local OpenAI-compatible servers need no key.
Python SDK
pip install memrust — zero dependencies, standard library only.
| Method | Returns |
|---|---|
MemrustClient(base_url, *, agent_id, api_key, namespace, timeout) | client |
.remember(text, *, kind, tags, session_id, agent_id, importance, ttl_seconds, visibility, metadata, embedding) | dict — the record |
.remember_batch(items) | list[dict] |
.recall(query, *, top_k, strategy, as_agent, kinds, tags, session_id, since, until, rerank, query_embedding, ef_search) | list[dict] — hits |
.forget(memory_id) | bool |
.health() | dict — stats |
.run_lifecycle() | dict — report |
.snapshot(session_id=None) / .restore(records) | dict / int |
.checkpoint() | None |
.namespaces() / .drop_namespace(ns) | list[str] / bool |
Constructor agent_id is applied to both sides: writes are stamped
with it, and recalls run as_agent automatically. Errors raise
MemrustError.
TypeScript SDK
npm install memrust-client — zero dependencies, uses
fetch. Works in Node 18+, Bun, Deno and browsers. Same surface,
camelCase options (topK, asAgent,
sessionId, ttlSeconds, efSearch), fully
typed: MemoryRecord, RecallHit,
RecallSignals, EngineStats,
LifecycleReport, Snapshot.
import { MemrustClient, type RecallHit } from "memrust-client"; const memory = new MemrustClient(process.env.MEMRUST_URL!, { agentId: "planner", apiKey: process.env.MEMRUST_API_KEY, namespace: "acme", }); const hits: RecallHit[] = await memory.recall("open incidents", { strategy: "recent", kinds: ["episodic"], topK: 5, });
Rust crate
cargo add memrust gives you the engine, not a client —
no server, no HTTP, no serialization. Use it when memory lives inside your
process.
use memrust::{MemoryEngine, RememberRequest, RecallRequest, RecallStrategy}; let mut memory = MemoryEngine::open("./data".as_ref())?; memory.remember(RememberRequest { text: "deploy blocked on the expired TLS cert".into(), ..Default::default() })?; let hits = memory.recall(&RecallRequest { query: "why is the deploy blocked?".into(), strategy: RecallStrategy::Recent, top_k: Some(5), ..Default::default() }); let report = memory.run_lifecycle()?; memory.checkpoint()?;
recall takes &self; remember,
forget, run_lifecycle and checkpoint
take &mut self. For concurrent writers, stage
builds a record under a read lock and apply_staged commits it
under a write lock, which keeps the exclusive section short. Other useful
entry points: open_with_embedder, open_with_options,
set_reranker, snapshot, restore,
compact, stats, top_entities.
Benchmarks
Three scripts, all in benches/, all re-runnable.
| Script | Measures |
|---|---|
agent_recall.py | The 27% vs 75% result: identifier hit@5 against FAISS, Chroma and Qdrant using identical all-MiniLM-L6-v2 embeddings. |
compare.py | Ingest and query throughput against the same libraries. |
concurrency.py | Crash safety: concurrent writers, checkpoints, kill -9, recovery count. |
cd benches pip install -r requirements.txt python agent_recall.py # the headline result python concurrency.py # durability under kill -9
The comparison holds embeddings constant on purpose. Every system gets the same vectors, so the difference is retrieval design, not embedding quality.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
Recall returns nothing, but /health shows memories |
Usually a visibility or filter mismatch. Drop as_agent and the filter, then add them back one at a time. Also check expires_at — working memories expire after a day. |
supplied embedding has dim N but this collection's vector index is dim M |
You changed embedding model. Vectors from different models are not comparable; re-ingest into a fresh namespace. |
Everything is found by lexical, vector is always weak |
You are on the default hash embedder. Expected. Switch to --embedder openai or a local model. |
graph: 0.0 on a query you expected to match an entity |
A single capitalised word at the start of a query is skipped by the extractor. Query a phrase, or pass entities explicitly on write. See the gotcha. |
| Memories vanish after a redeploy | Ephemeral storage. Mount a real volume — see deployment. |
| MCP server shows as failed in Claude | memrust is not on the client's PATH, or
--data-dir is relative. Use absolute paths for both. |
| Two clients are not sharing memory | They are pointed at different --data-dir values, or one is on a different namespace. Compare GET /health → namespace. |
| Writes are slow | Every write fsyncs. Use remember_batch — one fsync for the batch. If you are on a remote embedder, that call usually dominates anyway. |
401 with a key you are certain is right |
Header must be Authorization: Bearer <key> or X-API-Key: <key>. Scoped keys also 403 on admin routes — that is not an auth failure, it is the scope. |