Lethe
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.
Even at a smaller index than where Lethe naturally settles, a fixed-capacity FIFO baseline (evict oldest when full) forgets 2.5x as many durable facts as Lethe does — 53% vs 21% false-forget rate — while Lethe keeps retrieval recall within about 1.4% of an unbounded "store everything" baseline. Full numbers and methodology in benchmark/RESULTS.md.
Quickstart
git clone https://github.com/Fqih/lethe.git
cd lethe
pip install -e ".[benchmark]"
python examples/phase1_quickstart.py
from 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.
The problem
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.
How it works
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.
Benchmark: 30 simulated days, three policies
Averaged over 3 random seeds (full numbers here):
| Metric | Lethe | Naive (store everything) | FIFO (cap 100) |
|---|---|---|---|
| Final store size | 143 ± 2 | 184 ± 1 | 100 (cap) |
| Held-out recall @ 1 | 0.789 ± 0.016 | 0.800 ± 0.000 | 0.467 ± 0.072 |
| Held-out recall @ 5 | 0.932 ± 0.024 | 0.907 ± 0.029 | 0.708 ± 0.052 |
| False-forget rate | 0.211 ± 0.016 | 0.200 ± 0.000 | 0.533 ± 0.072 |
| Mean retrieval latency | 1.88 ± 0.08 ms | 2.17 ± 0.06 ms | 1.65 ± 0.01 ms |
The comparison worth paying attention to is Lethe against FIFO. The FIFO cap here is set deliberately below where Lethe settles on its own (100 vs 143) — otherwise FIFO never evicts anything and the comparison is meaningless. Even at that smaller size, FIFO loses more than twice as many durable facts as Lethe does. "Drop the oldest thing" sounds like a reasonable policy until you realize age has nothing to do with importance.
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 the three approaches is the part I'd stand behind; treat the absolute numbers as directional.
Run it yourself:
python benchmark/run_benchmark.py --seeds 3 --fifo-max 100
Architecture
capture → 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.
A longer demo
For a day-by-day walkthrough of the 30-day session — watching memory grow, decay, and get pruned as it happens:
python examples/long_running_agent_demo.py
Pauses at a few key days so there's time to read what happened. Add
--no-pause to run it straight through.
Using it with LangGraph
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.
Tests
pip install -e ".[dev,benchmark]"
pytest
Covers 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.
What this isn't
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.
Status
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.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lethe_agent-0.1.0.tar.gz.
File metadata
- Download URL: lethe_agent-0.1.0.tar.gz
- Upload date:
- Size: 51.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
75807fd38291aa81918772bcf690d0ee5797412fc1750ec16b128cbcbf7faef8
|
|
| MD5 |
f7963d63fe323f7b402163a9825cde85
|
|
| BLAKE2b-256 |
d170a529e12250230dd6095467b615388aef861dfc124826895b395a1403dfb7
|
File details
Details for the file lethe_agent-0.1.0-py3-none-any.whl.
File metadata
- Download URL: lethe_agent-0.1.0-py3-none-any.whl
- Upload date:
- Size: 31.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b8e8a1cee5b6251ddcad04431b59b6308321efc4dc36ecc4a8b8e046e6f74375
|
|
| MD5 |
9ae8ae92dce2b5b151f87a33f6d5f72b
|
|
| BLAKE2b-256 |
97ea65eadb575d3d138c8f558b231fa06c51878ee71791fe3b6a4e0b0d508c7b
|