Long-term memory for AI agents that's organized the way work actually is. Facts, episodes, workstreams, open loops — captured crash-safe, retrieved hybrid, injected once per turn. One SQLite file. No vector database, no graph service, no cloud.
$ episodic recall "what did sarah say about the deck"
[correction] The Series B deck has 14 slides, not 12 (previously: 12 slides) (high, 2d ago)
[decision] Sarah wants the pipeline chart moved to slide 3 (high, 2d ago)
$ episodic loops
▸ Series B deck (confirmed, 9 facts)
· sensitivity table still pending sign-off
· send updated deck to Sarah before Thursday
This is the episodic-memory layer of Apprentice, a macOS AI apprentice, extracted as a standalone package. It shipped to real users first; the code here is the shipped code — including the self-audit that reshaped it, and the bug that audit caught.
Agents forget. Everything between sessions — decisions, corrections, who Sarah is, what you were mid-way through — evaporates with the context window.
The standard fix is a fact store: extract facts, embed them, retrieve top-K. We built that (it works, details below) and learned the harder lesson: a fact store recalls; it doesn't remember. Months in, ours held 826 facts across 371 entities and using it still felt like talking to a filing cabinet. An audit of the live store said why:
- 34% of facts had no entity link — and the rate grows with corpus size, because abstract preferences and process decisions genuinely have no proper-noun subject. The entity index wasn't broken. It was the wrong index.
- The entity "graph" was a star-forest: one hub, a long tail of singletons, zero entity-to-entity edges.
- The confidence signal was dead: 92.5% of facts scored "high."
Memory systems organize by subject. Humans organize work by activity — the thread, not the noun. The question that opens a session isn't "who is X?"; it's "where were we?"
turns ──▶ session buffer (crash-safe JSONL, mid-session + end-of-session consolidation)
│
▼ extract (LLM) → salience route → novelty gate → dedup referee (LLM)
┌─────────────┐
│ facts │ 9 types · supersession chain ("14 slides (previously: 12)")
│ episodes │ 1-3 sentence session summaries
│ workstreams │ activity threads: embed → attach / judge / spawn
│ open loops │ strict unfinished-business detection
│ profiles │ per-entity synthesized summaries
└─────────────┘
│
▼ BM25 + dense → RRF(k=60) → recency → cross-encoder rerank
[Recalled context] per turn · [Ongoing work] once per session
Capture is durable first. Every turn appends to an on-disk journal before anything intelligent happens. Consolidation runs every 8 turns, at session end, and a startup sweep recovers buffers from crashed sessions. (Both of the capture bugs we shipped — an archive path that never consolidated, and streaming replies that were never buffered — failed silently. Journal first; intelligence later.)
The gate is cheap and honest. A salience floor, then compression-novelty: gzip the candidate against a sample of what's stored — if it barely compresses, it's a restatement. High-value types (decisions, corrections) get supersede bonuses. No LLM in the gate.
An LLM referees only the ambiguous band. Cosine can't tell a restatement from a contradiction ("the deck has 12 slides" / "the deck has 14 slides" embed nearly identically). Inside the ambiguous band a small model rules add / update / skip. Contradictions supersede; history stays walkable.
Salience routes, never deletes. Facts land durable, episodic (demoted into their episode's detail — kept, just not top-shelf), or dropped — and drop is reserved for pure filler. Calibrated on a real store: 87% durable / 9% demoted / 4% dropped.
Workstreams are the missing middle. Each episode embeds and lands near its neighbors: ≥0.55 attaches (nudging the centroid), 0.38–0.55 goes to an LLM judge, below spawns a new workstream. A workstream is a hypothesis until a second session attaches — one conversation is a topic; two is a thread. Open-loop detection is precision-biased: most sessions have 0–2, and an empty list is a correct answer.
Retrieval is hybrid and adaptive. BM25 (FTS5) and dense cosine fused with reciprocal-rank fusion, recency blended as an independent tie-breaker, a cross-encoder reranking the final pool. A regex query classifier tilts BM25-vs-dense weights per query type (entity → lexical, preference → semantic) in microseconds.
Injection is disciplined. Recalled facts prepend to the user message —
never the system prompt, so prompt caching survives. An injector dedups
already-injected facts (context grows with distinct facts, not turns), and
[Ongoing work] — your top confirmed workstreams and their open loops —
appears once per session. That block is the felt difference between a
database and a colleague.
pip install "episodic-memory[anthropic] @ git+https://github.com/Reppin123/episodic-memory.git"
export ANTHROPIC_API_KEY=sk-ant-...
episodic ingest transcript.jsonl # {"role": "user"|"assistant", "content": "..."} per line
episodic recall "what's the state of the fund model"
episodic stats
episodic loopsModels download once on first use — the embedder (all-MiniLM-L6-v2 ONNX, ~90MB) and the reranker (ms-marco-MiniLM-L-6-v2 int8, ~22MB). After that, retrieval runs fully offline. No PyTorch anywhere.
import asyncio
from episodic_memory import get_store, retrieve, EpisodicInjector
from episodic_memory.session_buffer import SessionBuffer
from episodic_memory.pipeline import consolidate_buffer
buf = SessionBuffer("session-1")
buf.append_turn("user", "The Series B deck needs 14 slides, Sarah confirmed.")
buf.append_turn("assistant", "Noted — updating the outline to 14.")
asyncio.run(consolidate_buffer(buf)) # extract → gate → store → L2
facts = retrieve("how many slides in the deck", get_store(), k=5)
injector = EpisodicInjector() # one per agent session
ctx = injector.get_context("where were we on the deck?")
prompt = f"{ctx}\n\n{user_message}" if ctx else user_messageExtraction, dedup arbitration, episode summaries, workstream judging, and
entity profiles each make one small LLM call (default:
claude-haiku-4-5-20251001 via the anthropic extra). Bring any model:
from episodic_memory import llm
class MyBackend:
async def ask(self, system_prompt, user_prompt, *, model=None) -> str:
return await my_llm(system_prompt, user_prompt)
llm.set_backend(MyBackend())Every consumer degrades gracefully when the backend returns nothing: no extraction, dedup falls back to pure-cosine gating, profiles skip, ambiguous episodes spawn rather than misfile. Retrieval never needs an LLM at all.
Duplicate threshold 0.88 · supersede band 0.72 · gzip-novelty floor 0.12 · RRF k=60 · recency half-life 30d at weight 0.15 · dense floor 0.20 · rerank pool 30 · workstream attach 0.55, judge band 0.38–0.55 · salience durable floor 0.55, recurrence +0.10 per repeat capped +0.40 · consolidation every 8 turns · max 5 injected facts per turn.
None are sacred. All were calibrated against one real store (826 facts / 371 entities at audit time), disclosed as such — there is no public benchmark run for this layer yet.
- The recency-blend bug. As shipped, the recency term was multiplied by the RRF ratio — so recency could never lift a fresh-but-low-fusion fact, the opposite of its documented "independent tie-breaker" role. Fixed here; the audit trail is in the comments.
delete_allprivacy gap. "Forget everything" originally wiped facts but left workstreams standing. Deletion now wipes every layer.
Known limitations (documented, not hidden)
- Entity graph has no entity↔entity edges (it's fact↔entity only).
- The injector's dedup set grows monotonically per session — a topic recurring 30 turns later won't re-surface in the same session.
- Proper-noun query detection is capitalization-based; lowercase voice transcripts lose the entity-query BM25 boost.
sqlite-vecANN (EPISODIC_MEMORY_SQLITE_VEC=1) rebuilds its index on open: single-process only. Validated 12–14× faster than brute force at 2k facts with exact parity — default stays off until multi-process safe.- The ingest lock uses
fcntl— it degrades to a no-op on Windows. - Salience priors are hand-set, not learned.
- No memory-as-a-service. One SQLite file you own, with an optional
git-tracked markdown mirror (
vault/) so your memory is diffable and greppable by anything. - No agent-managed memory tools. Unlike MemGPT-style self-editing memory, this is a passive layer: written at consolidation, read at injection. The agent doesn't spend turns managing its own memory.
- No knowledge-graph database. Relationships stay lightweight on purpose.
37 offline tests (pytest tests/) — salience routing, L2 store CRUD and
migration, and the full ingestion pipeline with every LLM boundary stubbed
(the real ONNX embedder runs). examples/demo_e2e.py is the live two-session
demo: recall plus a contradiction that supersedes with walkable history.
Inspired by TrueMemory's architecture — encoding gate, hybrid retrieval, cross-encoder, entity engrams (ideas only; it's AGPL, this is a clean-room MIT implementation). Optional ANN via sqlite-vec (MIT). Extracted from Apprentice. Companion project: thought-search.
MIT.