lethe-memory 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lethe_memory-0.2.2/.release-please-manifest.json +3 -0
- lethe_memory-0.2.2/ARCHITECTURE.md +74 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/CHANGELOG.md +14 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/CLAUDE.md +4 -0
- lethe_memory-0.2.2/LICENSE +21 -0
- lethe_memory-0.2.2/PKG-INFO +97 -0
- lethe_memory-0.2.2/README.md +72 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/pyproject.toml +3 -1
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/__init__.py +1 -1
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/cli.py +8 -3
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/db.py +6 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/markdown_store.py +1 -30
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_cli.py +0 -1
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_markdown_store.py +17 -16
- lethe_memory-0.2.0/.github/workflows/publish-pypi.yml +0 -40
- lethe_memory-0.2.0/.release-please-manifest.json +0 -3
- lethe_memory-0.2.0/PKG-INFO +0 -21
- lethe_memory-0.2.0/README.md +0 -288
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/.claude-plugin/marketplace.json +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/.github/workflows/release-please.yml +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/.gitignore +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/BENCHMARKS.md +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/RESEARCH_JOURNEY.md +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/assets/demo.gif +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/assets/demo.mp4 +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/assets/logo.png +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/_lib/__init__.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/_lib/metrics.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_benchmark.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_lifecycle_benchmark.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_benchmark.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_clustered.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_enriched.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_explore.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_explore_smart.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_explore_threshold.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_explore_validation.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_extended_metrics.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_gap.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/benchmarks/run_rif_sweep.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/.gitignore +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/package.json +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/public/logo.png +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/remotion.config.ts +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/scripts/_pass.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/scripts/_pass_injected.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/scripts/ab_test.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/scripts/collect.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/Composition.tsx +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/Intro.tsx +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/LearningCurve.tsx +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/Outro.tsx +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/RifSchematic.tsx +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/Root.tsx +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/index.ts +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/theme.ts +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/src/types.ts +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/demo/tsconfig.json +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/.claude-plugin/plugin.json +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/README.md +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/hooks/common.sh +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/hooks/hooks.json +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/hooks/parse-transcript.sh +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/hooks/session-end.sh +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/hooks/session-start.sh +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/hooks/stop.sh +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/hooks/user-prompt-submit.sh +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/scripts/derive-collection.sh +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/scripts/transcript.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/plugins/claude-code/skills/memory-recall/SKILL.md +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/release-please-config.json +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/__init__.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/README.md +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/__init__.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/analyze.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/baselines.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/config.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/graph.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/rescue_index.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/run_experiment.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/segmentation.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/gc_mutation/store.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/sdm/README.md +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/sdm/dataset.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/sdm/embedding.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/sdm/eval.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/sdm/main.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/research/sdm/sdm.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/scripts/enrich_longmemeval.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/scripts/prep_longmemeval.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/_lock.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/_registry.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/dedup.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/encoders.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/enrichment.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/entry.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/memory_store.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/reranker.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/rif.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/union_store.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/src/lethe/vectors.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/__init__.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/conftest.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_db.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_dedup.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_enrichment.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_entry.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_memory_store.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_metrics.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_reranker.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_rif.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_union_store.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/tests/test_vectors.py +0 -0
- {lethe_memory-0.2.0 → lethe_memory-0.2.2}/uv.lock +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Retrieval pipeline
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Query
|
|
7
|
+
│
|
|
8
|
+
├── FAISS top-30 (dense vector similarity)
|
|
9
|
+
├── BM25 top-30 (sparse keyword match)
|
|
10
|
+
│
|
|
11
|
+
└── Merge (RRF)
|
|
12
|
+
│
|
|
13
|
+
└── RIF suppression penalty (per-cluster, gap-based)
|
|
14
|
+
│
|
|
15
|
+
└── Cross-encoder rerank → top-k
|
|
16
|
+
│
|
|
17
|
+
└── Update suppression state, affinities, tier
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Optional write-time LLM enrichment layer** (`src/lethe/enrichment.py`): before indexing, each memory can be processed by an LLM (default `claude-haiku-4-5`) to produce a gist, 3 anticipated queries, entities, and temporal markers. All fields index alongside the original text; cross-encoder still scores against original. Attacks the vocabulary-mismatch failure mode.
|
|
21
|
+
|
|
22
|
+
## RIF: technical details
|
|
23
|
+
|
|
24
|
+
The formula that made it work (checkpoint 13 of 17):
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
competition_strength = max(0, xenc_rank - initial_rank) / pool_size × sigmoid(-xenc_score)
|
|
28
|
+
suppression[eid, cluster] += learning_rate × competition_strength
|
|
29
|
+
score_adjusted = base_score - alpha × suppression[eid, cluster]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The rank-gap term only penalises entries that **dropped in rank** between the initial hybrid retrieval and the cross-encoder rerank AND were actively rejected (negative xenc score). Entries that just lost a close race aren't suppressed. That prevents the system from forgetting legitimately useful context because it happened to rank second once.
|
|
33
|
+
|
|
34
|
+
k-means runs once (30 clusters) on the bi-encoder query embedding at retrieval time; the cluster assignment is the "cue" coordinate. Based on Anderson's inhibition theory (1994) and the SAM competitive-sampling model (Raaijmakers & Shiffrin, 1981).
|
|
35
|
+
|
|
36
|
+
## Storage layers
|
|
37
|
+
|
|
38
|
+
| Layer | File | Purpose |
|
|
39
|
+
|-------|------|---------|
|
|
40
|
+
| DuckDB | `lethe.duckdb` | Entries, suppression state, rescue cache, stats. Many project DBs can be `ATTACH`ed simultaneously for cross-project search. |
|
|
41
|
+
| numpy + FAISS | `embeddings.npz`, `faiss.index` | Vector storage |
|
|
42
|
+
| BM25 | In-memory, rebuilt on startup | Sparse keyword index |
|
|
43
|
+
|
|
44
|
+
## Entry lifecycle (germinal-center inspired)
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
NAIVE → GC → MEMORY
|
|
48
|
+
↓
|
|
49
|
+
APOPTOTIC
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- **Naive**: new entries, unproven
|
|
53
|
+
- **GC**: retrieved 3+ times, actively evaluated
|
|
54
|
+
- **Memory**: high affinity + frequently retrieved, stable, exempt from decay
|
|
55
|
+
- **Apoptotic**: low affinity + idle > 1000 steps, excluded from search
|
|
56
|
+
|
|
57
|
+
Useful for long-running agents; doesn't directly improve retrieval quality (that's what RIF and enrichment do).
|
|
58
|
+
|
|
59
|
+
## Deduplication (on add)
|
|
60
|
+
|
|
61
|
+
1. **Exact**: SHA-256 content hash (free)
|
|
62
|
+
2. **Near-duplicate**: cosine similarity > 0.95 (keeps the longer entry)
|
|
63
|
+
|
|
64
|
+
## Global search across projects
|
|
65
|
+
|
|
66
|
+
Every `lethe index` registers the project in `~/.lethe/projects.json`. `lethe search --all` then ATTACHes each registered `lethe.duckdb` as a read-only schema and runs the full hybrid + RIF + cross-encoder pipeline across the union.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
lethe search "mongodb pool" --all --top-k 5
|
|
70
|
+
lethe projects list
|
|
71
|
+
lethe projects prune
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
DuckDB has no cap on attached databases, so the 10/125 ceiling in SQLite's `ATTACH DATABASE` doesn't apply.
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.2](https://github.com/teimurjan/lethe/compare/lethe-memory-v0.2.1...lethe-memory-v0.2.2) (2026-04-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* Remove chunk_map.json, expand reads from DuckDB directly ([5cb8c84](https://github.com/teimurjan/lethe/commit/5cb8c843213d91b5c561e97a5b058d14d52b0652))
|
|
9
|
+
|
|
10
|
+
## [0.2.1](https://github.com/teimurjan/lethe/compare/lethe-memory-v0.2.0...lethe-memory-v0.2.1) (2026-04-17)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* Add README to PyPI, absolute logo URL, MIT license ([78188dd](https://github.com/teimurjan/lethe/commit/78188dd9642694f441f1a42a4ea99ae42a498dd7))
|
|
16
|
+
|
|
3
17
|
## [0.2.0](https://github.com/teimurjan/lethe/compare/lethe-memory-v0.1.0...lethe-memory-v0.2.0) (2026-04-17)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -102,3 +102,7 @@ The 0.3680 is from BM25+vector+cross-encoder reranking (standard IR). The GC mec
|
|
|
102
102
|
17 checkpoints. Checkpoints 1-10 (GC mechanisms): all failed. Checkpoint 11 (global RIF): +1.1%. Checkpoint 12 (clustered RIF): +5.8%. Checkpoint 13 (clustered + rank-gap): +6.5% NDCG, +9.5% recall@30 — retrieval-only best. Checkpoint 14 (exploration + rescue list): negative at full scale. Checkpoint 15 (SDM research prototype in `sdm/`): negative. Checkpoint 16 (extended metrics for checkpoint 13): RIF's gain is primarily from reducing wrong_family (−1.6pp); sibling_confusion and stale_fact unchanged. Checkpoint 17 (LLM enrichment layer, Haiku write-time gist + anticipated queries + entities + temporal markers): **+8.3pp NDCG on covered queries (+21% rel over RIF), largest single-lever gain in the journey**. Overall diluted to +1.2pp by partial coverage (15% of queries). Full corpus enrichment expected ~$16.
|
|
103
103
|
|
|
104
104
|
Full research journey: [RESEARCH_JOURNEY.md](RESEARCH_JOURNEY.md)
|
|
105
|
+
|
|
106
|
+
## git commits
|
|
107
|
+
|
|
108
|
+
Use conventional commits. release-please is configured to only bump on `feat:` (minor) and `fix:` (patch). Use `feat:` or `fix:` only when you want a PyPI release. For everything else use a plain message with no prefix (release-please ignores non-conventional commits entirely).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Teimur Gasanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lethe-memory
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Self-improving memory store for LLM agents: hybrid retrieval, clustered retrieval-induced forgetting, optional LLM enrichment
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: anthropic>=0.30
|
|
9
|
+
Requires-Dist: duckdb>=0.10
|
|
10
|
+
Requires-Dist: faiss-cpu>=1.7
|
|
11
|
+
Requires-Dist: fastembed>=0.4
|
|
12
|
+
Requires-Dist: numpy>=1.24
|
|
13
|
+
Requires-Dist: rank-bm25>=0.2
|
|
14
|
+
Provides-Extra: benchmarks
|
|
15
|
+
Requires-Dist: beir>=2.0; extra == 'benchmarks'
|
|
16
|
+
Requires-Dist: datasets>=2.0; extra == 'benchmarks'
|
|
17
|
+
Requires-Dist: matplotlib>=3.7; extra == 'benchmarks'
|
|
18
|
+
Requires-Dist: sentence-transformers>=2.0; extra == 'benchmarks'
|
|
19
|
+
Requires-Dist: tqdm>=4.60; extra == 'benchmarks'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# lethe
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
<img src="https://raw.githubusercontent.com/teimurjan/lethe/main/assets/logo.png" width="300" height="300" />
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
> *Λήθη: the ancient Greek personification of forgetfulness, and one of the five rivers of the underworld.*
|
|
33
|
+
|
|
34
|
+
A memory store for LLM agents that **gets better the more you use it**. Hybrid BM25 + dense retrieval, cross-encoder reranking, clustered retrieval-induced forgetting (RIF), and optional LLM enrichment at write time.
|
|
35
|
+
|
|
36
|
+
Most memory tools are static caches - you put strings in, you get strings back by similarity, and the retrieval function never changes. lethe is different: every retrieval teaches it which entries are chronic distractors for which kinds of queries, and it quietly suppresses them over time. No fine-tuning, no extra LLM calls - just bookkeeping inspired by how human memory actually works ([Anderson, 1994](https://psycnet.apa.org/record/1994-29917-001)).
|
|
37
|
+
|
|
38
|
+
## Benchmark
|
|
39
|
+
|
|
40
|
+
Numbers on the full 199,509-turn LongMemEval S corpus, **turn-level retrieval, NDCG@10, no leakage**. Most memory-tool benchmarks use ~50 sessions at session granularity - a ~2000× easier task. Those 99% numbers don't translate to this setup.
|
|
41
|
+
|
|
42
|
+
| Stage | NDCG@10 | notes |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Hybrid BM25 + vector (RRF) | 0.217 | basic retrieval (most popular) |
|
|
45
|
+
| + cross-encoder reranking | 0.293 | +35% from semantic reranking |
|
|
46
|
+
| + clustered+gap RIF (checkpoint 13) | **0.312** | +6.5% from retrieval-induced forgetting |
|
|
47
|
+
| + LLM enrichment, on covered queries | **0.473** | +21% on the 75 queries where the answer turn was Haiku-enriched |
|
|
48
|
+
|
|
49
|
+
Full methodology in [BENCHMARKS.md](BENCHMARKS.md). 17 checkpoints (10 failed) in [RESEARCH_JOURNEY.md](RESEARCH_JOURNEY.md).
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
https://github.com/user-attachments/assets/ea6e9657-7525-4fed-ba40-df7a14e6b9f4
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Install and quick start
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install lethe-memory
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from lethe import MemoryStore
|
|
63
|
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
|
64
|
+
|
|
65
|
+
store = MemoryStore(
|
|
66
|
+
"./my_memories",
|
|
67
|
+
bi_encoder=SentenceTransformer("all-MiniLM-L6-v2"),
|
|
68
|
+
cross_encoder=CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
store.add("I prefer window seats on flights", session_id="trip")
|
|
72
|
+
store.add("My wife needs aisle seats", session_id="trip")
|
|
73
|
+
store.add("I work at Google as a software engineer", session_id="work")
|
|
74
|
+
|
|
75
|
+
results = store.retrieve("What are my travel preferences?", k=5)
|
|
76
|
+
for entry_id, content, score in results:
|
|
77
|
+
print(f" [{score:.1f}] {content}")
|
|
78
|
+
|
|
79
|
+
store.save()
|
|
80
|
+
store.close()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
As a CLI: `uv tool install lethe-memory && lethe --version`
|
|
84
|
+
|
|
85
|
+
As a Claude Code plugin: `/plugin marketplace add teimurjan/lethe`
|
|
86
|
+
|
|
87
|
+
See [plugins/claude-code/README.md](plugins/claude-code/README.md) for plugin details.
|
|
88
|
+
|
|
89
|
+
## How it works
|
|
90
|
+
|
|
91
|
+
[ARCHITECTURE.md](ARCHITECTURE.md) - pipeline diagram, RIF formula, storage layers, entry lifecycle, cross-project search.
|
|
92
|
+
|
|
93
|
+
[RESEARCH_JOURNEY.md](RESEARCH_JOURNEY.md) - 17 checkpoints from biology-inspired mutation (all failed) through cognitive-science RIF (+6.5%) to LLM enrichment (+21% on covered queries).
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# lethe
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/teimurjan/lethe/main/assets/logo.png" width="300" height="300" />
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
> *Λήθη: the ancient Greek personification of forgetfulness, and one of the five rivers of the underworld.*
|
|
8
|
+
|
|
9
|
+
A memory store for LLM agents that **gets better the more you use it**. Hybrid BM25 + dense retrieval, cross-encoder reranking, clustered retrieval-induced forgetting (RIF), and optional LLM enrichment at write time.
|
|
10
|
+
|
|
11
|
+
Most memory tools are static caches - you put strings in, you get strings back by similarity, and the retrieval function never changes. lethe is different: every retrieval teaches it which entries are chronic distractors for which kinds of queries, and it quietly suppresses them over time. No fine-tuning, no extra LLM calls - just bookkeeping inspired by how human memory actually works ([Anderson, 1994](https://psycnet.apa.org/record/1994-29917-001)).
|
|
12
|
+
|
|
13
|
+
## Benchmark
|
|
14
|
+
|
|
15
|
+
Numbers on the full 199,509-turn LongMemEval S corpus, **turn-level retrieval, NDCG@10, no leakage**. Most memory-tool benchmarks use ~50 sessions at session granularity - a ~2000× easier task. Those 99% numbers don't translate to this setup.
|
|
16
|
+
|
|
17
|
+
| Stage | NDCG@10 | notes |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Hybrid BM25 + vector (RRF) | 0.217 | basic retrieval (most popular) |
|
|
20
|
+
| + cross-encoder reranking | 0.293 | +35% from semantic reranking |
|
|
21
|
+
| + clustered+gap RIF (checkpoint 13) | **0.312** | +6.5% from retrieval-induced forgetting |
|
|
22
|
+
| + LLM enrichment, on covered queries | **0.473** | +21% on the 75 queries where the answer turn was Haiku-enriched |
|
|
23
|
+
|
|
24
|
+
Full methodology in [BENCHMARKS.md](BENCHMARKS.md). 17 checkpoints (10 failed) in [RESEARCH_JOURNEY.md](RESEARCH_JOURNEY.md).
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
https://github.com/user-attachments/assets/ea6e9657-7525-4fed-ba40-df7a14e6b9f4
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## Install and quick start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install lethe-memory
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from lethe import MemoryStore
|
|
38
|
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
|
39
|
+
|
|
40
|
+
store = MemoryStore(
|
|
41
|
+
"./my_memories",
|
|
42
|
+
bi_encoder=SentenceTransformer("all-MiniLM-L6-v2"),
|
|
43
|
+
cross_encoder=CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
store.add("I prefer window seats on flights", session_id="trip")
|
|
47
|
+
store.add("My wife needs aisle seats", session_id="trip")
|
|
48
|
+
store.add("I work at Google as a software engineer", session_id="work")
|
|
49
|
+
|
|
50
|
+
results = store.retrieve("What are my travel preferences?", k=5)
|
|
51
|
+
for entry_id, content, score in results:
|
|
52
|
+
print(f" [{score:.1f}] {content}")
|
|
53
|
+
|
|
54
|
+
store.save()
|
|
55
|
+
store.close()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
As a CLI: `uv tool install lethe-memory && lethe --version`
|
|
59
|
+
|
|
60
|
+
As a Claude Code plugin: `/plugin marketplace add teimurjan/lethe`
|
|
61
|
+
|
|
62
|
+
See [plugins/claude-code/README.md](plugins/claude-code/README.md) for plugin details.
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
[ARCHITECTURE.md](ARCHITECTURE.md) - pipeline diagram, RIF formula, storage layers, entry lifecycle, cross-project search.
|
|
67
|
+
|
|
68
|
+
[RESEARCH_JOURNEY.md](RESEARCH_JOURNEY.md) - 17 checkpoints from biology-inspired mutation (all failed) through cognitive-science RIF (+6.5%) to LLM enrichment (+21% on covered queries).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -4,8 +4,10 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lethe-memory"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2" # x-release-please-version
|
|
8
8
|
description = "Self-improving memory store for LLM agents: hybrid retrieval, clustered retrieval-induced forgetting, optional LLM enrichment"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
9
11
|
requires-python = ">=3.11"
|
|
10
12
|
dependencies = [
|
|
11
13
|
"faiss-cpu>=1.7",
|
|
@@ -416,10 +416,15 @@ def _snippet(content: str, width: int = 160) -> str:
|
|
|
416
416
|
|
|
417
417
|
def cmd_expand(args: argparse.Namespace) -> int:
|
|
418
418
|
paths = resolve_paths(args.root)
|
|
419
|
-
|
|
419
|
+
db_path = paths.index / "lethe.duckdb"
|
|
420
|
+
if not db_path.exists():
|
|
421
|
+
print(f"chunk {args.chunk_id!r} not found", file=sys.stderr)
|
|
422
|
+
return 1
|
|
423
|
+
from lethe.db import MemoryDB
|
|
420
424
|
|
|
421
|
-
|
|
422
|
-
content =
|
|
425
|
+
db = MemoryDB(db_path)
|
|
426
|
+
content = db.get_content(args.chunk_id)
|
|
427
|
+
db.close()
|
|
423
428
|
if content is None:
|
|
424
429
|
print(f"chunk {args.chunk_id!r} not found", file=sys.stderr)
|
|
425
430
|
return 1
|
|
@@ -142,6 +142,12 @@ class MemoryDB:
|
|
|
142
142
|
)
|
|
143
143
|
return _rows_as_dicts(cur)
|
|
144
144
|
|
|
145
|
+
def get_content(self, entry_id: str) -> str | None:
|
|
146
|
+
row = self._conn.execute(
|
|
147
|
+
"SELECT content FROM entries WHERE id=?", [entry_id]
|
|
148
|
+
).fetchone()
|
|
149
|
+
return str(row[0]) if row else None
|
|
150
|
+
|
|
145
151
|
def delete_entry(self, entry_id: str) -> None:
|
|
146
152
|
self._conn.execute("DELETE FROM entries WHERE id=?", [entry_id])
|
|
147
153
|
|
|
@@ -16,7 +16,6 @@ on a directory of markdown files and a :class:`~lethe.memory_store.MemoryStore`.
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import hashlib
|
|
19
|
-
import json
|
|
20
19
|
import re
|
|
21
20
|
from dataclasses import dataclass
|
|
22
21
|
from pathlib import Path
|
|
@@ -165,14 +164,11 @@ class MarkdownStore:
|
|
|
165
164
|
so ``lethe expand`` can return the full section.
|
|
166
165
|
"""
|
|
167
166
|
|
|
168
|
-
CHUNK_MAP_FILENAME = "chunk_map.json"
|
|
169
|
-
|
|
170
167
|
def __init__(self, memory_dir: Path, index_dir: Path) -> None:
|
|
171
168
|
self.memory_dir = Path(memory_dir)
|
|
172
169
|
self.index_dir = Path(index_dir)
|
|
173
170
|
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
|
174
171
|
self.index_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
-
self._chunk_map_path = self.index_dir / self.CHUNK_MAP_FILENAME
|
|
176
172
|
|
|
177
173
|
# ---- Scanning ----------------------------------------------------------
|
|
178
174
|
|
|
@@ -190,26 +186,6 @@ class MarkdownStore:
|
|
|
190
186
|
out.extend(split_into_chunks(text, f))
|
|
191
187
|
return out
|
|
192
188
|
|
|
193
|
-
# ---- Chunk map persistence --------------------------------------------
|
|
194
|
-
|
|
195
|
-
def _load_chunk_map(self) -> dict[str, str]:
|
|
196
|
-
if not self._chunk_map_path.exists():
|
|
197
|
-
return {}
|
|
198
|
-
try:
|
|
199
|
-
data = json.loads(self._chunk_map_path.read_text(encoding="utf-8"))
|
|
200
|
-
except (OSError, json.JSONDecodeError):
|
|
201
|
-
return {}
|
|
202
|
-
return {str(k): str(v) for k, v in data.items() if isinstance(v, str)}
|
|
203
|
-
|
|
204
|
-
def _save_chunk_map(self, chunk_map: dict[str, str]) -> None:
|
|
205
|
-
self._chunk_map_path.write_text(
|
|
206
|
-
json.dumps(chunk_map, ensure_ascii=False, indent=0),
|
|
207
|
-
encoding="utf-8",
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
def get_chunk(self, chunk_id: str) -> str | None:
|
|
211
|
-
return self._load_chunk_map().get(chunk_id)
|
|
212
|
-
|
|
213
189
|
# ---- Reindex -----------------------------------------------------------
|
|
214
190
|
|
|
215
191
|
def reindex(self, store: MemoryStore) -> dict[str, int]:
|
|
@@ -221,7 +197,6 @@ class MarkdownStore:
|
|
|
221
197
|
"""
|
|
222
198
|
chunks = self.scan()
|
|
223
199
|
current_ids = {c.id for c in chunks}
|
|
224
|
-
previous = self._load_chunk_map()
|
|
225
200
|
|
|
226
201
|
added = 0
|
|
227
202
|
unchanged = 0
|
|
@@ -239,10 +214,9 @@ class MarkdownStore:
|
|
|
239
214
|
if inserted is not None:
|
|
240
215
|
added += 1
|
|
241
216
|
else:
|
|
242
|
-
# Dedup suppressed the insert (exact hash collision or near-dup).
|
|
243
217
|
unchanged += 1
|
|
244
218
|
|
|
245
|
-
for old_id in set(
|
|
219
|
+
for old_id in set(store.entries) - current_ids:
|
|
246
220
|
if old_id in store.entries:
|
|
247
221
|
store.db.delete_entry(old_id)
|
|
248
222
|
store.entries.pop(old_id, None)
|
|
@@ -252,9 +226,6 @@ class MarkdownStore:
|
|
|
252
226
|
if removed:
|
|
253
227
|
store._rebuild_index()
|
|
254
228
|
|
|
255
|
-
chunk_map = {c.id: c.content for c in chunks}
|
|
256
|
-
self._save_chunk_map(chunk_map)
|
|
257
|
-
|
|
258
229
|
return {
|
|
259
230
|
"added": added,
|
|
260
231
|
"removed": removed,
|
|
@@ -135,7 +135,7 @@ def test_scan_reads_all_md_files_in_order(tmp_path: Path) -> None:
|
|
|
135
135
|
assert [c.heading for c in chunks] == ["A", "B"]
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def
|
|
138
|
+
def test_reindex_adds_new_chunks(
|
|
139
139
|
tmp_path: Path, mock_bi_encoder, mock_cross_encoder
|
|
140
140
|
) -> None:
|
|
141
141
|
memory = tmp_path / "memory"
|
|
@@ -157,10 +157,7 @@ def test_reindex_adds_new_chunks_and_persists_chunk_map(
|
|
|
157
157
|
store.close()
|
|
158
158
|
|
|
159
159
|
assert counts == {"added": 2, "removed": 0, "unchanged": 0, "total": 2}
|
|
160
|
-
|
|
161
|
-
chunk_map = md._load_chunk_map()
|
|
162
|
-
assert len(chunk_map) == 2
|
|
163
|
-
assert all(len(cid) == 16 for cid in chunk_map)
|
|
160
|
+
assert store.size == 2
|
|
164
161
|
|
|
165
162
|
|
|
166
163
|
def test_reindex_is_idempotent(
|
|
@@ -251,21 +248,25 @@ def test_get_chunk_returns_full_markdown(
|
|
|
251
248
|
)
|
|
252
249
|
try:
|
|
253
250
|
md.reindex(store)
|
|
251
|
+
[chunk] = md.scan()
|
|
252
|
+
expanded = store.db.get_content(chunk.id)
|
|
253
|
+
assert expanded is not None
|
|
254
|
+
assert "bullet" in expanded
|
|
254
255
|
finally:
|
|
255
256
|
store.close()
|
|
256
257
|
|
|
257
|
-
[chunk] = md.scan()
|
|
258
|
-
expanded = md.get_chunk(chunk.id)
|
|
259
|
-
assert expanded is not None
|
|
260
|
-
assert "bullet" in expanded
|
|
261
|
-
assert parse_anchor(expanded) == {
|
|
262
|
-
"session": "s", "turn": "t", "transcript": "/x",
|
|
263
|
-
}
|
|
264
|
-
|
|
265
258
|
|
|
266
|
-
def
|
|
267
|
-
|
|
268
|
-
|
|
259
|
+
def test_get_content_returns_none_for_unknown_id(
|
|
260
|
+
tmp_path: Path, mock_bi_encoder, mock_cross_encoder,
|
|
261
|
+
) -> None:
|
|
262
|
+
store = MemoryStore(
|
|
263
|
+
tmp_path / "store",
|
|
264
|
+
bi_encoder=mock_bi_encoder,
|
|
265
|
+
cross_encoder=mock_cross_encoder,
|
|
266
|
+
dim=mock_bi_encoder.dim,
|
|
267
|
+
)
|
|
268
|
+
assert store.db.get_content("deadbeef" * 2) is None
|
|
269
|
+
store.close()
|
|
269
270
|
|
|
270
271
|
|
|
271
272
|
def test_chunk_anchor_property_pulls_from_content() -> None:
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
name: publish
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [published]
|
|
6
|
-
|
|
7
|
-
jobs:
|
|
8
|
-
build:
|
|
9
|
-
name: build sdist + wheel
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
steps:
|
|
12
|
-
- uses: actions/checkout@v4
|
|
13
|
-
- uses: actions/setup-python@v5
|
|
14
|
-
with:
|
|
15
|
-
python-version: "3.12"
|
|
16
|
-
- name: Install build tooling
|
|
17
|
-
run: python -m pip install --upgrade build
|
|
18
|
-
- name: Build distributions
|
|
19
|
-
run: python -m build
|
|
20
|
-
- uses: actions/upload-artifact@v4
|
|
21
|
-
with:
|
|
22
|
-
name: dist
|
|
23
|
-
path: dist/
|
|
24
|
-
if-no-files-found: error
|
|
25
|
-
|
|
26
|
-
publish:
|
|
27
|
-
name: upload to PyPI (trusted publishing)
|
|
28
|
-
needs: build
|
|
29
|
-
runs-on: ubuntu-latest
|
|
30
|
-
environment:
|
|
31
|
-
name: pypi
|
|
32
|
-
url: https://pypi.org/p/lethe-memory
|
|
33
|
-
permissions:
|
|
34
|
-
id-token: write
|
|
35
|
-
steps:
|
|
36
|
-
- uses: actions/download-artifact@v4
|
|
37
|
-
with:
|
|
38
|
-
name: dist
|
|
39
|
-
path: dist/
|
|
40
|
-
- uses: pypa/gh-action-pypi-publish@release/v1
|
lethe_memory-0.2.0/PKG-INFO
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: lethe-memory
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Self-improving memory store for LLM agents: hybrid retrieval, clustered retrieval-induced forgetting, optional LLM enrichment
|
|
5
|
-
Requires-Python: >=3.11
|
|
6
|
-
Requires-Dist: anthropic>=0.30
|
|
7
|
-
Requires-Dist: duckdb>=0.10
|
|
8
|
-
Requires-Dist: faiss-cpu>=1.7
|
|
9
|
-
Requires-Dist: fastembed>=0.4
|
|
10
|
-
Requires-Dist: numpy>=1.24
|
|
11
|
-
Requires-Dist: rank-bm25>=0.2
|
|
12
|
-
Provides-Extra: benchmarks
|
|
13
|
-
Requires-Dist: beir>=2.0; extra == 'benchmarks'
|
|
14
|
-
Requires-Dist: datasets>=2.0; extra == 'benchmarks'
|
|
15
|
-
Requires-Dist: matplotlib>=3.7; extra == 'benchmarks'
|
|
16
|
-
Requires-Dist: sentence-transformers>=2.0; extra == 'benchmarks'
|
|
17
|
-
Requires-Dist: tqdm>=4.60; extra == 'benchmarks'
|
|
18
|
-
Provides-Extra: dev
|
|
19
|
-
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
20
|
-
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
21
|
-
Requires-Dist: pytest>=7.0; extra == 'dev'
|
lethe_memory-0.2.0/README.md
DELETED
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
# lethe
|
|
2
|
-
|
|
3
|
-
<div align="center">
|
|
4
|
-
<img src="./assets/logo.png" width="300" height="300" />
|
|
5
|
-
</div>
|
|
6
|
-
|
|
7
|
-
> *Λήθη: the ancient Greek personification of forgetfulness, and one of the five rivers of the underworld.*
|
|
8
|
-
|
|
9
|
-
A memory store for LLM agents. Hybrid BM25 + dense retrieval, cross-encoder reranking, **clustered retrieval-induced forgetting (RIF)**, and an optional LLM enrichment layer at write time.
|
|
10
|
-
|
|
11
|
-
## Read this before the benchmark numbers
|
|
12
|
-
|
|
13
|
-
Most memory-tool READMEs advertise NDCG or recall in the 95 to 99% range. Those numbers come from evaluating on a per-query fresh database of ~50 sessions, at session granularity, with recall@5. That's a roughly 2000× easier task than "find this specific turn among 200,000". Some implementations additionally leak ground-truth annotations into the index.
|
|
14
|
-
|
|
15
|
-
lethe's numbers are reported on the full 199,509-turn LongMemEval S corpus, **turn-level retrieval, NDCG@10, no leakage**. Random baseline is 0.005%. Vector-only search gets 0.14. BM25 alone gets 0.24. When other tools are run on *this* setup, they land in the same 0.1 to 0.4 range. The 99% numbers don't translate.
|
|
16
|
-
|
|
17
|
-
No 99%. No cherry-picking. These are the honest numbers on a hard benchmark:
|
|
18
|
-
|
|
19
|
-
| Stage | NDCG@10 | notes |
|
|
20
|
-
|---|---|---|
|
|
21
|
-
| Hybrid BM25 + vector (RRF) | 0.217 | basic retrieval (most popular) |
|
|
22
|
-
| + cross-encoder reranking | 0.293 | +35% from semantic reranking |
|
|
23
|
-
| + clustered+gap RIF (checkpoint 13) | **0.312** | +6.5% from retrieval-induced forgetting |
|
|
24
|
-
| + LLM enrichment, on covered queries | **0.473** | +21% on the 75 queries where the answer turn was Haiku-enriched |
|
|
25
|
-
|
|
26
|
-
Enrichment only helps where it's been applied; the full-corpus number is diluted by the uncovered 425 queries. Full methodology in [BENCHMARKS.md](BENCHMARKS.md). The research path, including 10 failed approaches before anything worked, is in [RESEARCH_JOURNEY.md](RESEARCH_JOURNEY.md).
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
https://github.com/user-attachments/assets/ea6e9657-7525-4fed-ba40-df7a14e6b9f4
|
|
30
|
-
|
|
31
|
-
> Lethe full stack vs basic hybrid RRF on the 199,509-turn LongMemEval corpus. NDCG@10 over 5,000 queries.
|
|
32
|
-
|
|
33
|
-
## What's new here: retrieval-induced forgetting
|
|
34
|
-
|
|
35
|
-
RIF is a well-studied phenomenon in cognitive psychology (Anderson, 1994). In plain words:
|
|
36
|
-
|
|
37
|
-
> *When you retrieve one thing, related things that could have been retrieved but weren't get quietly suppressed. Ask someone to name a fruit; they say "apple". A few minutes later, "pear" and "orange" are measurably harder for them to recall, even though nothing asked about them. Remembering is also an act of forgetting.*
|
|
38
|
-
|
|
39
|
-
lethe brings this mechanism to vector memory:
|
|
40
|
-
|
|
41
|
-
- Every retrieval surfaces a candidate pool (BM25 ∪ vector top-k).
|
|
42
|
-
- The cross-encoder picks the winners.
|
|
43
|
-
- Entries that reached the pool but lost don't disappear. They accumulate a per-cluster **suppression score**.
|
|
44
|
-
- On future retrievals in the same query cluster ("travel-like queries", "debugging-like queries"), those entries get their scores penalised *before* the cross-encoder sees them, freeing slots for entries that were previously crowded out.
|
|
45
|
-
|
|
46
|
-
The key detail: suppression is **cue-dependent, not global**. An entry suppressed for travel queries stays fully available for food queries. Global suppression was ~5× weaker in our benchmarks. The clustering is what made it work.
|
|
47
|
-
|
|
48
|
-
## Why this matters: a memory that evolves
|
|
49
|
-
|
|
50
|
-
Most memory stores are static caches. You put strings in; you retrieve them by similarity. The retrieval function never changes.
|
|
51
|
-
|
|
52
|
-
RIF makes retrieval behaviour **adapt over time without retraining any model**:
|
|
53
|
-
|
|
54
|
-
- Entries that keep losing to more relevant alternatives get quieter in their own cluster.
|
|
55
|
-
- Entries that keep winning get amplified via tier promotion (naive → gc → memory) and exemption from decay.
|
|
56
|
-
- The system learns "in the context of *this kind* of query, you usually mean *that* chunk, not *this* one" from its own retrieval history. No fine-tuning. No extra LLM calls. Just O(n_clusters × n_entries) bookkeeping.
|
|
57
|
-
|
|
58
|
-
Concrete example: you store both *"I prefer window seats on flights"* and *"my wife needs aisle seats"*. Travel queries keep picking your preference first. The system quietly penalises your wife's preference for travel-sounding queries, not globally, just in that cluster. Ask about your spouse's preferences and both entries remain equally findable.
|
|
59
|
-
|
|
60
|
-
Checkpoint 13 (clustered + rank-gap RIF) gives +6.5% NDCG and +9.5% recall@30 on LongMemEval over the baseline hybrid pipeline. As far as I can find, this is the first implementation of clustered RIF in a production retrieval system.
|
|
61
|
-
|
|
62
|
-
## Claude Code plugin
|
|
63
|
-
|
|
64
|
-
Lethe ships as a Claude Code plugin that persists memory across sessions per-project:
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
/plugin marketplace add teimurjan/lethe
|
|
68
|
-
/plugin install lethe
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Hooks write daily markdown to `.lethe/memory/YYYY-MM-DD.md`, summarize each turn with `claude -p --model haiku` (no extra API key), reindex via the `lethe` CLI, and a `memory-recall` skill surfaces prior-session context on demand.
|
|
72
|
-
|
|
73
|
-
See [plugins/claude-code/README.md](plugins/claude-code/README.md) for hook behavior, CLI reference, and local-install instructions.
|
|
74
|
-
|
|
75
|
-
## Quick start
|
|
76
|
-
|
|
77
|
-
```python
|
|
78
|
-
from lethe import MemoryStore
|
|
79
|
-
from sentence_transformers import SentenceTransformer, CrossEncoder
|
|
80
|
-
|
|
81
|
-
store = MemoryStore(
|
|
82
|
-
"./my_memories",
|
|
83
|
-
bi_encoder=SentenceTransformer("all-MiniLM-L6-v2"),
|
|
84
|
-
cross_encoder=CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2"),
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
store.add("I prefer window seats on flights", session_id="trip")
|
|
88
|
-
store.add("My wife needs aisle seats", session_id="trip")
|
|
89
|
-
store.add("I work at Google as a software engineer", session_id="work")
|
|
90
|
-
|
|
91
|
-
results = store.retrieve("What are my travel preferences?", k=5)
|
|
92
|
-
for entry_id, content, score in results:
|
|
93
|
-
print(f" [{score:.1f}] {content}")
|
|
94
|
-
|
|
95
|
-
store.save()
|
|
96
|
-
store.close()
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
## Architecture
|
|
100
|
-
|
|
101
|
-
```
|
|
102
|
-
Query
|
|
103
|
-
│
|
|
104
|
-
├── FAISS top-30 (dense vector similarity)
|
|
105
|
-
├── BM25 top-30 (sparse keyword match)
|
|
106
|
-
│
|
|
107
|
-
└── Merge (RRF)
|
|
108
|
-
│
|
|
109
|
-
└── RIF suppression penalty (per-cluster, gap-based)
|
|
110
|
-
│
|
|
111
|
-
└── Cross-encoder rerank → top-k
|
|
112
|
-
│
|
|
113
|
-
└── Update suppression state, affinities, tier
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
**Optional write-time LLM enrichment layer** (`src/lethe/enrichment.py`): before indexing, each memory can be processed by an LLM (default `claude-haiku-4-5`) to produce a gist, 3 anticipated queries, entities, and temporal markers. All fields index alongside the original text; cross-encoder still scores against original. Attacks the vocabulary-mismatch failure mode.
|
|
117
|
-
|
|
118
|
-
### RIF: technical details
|
|
119
|
-
|
|
120
|
-
The formula that made it work (checkpoint 13 of 17):
|
|
121
|
-
|
|
122
|
-
```
|
|
123
|
-
competition_strength = max(0, xenc_rank - initial_rank) / pool_size × sigmoid(-xenc_score)
|
|
124
|
-
suppression[eid, cluster] += learning_rate × competition_strength
|
|
125
|
-
score_adjusted = base_score - alpha × suppression[eid, cluster]
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
The rank-gap term only penalises entries that **dropped in rank** between the initial hybrid retrieval and the cross-encoder rerank AND were actively rejected (negative xenc score). Entries that just lost a close race aren't suppressed. That prevents the system from forgetting legitimately useful context because it happened to rank second once.
|
|
129
|
-
|
|
130
|
-
k-means runs once (30 clusters) on the bi-encoder query embedding at retrieval time; the cluster assignment is the "cue" coordinate. Based on Anderson's inhibition theory (1994) and the SAM competitive-sampling model (Raaijmakers & Shiffrin, 1981).
|
|
131
|
-
|
|
132
|
-
### Three storage layers
|
|
133
|
-
|
|
134
|
-
| Layer | File | Purpose |
|
|
135
|
-
|-------|------|---------|
|
|
136
|
-
| DuckDB | `lethe.duckdb` | Entries, suppression state, rescue cache, stats. Many project DBs can be `ATTACH`ed simultaneously for cross-project search. |
|
|
137
|
-
| numpy + FAISS | `embeddings.npz`, `faiss.index` | Vector storage |
|
|
138
|
-
| BM25 | In-memory, rebuilt on startup | Sparse keyword index |
|
|
139
|
-
|
|
140
|
-
### Entry lifecycle (germinal-center inspired)
|
|
141
|
-
|
|
142
|
-
```
|
|
143
|
-
NAIVE → GC → MEMORY
|
|
144
|
-
↓
|
|
145
|
-
APOPTOTIC
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
- **Naive**: new entries, unproven
|
|
149
|
-
- **GC**: retrieved 3+ times, actively evaluated
|
|
150
|
-
- **Memory**: high affinity + frequently retrieved, stable, exempt from decay
|
|
151
|
-
- **Apoptotic**: low affinity + idle > 1000 steps, excluded from search
|
|
152
|
-
|
|
153
|
-
Useful for long-running agents; doesn't directly improve retrieval quality (that's what RIF and enrichment do).
|
|
154
|
-
|
|
155
|
-
### Deduplication (on add)
|
|
156
|
-
|
|
157
|
-
1. **Exact**: SHA-256 content hash (free)
|
|
158
|
-
2. **Near-duplicate**: cosine similarity > 0.95 (keeps the longer entry)
|
|
159
|
-
|
|
160
|
-
## Install
|
|
161
|
-
|
|
162
|
-
From PyPI (the distribution name is `lethe-memory`; the import name is still `lethe`):
|
|
163
|
-
|
|
164
|
-
```bash
|
|
165
|
-
pip install lethe-memory
|
|
166
|
-
# or
|
|
167
|
-
uv pip install lethe-memory
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
As the `lethe` CLI on PATH without a venv:
|
|
171
|
-
|
|
172
|
-
```bash
|
|
173
|
-
uv tool install lethe-memory
|
|
174
|
-
lethe --version
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
From source, for development:
|
|
178
|
-
|
|
179
|
-
```bash
|
|
180
|
-
git clone https://github.com/teimurjan/lethe && cd lethe
|
|
181
|
-
uv venv --python 3.12 && uv pip install -e .
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
As a Claude Code plugin (see [plugin README](plugins/claude-code/README.md)):
|
|
185
|
-
|
|
186
|
-
```
|
|
187
|
-
/plugin marketplace add teimurjan/lethe
|
|
188
|
-
/plugin install lethe
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
## Global search across projects
|
|
192
|
-
|
|
193
|
-
Every `lethe index` registers the project in `~/.lethe/projects.json`. `lethe search --all` then ATTACHes each registered `lethe.duckdb` as a read-only schema and runs the full hybrid + RIF + cross-encoder pipeline across the union.
|
|
194
|
-
|
|
195
|
-
```bash
|
|
196
|
-
# In each project you care about:
|
|
197
|
-
lethe index
|
|
198
|
-
|
|
199
|
-
# Search across all registered projects:
|
|
200
|
-
lethe search "mongodb pool" --all --top-k 5
|
|
201
|
-
# [p_site_fix_pentest_8a99] [+4.63] 06e6... - MongoDB connection pool issue at 100% rollout…
|
|
202
|
-
# [p_lethe_77be] [+3.41] 4670... - lethe search returning real hybrid results…
|
|
203
|
-
|
|
204
|
-
# Restrict to a subset:
|
|
205
|
-
lethe search "auth flow" --projects p_site_fix_pentest_8a99,p_api_server_12ab
|
|
206
|
-
|
|
207
|
-
# Inspect / manage the registry:
|
|
208
|
-
lethe projects list
|
|
209
|
-
lethe projects add /path/to/other-project
|
|
210
|
-
lethe projects remove <slug-or-path>
|
|
211
|
-
lethe projects prune # drop entries whose roots no longer exist
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
Opt out of auto-registration in three ways:
|
|
215
|
-
- Per invocation: `lethe index --no-register`
|
|
216
|
-
- Per project: `lethe config set auto_register false`
|
|
217
|
-
- Globally: `export LETHE_DISABLE_GLOBAL_REGISTRY=1`
|
|
218
|
-
|
|
219
|
-
The registry only stores absolute project paths, never contents. Cross-project search is read-only; it does not mutate RIF state. Local `lethe search` continues to learn as normal.
|
|
220
|
-
|
|
221
|
-
DuckDB has no cap on attached databases, so the 10/125 ceiling in SQLite's `ATTACH DATABASE` doesn't apply. Real-world bottleneck before you'd feel it is the per-project BM25 rebuild, which scales linearly.
|
|
222
|
-
|
|
223
|
-
## Benchmark
|
|
224
|
-
|
|
225
|
-
```bash
|
|
226
|
-
# prep LongMemEval
|
|
227
|
-
uv run python experiments/data_prep.py --dataset longmemeval
|
|
228
|
-
|
|
229
|
-
# retrieval-only baseline + RIF variants
|
|
230
|
-
uv run python benchmarks/run_benchmark.py
|
|
231
|
-
uv run python benchmarks/run_rif_benchmark.py
|
|
232
|
-
|
|
233
|
-
# LLM enrichment layer (needs ANTHROPIC_API_KEY)
|
|
234
|
-
export ANTHROPIC_API_KEY=sk-ant-...
|
|
235
|
-
uv run python experiments/enrich_longmemeval.py # one-time, ~$16 for 10k entries
|
|
236
|
-
uv run python benchmarks/run_rif_enriched.py # 3-arm benchmark
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
See [BENCHMARKS.md](BENCHMARKS.md) for results tables and methodology details.
|
|
240
|
-
|
|
241
|
-
## Project structure
|
|
242
|
-
|
|
243
|
-
```
|
|
244
|
-
src/lethe/ # Production library (162 tests, ~95% coverage)
|
|
245
|
-
├── memory_store.py
|
|
246
|
-
├── markdown_store.py # Markdown chunker for .lethe/memory/*.md
|
|
247
|
-
├── cli.py # `lethe` console script
|
|
248
|
-
├── union_store.py # Cross-project read-only search via DuckDB ATTACH
|
|
249
|
-
├── db.py # DuckDB persistence
|
|
250
|
-
├── vectors.py # FAISS + BM25 index
|
|
251
|
-
├── reranker.py # Cross-encoder + adaptive depth
|
|
252
|
-
├── rif.py # Retrieval-induced forgetting (clustered + gap)
|
|
253
|
-
├── enrichment.py # Optional LLM write-time enrichment (Anthropic SDK)
|
|
254
|
-
├── dedup.py # Hash + cosine deduplication
|
|
255
|
-
└── entry.py # MemoryEntry dataclass + Tier enum
|
|
256
|
-
|
|
257
|
-
plugins/claude-code/ # Claude Code plugin (hooks + memory-recall skill)
|
|
258
|
-
.claude-plugin/ # Marketplace manifest
|
|
259
|
-
|
|
260
|
-
benchmarks/ # Per-checkpoint benchmark scripts
|
|
261
|
-
├── run_*.py
|
|
262
|
-
├── _lib/ # Benchmark-only helpers (metrics, etc.)
|
|
263
|
-
└── results/ # Raw per-run output markdowns
|
|
264
|
-
|
|
265
|
-
scripts/ # Reproducibility utilities (dataset prep, enrichment runner)
|
|
266
|
-
|
|
267
|
-
research/ # Experimental / research code (not production)
|
|
268
|
-
├── gc_mutation/ # Germinal-center mutation thread (checkpoints 1-10)
|
|
269
|
-
└── sdm/ # Sparse Distributed Memory prototype (checkpoint 15)
|
|
270
|
-
|
|
271
|
-
tests/ # Production unit tests
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
## Research background
|
|
275
|
-
|
|
276
|
-
This project started as an experiment porting the immune system's germinal-center mechanism to vector memory. 17 checkpoints so far:
|
|
277
|
-
|
|
278
|
-
1. **Checkpoints 1-10** (GC-mutation approaches): all failed to improve retrieval quality. Useful for lifecycle management, not retrieval.
|
|
279
|
-
2. **Checkpoints 11-13** (RIF): cognitive-science inspired. Clustered + gap-formula variant gives +6.5% NDCG, +9.5% recall@30 on the full 500-query eval. First positive learned-retrieval result.
|
|
280
|
-
3. **Checkpoints 14-15** (exploration/rescue, Sparse Distributed Memory prototype): both negative at full scale.
|
|
281
|
-
4. **Checkpoint 16** (extended behavior metrics for checkpoint 13): RIF's gain is primarily from reducing cross-topic retrieval (−1.6pp wrong_family); sibling confusion and stale-fact rate unchanged.
|
|
282
|
-
5. **Checkpoint 17** (LLM enrichment layer): write-time structured extraction via Haiku. On covered queries: +8.3pp NDCG over checkpoint 13, the largest single-lever gain in the journey. Overall diluted by partial coverage; scaling to full coverage is pending.
|
|
283
|
-
|
|
284
|
-
Full journey in [RESEARCH_JOURNEY.md](RESEARCH_JOURNEY.md).
|
|
285
|
-
|
|
286
|
-
## License
|
|
287
|
-
|
|
288
|
-
MIT.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|