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::path::PathBuf;
9
10use crate::core::memory_policy::EpisodicPolicy;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct EpisodicStore {
14    pub project_hash: String,
15    pub episodes: Vec<Episode>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Episode {
20    pub id: String,
21    pub session_id: String,
22    pub timestamp: DateTime<Utc>,
23    pub task_description: String,
24    pub actions: Vec<Action>,
25    pub outcome: Outcome,
26    pub affected_files: Vec<String>,
27    pub summary: String,
28    pub duration_secs: u64,
29    pub tokens_used: u64,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Action {
34    pub tool: String,
35    pub description: String,
36    pub timestamp: DateTime<Utc>,
37    pub duration_ms: u64,
38    pub success: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub enum Outcome {
43    Success { tests_passed: bool },
44    Failure { error: String },
45    Partial { details: String },
46    Unknown,
47}
48
49impl Outcome {
50    pub fn label(&self) -> &'static str {
51        match self {
52            Outcome::Success { .. } => "success",
53            Outcome::Failure { .. } => "failure",
54            Outcome::Partial { .. } => "partial",
55            Outcome::Unknown => "unknown",
56        }
57    }
58}
59
60impl EpisodicStore {
61    pub fn new(project_hash: &str) -> Self {
62        Self {
63            project_hash: project_hash.to_string(),
64            episodes: Vec::new(),
65        }
66    }
67
68    pub fn record_episode(&mut self, mut episode: Episode, policy: &EpisodicPolicy) {
69        episode.actions.truncate(policy.max_actions_per_episode);
70
71        if episode.summary.is_empty() {
72            episode.summary = auto_summarize(&episode, policy.summary_max_chars);
73        }
74
75        self.episodes.push(episode);
76
77        if self.episodes.len() > policy.max_episodes {
78            self.episodes
79                .drain(0..self.episodes.len() - policy.max_episodes);
80        }
81    }
82
83    pub fn search(&self, query: &str) -> Vec<&Episode> {
84        let q = query.to_lowercase();
85        let terms: Vec<&str> = q.split_whitespace().collect();
86
87        let mut scored: Vec<(&Episode, f32)> = self
88            .episodes
89            .iter()
90            .filter_map(|ep| {
91                let searchable = format!(
92                    "{} {} {}",
93                    ep.task_description.to_lowercase(),
94                    ep.summary.to_lowercase(),
95                    ep.affected_files.join(" ").to_lowercase()
96                );
97                let hits = terms.iter().filter(|t| searchable.contains(**t)).count();
98                if hits > 0 {
99                    let relevance = hits as f32 / terms.len() as f32;
100                    Some((ep, relevance))
101                } else {
102                    None
103                }
104            })
105            .collect();
106
107        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
108        scored.into_iter().map(|(ep, _)| ep).collect()
109    }
110
111    pub fn recent(&self, n: usize) -> Vec<&Episode> {
112        self.episodes.iter().rev().take(n).collect()
113    }
114
115    pub fn by_outcome(&self, outcome_label: &str) -> Vec<&Episode> {
116        self.episodes
117            .iter()
118            .filter(|ep| ep.outcome.label() == outcome_label)
119            .collect()
120    }
121
122    pub fn by_file(&self, file_path: &str) -> Vec<&Episode> {
123        self.episodes
124            .iter()
125            .filter(|ep| ep.affected_files.iter().any(|f| f.contains(file_path)))
126            .collect()
127    }
128
129    pub fn stats(&self) -> EpisodicStats {
130        let total = self.episodes.len();
131        let successes = self
132            .episodes
133            .iter()
134            .filter(|ep| matches!(ep.outcome, Outcome::Success { .. }))
135            .count();
136        let failures = self
137            .episodes
138            .iter()
139            .filter(|ep| matches!(ep.outcome, Outcome::Failure { .. }))
140            .count();
141        let total_tokens: u64 = self.episodes.iter().map(|ep| ep.tokens_used).sum();
142
143        EpisodicStats {
144            total_episodes: total,
145            successes,
146            failures,
147            success_rate: if total > 0 {
148                successes as f32 / total as f32
149            } else {
150                0.0
151            },
152            total_tokens,
153        }
154    }
155
156    fn store_path(project_hash: &str) -> Option<PathBuf> {
157        let dir = crate::core::data_dir::lean_ctx_data_dir()
158            .ok()?
159            .join("memory")
160            .join("episodes");
161        Some(dir.join(format!("{project_hash}.json")))
162    }
163
164    pub fn load(project_hash: &str) -> Option<Self> {
165        let path = Self::store_path(project_hash)?;
166        let data = std::fs::read_to_string(path).ok()?;
167        let mut store: Self = serde_json::from_str(&data).ok()?;
168        if store.migrate_legacy_episodes() {
169            let _ = store.save();
170        }
171        Some(store)
172    }
173
174    /// One-time repair for episodes recorded before per-task metrics existed:
175    /// those episodes share one id per session (derived from the session
176    /// *start* date) and carry cumulative session token counters instead of
177    /// per-task deltas. Detected via duplicate ids within one session;
178    /// idempotent because rewritten ids are unique afterwards.
179    fn migrate_legacy_episodes(&mut self) -> bool {
180        use std::collections::HashSet;
181
182        let mut seen: HashSet<(String, String)> = HashSet::new();
183        let mut needs_migration = false;
184        for ep in &self.episodes {
185            if !seen.insert((ep.session_id.clone(), ep.id.clone())) {
186                needs_migration = true;
187                break;
188            }
189        }
190        if !needs_migration {
191            return false;
192        }
193
194        let mut sessions: HashSet<String> = HashSet::new();
195        for ep in &self.episodes {
196            sessions.insert(ep.session_id.clone());
197        }
198
199        for session_id in sessions {
200            let mut idx: Vec<usize> = (0..self.episodes.len())
201                .filter(|&i| self.episodes[i].session_id == session_id)
202                .collect();
203            idx.sort_by_key(|&i| self.episodes[i].timestamp);
204
205            // Cumulative counters are monotonically non-decreasing; only
206            // then is converting to deltas safe.
207            let monotonic = idx
208                .windows(2)
209                .all(|w| self.episodes[w[0]].tokens_used <= self.episodes[w[1]].tokens_used);
210
211            let mut prev_tokens: u64 = 0;
212            let mut prev_ts: Option<DateTime<Utc>> = None;
213            let mut used_ids: HashSet<String> = HashSet::new();
214            for &i in &idx {
215                let ts = self.episodes[i].timestamp;
216                let ep = &mut self.episodes[i];
217                let mut id = format!("ep-{}", ts.format("%Y%m%d-%H%M%S"));
218                let mut n = 1;
219                while !used_ids.insert(id.clone()) {
220                    n += 1;
221                    id = format!("ep-{}-{n}", ts.format("%Y%m%d-%H%M%S"));
222                }
223                ep.id = id;
224                if monotonic {
225                    let cumulative = ep.tokens_used;
226                    ep.tokens_used = cumulative.saturating_sub(prev_tokens);
227                    prev_tokens = cumulative;
228                }
229                if ep.duration_secs == 0 {
230                    if let Some(p) = prev_ts {
231                        ep.duration_secs = (ts - p).num_seconds().max(0) as u64;
232                    }
233                }
234                prev_ts = Some(ts);
235            }
236        }
237        true
238    }
239
240    pub fn load_or_create(project_hash: &str) -> Self {
241        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
242    }
243
244    pub fn save(&self) -> Result<(), String> {
245        let path = Self::store_path(&self.project_hash)
246            .ok_or_else(|| "Cannot determine data directory".to_string())?;
247        if let Some(dir) = path.parent() {
248            std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
249        }
250        let json = serde_json::to_string_pretty(self).map_err(|e| format!("{e}"))?;
251        std::fs::write(path, json).map_err(|e| format!("{e}"))
252    }
253}
254
255#[derive(Debug)]
256pub struct EpisodicStats {
257    pub total_episodes: usize,
258    pub successes: usize,
259    pub failures: usize,
260    pub success_rate: f32,
261    pub total_tokens: u64,
262}
263
264pub fn create_episode_from_session(
265    session: &super::session::SessionState,
266    tool_calls: &[(String, u64)],
267) -> Episode {
268    let actions: Vec<Action> = tool_calls
269        .iter()
270        .map(|(tool, duration_ms)| Action {
271            tool: tool.clone(),
272            description: String::new(),
273            timestamp: Utc::now(),
274            duration_ms: *duration_ms,
275            success: true,
276        })
277        .collect();
278
279    let affected_files: Vec<String> = session
280        .files_touched
281        .iter()
282        .map(|f| f.path.clone())
283        .collect();
284
285    let task_description = session
286        .task
287        .as_ref()
288        .map(|t| t.description.clone())
289        .unwrap_or_default();
290
291    let outcome = if session.findings.iter().any(|f| {
292        f.summary.to_lowercase().contains("error") || f.summary.to_lowercase().contains("failed")
293    }) {
294        Outcome::Failure {
295            error: session
296                .findings
297                .iter()
298                .find(|f| {
299                    f.summary.to_lowercase().contains("error")
300                        || f.summary.to_lowercase().contains("failed")
301                })
302                .map(|f| f.summary.clone())
303                .unwrap_or_default(),
304        }
305    } else if !session.findings.is_empty() || !session.decisions.is_empty() {
306        Outcome::Success { tests_passed: true }
307    } else {
308        Outcome::Unknown
309    };
310
311    Episode {
312        // Record-time based id: session ids start with the session *start*
313        // date, so deriving the episode id from them produced colliding ids
314        // for every task completed in one long-running session.
315        id: format!("ep-{}", Utc::now().format("%Y%m%d-%H%M%S")),
316        session_id: session.id.clone(),
317        timestamp: Utc::now(),
318        task_description,
319        actions,
320        outcome,
321        affected_files,
322        summary: String::new(),
323        duration_secs: 0,
324        // Cumulative session counter at record time; the caller converts
325        // this into a per-task delta (see `finalize_episode_metrics`).
326        tokens_used: session.stats.total_tokens_saved,
327    }
328}
329
330/// Converts the cumulative session counters captured by
331/// [`create_episode_from_session`] into per-task values.
332///
333/// `tokens_used` becomes the delta since the previous episode of the same
334/// session (so the per-session sum of episode tokens matches the session
335/// total), and `duration_secs` becomes the wall-clock span this task was the
336/// active one (since the previous episode, or since session start for the
337/// first).
338pub fn finalize_episode_metrics(
339    episode: &mut Episode,
340    store: &EpisodicStore,
341    session_started_at: DateTime<Utc>,
342) {
343    let prior_tokens: u64 = store
344        .episodes
345        .iter()
346        .filter(|e| e.session_id == episode.session_id)
347        .map(|e| e.tokens_used)
348        .sum();
349    episode.tokens_used = episode.tokens_used.saturating_sub(prior_tokens);
350
351    let since = store
352        .episodes
353        .iter()
354        .filter(|e| e.session_id == episode.session_id)
355        .map(|e| e.timestamp)
356        .max()
357        .unwrap_or(session_started_at);
358    episode.duration_secs = (episode.timestamp - since).num_seconds().max(0) as u64;
359}
360
361fn auto_summarize(episode: &Episode, max_chars: usize) -> String {
362    let tool_counts = count_tools(&episode.actions);
363    let top_tools: Vec<String> = tool_counts
364        .into_iter()
365        .take(3)
366        .map(|(tool, count)| format!("{tool}x{count}"))
367        .collect();
368
369    let files_hint = if episode.affected_files.len() <= 3 {
370        episode.affected_files.join(", ")
371    } else {
372        format!(
373            "{}, ... +{} more",
374            episode.affected_files[..3].join(", "),
375            episode.affected_files.len() - 3
376        )
377    };
378
379    let task = if episode.task_description.chars().count() > max_chars {
380        episode.task_description.chars().take(max_chars).collect()
381    } else {
382        episode.task_description.clone()
383    };
384    let mut summary = format!(
385        "{task} [{}] tools:[{}]",
386        episode.outcome.label(),
387        top_tools.join(",")
388    );
389
390    if !files_hint.is_empty() {
391        summary.push_str(&format!(" files:[{files_hint}]"));
392    }
393
394    summary
395}
396
397fn count_tools(actions: &[Action]) -> Vec<(String, usize)> {
398    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
399    for action in actions {
400        *counts.entry(&action.tool).or_insert(0) += 1;
401    }
402    let mut sorted: Vec<(String, usize)> = counts
403        .into_iter()
404        .map(|(k, v)| (k.to_string(), v))
405        .collect();
406    sorted.sort_by_key(|item| std::cmp::Reverse(item.1));
407    sorted
408}
409
410pub fn format_episode_compact(episode: &Episode) -> String {
411    format!(
412        "[{}] {} — {} ({} actions, {} files)",
413        episode.outcome.label(),
414        episode.task_description,
415        episode.summary,
416        episode.actions.len(),
417        episode.affected_files.len()
418    )
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn make_episode(task: &str, outcome: Outcome) -> Episode {
426        Episode {
427            id: "ep-test".to_string(),
428            session_id: "sess-1".to_string(),
429            timestamp: Utc::now(),
430            task_description: task.to_string(),
431            actions: vec![
432                Action {
433                    tool: "ctx_read".to_string(),
434                    description: String::new(),
435                    timestamp: Utc::now(),
436                    duration_ms: 50,
437                    success: true,
438                },
439                Action {
440                    tool: "ctx_shell".to_string(),
441                    description: String::new(),
442                    timestamp: Utc::now(),
443                    duration_ms: 200,
444                    success: true,
445                },
446            ],
447            outcome,
448            affected_files: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
449            summary: String::new(),
450            duration_secs: 60,
451            tokens_used: 5000,
452        }
453    }
454
455    #[test]
456    fn record_and_search() {
457        let policy = EpisodicPolicy::default();
458        let mut store = EpisodicStore::new("test");
459        store.record_episode(
460            make_episode(
461                "Refactor auth module",
462                Outcome::Success { tests_passed: true },
463            ),
464            &policy,
465        );
466        store.record_episode(
467            make_episode(
468                "Fix database connection",
469                Outcome::Failure {
470                    error: "timeout".to_string(),
471                },
472            ),
473            &policy,
474        );
475
476        let results = store.search("auth refactor");
477        assert_eq!(results.len(), 1);
478        assert!(results[0].task_description.contains("auth"));
479    }
480
481    #[test]
482    fn filter_by_outcome() {
483        let policy = EpisodicPolicy::default();
484        let mut store = EpisodicStore::new("test");
485        store.record_episode(
486            make_episode("Task 1", Outcome::Success { tests_passed: true }),
487            &policy,
488        );
489        store.record_episode(
490            make_episode(
491                "Task 2",
492                Outcome::Failure {
493                    error: "err".to_string(),
494                },
495            ),
496            &policy,
497        );
498        store.record_episode(
499            make_episode(
500                "Task 3",
501                Outcome::Success {
502                    tests_passed: false,
503                },
504            ),
505            &policy,
506        );
507
508        assert_eq!(store.by_outcome("success").len(), 2);
509        assert_eq!(store.by_outcome("failure").len(), 1);
510    }
511
512    #[test]
513    fn filter_by_file() {
514        let policy = EpisodicPolicy::default();
515        let mut store = EpisodicStore::new("test");
516        store.record_episode(make_episode("Task", Outcome::Unknown), &policy);
517
518        let results = store.by_file("main.rs");
519        assert_eq!(results.len(), 1);
520
521        let results = store.by_file("nonexistent.rs");
522        assert!(results.is_empty());
523    }
524
525    #[test]
526    fn recent_episodes() {
527        let policy = EpisodicPolicy::default();
528        let mut store = EpisodicStore::new("test");
529        for i in 0..5 {
530            store.record_episode(
531                make_episode(&format!("Task {i}"), Outcome::Unknown),
532                &policy,
533            );
534        }
535
536        let recent = store.recent(3);
537        assert_eq!(recent.len(), 3);
538        assert!(recent[0].task_description.contains('4'));
539    }
540
541    #[test]
542    fn stats_calculation() {
543        let policy = EpisodicPolicy::default();
544        let mut store = EpisodicStore::new("test");
545        store.record_episode(
546            make_episode("T1", Outcome::Success { tests_passed: true }),
547            &policy,
548        );
549        store.record_episode(
550            make_episode(
551                "T2",
552                Outcome::Failure {
553                    error: "e".to_string(),
554                },
555            ),
556            &policy,
557        );
558        store.record_episode(
559            make_episode(
560                "T3",
561                Outcome::Success {
562                    tests_passed: false,
563                },
564            ),
565            &policy,
566        );
567
568        let stats = store.stats();
569        assert_eq!(stats.total_episodes, 3);
570        assert_eq!(stats.successes, 2);
571        assert_eq!(stats.failures, 1);
572        assert!((stats.success_rate - 0.6667).abs() < 0.01);
573    }
574
575    #[test]
576    fn auto_summary_generation() {
577        let mut ep = make_episode("Fix the login bug", Outcome::Success { tests_passed: true });
578        ep.summary = String::new();
579        let summary = auto_summarize(&ep, EpisodicPolicy::default().summary_max_chars);
580        assert!(summary.contains("Fix the login bug"));
581        assert!(summary.contains("[success]"));
582        assert!(summary.contains("ctx_read"));
583    }
584
585    #[test]
586    fn max_episodes_enforced() {
587        let policy = EpisodicPolicy::default();
588        let mut store = EpisodicStore::new("test");
589        for i in 0..510 {
590            store.record_episode(
591                make_episode(&format!("Task {i}"), Outcome::Unknown),
592                &policy,
593            );
594        }
595        assert!(store.episodes.len() <= policy.max_episodes);
596    }
597
598    #[test]
599    fn format_compact() {
600        let ep = make_episode("Deploy v2", Outcome::Success { tests_passed: true });
601        let output = format_episode_compact(&ep);
602        assert!(output.contains("[success]"));
603        assert!(output.contains("Deploy v2"));
604    }
605
606    #[test]
607    fn finalize_metrics_converts_cumulative_to_delta() {
608        let mut store = EpisodicStore::new("test");
609        let started = Utc::now() - chrono::Duration::seconds(600);
610
611        let mut first = make_episode("T1", Outcome::Unknown);
612        first.session_id = "sess-x".to_string();
613        first.tokens_used = 1000; // cumulative at record time
614        first.timestamp = started + chrono::Duration::seconds(100);
615        finalize_episode_metrics(&mut first, &store, started);
616        assert_eq!(first.tokens_used, 1000);
617        assert_eq!(first.duration_secs, 100);
618        store.episodes.push(first);
619
620        let mut second = make_episode("T2", Outcome::Unknown);
621        second.session_id = "sess-x".to_string();
622        second.tokens_used = 1800; // cumulative at record time
623        second.timestamp = started + chrono::Duration::seconds(400);
624        finalize_episode_metrics(&mut second, &store, started);
625        assert_eq!(second.tokens_used, 800); // delta since first
626        assert_eq!(second.duration_secs, 300);
627    }
628
629    #[test]
630    fn migrate_legacy_dedupes_ids_and_converts_tokens() {
631        let mut store = EpisodicStore::new("test");
632        let base = Utc::now() - chrono::Duration::seconds(1000);
633        for (i, cumulative) in [1000u64, 3000, 6000].iter().enumerate() {
634            let mut ep = make_episode(&format!("T{i}"), Outcome::Unknown);
635            ep.id = "ep-20260516".to_string(); // legacy colliding id
636            ep.session_id = "sess-legacy".to_string();
637            ep.tokens_used = *cumulative;
638            ep.duration_secs = 0;
639            ep.timestamp = base + chrono::Duration::seconds(i as i64 * 120);
640            store.episodes.push(ep);
641        }
642
643        assert!(store.migrate_legacy_episodes());
644        let ids: std::collections::HashSet<String> =
645            store.episodes.iter().map(|e| e.id.clone()).collect();
646        assert_eq!(ids.len(), 3, "ids must be unique after migration");
647        let tokens: Vec<u64> = store.episodes.iter().map(|e| e.tokens_used).collect();
648        assert_eq!(tokens, vec![1000, 2000, 3000]);
649        // Idempotent: unique ids → no second migration.
650        assert!(!store.migrate_legacy_episodes());
651    }
652}