Metadata-Version: 2.4
Name: lethe-agent
Version: 0.1.1
Summary: Selective memory layer for AI agents: importance-scored, self-decaying, pluggable.
Author: Muhammad Faqih Hakim
License: MIT
Project-URL: Homepage, https://github.com/Fqih/lethe
Project-URL: Repository, https://github.com/Fqih/lethe
Project-URL: Issues, https://github.com/Fqih/lethe/issues
Keywords: memory,agents,ai,decay,rag
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Provides-Extra: embeddings
Requires-Dist: sentence-transformers>=2.2; extra == "embeddings"
Provides-Extra: benchmark
Requires-Dist: matplotlib>=3.7; extra == "benchmark"
Provides-Extra: langgraph
Dynamic: license-file

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

![Benchmark chart: store size and recall over a 30-day simulated agent session](benchmark/comparison_chart.png)

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](benchmark/RESULTS.md).

## Quickstart

Install Lethe directly from PyPI:

```bash
pip install lethe-agent
```

```python
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.

For a real sentence-transformers-backed embedder, install the optional extra
and plug it in:

```bash
pip install "lethe-agent[sentence-transformers]"
```

```python
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.

## 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, all six retention policies

Averaged over 3 random seeds, default workload
([full numbers here](benchmark/RESULTS.md)):

### Aggregate table — all policies side by side

| Metric                 | Naive          | FIFO (100)    | LRU (100)     | LFU (100)     | TTL (7 d)      | LIFO (100)    | **Lethe**      |
| ---------------------- | -------------- | ------------- | ------------- | ------------- | -------------- | ------------- | -------------- |
| Final store size       | 184 ± 1        | 100           | 100           | 100           | 33 ± 1         | 100           | **143 ± 2**    |
| Held-out recall @ 1    | **0.800**      | 0.467 ± 0.072 | 0.711 ± 0.047 | 0.778 ± 0.016 | 0.189 ± 0.016  | **0.800**     | 0.789 ± 0.016  |
| Held-out recall @ 5    | 0.900          | 0.544 ± 0.096 | 0.800 ± 0.027 | 0.878 ± 0.016 | 0.200 ± 0.027  | 0.922 ± 0.016 | **0.956 ± 0.016** |
| False-forget rate      | **0.200**      | 0.533 ± 0.072 | 0.300 ± 0.047 | 0.222 ± 0.016 | 0.811 ± 0.016  | **0.200**     | 0.211 ± 0.016  |
| Mean latency (ms)      | 2.21 ± 0.04    | 1.81 ± 0.06   | 1.81 ± 0.04   | 1.80 ± 0.05   | 0.87 ± 0.03    | 1.79 ± 0.04   | 1.99 ± 0.06    |

### Visual comparison — final store size vs false-forget rate

Each policy's marker position reflects the *combined* efficiency/quality
trade-off: lower-left is better (smaller index, fewer forgotten facts).

```
                  False-forget rate
                  0.10   0.20   0.30   0.40   0.50   0.60   0.70   0.80   0.90
                  ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
Final store size  │                                                          │
                  │                                                          │
       50  ────── │                              ★ TTL (33, 0.81)             │
                  │                                                          │
       90  ────── │                                                          │
                  │                                                          │
      100  ────── │            ★ LIFO   ★ LFU         ★ FIFO       ★ LRU     │
                  │            (100,    (100,        (100,         (100,     │
                  │            0.20)    0.22)         0.53)         0.30)     │
      110  ────── │                                                          │
                  │                                                          │
      130  ────── │                                                          │
                  │                                                          │
      140  ────── │            ★ Lethe (143, 0.21)                          │
                  │                  (default)                               │
      150  ────── │                                                          │
                  │                                                          │
      180  ────── │  ★ Naive (184, 0.20)                                     │
                  │                                                          │
                  └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
```

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 lives in the sweet spot**: 22% smaller than Naive with effectively the same false-forget rate.
- **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).

### Visual comparison — recall @ 1 by policy

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.789      ███████████████▏
LFU       0.778      ███████████████
LRU       0.711      ██████████████▍
FIFO      0.467      █████████▍
TTL       0.189      ███▊
```

### Visual comparison — false-forget rate by policy

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.811          ████████████████▏
FIFO      0.533          ██████████▌
LRU       0.300          ██████
LFU       0.222          ████▌
Lethe     0.211          ████▏
LIFO      0.200          ████
Naive     0.200          ████
```

### Sub-scenario results (full tables in `benchmark/RESULTS.md`)

The benchmark also ran three labeled sub-scenarios. The headlines:

- **Durable-fact-heavy** (every fact queried every day): LRU/LFU/LIFO all
  match Naive (R@1 0.800). Lethe wins on R@5 (0.947) and has the
  smallest index (118.7 ± 3.4).
- **Recency-biased** (queries focus on recently captured items): LIFO and
  Naive tie at 0.800 R@1 because durable facts are old. LRU suffers
  (0.622) — its signal is noisy when access recency doesn't correlate
  with importance.
- **High-frequency-skew** (one fact queried 10×/day): LFU matches Lethe
  (0.789 R@1). LFU is the right call for hot-item workloads.

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=3 seeds** per (policy, workload) — see RESULTS.md for
the full stability report and the sub-scenario metrics that exceed CV=20%.

Run it yourself:

```bash
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](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:

```bash
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:

```python
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

Clone the repository to run the complete test and benchmark suites:

```bash
git clone https://github.com/Fqih/lethe.git
cd lethe
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.

## Which policy should I use?

Lethe ships with six retention policies. The benchmark
([benchmark/RESULTS.md](benchmark/RESULTS.md)) ran all six across
four workloads (default, durable_heavy, recency_biased,
high_freq_skew) over 3 seeds. Every recommendation below is backed
by a number in the tables there.

### `Lethe` (default — score-decay with consolidation)

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
~22% smaller and ~10% faster (aggregate workload: 0.789 R@1 vs
Naive's 0.800, 143-item index vs 184).

**Evidence** ([RESULTS.md](benchmark/RESULTS.md) aggregate table):
Lethe 0.789 ± 0.016 R@1, 0.211 ± 0.016 false-forget, 1.99 ± 0.06 ms
latency.

### `FIFOPolicy`

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 (smaller than Lethe's natural
143), FIFO forgets 53% of durable facts while Lethe forgets
21% — a 2.5× gap *in favor of the larger index*. Even on the
durable_heavy workload where every fact is queried constantly,
FIFO still loses 49% 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.467 ± 0.072 R@1 (aggregate), 0.511 ± 0.058 R@1
(durable_heavy), 0.533 ± 0.072 false-forget (aggregate).

### `LRUPolicy`

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.711 (aggregate) to 0.622 (recency_biased).

**Evidence**: 0.711 ± 0.047 R@1 (aggregate), 0.622 ± 0.047 R@1
(recency_biased), 0.300 ± 0.047 false-forget (aggregate).

### `LFUPolicy`

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.954 vs Naive's 0.958) — 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.778 ± 0.016 R@1 (aggregate), 0.789 ± 0.016 R@1
(high_freq_skew), 0.222 ± 0.016 false-forget (aggregate).

### `TTLPolicy`

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 30-day
simulation; every fact older than a week gets dropped, and TTL
ends up with the worst false-forget rate (0.81 aggregate).

**Evidence**: 0.189 ± 0.016 R@1 (aggregate), 0.244 ± 0.057 R@1
(durable_heavy), 0.811 ± 0.016 false-forget (aggregate).

### `LIFOPolicy`

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 ± 0.000 R@1 (aggregate — looks great, by
accident of the benchmark's capture order); comparable false-forget
to Naive on aggregate. 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.

### `PinnedOverlay`

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.

### Choosing between policies in practice

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

## License

MIT: see [LICENSE](LICENSE).
