Skip to content

Commit 6b2baff

Browse files
jkyberneeesclaude
andcommitted
feat(memory): episode lifecycle — dedup-on-write + eviction (cap/TTL)
EpisodeStore had no dedup and no eviction: episodes accumulated forever and near-duplicate sessions piled up unbounded (disk growth, noisier recall). This adds bounded, de-duplicated episode storage, config-gated with safe defaults, no on-disk format change. - Dedup-on-write: a new episode whose cosine similarity to an existing one is >= episode_dedup_threshold (default 0.92) REPLACES it (newest-wins). Uses an ephemeral RP embedder (the NewRPRanker primitive) over full on-disk summaries — never the shared dirty-rebuild vector index, avoiding mid-write re-entrancy. Provenance-gated: an untrusted near-dup can never evict a trusted/approved episode (trustRank mirrors the recall filter). - Eviction: prune-on-write applies TTL (episode_ttl_days, default 0/off) then a count cap (max_episodes, default 500), deleting both the .md file and the index entry. Crash-safe order: files -> writeIndex -> markDirty. Also exposes EpisodeStore.Prune() for session-end/CLI use. - Locking: dedup + .md write + index update + prune + markDirty now happen under a single e.mu hold (new writeLocked). Fixes a latent bug where re-writing the same sessionID appended a duplicate index entry. - Config: EpisodeDedupThreshold / MaxEpisodes / EpisodeTTLDays wired through MemoryConfig, DefaultMemoryConfig, both overlay sites, and a new NewEpisodeStoreWithLifecycle (bare NewEpisodeStore keeps lifecycle off, so existing callers/tests are unaffected). Documented in docs/CONFIG.md. Fact supersession is deferred (facts lack per-entry metadata; already covered by merge-on-write + session-end LLM consolidation). Tests: dedup replace/threshold/disabled, provenance safety (both directions), eviction by count + TTL, TTL-disabled, self-overwrite regression, evicted-id absent from recall, -race concurrency (16 goroutines), config defaults + overlay-to-store wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7662fe0 commit 6b2baff

5 files changed

Lines changed: 566 additions & 21 deletions

File tree

docs/CONFIG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
203203
"llm_consolidate": true,
204204
"merge_threshold": 0.7,
205205
"add_threshold": 0.3,
206-
"auto_approve_episodes": false
206+
"auto_approve_episodes": false,
207+
"episode_dedup_threshold": 0.92,
208+
"max_episodes": 500,
209+
"episode_ttl_days": 0
207210
}
208211
}
209212
```
@@ -225,6 +228,9 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
225228
| `merge_threshold` | 0.7 | Cosine similarity above which two fact entries are **auto-merged** without an LLM call (0.0–1.0). Raise it to merge less aggressively; lower it to merge more. |
226229
| `add_threshold` | 0.3 | Cosine similarity below which a new fact entry is **auto-added** without an LLM call (0.0–1.0). Between `add_threshold` and `merge_threshold` the LLM decides. Keep `add_threshold` < `merge_threshold`. |
227230
| `auto_approve_episodes` | false | **Security trade-off.** When true, untrusted episodes (sessions that touched web/MCP/out-of-workspace content) are auto-approved at session end so they are recalled without a manual `odek memory promote`. Leaving it `false` keeps the human review gate (recommended). |
231+
| `episode_dedup_threshold` | 0.92 | Cosine similarity above which a newly written episode is treated as a near-duplicate of an existing one and **replaces** it (newest wins). An untrusted episode never replaces a trusted/approved one. `0` disables dedup. |
232+
| `max_episodes` | 500 | Maximum number of stored episodes. On each write, episodes beyond this count are evicted oldest-first (both the summary file and the index entry). `0` disables the cap. |
233+
| `episode_ttl_days` | 0 | Evict episodes older than this many days. `0` (default) disables TTL-based eviction. |
228234

229235
### ⚠️ `extract_facts` — automatic fact learning (opt-in, off by default)
230236

internal/config/loader.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,15 @@ func resolveMemory(cfg *memory.MemoryConfig) memory.MemoryConfig {
859859
if cfg.AutoApproveEpisodes != nil {
860860
def.AutoApproveEpisodes = cfg.AutoApproveEpisodes
861861
}
862+
if cfg.EpisodeDedupThreshold > 0 {
863+
def.EpisodeDedupThreshold = cfg.EpisodeDedupThreshold
864+
}
865+
if cfg.MaxEpisodes > 0 {
866+
def.MaxEpisodes = cfg.MaxEpisodes
867+
}
868+
if cfg.EpisodeTTLDays > 0 {
869+
def.EpisodeTTLDays = cfg.EpisodeTTLDays
870+
}
862871
return def
863872
}
864873

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
package memory
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
"testing"
10+
"time"
11+
)
12+
13+
// countMDFiles returns the number of *.md episode summary files in dir.
14+
func countMDFiles(t *testing.T, dir string) int {
15+
t.Helper()
16+
matches, err := filepath.Glob(filepath.Join(dir, "*.md"))
17+
if err != nil {
18+
t.Fatalf("glob: %v", err)
19+
}
20+
return len(matches)
21+
}
22+
23+
// writeIndexDirect writes index.json + matching .md files straight to disk,
24+
// bypassing WriteWithProvenance so tests can control CreatedAt (e.g. backdate
25+
// for TTL). Returns nothing; fatals on error.
26+
func writeIndexDirect(t *testing.T, dir string, metas []EpisodeMeta) {
27+
t.Helper()
28+
if err := os.MkdirAll(dir, 0700); err != nil {
29+
t.Fatal(err)
30+
}
31+
for _, m := range metas {
32+
if err := os.WriteFile(filepath.Join(dir, m.SessionID+".md"), []byte(m.Summary), 0600); err != nil {
33+
t.Fatal(err)
34+
}
35+
}
36+
data, err := json.MarshalIndent(metas, "", " ")
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
if err := os.WriteFile(filepath.Join(dir, episodeIndexFile), data, 0600); err != nil {
41+
t.Fatal(err)
42+
}
43+
}
44+
45+
// ── Dedup ──────────────────────────────────────────────────────────────
46+
47+
func TestEpisodeDedup_ReplaceNewestWins(t *testing.T) {
48+
resetEpIdxes()
49+
dir := t.TempDir()
50+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0.92, 0, 0)
51+
52+
const summary = "refactored the postgres connection pool and added retry logic"
53+
writeTestEpisode(t, es, "20260101-a", summary)
54+
time.Sleep(2 * time.Millisecond)
55+
writeTestEpisode(t, es, "20260102-b", summary) // identical → cosine 1.0 → replace
56+
57+
idx, err := es.ReadIndex()
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
if len(idx) != 1 {
62+
t.Fatalf("expected 1 episode after dedup, got %d", len(idx))
63+
}
64+
if idx[0].SessionID != "20260102-b" {
65+
t.Errorf("expected newest to win (20260102-b), kept %q", idx[0].SessionID)
66+
}
67+
if n := countMDFiles(t, dir); n != 1 {
68+
t.Errorf("expected 1 .md file after dedup, got %d", n)
69+
}
70+
}
71+
72+
func TestEpisodeDedup_BelowThresholdKeepsBoth(t *testing.T) {
73+
resetEpIdxes()
74+
dir := t.TempDir()
75+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0.92, 0, 0)
76+
77+
// Disjoint vocabulary → cosine well below 0.92.
78+
writeTestEpisode(t, es, "20260101-a", "refactored the postgres database schema migration")
79+
writeTestEpisode(t, es, "20260102-b", "updated frontend button hover animation styling")
80+
81+
idx, _ := es.ReadIndex()
82+
if len(idx) != 2 {
83+
t.Fatalf("expected 2 distinct episodes, got %d", len(idx))
84+
}
85+
}
86+
87+
func TestEpisodeDedup_Disabled(t *testing.T) {
88+
resetEpIdxes()
89+
dir := t.TempDir()
90+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0, 0, 0) // dedup off
91+
92+
const summary = "identical episode summary content here"
93+
writeTestEpisode(t, es, "20260101-a", summary)
94+
writeTestEpisode(t, es, "20260102-b", summary)
95+
96+
idx, _ := es.ReadIndex()
97+
if len(idx) != 2 {
98+
t.Fatalf("dedup disabled should keep both identical episodes, got %d", len(idx))
99+
}
100+
}
101+
102+
func TestEpisodeDedup_ProvenanceSafety(t *testing.T) {
103+
const summary = "investigated the auth token refresh flow in detail"
104+
105+
t.Run("untrusted does not evict trusted", func(t *testing.T) {
106+
resetEpIdxes()
107+
dir := t.TempDir()
108+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0.92, 0, 0)
109+
110+
if err := es.WriteWithProvenance("20260101-a", summary, 5, EpisodeProvenance{}); err != nil {
111+
t.Fatal(err) // trusted
112+
}
113+
if err := es.WriteWithProvenance("20260102-b", summary, 5, EpisodeProvenance{Untrusted: true}); err != nil {
114+
t.Fatal(err) // untrusted near-dup
115+
}
116+
117+
idx, _ := es.ReadIndex()
118+
if len(idx) != 2 {
119+
t.Fatalf("untrusted near-dup must NOT replace trusted; expected 2, got %d", len(idx))
120+
}
121+
// The trusted episode must still be present.
122+
var trustedPresent bool
123+
for _, m := range idx {
124+
if m.SessionID == "20260101-a" && !m.Provenance.Untrusted {
125+
trustedPresent = true
126+
}
127+
}
128+
if !trustedPresent {
129+
t.Error("trusted episode 20260101-a was lost")
130+
}
131+
})
132+
133+
t.Run("trusted evicts untrusted", func(t *testing.T) {
134+
resetEpIdxes()
135+
dir := t.TempDir()
136+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0.92, 0, 0)
137+
138+
if err := es.WriteWithProvenance("20260101-a", summary, 5, EpisodeProvenance{Untrusted: true}); err != nil {
139+
t.Fatal(err) // untrusted
140+
}
141+
if err := es.WriteWithProvenance("20260102-b", summary, 5, EpisodeProvenance{}); err != nil {
142+
t.Fatal(err) // trusted near-dup → may replace (upgrade)
143+
}
144+
145+
idx, _ := es.ReadIndex()
146+
if len(idx) != 1 {
147+
t.Fatalf("trusted near-dup should replace untrusted; expected 1, got %d", len(idx))
148+
}
149+
if idx[0].SessionID != "20260102-b" || idx[0].Provenance.Untrusted {
150+
t.Errorf("expected trusted 20260102-b to remain, got %+v", idx[0])
151+
}
152+
})
153+
}
154+
155+
// ── Eviction ───────────────────────────────────────────────────────────
156+
157+
func TestEpisodeEviction_ByCount(t *testing.T) {
158+
resetEpIdxes()
159+
dir := t.TempDir()
160+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0, 3, 0) // dedup off, cap 3
161+
162+
for i := 0; i < 5; i++ {
163+
writeTestEpisode(t, es, fmt.Sprintf("20260101-%02d", i),
164+
fmt.Sprintf("session number %d distinct work item alpha%d", i, i))
165+
time.Sleep(2 * time.Millisecond) // ensure strictly increasing CreatedAt
166+
}
167+
168+
idx, _ := es.ReadIndex()
169+
if len(idx) != 3 {
170+
t.Fatalf("expected cap of 3, got %d", len(idx))
171+
}
172+
if n := countMDFiles(t, dir); n != 3 {
173+
t.Errorf("expected 3 .md files after eviction, got %d", n)
174+
}
175+
// The 3 newest (02,03,04) must remain; oldest (00,01) evicted.
176+
for _, m := range idx {
177+
if m.SessionID == "20260101-00" || m.SessionID == "20260101-01" {
178+
t.Errorf("oldest episode %q should have been evicted", m.SessionID)
179+
}
180+
}
181+
}
182+
183+
func TestEpisodeEviction_ByTTL(t *testing.T) {
184+
resetEpIdxes()
185+
dir := t.TempDir()
186+
now := time.Now().UTC()
187+
188+
// Two fresh, one 10 days old.
189+
writeIndexDirect(t, dir, []EpisodeMeta{
190+
{SessionID: "20260101-a", CreatedAt: now.Add(-1 * time.Hour), Summary: "fresh one"},
191+
{SessionID: "20260101-b", CreatedAt: now.Add(-2 * time.Hour), Summary: "fresh two"},
192+
{SessionID: "20260101-c", CreatedAt: now.Add(-10 * 24 * time.Hour), Summary: "stale one"},
193+
})
194+
195+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0, 0, 7) // TTL 7 days
196+
if err := es.Prune(); err != nil {
197+
t.Fatalf("Prune: %v", err)
198+
}
199+
200+
idx, _ := es.ReadIndex()
201+
if len(idx) != 2 {
202+
t.Fatalf("expected 2 fresh episodes after TTL prune, got %d", len(idx))
203+
}
204+
for _, m := range idx {
205+
if m.SessionID == "20260101-c" {
206+
t.Error("stale episode 20260101-c should have been evicted by TTL")
207+
}
208+
}
209+
if _, err := os.Stat(filepath.Join(dir, "20260101-c.md")); !os.IsNotExist(err) {
210+
t.Error("stale .md file should have been removed")
211+
}
212+
}
213+
214+
func TestEpisodeEviction_TTLDisabled(t *testing.T) {
215+
resetEpIdxes()
216+
dir := t.TempDir()
217+
now := time.Now().UTC()
218+
writeIndexDirect(t, dir, []EpisodeMeta{
219+
{SessionID: "20260101-a", CreatedAt: now.Add(-365 * 24 * time.Hour), Summary: "ancient"},
220+
})
221+
222+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0, 0, 0) // TTL disabled
223+
if err := es.Prune(); err != nil {
224+
t.Fatalf("Prune: %v", err)
225+
}
226+
idx, _ := es.ReadIndex()
227+
if len(idx) != 1 {
228+
t.Fatalf("TTL disabled must retain old episodes, got %d", len(idx))
229+
}
230+
}
231+
232+
// ── Self-overwrite ─────────────────────────────────────────────────────
233+
234+
func TestEpisodeWrite_SelfOverwriteSingleEntry(t *testing.T) {
235+
resetEpIdxes()
236+
dir := t.TempDir()
237+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0, 0, 0)
238+
239+
writeTestEpisode(t, es, "20260101-a", "first version of the summary")
240+
writeTestEpisode(t, es, "20260101-a", "second version of the summary")
241+
242+
idx, _ := es.ReadIndex()
243+
if len(idx) != 1 {
244+
t.Fatalf("re-writing same sessionID must not duplicate the index entry, got %d", len(idx))
245+
}
246+
full, _ := es.Read("20260101-a")
247+
if full != "second version of the summary" {
248+
t.Errorf("expected the latest summary on disk, got %q", full)
249+
}
250+
}
251+
252+
// ── Recall consistency ─────────────────────────────────────────────────
253+
254+
func TestEpisodeLifecycle_EvictedAbsentFromRecall(t *testing.T) {
255+
resetEpIdxes()
256+
dir := t.TempDir()
257+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0, 2, 0) // cap 2
258+
259+
writeTestEpisode(t, es, "20260101-00", "postgres database connection pooling work")
260+
time.Sleep(2 * time.Millisecond)
261+
writeTestEpisode(t, es, "20260101-01", "redis caching layer latency tuning")
262+
time.Sleep(2 * time.Millisecond)
263+
writeTestEpisode(t, es, "20260101-02", "kafka consumer rebalance bugfix") // evicts -00
264+
265+
got, err := es.recallByVector("postgres database", 5)
266+
if err != nil {
267+
t.Fatalf("recallByVector: %v", err)
268+
}
269+
for _, m := range got {
270+
if m.SessionID == "20260101-00" {
271+
t.Error("evicted episode 20260101-00 must not appear in recall (index should have rebuilt)")
272+
}
273+
}
274+
}
275+
276+
// ── Concurrency ────────────────────────────────────────────────────────
277+
278+
func TestEpisodeLifecycle_ConcurrentSafety(t *testing.T) {
279+
resetEpIdxes()
280+
dir := t.TempDir()
281+
const maxEp = 5
282+
es := NewEpisodeStoreWithLifecycle(dir, nil, 0.92, maxEp, 0)
283+
284+
var wg sync.WaitGroup
285+
for i := 0; i < 16; i++ {
286+
wg.Add(1)
287+
go func(i int) {
288+
defer wg.Done()
289+
// Mix of distinct and overlapping (near-dup) summaries.
290+
id := fmt.Sprintf("20260101-%02d", i)
291+
summary := fmt.Sprintf("worked on subsystem %d with shared common boilerplate text", i%4)
292+
_ = es.WriteWithProvenance(id, summary, 5, EpisodeProvenance{})
293+
_, _ = es.recallByVector("subsystem common", 3)
294+
}(i)
295+
}
296+
wg.Wait()
297+
298+
idx, err := es.ReadIndex()
299+
if err != nil {
300+
t.Fatalf("ReadIndex: %v", err)
301+
}
302+
if len(idx) > maxEp {
303+
t.Errorf("index length %d exceeds cap %d", len(idx), maxEp)
304+
}
305+
}
306+
307+
// ── Config wiring ──────────────────────────────────────────────────────
308+
309+
func TestMemoryConfig_EpisodeLifecycleDefaults(t *testing.T) {
310+
d := DefaultMemoryConfig()
311+
if d.EpisodeDedupThreshold != defaultEpisodeDedupThreshold {
312+
t.Errorf("EpisodeDedupThreshold default = %v, want %v", d.EpisodeDedupThreshold, defaultEpisodeDedupThreshold)
313+
}
314+
if d.MaxEpisodes != defaultMaxEpisodes {
315+
t.Errorf("MaxEpisodes default = %d, want %d", d.MaxEpisodes, defaultMaxEpisodes)
316+
}
317+
if d.EpisodeTTLDays != 0 {
318+
t.Errorf("EpisodeTTLDays default = %d, want 0 (disabled)", d.EpisodeTTLDays)
319+
}
320+
}
321+
322+
func TestMemoryConfig_EpisodeLifecycleOverlayWiredToStore(t *testing.T) {
323+
resetEpIdxes()
324+
dir := t.TempDir()
325+
cfg := DefaultMemoryConfig()
326+
cfg.EpisodeDedupThreshold = 0 // disable dedup to isolate the cap
327+
cfg.MaxEpisodes = 2
328+
mm := NewMemoryManager(dir, nil, cfg)
329+
330+
for i := 0; i < 4; i++ {
331+
_ = mm.episodes.WriteWithProvenance(fmt.Sprintf("20260101-%02d", i),
332+
fmt.Sprintf("distinct task %d zeta%d", i, i), 5, EpisodeProvenance{})
333+
time.Sleep(2 * time.Millisecond)
334+
}
335+
idx, _ := mm.episodes.ReadIndex()
336+
if len(idx) != 2 {
337+
t.Fatalf("NewMemoryManager should wire MaxEpisodes=2 into the store; got %d", len(idx))
338+
}
339+
}

0 commit comments

Comments
 (0)