Skip to content

Commit 6d2cecc

Browse files
jkyberneeesclaude
andauthored
feat(memory): episode lifecycle — dedup-on-write + eviction (cap/TTL) (#17)
* 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> * fix(memory): validate sessionID before deleting episode files Adversarial review (AI Verification Protocol) found a path-traversal defense-in-depth gap: eviction/dedup called os.Remove on sessionIDs read straight from index.json without validation, so a crafted/corrupted index entry (e.g. "../victim") could delete a .md file OUTSIDE the episodes dir. Every other file op in the package (Read/Write/Promote) already validates. Add removeEpisodeFile(sessionID) which calls session.ValidateSessionID before os.Remove, and route all three eviction/dedup deletions through it. Adds a traversal-safe regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7662fe0 commit 6d2cecc

5 files changed

Lines changed: 608 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

0 commit comments

Comments
 (0)