Most agent memory systems keep every interaction forever, and the index just bloats with noise over time. Lethe scores memories by importance, decays the ones that don't get used, reinforces the ones that do, and prunes the rest. The index stays small and retrieval stays fast even after weeks of continuous use.
Over a year-long session with 5 random seeds and 5 workloads (including a bursty workload that pushes capacity-based policies into real overflow pressure), a fixed-capacity FIFO baseline (evict oldest when full) forgets 2.5x as many durable facts as Lethe at half the index size: 0.507 vs 0.207 false-forget rate. Lethe keeps retrieval recall within 0.7% of an unbounded "store everything" baseline. Full numbers and methodology in benchmark/RESULTS.md.
Install Lethe directly from PyPI:
pip install lethe-agentfrom lethe import MemoryStore, DecayConfig
store = MemoryStore(
backend="sqlite", # durable across restarts
decay_config=DecayConfig(), # every tunable lives here
)
store.remember("client's fiscal year ends in March", session_id="s1", tags=["fact"])
results = store.recall("when does fiscal year end?", k=5)
for item in results:
print(item.content, "score:", round(item.importance_score, 3))No external API calls, no model downloads: this runs out of the box with a
bundled hash-based fake embedder. Swap in a real embedding model by passing
anything with an embed(text) -> list[float] method.
For a real sentence-transformers-backed embedder, install the optional extra and plug it in:
pip install "lethe-agent[sentence-transformers]"from lethe import MemoryStore, DecayConfig
from lethe.integrations.sentence_transformers_embedder import SentenceTransformersEmbedder
embedder = SentenceTransformersEmbedder(model_name="all-MiniLM-L6-v2")
store = MemoryStore(decay_config=DecayConfig(), embedder=embedder)The bundled HashFakeEmbedder caps paraphrase similarity around 0.4-0.7
cosine; all-MiniLM-L6-v2 puts true paraphrases above 0.7. The benchmark
runs entirely against the fake embedder for determinism (no model
downloads), but real workloads benefit substantially from a real model.
Vector-store-backed agent memory today mostly follows one pattern: embed everything, dump it in a vector store, retrieve top-k by similarity. Run it for weeks instead of minutes and two things go wrong. The index grows forever, so old and superseded facts start competing with current ones during search. And there's no notion of importance: "it's raining today" and "the client's fiscal year ends in March" get stored identically, with no way to tell them apart later.
Lethe treats forgetting as a feature, not a missing one.
Every memory gets an initial score on capture, based on recency, source type, and any explicit feedback. Retrieving a memory reinforces it: bumps the score, increments the access count, refreshes last-accessed time. Left alone, scores decay on an exponential half-life. A daily consolidation pass promotes short-term memories that earned their keep into long-term storage, demotes long-term memories that didn't, merges near-duplicates, and prunes anything that's decayed past the cold-storage grace period.
Every deletion gets written to an append-only Forget Log: the score, age, and last-access time it had at the moment it was removed. Silent data loss is the thing this is meant to avoid; forgetting should be something you can inspect after the fact, not something that just happens.
All the tunable constants: half-life, thresholds, weights, grace period,
live in one DecayConfig object. Nothing is hardcoded elsewhere.
Averaged over 5 random seeds, default workload (the same one used in the 0.1.1 release — comparison numbers below are means ± std across seeds):
| Metric | Naive | FIFO (100) | LRU (100) | LFU (100) | TTL (7 d) | LIFO (100) | Lethe |
|---|---|---|---|---|---|---|---|
| Final store size | 1677 ± 13 | 100 | 100 | 100 | 32 ± 1 | 100 | 1068 ± 18 |
| Held-out recall @ 1 | 0.800 | 0.493 ± 0.071 | 0.587 ± 0.026 | 0.793 ± 0.013 | 0.247 ± 0.050 | 0.800 | 0.793 ± 0.013 |
| Held-out recall @ 5 | 0.841 ± 0.008 | 0.577 ± 0.029 | 0.667 ± 0.029 | 0.882 ± 0.022 | 0.258 ± 0.011 | 0.926 ± 0.015 | 0.962 ± 0.004 |
| False-forget rate | 0.200 | 0.507 ± 0.071 | 0.413 ± 0.026 | 0.207 ± 0.013 | 0.753 ± 0.050 | 0.200 | 0.207 ± 0.013 |
| Mean latency (ms) | 24.96 ± 1.92 | 2.18 ± 0.04 | 2.18 ± 0.05 | 2.16 ± 0.09 | 0.77 ± 0.02 | 2.17 ± 0.07 | 16.57 ± 1.40 |
Index-size growth from 30 days to 365 days: Naive 184 → 1677 (~9x), Lethe 143 → 1068 (~7x). Naive scales linearly with workload volume; Lethe grows more slowly because the consolidation pipeline demotes low-signal items out of the hot index.
Each policy's marker position reflects the combined efficiency/quality trade-off: lower-left is better (smaller index, fewer forgotten facts).
Reading the chart:
- Lower-left is better. TTL is the worst (high false-forget despite smallest index). FIFO is the worst bounded policy on this workload.
- Lethe is the only policy that hits the sweet spot at scale: matches Naive on recall (R@1 0.793 vs 0.800) with 36% smaller index and 0.207 false-forget (within 0.7% of Naive's 0.200).
- LIFO and Naive happen to coincide on false-forget in this workload because the durable facts were captured first (see "Which policy should I use?" below for the caveat).
Higher is better. Naive and LIFO are tied for the top; TTL is the worst.
Policy Recall@1 Bar (each █ = 0.05 R@1)
─────────────────────────────────────────────────────────────────────────────
Naive 0.800 ████████████████
LIFO 0.800 ████████████████
Lethe 0.793 ███████████████▉
LFU 0.793 ███████████████▉
LRU 0.587 ███████████▊
FIFO 0.493 █████████▊
TTL 0.247 ████▉
Lower is better. Naive and LIFO tie for the lowest; TTL is by far the worst.
Policy False-forget Bar (each █ = 0.05 false-forget)
─────────────────────────────────────────────────────────────────────────────
TTL 0.753 ███████████████▎
FIFO 0.507 ██████████
LRU 0.413 ████████▎
LFU 0.207 ████▏
Lethe 0.207 ████▏
LIFO 0.200 ████
Naive 0.200 ████
The benchmark also ran four labeled sub-scenarios. The headlines:
- Durable-fact-heavy (every fact queried every day): LRU/LFU/LIFO all match Naive exactly (R@1 0.799, false-forget 0.200). Lethe is 49% smaller than Naive (865 vs 1693) and matches everyone on R@1 with the highest R@5 (0.963).
- Recency-biased (queries focus on recently captured items): LIFO still ties Naive (0.800 R@1) because durable facts are old; LRU drops to 0.621 — its signal is noisy when access recency doesn't correlate with importance. Lethe takes a small hit on R@1 (0.753) but jumps to 0.911 R@5.
- High-frequency-skew (one fact queried 10×/day): The hot fact dominates retrieval — LFU/Lethe/LIFO all reach 0.96+ R@1. Some seeds have a degenerate tie at top-1 (multiple facts with near-equal cosine to the hot query), pushing R@1 std to ~0.38; R@5 is stable at 0.71-0.99.
- Bursty (10% of days are burst days with 50 captures + 10 questions): the new stress workload. Naive's index swells to 3272 items (largest of any workload). FIFO's bounded 100-item cap collapses under the burst pressure (0.467 R@1, 0.533 false-forget); Lethe absorbs the burst intact (0.800 R@1, 0.200 false-forget at index 2214).
Full tables with ± std for every cell are in
benchmark/RESULTS.md.
One caveat worth stating plainly: these numbers come from a synthetic benchmark using a lightweight hash-based embedder for deterministic testing, not a production embedding model. The relative ordering between policies is what I'd stand behind; treat the absolute numbers as directional. Sample size is n=5 seeds per (policy, workload) — see RESULTS.md for the full stability report and the sub-scenario metrics that exceed CV=20%.
Run it yourself:
python benchmark/run_benchmark.py --days 365 --seeds 5capture → score → [reinforce | decay] → consolidate → retrieve → forget
↑ │
└────────── reinforcement ──────────────┘
MemoryStore orchestrates everything: the backend, the decay config, the
embedder, the clock, and the Forget Log. DecayConfig is the single source
of truth for tunable behavior. StorageBackend is a small interface with two
implementations: InMemoryBackend for speed and tests, SQLiteBackend for
anything that needs to survive a restart. Embedder is a protocol with one
default (HashFakeEmbedder, dependency-free and deterministic): plug in a
real embedding model the same way. ForgetLog follows the same in-memory /
SQLite pattern.
Full design rationale, the decay math, and the lifecycle rules are in DESIGN.md.
For a day-by-day walkthrough of a 30-day session, watching memory grow, decay, and get pruned as it happens:
python examples/long_running_agent_demo.pyPauses at a few key days so there's time to read what happened. Add
--no-pause to run it straight through.
lethe.integrations.langgraph_adapter.LetheMemoryNode wraps a MemoryStore
as plain callables shaped for LangGraph nodes:
from lethe import MemoryStore, DecayConfig
from lethe.integrations.langgraph_adapter import LetheMemoryNode
store = MemoryStore(decay_config=DecayConfig())
memory = LetheMemoryNode(store, k=5)
# graph.add_node("recall", memory.recall)
# graph.add_node("remember", memory.remember)LangGraph isn't a dependency of the core library: this is just a reference integration. Ignore it if you're not using LangGraph.
Clone the repository to run the complete test and benchmark suites:
git clone https://github.com/Fqih/lethe.git
cd lethe
pip install -e ".[dev,benchmark]"
pytestCovers capture, scoring, decay math, retrieval reinforcement, consolidation (promotion, demotion, dedup), the Forget Log's zero-gaps invariant, parity between the in-memory and SQLite backends, and the LangGraph adapter.
There's no real LLM call anywhere in the core library: embedding goes
through whatever Embedder you pass in, and the demo/benchmark default to
the bundled fake so everything runs offline. The default backends (a dict,
SQLite) are fine for thousands of items; past that you'd want a real vector
index like FAISS or Chroma, wrapped as a StorageBackend. And it's a
library, not a service: there's no GUI here.
Early and still rough in places. Built to explore what selective memory could look like for long-running agents, not a hardened production library yet. Issues and PRs welcome. See CHANGELOG.md for what changed between releases.
Lethe ships with seven retention policies. The benchmark (benchmark/RESULTS.md) ran all seven across five workloads (default, durable_heavy, recency_biased, high_freq_skew, bursty) over 5 seeds at 365 simulated days. Every recommendation below is backed by a number in the tables there.
What it does: items get an initial importance score on capture, decay over time on an exponential half-life, get reinforced when retrieved, and the daily consolidation pass promotes durable items to long-term, demotes decayed items to cold, merges near-duplicates, and prunes cold items past their grace period.
Recommended for: any general-purpose agent. This is the default because the benchmark shows it ties Naive on recall while staying ~36% smaller (aggregate workload: 0.793 R@1 vs Naive's 0.800, 1068-item index vs 1677).
Evidence (RESULTS.md aggregate table): Lethe 0.793 ± 0.013 R@1, 0.207 ± 0.013 false-forget, 16.57 ± 1.40 ms latency.
What it does: evicts the oldest-captured item first.
Recommended against for general use. The benchmark makes the case clearly: at a 100-item cap, FIFO forgets 51% of durable facts while Lethe forgets 21% — a 2.5× gap in favor of the larger index. On the bursty workload (where every policy sees ~36 burst days in 365 days), FIFO collapses to 0.467 R@1 and 0.533 false-forget. Even on the durable_heavy workload where every fact is queried constantly, FIFO still loses 55% of facts to eviction.
Where it might be OK: ephemeral caches where age genuinely correlates with irrelevance and you don't have access signals.
Evidence: 0.493 ± 0.071 R@1 (aggregate), 0.447 ± 0.072 R@1 (durable_heavy), 0.507 ± 0.071 false-forget (aggregate).
What it does: evicts the least-recently-accessed item first.
Recommended for: read-heavy caches where hot items are queried repeatedly (recent accesses matter more than creation order). Outperforms FIFO on every workload because it uses a real signal the workload produces.
Caveat: under recency-biased workloads where everything gets touched occasionally, the LRU signal is noisy. R@1 drops from 0.587 (aggregate) to 0.621 (recency_biased) — but on the bursty workload the warm-up behavior keeps LRU close to its aggregate R@1 (0.595).
Evidence: 0.587 ± 0.026 R@1 (aggregate), 0.621 ± 0.022 R@1 (recency_biased), 0.413 ± 0.026 false-forget (aggregate).
What it does: evicts the least-frequently-accessed item first.
Recommended for: workloads with a stable set of "hot" items that get queried over and over (e.g., a knowledge base for a specific domain). LFU matches Naive on R@5 under high_freq_skew (0.982 vs Naive's 0.708) — frequency tracking is the right signal when a few items dominate the access pattern.
Caveat: ties broken by age, so cold items in the long tail of the access distribution still get evicted. Worst-case false-forget of the bounded policies that don't filter on time.
Evidence: 0.793 ± 0.013 R@1 (aggregate), 0.800 R@1 (high_freq_skew — R@5 0.982), 0.207 ± 0.013 false-forget (aggregate).
What it does: hard expiry after max_age_seconds, regardless of
access count or importance.
Recommended for: compliance-sensitive deployments where memory must be gone after a fixed time no matter how important it scored (e.g., GDPR-style "right to be forgotten" with a hard deadline). The benchmark shows the cost; the use case is explicitly time-bounded.
Not recommended for: any workload with durable facts older than the TTL window. The benchmark uses a 7-day TTL on a 365-day simulation; every fact older than a week gets dropped, and TTL ends up with the worst false-forget rate (0.753 aggregate). On the bursty workload TTL's index size jumps from 30 to 78-126 because burst days dump more items than 7 days can absorb.
Evidence: 0.247 ± 0.050 R@1 (aggregate), 0.160 ± 0.039 R@1 (durable_heavy), 0.753 ± 0.050 false-forget (aggregate).
What it does: evicts the newest-captured item first.
Documented as the outlier/niche case, not a general recommendation. The benchmark includes it because it's the natural counterpart to FIFO, but LIFO's behavior is path-dependent on capture order: it matches Naive on the default workload because durable facts were captured first (and LIFO preserves older items), but it would do the opposite on a workload where new durable facts arrive late. Don't reach for it unless you've verified your workload's capture order.
Evidence: 0.800 R@1 (aggregate — looks great, by accident of the benchmark's capture order); 0.200 false-forget matches Naive across every workload. The recency_biased workload shows it can match Naive too. The benchmark can't construct a workload that disadvantages LIFO without artificially reordering captures, so the apparent win is real but circumstantial.
What it does: no retention policy. Stores every interaction forever.
Not a real choice for production — included as the unbounded baseline. The benchmark shows the cost of "keep everything": a 1677-item index at 365 days (vs Lethe's 1068) and 25ms mean retrieval latency (vs Lethe's 17ms). The 36% index difference comes from Lethe's consolidation pipeline demoting low-signal items.
Not a policy — a wrapper around any of the above. Pin specific items (by id, by tag, or any other predicate) so they survive eviction regardless of what the base policy decides. Useful for "this user's stated identity must never be evicted" or "these configuration values are permanent."
Evidence: integration test in
tests/test_store_retention.py::test_pinned_overlay_protects_pinned_items_through_consolidation
— a pinned item survives multiple consolidation passes even
under tight capacity pressure.
| Workload | First choice | Avoid |
|---|---|---|
| General-purpose agent | Lethe | TTL |
| Read-heavy knowledge base | LFU | FIFO |
| Compliance with a hard deadline | TTL | (any other) |
| Hot items dominate the access pattern | LFU | LIFO, FIFO |
| Long-tail access with frequent warm-up | LRU | TTL |
| Bursty / spiky workload (real-world agents) | Lethe | FIFO, LRU |
| Items must never be evicted, full stop | PinnedOverlay(any) | — |
The defaults of MemoryStore(decay_config=DecayConfig()) give you
Lethe. If you know your workload, swap in a different
retention_policy= and (for capacity-based policies) a
capacity=. The benchmark script in benchmark/run_benchmark.py
runs all of them against your scenario in one shot — copy it as a
starting point for tuning.
MIT: see LICENSE.

