Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

episodic-memory

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.

The problem

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

The shape

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.

Install

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 loops

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

As a library

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_message

The LLM seam

Extraction, 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.

The numbers that run it

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.

Fixed in the open (from our own audit)

  • 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_all privacy 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-vec ANN (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.

What it deliberately doesn't do

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

Tests

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.

Credits

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.

About

Long-term memory for AI agents, organized the way work actually is: facts, episodes, workstreams, open loops. One SQLite file, local-first, no vector DB.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages