This is the exhaustive method-by-method reference for the Engram and AsyncEngram classes, the LLM adapters, the LangChain / LlamaIndex / MCP integrations, the on-disk architecture and storage schema, benchmark numbers, and the DecayConfig tuning knobs. For the narrative introduction, install instructions, and quickstart, see the README.
from engram import Engram, DecayConfig, AnthropicAdapter
mem = Engram(
path="./agent.engram", # path to .engram file, or ":memory:" for in-process
embedder_model="BAAI/bge-small-en-v1.5", # default; local ONNX, ~23 MB
decay_config=DecayConfig(
lambda_=0.1, # Ebbinghaus decay rate. 0.1 ≈ half-life ~7 days.
alpha=0.2, # Reinforcement weight per recall access.
beta=0.1, # Emotional valence weight.
threshold=0.1, # Prune memories below this importance during reflect().
),
llm=AnthropicAdapter(), # optional; used by reflect() and compress()
agent_id="agent://acme.example/my-agent", # optional; scopes writes and reads
key="passphrase", # optional; enables SQLCipher encryption-at-rest
events_path="./agent.events.ndjson", # optional; see Agent Passport events below
)
# Context-manager supported
with Engram(path=":memory:") as mem:
mem.observe("hello world")Encryption-at-rest: pass
key="..."to encrypt the database via SQLCipher (pip install 'engdbram[encryption]'). Plain (no-key) stores are unchanged. Usemem.rekey(new_key)to change or remove the key.
Agent Passport events: pass
events_path="..."(or setENGRAM_EVENTS_PATH) to opt in to an NDJSON event log conforming to the Agent Passportagent-eventenvelope (SPEC.md §6), schemataipanbox.dev/agent-event/v0.2. Off by default - no file is written unless configured.observe(),observe_many(),assert_fact(),forget(),forget_fact()andforget_entity()each emit onememory_written/memory_forgottenevent per memory written or erased (info), so a bulk write of N episodes is N events and an entity erasure is one per episode and fact it removes;compress()emits amemory_writtenfor each summary it creates and amemory_forgottenfor each source episode it deletes, each naming the summary that replaced it;reflect()emits onereflection_runevent (info) plus onecontradiction_foundevent (medium) per fact it supersedes with a differing object (a same-object re-extraction supersedes silently, as agreement). Events with noagent_idset on the instance are skipped, never fabricated; anagent_idthat is not anagent://<trust-domain>/<name>URI is written, warned about once and counted (EventLog.nonconforming_agent_id), because a consumer validating the shared envelope will reject those lines. A local file append is not a network call, so this does not violate Engram's write-time invariant - seeengram/events.pyfor the full reasoning. Failures to write an event are logged as a warning and never raised into the memory operation. Each event also carries a SPEC.md §6.5prev_hashchain (one file, one chain, resumed across restarts) - verify withagent-conform -chain <file>. An events file written by an earlier release keeps its v0.1 lines and is appended to, never restarted: the chain links across the version change like any other line, and a consumer accepts both versions (SPEC.md §6.4).
Record a raw episodic observation. Returns the episode id. No LLM call. A median of 8 ms on an Apple Silicon Mac (engram-bench latency, 200 writes, 2026-10-13).
ep_id = mem.observe(
"Alice presented the Q3 roadmap",
actors=["Alice"],
tags=["work", "roadmap"],
salience=0.8, # subjective importance at encoding (0–1)
emotional_valence=0.3, # –1 (negative) … +1 (positive)
)
# Back-filling history: date it when it happened, not when it was written,
# so recall(as_of=...) places it in the period it belongs to.
ep_id = mem.observe("Alice joined Globex", timestamp=datetime(2024, 3, 1, tzinfo=UTC))
# Where it came from: "user" (typed by the person), "tool" or "external".
# Omitted, it is "external". Any other value raises ValueError.
ep_id = mem.observe("Alice joined Globex", provenance="user")recall() results carry the source as result.episode.provenance. Memories written
before the field existed read as "unknown".
quarantined=True records that the writer had read untrusted content before this
write, so the text may carry instructions it picked up there. Results carry it as
result.episode.quarantined. The MCP server sets both itself rather than taking them
from the caller (see MCP Server). An episode written before the mark
existed reads as quarantined, and its provenance as "unknown": the source it
recorded then was whatever the writer declared, user included.
Batch variant of observe(). Accepts a list of ObserveInput instances, runs a single ONNX inference pass and inserts all rows in one SQL transaction. ~2× faster than a loop at 100+ episodes.
from engram import ObserveInput
ids = mem.observe_many([
ObserveInput(content="Alice joined as CTO", actors=["Alice"], salience=0.9),
ObserveInput(content="Q3 planning complete", tags=["planning"]),
])ObserveInput fields: content (required), actors, tags, salience (default 0.5), emotional_valence (default 0.0), timestamp, provenance (default "external"), quarantined (default False).
recall(query, k, *, mode, depth, decay, vector_weight, fts_weight, as_of, cross_agent, candidate_limit) → list[SearchResult]
# Default: hybrid, BM25 keyword blended with cosine vector
results = mem.recall("where does Ivan work?", k=5)
results = mem.recall("exact term", k=5, vector_weight=0.3, fts_weight=0.7)
# Vector similarity alone, no keyword component
results = mem.recall("Ivan Globex transfer", k=5, mode="cosine")
# Graph-based spreading-activation
results = mem.recall("Ivan", k=5, mode="spreading", depth=2, decay=0.5)
# Time travel: only episodes that existed at this point (works in all modes)
results = mem.recall(
"Ivan employer",
k=5,
as_of=datetime(2024, 3, 1, tzinfo=UTC),
)
# Cross-agent: bypass agent_id scope
results = mem.recall("migration", k=10, cross_agent=True)
# Tune the candidate pool for hybrid search
results = mem.recall("Q3 revenue", k=5, mode="hybrid", candidate_limit=64) # per-source poolk is capped at 4096, the vec0 KNN limit; above it recall raises ValueError.
k_inner is accepted but ignored since v2.3, and warns. It sized an over-fetch
that compensated for as_of and agent_id being applied after the vector
search. They are now a metadata column and a partition key on the index itself,
evaluated during the scan, so k counts only rows that already passed them and
there is no inner limit left to tune.
SearchResult fields: episode, score (0–1, higher is better - derived
from the L2 distance of unit-norm embeddings, so monotone in cosine),
distance (raw L2 from sqlite-vec), importance.
User-supplied query strings are safe to pass even when they contain FTS5
operators (*, (, OR, NOT, -, "); tokens are escaped and wrapped
as phrases before reaching SQLite.
Store a semantic triple directly. No LLM required. Returns the fact id.
fact_id = mem.assert_fact("Ivan", "works_at", "Globex", confidence=0.95)
fact_id = mem.assert_fact("Alice", "role", "CTO", source="linkedin-profile")Run the reflection loop (requires llm):
run = mem.reflect() # synchronous
thread = mem.reflect_async() # background thread; call .join() when ready
print(f"{run.facts_extracted} facts from {run.episodes_processed} episodes")
print(f"Resolved {run.contradictions_resolved} contradictions")
print(f"Cost: {run.cost_tokens} tokens")Fact history for an entity, in chronological order. By default returns
everything (including superseded facts) so callers can see how beliefs
evolved. Pass as_of=... to get only facts whose validity interval
contains that timestamp - the public entry point to the bitemporal
fact path.
# Full history, including superseded facts
for f in mem.timeline("Ivan"):
end = f.valid_to.date() if f.valid_to else "now"
print(f"[{f.valid_from.date()} → {end}] Ivan {f.predicate} {f.object}")
# What did the agent believe about Ivan in March 2024?
for f in mem.timeline("Ivan", as_of=datetime(2024, 3, 1, tzinfo=UTC)):
print(f"valid: Ivan {f.predicate} {f.object}")Explain where a fact came from (provenance).
mem.why(fact_id)
# {
# "fact": "Ivan works_at Globex",
# "extracted_from": ["ep-uuid-1", "ep-uuid-2"],
# "extracted_by": "reflection-run-uuid",
# "confidence": 0.87,
# "model": "claude-haiku-4-5-20251001",
# "provenance": "external", # least trusted source among the episodes above
# "quarantined": False, # the fact, or any of those episodes, was quarantined
# "agent_id": "agent://acme.example/planner", # who wrote it; None if unscoped or old
# "shared": True # visible to every agent, or only to its writer
# }provenance is the least trusted source among the episodes the fact was extracted from
(unknown < external < tool < user). A fact asserted with assert_fact() names no
episodes, so it reads "unknown".
A fact belongs to the agent that wrote it. assert_fact(..., shared=True) (the library
default, as facts always were) makes it visible to every agent in the file;
shared=False keeps it to its writer and to an instance opened without an agent_id.
On an agent-scoped instance why(), timeline(), contradictions(), reflection and
export_json() leave out another agent's private facts, and why() raises KeyError
for one. A fact written before ownership was recorded stays visible to every agent and
reads as quarantined.
Surface active facts that share (subject, predicate) but differ in object.
for a, b in mem.contradictions():
print(f"CONFLICT: {a.subject} {a.predicate} '{a.object}' vs '{b.object}'")Permanently erase a single episode from all storage structures (vector index, FTS index, access log, graph edges). Raises KeyError if the episode does not exist.
mem.forget(ep_id)Permanently erase a single semantic fact (a triple returned by assert_fact() or produced by reflect()). Raises KeyError if the fact does not exist or is another agent's private fact. On an agent-scoped instance only the facts that agent wrote can be erased: a fact another agent shared, or one written before engram recorded who wrote facts, raises PermissionError. An instance opened without an agent_id, the file's operator, can erase any fact.
mem.forget_fact(fact_id)GDPR right-to-be-forgotten: permanently delete all data about a named entity across all agents. Removes episodes where the entity appears in actors, all facts where it is subject or object, and all graph edges connected to it.
result = mem.forget_entity("Ivan")
print(f"Deleted {result.episodes_deleted} episodes, {result.facts_deleted} facts")Compress low-importance episodes into LLM-generated summary episodes. Requires an llm adapter.
result = mem.compress(
max_episodes=1000, # no-op if store has fewer episodes than this
importance_threshold=0.3, # compress episodes with importance_score < threshold
batch_size=20, # episodes grouped per LLM call
)
# CompressionRun fields: episodes_removed, summaries_created, model_used, cost_tokens
print(f"Removed {result.episodes_removed} → {result.summaries_created} summaries")Hot backup using SQLite's built-in online backup API. Safe to call while the store is open and actively written to.
A backup copies the whole file and cannot be filtered, so it raises ValueError on an instance scoped to an agent_id. Open the store without one for a whole-file backup, or use export_json() for a single agent's data.
mem.backup("./agent_backup.engram") # str or PathChange the SQLCipher passphrase of an encrypted store, or pass None to
remove encryption entirely. Only valid on databases originally opened
with Engram(..., key=...). To encrypt a plain database, dump it with
export_json() and re-import into a fresh Engram(key=...).
mem = Engram(path="./agent.engram", key="old-pass")
mem.rekey("new-pass") # rotate
mem.rekey(None) # drop encryptionExport the store (episodes, facts, entities, edges) to a JSON file. Returns the document dict.
Scoped to the instance's agent_id: episodes and edges are filtered to it, and facts the way every fact read is (its own, the shared ones, and those written before sharing was recorded). Entities carry no agent_id and are exported whole. An unscoped instance exports the entire store. The quarantine mark, and a fact's owner and sharing, travel with the rows; a document written before they existed imports with them unset, which reads as the least trusted.
doc = mem.export_json("./agent_dump.json")
print(doc["counts"]) # {'episodes': 842, 'facts': 134, 'entities': 41, 'edges': 97}Import from a JSON file produced by export_json(). Returns counts of inserted rows per table.
counts = mem.import_json("./agent_dump.json") # raises on duplicate ids
counts = mem.import_json("./agent_dump.json", merge=True) # skip duplicates silentlyRecompute importance scores using the Ebbinghaus formula. Scoped to this instance's agent_id when set (all episodes when unscoped). Called automatically by reflect(). Returns the number of episodes updated.
Uses a single SQL GROUP BY fetch and a single executemany update - O(1) SQL round-trips regardless of episode count.
Return all distinct agent_id values that have written to this store.
with Engram(path="./team.engram") as mem:
print(mem.list_agents()) # ['coder', 'planner', 'reviewer']LRU scratchpad with optional long-term spillover.
from engram import WorkingMemory, WorkingMemoryItem
wm = WorkingMemory(
capacity=7, # max slots (default 7, per Miller's 7±2 law)
engram=mem, # optional; evicted items written via observe()
)
wm.set("key", "content", priority=1) # kwargs stored in item.metadata
item: WorkingMemoryItem = wm.get("key") # promotes to MRU; None if missing
item = wm.peek("key") # no LRU change
wm.delete("key") # remove one item
wm.flush() # write all to long-term store + clear
wm.clear() # discard without writing
len(wm) # current size
"key" in wm # membership test
wm.items() # list[WorkingMemoryItem] from LRU to MRU
wm.capacity # intWorkingMemoryItem fields: key, content, metadata (dict), created_at, accessed_at.
Async-compatible wrapper with the same interface as Engram. Every method
is async def and dispatches to the synchronous implementation via
asyncio.to_thread - the event loop is never blocked by ONNX inference or
SQLite I/O. The surface is at parity with the sync API: recall accepts
k_inner/candidate_limit, timeline accepts as_of=.
from engram import AsyncEngram
async with AsyncEngram(path="./agent.engram") as mem:
ep_id = await mem.observe("Hello world")
results = await mem.recall("hello", k=3, mode="hybrid", candidate_limit=64)
bitemporal = await mem.timeline("Alice", as_of=datetime(2024, 3, 1, tzinfo=UTC))
fact_id = await mem.assert_fact("Alice", "role", "CTO")
await mem.decay()
await mem.backup("./backup.engram")
doc = await mem.export_json("./dump.json")
counts = await mem.import_json("./dump.json", merge=True)
await mem.forget(ep_id)
await mem.forget_fact(fact_id)
result = await mem.forget_entity("Bob")Both reflect() and compress() use the LLM adapter:
from engram import (
AnthropicAdapter,
OpenAIAdapter,
GeminiAdapter,
DeepSeekAdapter,
QwenAdapter,
KimiAdapter,
StubLLMAdapter, # tests / offline development
)
# Claude (default: haiku - fast, cheap)
llm = AnthropicAdapter(model="claude-haiku-4-5-20251001")
# Claude routed through a proxy (e.g. TokenFuse) instead of the API directly
llm = AnthropicAdapter(model="claude-haiku-4-5-20251001",
base_url="http://localhost:8080", api_key="proxy-key")
# OpenAI
llm = OpenAIAdapter(model="gpt-4o-mini")
# Ollama or any OpenAI-compatible local model
llm = OpenAIAdapter(model="llama3.2", base_url="http://localhost:11434/v1")
# Google Gemini (reads GOOGLE_API_KEY by default)
llm = GeminiAdapter(model="gemini-2.0-flash")
# OpenAI-compatible providers pre-wired with the right base URL
llm = DeepSeekAdapter(model="deepseek-chat") # DEEPSEEK_API_KEY
llm = QwenAdapter(model="qwen-max") # DASHSCOPE_API_KEY
llm = KimiAdapter(model="moonshot-v1-8k") # MOONSHOT_API_KEY
mem = Engram(path="./agent.engram", llm=llm)Prompt-injection hardening. Episode bodies sent to an LLM during
reflect() are wrapped in <observation> blocks and the system prompt
instructs the model to ignore directives inside them. Every extraction
runs at temperature=0, and any LLM-derived confidence is capped at
0.95 so a successful injection cannot persist a fabricated fact as
absolute truth. Facts you assert directly via mem.assert_fact(..., confidence=1.0) are not capped - the cap is specifically for facts
mined from user-controlled text.
pip install 'engdbram[mcp]' adds an engram-mcp console command that exposes
a store as an MCP tool server over stdio -
no network listener, compatible with Claude Desktop, Claude Code, Cursor, and
any other MCP host:
engram-mcp --db ./agent.engram --agent-id agent://acme.example/my-agent # the agent this server is
engram-mcp --db ./agent.engram --agent-id agent://acme.example/my-agent --provenance tool
engram-mcp --db ./agent.engram --agent-id agent://acme.example/my-agent --session-untrusted
engram-mcp --db ./agent.engram --events ./agent.events.ndjson # opt in to Agent Passport events
# equivalently, via environment variables
ENGRAM_MCP_DB=./agent.engram ENGRAM_MCP_AGENT_ID=agent://acme.example/my-agent ENGRAM_MCP_PROVENANCE=tool ENGRAM_MCP_SESSION_UNTRUSTED=1 ENGRAM_MCP_EVENTS=./agent.events.ndjson engram-mcpIf --db/ENGRAM_MCP_DB is omitted, the server falls back to an in-memory
store that is discarded on exit - fine for a quick trial, not for anything
you want to keep.
Scope and trust are the server's, not the caller's. The tool caller is a model, and a model can be steered by what it read, so nothing a tool call says decides whose memory it reaches or how far it is trusted:
- The scope is
--agent-id. Every tool reads and writes that agent's memory. A call that names anotheragent_idis refused; naming the server's own identity, or nothing, is served. Started without--agent-id, the server serves the whole file as one tenant (it says so on stderr) and refuses any call that names an agent. Run one server per agent over a shared file. - The server sets each write's provenance.
--provenance(envENGRAM_MCP_PROVENANCE) is the most trusted source a write can carry:externalby default, because a write here is an agent's own output and the server cannot see whether it was relaying the person's words;tooloruserfor a client the operator knows relays a tool's output or the person's own words. A caller may passprovenanceto lower it, totoolorexternal.useris refused. - Quarantine. The server marks the session untrusted the first time a
recallorwhyreturns a memory that is not the user's own or is itself quarantined, when the caller writes content it labelstoolorexternalor passesread_untrusted=true(it summarises or follows content from a tool, a page or another agent), or from the start with--session-untrusted(envENGRAM_MCP_SESSION_UNTRUSTED). Every write after that is storedquarantined: true, and never asuser.recallandwhyreturn the mark with the content, andstatsreports the session's state and the first reason it changed. Nothing can clear it except a new session. - Facts. A fact written through
rememberis private to its writer unlessshared=trueis passed at write time. Only its writer canforgetit: another agent's private fact is not found, and its shared fact is refused.
Five tools are registered:
| Tool | Purpose |
|---|---|
remember |
Store a memory. kind="episodic" (default) takes content; kind="semantic" takes subject/predicate/object instead - the structured triple, not free text. agent_id is optional and must be the server's own. An episodic memory takes an optional provenance that can only lower the server's (tool or external); a semantic fact takes none, since it reports the least trusted source among its episodes, and takes shared (default false). Both take read_untrusted. Returns the stored provenance and quarantined. |
recall |
Retrieve this agent's memories relevant to a natural-language query, with mode="hybrid" (default), "cosine", or "spreading", and an optional limit. Each result carries its provenance and quarantined. |
why |
Explain a memory's provenance by id - extraction chain, confidence, writer and sharing for a fact, encoding/access metadata for an episode. Both kinds report a provenance (for a fact, the least trusted source among its episodes) and quarantined. |
forget |
Permanently delete one of this agent's memories (episodic or semantic) by id. Irreversible. |
stats |
Counts (episodic, semantic, procedural - the last is always 0, see below), fact validity breakdown, entity/reflection counts, db file size, a scope object naming which counts are this agent's, and the session quarantine state. |
A few things worth calling out:
kind="procedural"is deliberately rejected. Engram's store implements episodic and semantic memory only; there is no procedural memory layer to write to, soremember(kind="procedural", ...)raises a clear error instead of silently doing nothing.reflect()is not exposed as a tool. It can call an external LLM to extract facts from episodes, which is out of scope for a zero-config memory server and would break the "no network calls at write time" guarantee every other tool here upholds. Run reflection out-of-band -engram reflect(CLI) ormem.reflect_async()(library) - against the same.engramfile.agent_idis an opaque string to the store, and a shape to the event log. Any string is accepted and used verbatim as the scoping key: Engram does not parse or resolve it, and never refuses a store over it. With--eventson, an id that is not anagent://<trust-domain>/<name>URI (the envelope schema's ownagent_idpattern) produces lines a validating consumer rejects, so it is warned about once per id and counted inEventLog.nonconforming_agent_id. The events are still written: a wire mismatch you can see beats an event log that silently stopped.
Add to your MCP client's config (e.g. claude_desktop_config.json):
{
"mcpServers": {
"engram": {
"command": "engram-mcp",
"args": ["--db", "/path/to/agent.engram"]
}
}
}from engram import Engram
from engram.adapters.langchain import EngramRetriever, EngramChatMessageHistory
mem = Engram(path="./agent.engram")
# Retriever - plug into any RAG chain
retriever = EngramRetriever(engram=mem, k=5)
docs = retriever.invoke("Ivan project")
# Chat history - persists conversation turns across sessions
history = EngramChatMessageHistory(engram=mem)
history.add_user_message("What did Ivan say about Globex?")
history.add_ai_message("Ivan mentioned he joined Globex last week.")from engram.adapters.llamaindex import EngramMemory
from llama_index.core.llms import ChatMessage, MessageRole
memory = EngramMemory.from_defaults(engram_path="./agent.engram", k=5)
memory.put(ChatMessage(role=MessageRole.USER, content="Hello!"))
# Semantic recall when a query is provided
msgs = memory.get("Ivan Globex")Engram
├── observe() / observe_many() → Episode (content + embedding + FTS stored immediately)
│ ↓
│ vec_episodes (sqlite-vec ANN index)
│ fts_episodes (FTS5 full-text index)
│ episodes (metadata, agent_id, importance_score)
│
├── recall() ─cosine──────────→ KNN search → SearchResult[]
│ ─hybrid───────────→ FTS5 BM25 + KNN → blended score → SearchResult[]
│ ─spreading────────→ KNN seeds → BFS activation graph → SearchResult[]
│ ─as_of────────────→ time-filtered KNN → SearchResult[]
│ ─cross_agent──────→ bypass agent_id scope
│
├── WorkingMemory → LRU scratchpad, capacity 7±2
│ eviction → observe() into long-term store
│
├── AsyncEngram → async def wrappers via run_in_executor
│
├── reflect() / reflect_async() → LLM fact extraction (async, background)
│ ↓
│ facts (bitemporal s/p/o triples)
│ entities (unique named entities)
│ edges (Hebbian-weighted graph)
│
├── compress() → LLM summarisation of low-importance episodes
│ originals hard-deleted → summary episode stored
│
├── timeline(entity) → facts WHERE subject=? ORDER BY valid_from
├── why(fact_id) → provenance: derived_from + extracted_by
├── contradictions() → active facts with same (subject, predicate), different object
├── forget() → hard-delete one episode (all structures)
├── forget_fact() → hard-delete one semantic fact
├── forget_entity() → GDPR: hard-delete all data about a named entity
├── backup(dest) → SQLite online backup API (safe while open)
├── export_json(dest) → portable JSON dump (episodes, facts, entities, edges)
├── import_json(src) → restore from JSON dump, merge mode available
└── list_agents() → distinct agent_ids in the store
-- Raw observations (one row per observed event, scoped by agent_id)
CREATE TABLE episodes (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
timestamp DATETIME,
actors JSON, -- ["Ivan", "Alice"]
tags JSON,
salience REAL,
emotional_valence REAL,
summary_of JSON, -- episode ids this row summarises (compress())
importance_score REAL,
agent_id TEXT DEFAULT NULL, -- NULL = unscoped / backward-compatible
provenance TEXT DEFAULT NULL, -- 'user' | 'tool' | 'external'; NULL reads as 'unknown'
quarantined INTEGER DEFAULT NULL -- 1 = written after reading untrusted content;
-- NULL (written before the mark) reads as 1, and
-- the row's provenance as 'unknown'
);
-- ANN vector index (sqlite-vec virtual table, mirrors episodes rowid)
CREATE VIRTUAL TABLE vec_episodes USING vec0(embedding float[384]);
-- Full-text search index (FTS5 content table, mirrors episodes rowid)
CREATE VIRTUAL TABLE fts_episodes USING fts5(content, content='episodes', content_rowid='rowid');
-- Bitemporal semantic facts (owned by their writer; shared unless written private)
CREATE TABLE facts (
id TEXT PRIMARY KEY,
subject TEXT, predicate TEXT, object TEXT,
valid_from DATETIME, -- when true in reality
valid_to DATETIME, -- NULL = still valid
recorded_at DATETIME, -- when system learned it
superseded_at DATETIME,
superseded_by TEXT, -- FK to facts.id
confidence REAL,
derived_from JSON, -- provenance: episode ids
extracted_by TEXT, -- FK to reflections.id
agent_id TEXT, -- writer; only it may delete; NULL = unscoped or old
shared INTEGER, -- 0 = visible to its writer only; NULL (old) reads as shared
quarantined INTEGER -- as on episodes; NULL (old) reads as 1
);
-- Entity graph (shared across all agents)
CREATE TABLE entities (id TEXT PRIMARY KEY, name TEXT, type TEXT, aliases JSON, ...);
CREATE TABLE edges (
src_id TEXT, dst_id TEXT, relation TEXT,
weight REAL, -- Hebbian-accumulated on co-access
PRIMARY KEY (src_id, dst_id, relation)
);
-- Retrieval history (scoped by agent_id)
CREATE TABLE access_log (
memory_id TEXT, accessed_at DATETIME, query TEXT, rank INTEGER,
agent_id TEXT DEFAULT NULL
);
-- Reflection audit log (scoped by agent_id)
CREATE TABLE reflections (
id TEXT PRIMARY KEY, started_at DATETIME, finished_at DATETIME,
episodes_processed INTEGER, facts_extracted INTEGER,
contradictions_resolved INTEGER, model_used TEXT, cost_tokens INTEGER,
agent_id TEXT DEFAULT NULL
);Single-file design: the .engram file is a standard SQLite database. Copy it, back it up with rsync or mem.backup(), or open it with any SQLite browser. No migration daemon, no schema registry, no lock files.
Zero-dependency writes: every observe() call hits only Python + SQLite. The ONNX runtime for embeddings is already in-process. No network, no external API call.
Backward compatibility: stores created before v1.3 (without agent_id) open without modification. The migration silently adds missing columns with DEFAULT NULL, preserving all existing data.
Measured on Apple M-series, fastembed BAAI/bge-small-en-v1.5, SQLite WAL mode.
| Operation | p50 | p99 | Notes |
|---|---|---|---|
observe() |
4.1 ms | 4.8 ms | Embedding dominates (~3.5 ms ONNX) |
observe_many() 100 eps |
2.0 ms/ep | - | Single ONNX pass + single transaction |
observe_many() 500 eps |
1.6 ms/ep | - | Batch efficiency increases with N |
engram-bench scale --sizes 1000,10000,100000. Search columns are p50/p95 in
milliseconds against a pre-computed query embedding, so they show the part
that grows with the store; recall() keeps the ~4 ms embedding in, because
that is what a caller waits for. RSS is a fresh process opening the finished
store and recalling from it.
| episodes | file | RSS | cosine | hybrid | as_of |
scoped | recall() |
|---|---|---|---|---|---|---|---|
| 1 000 | 2.4 MB | 342 MB | 0.18 / 0.18 | 0.45 / 0.53 | 0.87 / 1.03 | 0.05 / 0.05 | 4.4 / 4.9 |
| 10 000 | 19.8 MB | 346 MB | 3.03 / 3.25 | 3.59 / 3.68 | 10.0 / 10.5 | 0.05 / 0.07 | 7.2 / 7.7 |
| 100 000 | 193.6 MB | 353 MB | 31.8 / 32.5 | 35.8 / 36.9 | 107 / 107 | 0.09 / 0.11 | 35.7 / 36.6 |
Read it as four facts:
- Unscoped search is linear, ten times the episodes for ten times the cost, because the scan is exact. Around 100 000 episodes it stops being free at ~32 ms, still small next to any LLM call in the same loop, and at a million it would be the slowest thing an agent does.
- Scoped search does not grow at all.
agent_idis a vec0 partition key, so an agent holding five episodes pays for five however large the shared store gets: 350x faster than unscoped at 100 000 episodes. as_ofcosts about 3.4x cosine and hits the wall first, at ~107 ms per query at 100 000 episodes. Thetsmetadata filter is evaluated per row during the scan.- Memory is flat. Vectors live on disk and are read by SQLite, so the resident footprint is the ONNX runtime, not the store.
Spreading activation is absent because its edges come from reflect(), which
needs an LLM; on a store built by observe() alone the graph is empty and the
number would describe the seed KNN rather than the mode.
| Implementation | Latency |
|---|---|
| v1.x: N individual SQL round-trips | ~52 ms |
| v2.0+: batch GROUP BY + executemany | ~2.5 ms |
The batch rewrite eliminates 5 000 SQL calls and replaces them with 3.
| Journal mode | Latency per commit | Notes |
|---|---|---|
| DELETE (SQLite default) | ~0.31 ms | Exclusive lock + random-write sync |
| WAL (v2.0.1+) | ~0.07 ms | Sequential append, no exclusive lock |
WAL mode is enabled automatically for all file-based stores. Readers (recall, timeline) and writers (observe, reflect_async) now run concurrently without blocking each other.
Measured on the full 500 questions of
LongMemEval-S
(longmemeval_s_cleaned.json, ICLR 2025, MIT). Each question carries its own
history of 30 to 60 sessions; every turn is ingested as one episode, 246 738
in total, and the question is asked against that store. bge-small-en-v1.5,
no LLM anywhere in the loop.
| mode | session@5 | session@10 | turn@5 | turn@10 | ms/query |
|---|---|---|---|---|---|
hybrid (default, 0.5 / 0.5) |
0.970 | 0.982 | 0.830 | 0.894 | 10 |
cosine |
0.956 | 0.978 | 0.772 | 0.862 | 16 |
Both modes were scored against the same freshly built store for each
question, so the comparison is like for like. They disagreed on 67 of the 500
questions at turn@5 and on 19 at session@5 (counted from the committed sweep
records). Hybrid is the default because of the turn column: +5.8 points at
k=5 on finding the specific turn that holds the answer, which is what an
agent reads. Cosine is ahead on five of the per-type cuts: multi-session at
session@10 (0.992 against 0.985), multi-session at turn@5 and turn@10 (107
against 106, and 117 against 116, of 133), and single-session-preference at
session@10 and turn@5 (0.933 against 0.900, and 24 against 22, of 30).
The dataset marks both the sessions holding the evidence and the individual turns, so there are two honest numbers and they are not interchangeable:
- session recall counts a hit when any retrieved episode came from a
session listed in
answer_session_ids. A session can run a dozen turns, so this says the right conversation was found. - turn recall counts a hit only when a retrieved episode is one of the
896 turns flagged
has_answerout of 246 738. This says the answer itself was put in front of the agent, and it is 14 points lower at k=5.
A memory system quoting one unqualified "R@k" is almost certainly quoting the first. Ask which.
Session recall at k=5 by question type, hybrid against cosine, counted from the
committed sweep records (benchmarks/results/longmemeval_s_2026-07-30_weight_sweep.jsonl):
single-session-assistant 1.000 / 1.000, knowledge-update 1.000 / 0.974,
single-session-user 1.000 / 0.957, multi-session 0.970 / 0.970,
temporal-reasoning 0.940 / 0.925, single-session-preference 0.900 / 0.900.
Three readings of turn recall, and why the headline one is the loosest.
The dataset flags 896 turns across 500 questions, but not evenly: 182 questions
flag exactly one, 297 flag between two and six, and 21 flag none at all.
These counts are not in the committed records. They come from the dataset's
has_answer flags, counted by evidence_count() in
engram/benchmarks/longmemeval.py, and the dataset is not vendored, so they
cannot be recomputed from this repository. That matters twice over.
- The 21 unflagged questions can never produce a turn hit, so they lower the figure for a reason that has nothing to do with retrieval. Over the 479 questions that flag at least one turn, turn recall at k=5 is 0.866 rather than 0.830, and at k=10 it is 0.933 rather than 0.894.
- For the 297 questions needing more than one turn, "at least one retrieved" counts a hit while the agent was handed half of what it needed. The published figure is therefore an upper bound on the probability that everything required was in front of the model. Reasoning from the per-question records: for the 182 single-evidence questions the two readings coincide at 0.830, and across the whole answerable set the strict reading lies between 0.57 and 0.87 at k=5, the spread depending on how strongly the retrieval of one evidence turn predicts the retrieval of its siblings. It is not measured, so it is not claimed.
Runs from v2.4.1 onward record the rank of every evidence hit, which makes the
strict reading and any smaller k recomputable from the checkpoint without
repeating the pass. engram-bench longmemeval prints all three readings when
the checkpoint carries them.
0.7 / 0.3 shipped as the default without anyone measuring it. One run scores
every configuration, because ingesting a question's history costs ~40 seconds
and querying it costs ~10 ms, so eight variants add under a minute to a
six-hour pass. All 500 questions, all against the same store per question:
| blend (cosine / bm25) | session@5 | turn@5 | session@10 | turn@10 |
|---|---|---|---|---|
cosine mode |
0.956 | 0.772 | 0.978 | 0.862 |
| 0.0 / 1.0 | 0.938 | 0.750 | 0.960 | 0.814 |
| 0.1 / 0.9 | 0.938 | 0.764 | 0.966 | 0.822 |
| 0.3 / 0.7 | 0.954 | 0.786 | 0.970 | 0.860 |
| 0.5 / 0.5 (default) | 0.970 | 0.830 | 0.982 | 0.894 |
| 0.7 / 0.3 (previous) | 0.968 | 0.820 | 0.982 | 0.892 |
| 0.9 / 0.1 | 0.956 | 0.790 | 0.976 | 0.870 |
| 1.0 / 0.0 | 0.956 | 0.772 | 0.978 | 0.862 |
Read the margin honestly. 0.5 beats the old 0.7 by 1.0 point of turn@5, which is five questions out of five hundred. It is the default because it leads on three of the four metrics, ties on session@10, and costs nothing to adopt, not because the gap is large. The two sit on the same plateau.
The shape is the real result. Both ends are clearly worse than the middle:
BM25 alone is the weakest configuration measured (0.750 turn@5) and pure vector
is 0.772, against 0.830 in the middle. The blend adds signal rather than
diluting the stronger side, which is the claim mode="hybrid" rests on.
Hybrid at 1.0 / 0.0 reproduced mode="cosine" to three decimals on all four
metrics. The two paths differ in code (candidate pool, min-max normalisation)
and not measurably on this dataset.
Reproduce it (the dataset is 265 MB and is not vendored):
engram-bench longmemeval --data ./longmemeval_s_cleaned.json --k 5,10 \
--checkpoint ./lme.jsonl --resumeIngestion is the whole cost: 5.6 hours on eight dedicated cores, against 15 ms
per query. --checkpoint writes one record per question so a run that stops
can be resumed and scored from where it got to. The records behind the table
above are in benchmarks/results/.
The first run of this benchmark scored hybrid identically to cosine to three
decimals on all 500 questions, which is what exposed a bug: the BM25 query
joined its terms with an implicit AND, so it matched nothing for any question
longer than a few words and the blend reduced to cosine. The table above is
from a second full run against the fixed code. Records from both are in
benchmarks/results/; the earlier file is kept because it is the evidence
that produced the fix.
Cosine scored identically in both runs, to three decimals on all four figures, which is the reproducibility check on a six-hour job.
engram-bench locomo also scores hit@k and MRR over any file in LoCoMo's
format. The fixture bundled with it is synthetic, five hand-written sessions
whose keywords were chosen next to the text they match, so it is a smoke test
for the retrieval path and its scores are not quoted anywhere.
| Model | $/1k episodes |
|---|---|
| gpt-4o-mini | $0.0033 |
| claude-haiku-4.5 | $0.0056 |
| gpt-4o | $0.0542 |
| claude-sonnet-4.6 | $0.0677 |
Reflection is optional and async - you only pay when you need semantic fact extraction.
# Both spellings work
python -m engram.benchmarks all
engram-bench scale --sizes 1000,10000,100000
engram-bench latency --n 500
engram-bench locomo --data ./my_data.json
engram-bench cost --n 1000 --model gpt-4o-minifrom engram import DecayConfig
cfg = DecayConfig(
lambda_=0.1, # Ebbinghaus decay rate. Higher → faster forgetting.
# 0.1 ≈ half-life ~7 days without reinforcement.
alpha=0.2, # Reinforcement weight per recall access.
beta=0.1, # Emotional valence weight.
threshold=0.1, # Prune memories below this importance during reflect().
)
mem = Engram(path="./agent.engram", decay_config=cfg)