Skip to main content

lean_ctx/core/
episodic_memory.rs

1//! Episodic Memory — persistent cross-session experiences with outcomes.
2//!
3//! Automatically records what the agent did in each session, with what result.
4//! Enables learning from past experiences: "What happened last time I refactored auth?"
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::{
9    collections::HashMap,
10    path::{Path, PathBuf},
11    sync::{Arc, Mutex, OnceLock},
12};
13
14use crate::core::memory_policy::EpisodicPolicy;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EpisodicStore {
18    pub project_hash: String,
19    pub episodes: Vec<Episode>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Episode {
24    pub id: String,
25    pub session_id: String,
26    pub timestamp: DateTime<Utc>,
27    pub task_description: String,
28    pub actions: Vec<Action>,
29    pub outcome: Outcome,
30    pub affected_files: Vec<String>,
31    pub summary: String,
32    pub duration_secs: u64,
33    pub tokens_used: u64,
34    #[serde(default)]
35    pub agent_id: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Action {
40    pub tool: String,
41    pub description: String,
42    pub timestamp: DateTime<Utc>,
43    pub duration_ms: u64,
44    pub success: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub enum Outcome {
49    Success { tests_passed: bool },
50    Failure { error: String },
51    Partial { details: String },
52    Unknown,
53}
54
55impl Outcome {
56    pub fn label(&self) -> &'static str {
57        match self {
58            Outcome::Success { .. } => "success",
59            Outcome::Failure { .. } => "failure",
60            Outcome::Partial { .. } => "partial",
61            Outcome::Unknown => "unknown",
62        }
63    }
64}
65
66fn episodic_lock(project_hash: &str) -> Arc<Mutex<()>> {
67    static LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
68    let mut locks = LOCKS
69        .get_or_init(|| Mutex::new(HashMap::new()))
70        .lock()
71        .unwrap_or_else(std::sync::PoisonError::into_inner);
72    locks.entry(project_hash.to_string()).or_default().clone()
73}
74
75fn acquire_file_lock(path: &Path) -> Option<std::fs::File> {
76    use fs2::FileExt;
77    let parent = path.parent()?;
78    let name = path.file_name()?.to_string_lossy();
79    let lock_path = parent.join(format!(".{name}.lock"));
80    let file = std::fs::OpenOptions::new()
81        .create(true)
82        .truncate(false)
83        .write(true)
84        .open(&lock_path)
85        .ok()?;
86    #[cfg(unix)]
87    {
88        use std::os::unix::fs::PermissionsExt;
89        let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
90    }
91    file.lock_exclusive().ok()?;
92    Some(file)
93}
94
95impl EpisodicStore {
96    pub fn new(project_hash: &str) -> Self {
97        Self {
98            project_hash: project_hash.to_string(),
99            episodes: Vec::new(),
100        }
101    }
102
103    pub fn record_episode(&mut self, mut episode: Episode, policy: &EpisodicPolicy) {
104        episode.actions.truncate(policy.max_actions_per_episode);
105
106        if episode.summary.is_empty() {
107            episode.summary = auto_summarize(&episode, policy.summary_max_chars);
108        }
109
110        self.episodes.push(episode);
111
112        if self.episodes.len() > policy.max_episodes {
113            self.episodes
114                .drain(0..self.episodes.len() - policy.max_episodes);
115        }
116    }
117
118    pub fn search(&self, query: &str) -> Vec<&Episode> {
119        let q = query.to_lowercase();
120        let terms: Vec<&str> = q.split_whitespace().collect();
121
122        let mut scored: Vec<(&Episode, f32)> = self
123            .episodes
124            .iter()
125            .filter_map(|ep| {
126                let searchable = format!(
127                    "{} {} {}",
128                    ep.task_description.to_lowercase(),
129                    ep.summary.to_lowercase(),
130                    ep.affected_files.join(" ").to_lowercase()
131                );
132                let hits = terms.iter().filter(|t| searchable.contains(**t)).count();
133                if hits > 0 {
134                    let relevance = hits as f32 / terms.len() as f32;
135                    Some((ep, relevance))
136                } else {
137                    None
138                }
139            })
140            .collect();
141
142        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
143        scored.into_iter().map(|(ep, _)| ep).collect()
144    }
145
146    pub fn recent(&self, n: usize) -> Vec<&Episode> {
147        self.episodes.iter().rev().take(n).collect()
148    }
149
150    pub fn by_outcome(&self, outcome_label: &str) -> Vec<&Episode> {
151        self.episodes
152            .iter()
153            .filter(|ep| ep.outcome.label() == outcome_label)
154            .collect()
155    }
156
157    pub fn by_file(&self, file_path: &str) -> Vec<&Episode> {
158        self.episodes
159            .iter()
160            .filter(|ep| ep.affected_files.iter().any(|f| f.contains(file_path)))
161            .collect()
162    }
163
164    pub fn stats(&self) -> EpisodicStats {
165        let total = self.episodes.len();
166        let successes = self
167            .episodes
168            .iter()
169            .filter(|ep| matches!(ep.outcome, Outcome::Success { .. }))
170            .count();
171        let failures = self
172            .episodes
173            .iter()
174            .filter(|ep| matches!(ep.outcome, Outcome::Failure { .. }))
175            .count();
176        let total_tokens: u64 = self.episodes.iter().map(|ep| ep.tokens_used).sum();
177
178        EpisodicStats {
179            total_episodes: total,
180            successes,
181            failures,
182            success_rate: if total > 0 {
183                successes as f32 / total as f32
184            } else {
185                0.0
186            },
187            total_tokens,
188        }
189    }
190
191    fn store_path(project_hash: &str) -> Option<PathBuf> {
192        let dir = crate::core::data_dir::lean_ctx_data_dir()
193            .ok()?
194            .join("memory")
195            .join("episodes");
196        Some(dir.join(format!("{project_hash}.json")))
197    }
198
199    pub fn load(project_hash: &str) -> Option<Self> {
200        let path = Self::store_path(project_hash)?;
201        let data = std::fs::read_to_string(path).ok()?;
202        let mut store: Self = serde_json::from_str(&data).ok()?;
203        if store.migrate_legacy_episodes() {
204            let _ = store.save();
205        }
206        Some(store)
207    }
208
209    /// One-time repair for episodes recorded before per-task metrics existed:
210    /// those episodes share one id per session (derived from the session
211    /// *start* date) and carry cumulative session token counters instead of
212    /// per-task deltas. Detected via duplicate ids within one session;
213    /// idempotent because rewritten ids are unique afterwards.
214    fn migrate_legacy_episodes(&mut self) -> bool {
215        use std::collections::HashSet;
216
217        let mut seen: HashSet<(String, String)> = HashSet::new();
218        let mut needs_migration = false;
219        for ep in &self.episodes {
220            if !seen.insert((ep.session_id.clone(), ep.id.clone())) {
221                needs_migration = true;
222                break;
223            }
224        }
225        if !needs_migration {
226            return false;
227        }
228
229        let mut sessions: HashSet<String> = HashSet::new();
230        for ep in &self.episodes {
231            sessions.insert(ep.session_id.clone());
232        }
233
234        for session_id in sessions {
235            let mut idx: Vec<usize> = (0..self.episodes.len())
236                .filter(|&i| self.episodes[i].session_id == session_id)
237                .collect();
238            idx.sort_by_key(|&i| self.episodes[i].timestamp);
239
240            // Cumulative counters are monotonically non-decreasing; only
241            // then is converting to deltas safe.
242            let monotonic = idx
243                .windows(2)
244                .all(|w| self.episodes[w[0]].tokens_used <= self.episodes[w[1]].tokens_used);
245
246            let mut prev_tokens: u64 = 0;
247            let mut prev_ts: Option<DateTime<Utc>> = None;
248            let mut used_ids: HashSet<String> = HashSet::new();
249            for &i in &idx {
250                let ts = self.episodes[i].timestamp;
251                let ep = &mut self.episodes[i];
252                let mut id = format!("ep-{}", ts.format("%Y%m%d-%H%M%S"));
253                let mut n = 1;
254                while !used_ids.insert(id.clone()) {
255                    n += 1;
256                    id = format!("ep-{}-{n}", ts.format("%Y%m%d-%H%M%S"));
257                }
258                ep.id = id;
259                if monotonic {
260                    let cumulative = ep.tokens_used;
261                    ep.tokens_used = cumulative.saturating_sub(prev_tokens);
262                    prev_tokens = cumulative;
263                }
264                if ep.duration_secs == 0
265                    && let Some(p) = prev_ts
266                {
267                    ep.duration_secs = (ts - p).num_seconds().max(0) as u64;
268                }
269                prev_ts = Some(ts);
270            }
271        }
272        true
273    }
274
275    pub fn load_or_create(project_hash: &str) -> Self {
276        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
277    }
278
279    pub fn mutate_locked<T>(
280        project_hash: &str,
281        mutate: impl FnOnce(&mut Self) -> T,
282    ) -> Result<(Self, T), String> {
283        let lock = episodic_lock(project_hash);
284        let _guard = lock
285            .lock()
286            .unwrap_or_else(std::sync::PoisonError::into_inner);
287
288        let path = Self::store_path(project_hash)
289            .ok_or_else(|| "Cannot determine data directory".to_string())?;
290        if let Some(dir) = path.parent() {
291            std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
292        }
293        let _file_lock = acquire_file_lock(&path);
294
295        let mut store = Self::load_or_create(project_hash);
296        let result = mutate(&mut store);
297        store.save()?;
298        Ok((store, result))
299    }
300
301    pub fn save(&self) -> Result<(), String> {
302        let path = Self::store_path(&self.project_hash)
303            .ok_or_else(|| "Cannot determine data directory".to_string())?;
304        if let Some(dir) = path.parent() {
305            std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
306        }
307        let json = serde_json::to_string_pretty(self).map_err(|e| format!("{e}"))?;
308        crate::core::atomic_fs::write_bytes_with_fallback(&path, json.as_bytes(), None)
309    }
310}
311
312#[derive(Debug)]
313pub struct EpisodicStats {
314    pub total_episodes: usize,
315    pub successes: usize,
316    pub failures: usize,
317    pub success_rate: f32,
318    pub total_tokens: u64,
319}
320
321pub fn create_episode_from_session(
322    session: &super::session::SessionState,
323    tool_calls: &[(String, u64)],
324) -> Episode {
325    let actions: Vec<Action> = tool_calls
326        .iter()
327        .map(|(tool, duration_ms)| Action {
328            tool: tool.clone(),
329            description: String::new(),
330            timestamp: Utc::now(),
331            duration_ms: *duration_ms,
332            success: true,
333        })
334        .collect();
335
336    let affected_files: Vec<String> = session
337        .files_touched
338        .iter()
339        .map(|f| f.path.clone())
340        .collect();
341
342    let task_description = session
343        .task
344        .as_ref()
345        .map(|t| t.description.clone())
346        .unwrap_or_default();
347
348    let outcome = if session.findings.iter().any(|f| {
349        f.summary.to_lowercase().contains("error") || f.summary.to_lowercase().contains("failed")
350    }) {
351        Outcome::Failure {
352            error: session
353                .findings
354                .iter()
355                .find(|f| {
356                    f.summary.to_lowercase().contains("error")
357                        || f.summary.to_lowercase().contains("failed")
358                })
359                .map(|f| f.summary.clone())
360                .unwrap_or_default(),
361        }
362    } else if !session.findings.is_empty() || !session.decisions.is_empty() {
363        Outcome::Success { tests_passed: true }
364    } else {
365        Outcome::Unknown
366    };
367
368    Episode {
369        // Record-time based id: session ids start with the session *start*
370        // date, so deriving the episode id from them produced colliding ids
371        // for every task completed in one long-running session.
372        id: format!("ep-{}", Utc::now().format("%Y%m%d-%H%M%S")),
373        session_id: session.id.clone(),
374        timestamp: Utc::now(),
375        task_description,
376        actions,
377        outcome,
378        affected_files,
379        summary: String::new(),
380        duration_secs: 0,
381        // Cumulative session counter at record time; the caller converts
382        // this into a per-task delta (see `finalize_episode_metrics`).
383        tokens_used: session.stats.total_tokens_saved,
384        agent_id: None,
385    }
386}
387
388pub fn record_session_episode(
389    project_hash: &str,
390    session: &super::session::SessionState,
391    tool_calls: &[(String, u64)],
392    agent_id: Option<&str>,
393    policy: &EpisodicPolicy,
394    deduplicate: bool,
395) -> Result<Option<String>, String> {
396    let normalized_agent_id = agent_id
397        .map(str::trim)
398        .filter(|id| !id.is_empty())
399        .map(str::to_string);
400
401    let (_, episode_id) = EpisodicStore::mutate_locked(project_hash, |store| {
402        let mut episode = create_episode_from_session(session, tool_calls);
403        episode.agent_id.clone_from(&normalized_agent_id);
404
405        if deduplicate
406            && store.episodes.iter().any(|existing| {
407                existing.session_id == episode.session_id
408                    && existing.agent_id == episode.agent_id
409                    && existing.task_description == episode.task_description
410            })
411        {
412            return None;
413        }
414
415        finalize_episode_metrics(&mut episode, store, session.started_at);
416        let id = episode.id.clone();
417        store.record_episode(episode, policy);
418        Some(id)
419    })?;
420
421    Ok(episode_id)
422}
423
424/// Converts the cumulative session counters captured by
425/// [`create_episode_from_session`] into per-task values.
426///
427/// `tokens_used` becomes the delta since the previous episode of the same
428/// session (so the per-session sum of episode tokens matches the session
429/// total), and `duration_secs` becomes the wall-clock span this task was the
430/// active one (since the previous episode, or since session start for the
431/// first).
432pub fn finalize_episode_metrics(
433    episode: &mut Episode,
434    store: &EpisodicStore,
435    session_started_at: DateTime<Utc>,
436) {
437    let prior_tokens: u64 = store
438        .episodes
439        .iter()
440        .filter(|e| e.session_id == episode.session_id)
441        .map(|e| e.tokens_used)
442        .sum();
443    episode.tokens_used = episode.tokens_used.saturating_sub(prior_tokens);
444
445    let since = store
446        .episodes
447        .iter()
448        .filter(|e| e.session_id == episode.session_id)
449        .map(|e| e.timestamp)
450        .max()
451        .unwrap_or(session_started_at);
452    episode.duration_secs = (episode.timestamp - since).num_seconds().max(0) as u64;
453}
454
455fn auto_summarize(episode: &Episode, max_chars: usize) -> String {
456    let tool_counts = count_tools(&episode.actions);
457    let top_tools: Vec<String> = tool_counts
458        .into_iter()
459        .take(3)
460        .map(|(tool, count)| format!("{tool}x{count}"))
461        .collect();
462
463    let files_hint = if episode.affected_files.len() <= 3 {
464        episode.affected_files.join(", ")
465    } else {
466        format!(
467            "{}, ... +{} more",
468            episode.affected_files[..3].join(", "),
469            episode.affected_files.len() - 3
470        )
471    };
472
473    let task = if episode.task_description.chars().count() > max_chars {
474        episode.task_description.chars().take(max_chars).collect()
475    } else {
476        episode.task_description.clone()
477    };
478    let mut summary = format!(
479        "{task} [{}] tools:[{}]",
480        episode.outcome.label(),
481        top_tools.join(",")
482    );
483
484    if !files_hint.is_empty() {
485        summary.push_str(&format!(" files:[{files_hint}]"));
486    }
487
488    summary
489}
490
491fn count_tools(actions: &[Action]) -> Vec<(String, usize)> {
492    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
493    for action in actions {
494        *counts.entry(&action.tool).or_insert(0) += 1;
495    }
496    let mut sorted: Vec<(String, usize)> = counts
497        .into_iter()
498        .map(|(k, v)| (k.to_string(), v))
499        .collect();
500    sorted.sort_by_key(|item| std::cmp::Reverse(item.1));
501    sorted
502}
503
504pub fn format_episode_compact(episode: &Episode) -> String {
505    format!(
506        "[{}] {} — {} ({} actions, {} files)",
507        episode.outcome.label(),
508        episode.task_description,
509        episode.summary,
510        episode.actions.len(),
511        episode.affected_files.len()
512    )
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn make_episode(task: &str, outcome: Outcome) -> Episode {
520        Episode {
521            id: "ep-test".to_string(),
522            session_id: "sess-1".to_string(),
523            timestamp: Utc::now(),
524            task_description: task.to_string(),
525            actions: vec![
526                Action {
527                    tool: "ctx_read".to_string(),
528                    description: String::new(),
529                    timestamp: Utc::now(),
530                    duration_ms: 50,
531                    success: true,
532                },
533                Action {
534                    tool: "ctx_shell".to_string(),
535                    description: String::new(),
536                    timestamp: Utc::now(),
537                    duration_ms: 200,
538                    success: true,
539                },
540            ],
541            outcome,
542            affected_files: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
543            summary: String::new(),
544            duration_secs: 60,
545            tokens_used: 5000,
546            agent_id: None,
547        }
548    }
549
550    #[test]
551    fn mutate_locked_preserves_successive_agent_episodes() {
552        let policy = EpisodicPolicy::default();
553        let mut store = EpisodicStore::new("locked-writes-unit");
554
555        let mut ep_a = make_episode("Task from agent A", Outcome::Unknown);
556        ep_a.agent_id = Some("agent-a".to_string());
557        store.record_episode(ep_a, &policy);
558
559        let mut ep_b = make_episode("Task from agent B", Outcome::Unknown);
560        ep_b.agent_id = Some("agent-b".to_string());
561        store.record_episode(ep_b, &policy);
562
563        assert_eq!(store.episodes.len(), 2);
564        assert!(
565            store
566                .episodes
567                .iter()
568                .any(|episode| episode.agent_id.as_deref() == Some("agent-a"))
569        );
570        assert!(
571            store
572                .episodes
573                .iter()
574                .any(|episode| episode.agent_id.as_deref() == Some("agent-b"))
575        );
576    }
577
578    #[test]
579    fn record_session_episode_deduplicates_per_agent() {
580        let tmp = tempfile::tempdir().unwrap();
581        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
582        let policy = EpisodicPolicy::default();
583        let project_hash = "episodic-agent-dedup";
584        let mut session = super::super::session::SessionState::new();
585        session.set_task("same task", None);
586        let tool_calls = vec![("ctx_read".to_string(), 10)];
587
588        assert!(
589            record_session_episode(
590                project_hash,
591                &session,
592                &tool_calls,
593                Some("agent-a"),
594                &policy,
595                true,
596            )
597            .unwrap()
598            .is_some()
599        );
600        assert!(
601            record_session_episode(
602                project_hash,
603                &session,
604                &tool_calls,
605                Some("agent-a"),
606                &policy,
607                true,
608            )
609            .unwrap()
610            .is_none()
611        );
612        assert!(
613            record_session_episode(
614                project_hash,
615                &session,
616                &tool_calls,
617                Some("agent-b"),
618                &policy,
619                true,
620            )
621            .unwrap()
622            .is_some()
623        );
624
625        let store = EpisodicStore::load_or_create(project_hash);
626        assert_eq!(store.episodes.len(), 2);
627    }
628
629    #[test]
630    fn record_and_search() {
631        let policy = EpisodicPolicy::default();
632        let mut store = EpisodicStore::new("test");
633        store.record_episode(
634            make_episode(
635                "Refactor auth module",
636                Outcome::Success { tests_passed: true },
637            ),
638            &policy,
639        );
640        store.record_episode(
641            make_episode(
642                "Fix database connection",
643                Outcome::Failure {
644                    error: "timeout".to_string(),
645                },
646            ),
647            &policy,
648        );
649
650        let results = store.search("auth refactor");
651        assert_eq!(results.len(), 1);
652        assert!(results[0].task_description.contains("auth"));
653    }
654
655    #[test]
656    fn filter_by_outcome() {
657        let policy = EpisodicPolicy::default();
658        let mut store = EpisodicStore::new("test");
659        store.record_episode(
660            make_episode("Task 1", Outcome::Success { tests_passed: true }),
661            &policy,
662        );
663        store.record_episode(
664            make_episode(
665                "Task 2",
666                Outcome::Failure {
667                    error: "err".to_string(),
668                },
669            ),
670            &policy,
671        );
672        store.record_episode(
673            make_episode(
674                "Task 3",
675                Outcome::Success {
676                    tests_passed: false,
677                },
678            ),
679            &policy,
680        );
681
682        assert_eq!(store.by_outcome("success").len(), 2);
683        assert_eq!(store.by_outcome("failure").len(), 1);
684    }
685
686    #[test]
687    fn filter_by_file() {
688        let policy = EpisodicPolicy::default();
689        let mut store = EpisodicStore::new("test");
690        store.record_episode(make_episode("Task", Outcome::Unknown), &policy);
691
692        let results = store.by_file("main.rs");
693        assert_eq!(results.len(), 1);
694
695        let results = store.by_file("nonexistent.rs");
696        assert!(results.is_empty());
697    }
698
699    #[test]
700    fn recent_episodes() {
701        let policy = EpisodicPolicy::default();
702        let mut store = EpisodicStore::new("test");
703        for i in 0..5 {
704            store.record_episode(
705                make_episode(&format!("Task {i}"), Outcome::Unknown),
706                &policy,
707            );
708        }
709
710        let recent = store.recent(3);
711        assert_eq!(recent.len(), 3);
712        assert!(recent[0].task_description.contains('4'));
713    }
714
715    #[test]
716    fn stats_calculation() {
717        let policy = EpisodicPolicy::default();
718        let mut store = EpisodicStore::new("test");
719        store.record_episode(
720            make_episode("T1", Outcome::Success { tests_passed: true }),
721            &policy,
722        );
723        store.record_episode(
724            make_episode(
725                "T2",
726                Outcome::Failure {
727                    error: "e".to_string(),
728                },
729            ),
730            &policy,
731        );
732        store.record_episode(
733            make_episode(
734                "T3",
735                Outcome::Success {
736                    tests_passed: false,
737                },
738            ),
739            &policy,
740        );
741
742        let stats = store.stats();
743        assert_eq!(stats.total_episodes, 3);
744        assert_eq!(stats.successes, 2);
745        assert_eq!(stats.failures, 1);
746        assert!((stats.success_rate - 0.6667).abs() < 0.01);
747    }
748
749    #[test]
750    fn auto_summary_generation() {
751        let mut ep = make_episode("Fix the login bug", Outcome::Success { tests_passed: true });
752        ep.summary = String::new();
753        let summary = auto_summarize(&ep, EpisodicPolicy::default().summary_max_chars);
754        assert!(summary.contains("Fix the login bug"));
755        assert!(summary.contains("[success]"));
756        assert!(summary.contains("ctx_read"));
757    }
758
759    #[test]
760    fn max_episodes_enforced() {
761        let policy = EpisodicPolicy::default();
762        let mut store = EpisodicStore::new("test");
763        for i in 0..510 {
764            store.record_episode(
765                make_episode(&format!("Task {i}"), Outcome::Unknown),
766                &policy,
767            );
768        }
769        assert!(store.episodes.len() <= policy.max_episodes);
770    }
771
772    #[test]
773    fn format_compact() {
774        let ep = make_episode("Deploy v2", Outcome::Success { tests_passed: true });
775        let output = format_episode_compact(&ep);
776        assert!(output.contains("[success]"));
777        assert!(output.contains("Deploy v2"));
778    }
779
780    #[test]
781    fn finalize_metrics_converts_cumulative_to_delta() {
782        let mut store = EpisodicStore::new("test");
783        let started = Utc::now() - chrono::Duration::seconds(600);
784
785        let mut first = make_episode("T1", Outcome::Unknown);
786        first.session_id = "sess-x".to_string();
787        first.tokens_used = 1000; // cumulative at record time
788        first.timestamp = started + chrono::Duration::seconds(100);
789        finalize_episode_metrics(&mut first, &store, started);
790        assert_eq!(first.tokens_used, 1000);
791        assert_eq!(first.duration_secs, 100);
792        store.episodes.push(first);
793
794        let mut second = make_episode("T2", Outcome::Unknown);
795        second.session_id = "sess-x".to_string();
796        second.tokens_used = 1800; // cumulative at record time
797        second.timestamp = started + chrono::Duration::seconds(400);
798        finalize_episode_metrics(&mut second, &store, started);
799        assert_eq!(second.tokens_used, 800); // delta since first
800        assert_eq!(second.duration_secs, 300);
801    }
802
803    #[test]
804    fn migrate_legacy_dedupes_ids_and_converts_tokens() {
805        let mut store = EpisodicStore::new("test");
806        let base = Utc::now() - chrono::Duration::seconds(1000);
807        for (i, cumulative) in [1000u64, 3000, 6000].iter().enumerate() {
808            let mut ep = make_episode(&format!("T{i}"), Outcome::Unknown);
809            ep.id = "ep-20260516".to_string(); // legacy colliding id
810            ep.session_id = "sess-legacy".to_string();
811            ep.tokens_used = *cumulative;
812            ep.duration_secs = 0;
813            ep.timestamp = base + chrono::Duration::seconds(i as i64 * 120);
814            store.episodes.push(ep);
815        }
816
817        assert!(store.migrate_legacy_episodes());
818        let ids: std::collections::HashSet<String> =
819            store.episodes.iter().map(|e| e.id.clone()).collect();
820        assert_eq!(ids.len(), 3, "ids must be unique after migration");
821        let tokens: Vec<u64> = store.episodes.iter().map(|e| e.tokens_used).collect();
822        assert_eq!(tokens, vec![1000, 2000, 3000]);
823        // Idempotent: unique ids → no second migration.
824        assert!(!store.migrate_legacy_episodes());
825    }
826}