memrust DOCS GitHub Book a demo

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:

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:

IndexBuilt fromAnswers
vectoran embedding of the text (HNSW, M=16)“what means something similar?”
lexicaltokens of the text (BM25, k1=1.2, b=0.75)“what contains this exact term?”
graphentities extracted at write time, linked by co-occurrence“what is related to this?”
recencythe 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:

recall "INC-90312"
"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.

Rule of thumb

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

shell
cargo install memrust
memrust --version

# HTTP API and a built-in dashboard on :7700
memrust serve

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.

shell
# 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}'

The engine extracted the entities on its own. You never asked it to:

response · entities
"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.

FieldTypeMeaning
iduuidAssigned by the engine. Use it to forget.
textstringThe memory itself. Everything else is derived from or attached to it.
kindenumSee memory kinds. Default episodic.
created_ati64Unix milliseconds. Drives the recency signal. Defaults to now; pass it explicitly when importing history.
importance0.0–1.0A retrieval boost. Default 0.5. Contributes a small, bounded amount — it nudges ranking, it does not override relevance.
expires_ati64?Invisible to recall past this instant; deleted by the next lifecycle sweep. Set via ttl_seconds.
entitiesstring[]Extracted at write time. Lower-cased. Supply your own to skip the heuristic.
tagsstring[]Yours. Also fed to the entity graph, so a tag is a retrievable relation, not just a filter.
session_idstring?Conversation or run. Filterable, and the unit of snapshot.
agent_idstring?Owner. Setting it flips the default visibility to private.
visibilityenumshared or private. See agents.
metadatajson?Opaque passthrough. Stored and returned; not indexed or filterable.
sourcesuuid[]Provenance. Present on consolidated summaries: the memories folded into this one.
Note

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.

KindForLifecycle
episodic (default)Things that happened: events, turns, actions taken.Consolidated into semantic after 7 days.
semanticDistilled facts, independent of when they were learned.Never expires by default.
workingScratch state for the current task.Expires after 1 day unless you set a TTL.
reflectionThe agent's conclusions about its own behaviour.Never expires by default.
tool_callTool invocations and their results.Never expires by default.
proceduralHow-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.

python · filter by kind
# 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.

json · one 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
  }
}
Gotcha — entity extraction

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.

StrategyvectorlexicalgraphrecencyUse when
balanced (default)1.01.00.70.3You don't know. Genuinely the right default.
semantic1.00.30.50.1Conceptual questions, paraphrase, “what do we think about X”.
lexical0.31.00.50.1An identifier, error code or exact phrase you already know.
recent0.50.50.31.2“What changed?”, “where did we leave off?”, session resumption.
relational0.40.41.20.2“Everything connected to Acme” — entity-centred exploration.
shell · the same store, three questions
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:

shell
# 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").

Choosing a boundary

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.

python · two agents, one store
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")])
How it is enforced

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:

HeaderPurpose
X-Memrust-NamespaceSelects the namespace. Omitted means default.
Authorization: Bearer <key>Required when the server was started with --api-key. X-API-Key also works.
POST/v1/rememberstore one memory
FieldTypeDefault
text requiredstring
kindenumepisodic
tagsstring[][]
importance0.0–1.00.5
ttl_secondsint1 day for working, none otherwise. Counts from created_at.
created_ati64 (Unix ms)now — see importing history
session_idstringnull
agent_idstringnull
visibilityshared|privateprivate if agent_id set, else shared
metadataany jsonnull
embeddingfloat[]engine embeds the text
response
{
  "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.

POST/v1/remember_batchbulk ingest

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.

python
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"},
])
POST/v1/recallhybrid retrieval
FieldTypeNotes
query requiredstringNatural language, an identifier, or both.
top_kintDefault 10.
strategyenumSee strategies.
as_agentstringRecall on behalf of this agent. Omit for the unscoped view.
filter.kindsenum[]Any-of.
filter.tagsstring[]All-of, not any-of.
filter.session_idstringExact match.
filter.agent_idstringExact match, within the visible set.
filter.since / untili64Unix ms, inclusive.
ef_searchintHNSW beam width for this query. Higher = more accurate, slower. Default 100.
rerankboolSet false to skip a configured reranker for this call.
query_embeddingfloat[]Bring your own query vector. Must match the collection's dimension.
shell · a filtered, agent-scoped recall
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
  }
}'
Filters are pre-filters

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

POST/v1/forgetdelete one memory

{"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.

GET/v1/memoriesbrowse

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

GET/v1/entitieswhat this store is about
GET /v1/entities?limit=5
{"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.

POST/v1/lifecycle/runsweep & consolidate now
response
{"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.

POST/v1/checkpointpersist and truncate the WAL
POST/v1/snapshotexport

{"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.

POST/v1/restoreimport

{"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.

GET/v1/namespacesadmin
POST/v1/namespaces/dropirreversible
GET/healthstats
GET/healthzliveness — returns "ok"
GET/metricsPrometheus
GET /health
{
  "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
  }
}
Admin endpoints are not tenant-safe

/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

StatusMeansUsually
401Missing or wrong keyServer started with --api-key; send Authorization: Bearer.
403Key not scoped to this namespaceA scoped key reaching a namespace outside its list, or an admin route.
413Body too largeSplit the batch. Bring-your-own embeddings make bodies big fast.
422Body did not deserializeThe message names the field: missing field `query`.
500Engine errorDimension 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

shell
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

ToolArguments
memory_remembertext, kind, tags, importance
memory_recallquery, top_k, strategy
memory_forgetid
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

shell
claude mcp add memory -- memrust mcp --data-dir ~/.memrust --agent-id claude-code

# verify
claude mcp list

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.

Getting the model to actually use it

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:

.cursor/mcp.json · ~/.codeium/windsurf/mcp_config.json
{
  "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.

python
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()
Checkpointer vs. memory

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.

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

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

OptionFlagWhen
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 openaiDefault model text-embedding-3-small. Needs OPENAI_API_KEY or MEMRUST_EMBED_API_KEY.
Gemini--embedder geminiDefault 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 ownPass embedding on write and query_embedding on recall. The engine never calls out.
shell
# 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: "
One model per namespace, forever

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 /healthstats.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:

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

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

The honest gap

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.

shell
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
python
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.

FieldDefaultEffect
turns required[{role, content}]
store_rawtrueKeep the turns as episodic memories.
extracttrueRun the extractor. No extractor configured = verbatim write.
supersedefalseLet the model delete contradicted memories.
dedup_threshold0.95Cosine above which a fact counts as already known.
session_id, agent_id, tags, created_at, visibilityApplied to everything the ingest stores.
Recall does not stall during extraction

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.

This is the one place a model touches a write

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 conceptmemrust
CollectionNamespace
UpsertPOST /v1/remember_batch
SearchPOST /v1/recall
Payloadmetadata (returned, not queryable) + tags (queryable)
Delete by idPOST /v1/forget
Hybrid searchOn by default. Not a separate index to configure.
python · document RAG
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

Where a dedicated vector database is still the better choice

The honest framing

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.

SummarizerFlagBehaviour
extractive (default)--summarizer extractPicks the most lexically-central memories within a character budget. Deterministic, free, cannot hallucinate — it only ever selects text you already stored.
LLM--summarizer openaiGenuine 2–4 sentence abstraction preserving names, numbers and decisions. Better output; can hallucinate; costs money.
shell · tuning
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
Consolidation is lossy on purpose

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

SymptomTry
Misses an exact identifierstrategy: "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 decisionsstrategy: "recent", or filter.since. Consider raising importance on decisions.
Misses paraphraseYou are on the default hash embedder. Switch to a real one — this is the single biggest quality jump available.
Right topic, wrong specificsRaise top_k and let the model choose; or enable a reranker.
Recall too slowLower ef_search (default 100). Check whether a remote embedding call dominates — that is usually the real latency.
Recall not accurate enoughRaise ef_search to 200–400 for that query only. It is per-request; you do not have to rebuild anything.
Everything scores about the sameYour 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.

shell
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

docker-compose.yml
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:
Storage is not optional

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.

shell
# 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"
Scoped keys are not multi-tenancy

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.

Tested, not asserted

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

GET /metrics
# 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.

python · importing a transcript
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:

Milliseconds, and memrust will check

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

shell
# 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

shell
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

shell
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]
FlagDefaultEffect
--addr127.0.0.1:7700Bind address. Use 0.0.0.0 in a container.
--data-dir./memrust-dataWAL, checkpoint, per-namespace subdirectories.
--api-keynoneRepeatable. KEY or KEY:ns1,ns2.
--namespacedefaultNamespace for mcp.
--agent-idnonemcp: stamp writes and scope recalls to this identity.
--quantize / --no-quantizeauto at ≥1024 dimsForce SQ8 or f32.
--embedderhashhash | openai | gemini
--embedding-model / --embedding-urlprovider defaultsAny OpenAI-compatible server.
--embed-query-prefix / --embed-passage-prefixnoneFor asymmetric models (E5, BGE).
--rerankeroffopenai, with --reranker-model / --reranker-url.
--summarizerextractextract | openai
--lifecycle-interval-secs3000 disables the background pass.
--working-ttl-secs86400Default TTL for working memories.
--consolidate-after-secs604800Age at which episodic memories consolidate.
--log-formattexttext | 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.

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

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

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

ScriptMeasures
agent_recall.pyThe 27% vs 75% result: identifier hit@5 against FAISS, Chroma and Qdrant using identical all-MiniLM-L6-v2 embeddings.
compare.pyIngest and query throughput against the same libraries.
concurrency.pyCrash safety: concurrent writers, checkpoints, kill -9, recovery count.
shell
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

SymptomCause & 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_atworking 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 /healthnamespace.
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.