1use 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 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 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 && let Some(p) = prev_ts
231 {
232 ep.duration_secs = (ts - p).num_seconds().max(0) as u64;
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 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 tokens_used: session.stats.total_tokens_saved,
327 }
328}
329
330pub 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; 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; second.timestamp = started + chrono::Duration::seconds(400);
624 finalize_episode_metrics(&mut second, &store, started);
625 assert_eq!(second.tokens_used, 800); 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(); 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 assert!(!store.migrate_legacy_episodes());
651 }
652}