Skip to main content

lean_ctx/core/session_summary/
store.rs

1//! Bounded, per-project persistence for session summaries (#292).
2//!
3//! Mirrors the episodic-memory store pattern: one JSON file per project under
4//! `{data_dir}/memory/summaries/{project_hash}.json`, newest summaries kept.
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use super::record::SummaryRecord;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SummaryStore {
14    pub project_hash: String,
15    /// Tool-call count at the last recorded summary (cadence watermark).
16    #[serde(default)]
17    pub last_recorded_calls: u64,
18    #[serde(default)]
19    pub summaries: Vec<SummaryRecord>,
20}
21
22impl SummaryStore {
23    fn new(project_hash: &str) -> Self {
24        Self {
25            project_hash: project_hash.to_string(),
26            last_recorded_calls: 0,
27            summaries: Vec::new(),
28        }
29    }
30
31    fn store_path(project_hash: &str) -> Option<PathBuf> {
32        let dir = crate::core::data_dir::lean_ctx_data_dir()
33            .ok()?
34            .join("memory")
35            .join("summaries");
36        Some(dir.join(format!("{project_hash}.json")))
37    }
38
39    pub fn load_or_create(project_root: &str) -> Self {
40        let hash = crate::core::project_hash::hash_project_root(project_root);
41        let Some(path) = Self::store_path(&hash) else {
42            return Self::new(&hash);
43        };
44        std::fs::read_to_string(&path)
45            .ok()
46            .and_then(|c| serde_json::from_str::<SummaryStore>(&c).ok())
47            .unwrap_or_else(|| Self::new(&hash))
48    }
49
50    pub fn save(&self) -> Result<(), String> {
51        let path = Self::store_path(&self.project_hash)
52            .ok_or_else(|| "cannot resolve data dir".to_string())?;
53        if let Some(parent) = path.parent() {
54            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
55        }
56        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
57        std::fs::write(&path, json).map_err(|e| e.to_string())
58    }
59
60    /// Next sequence number (1-based), monotonic across the store's lifetime.
61    pub fn next_seq(&self) -> u32 {
62        self.summaries
63            .iter()
64            .filter_map(|s| s.id.rsplit('-').next())
65            .filter_map(|n| n.parse::<u32>().ok())
66            .max()
67            .unwrap_or(0)
68            + 1
69    }
70
71    /// Append a record and prune to `max_kept` (newest kept).
72    pub fn push(&mut self, record: SummaryRecord, max_kept: usize) {
73        self.summaries.push(record);
74        let cap = max_kept.max(1);
75        if self.summaries.len() > cap {
76            let excess = self.summaries.len() - cap;
77            self.summaries.drain(0..excess);
78        }
79    }
80
81    /// Lexical token-overlap search → `(index, score)`, best first.
82    pub fn search_lexical(&self, query: &str, top_k: usize) -> Vec<(usize, f64)> {
83        let terms = tokenize(query);
84        if terms.is_empty() {
85            return Vec::new();
86        }
87        let mut scored: Vec<(usize, f64)> = self
88            .summaries
89            .iter()
90            .enumerate()
91            .filter_map(|(i, s)| {
92                let hay = tokenize(&s.searchable_text());
93                if hay.is_empty() {
94                    return None;
95                }
96                let matches = terms.iter().filter(|t| hay.contains(*t)).count();
97                if matches == 0 {
98                    None
99                } else {
100                    Some((i, matches as f64 / terms.len() as f64))
101                }
102            })
103            .collect();
104        scored.sort_by(|a, b| {
105            b.1.partial_cmp(&a.1)
106                .unwrap_or(std::cmp::Ordering::Equal)
107                .then(a.0.cmp(&b.0))
108        });
109        scored.truncate(top_k);
110        scored
111    }
112}
113
114fn tokenize(text: &str) -> Vec<String> {
115    text.to_lowercase()
116        .split(|c: char| !c.is_alphanumeric())
117        .filter(|t| t.len() > 2)
118        .map(str::to_string)
119        .collect()
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use chrono::Utc;
126
127    fn rec(id: &str, title: &str, body: &str) -> SummaryRecord {
128        SummaryRecord {
129            id: id.to_string(),
130            session_id: "sess".to_string(),
131            created_at: Utc::now(),
132            title: title.to_string(),
133            body: body.to_string(),
134            files: vec![],
135            decisions: vec![],
136            next_steps: vec![],
137            tool_calls: 0,
138        }
139    }
140
141    #[test]
142    fn push_prunes_to_cap_keeping_newest() {
143        let mut s = SummaryStore::new("h");
144        for i in 0..5 {
145            s.push(rec(&format!("s-{i}"), "t", "b"), 3);
146        }
147        assert_eq!(s.summaries.len(), 3);
148        assert_eq!(s.summaries.first().unwrap().id, "s-2");
149        assert_eq!(s.summaries.last().unwrap().id, "s-4");
150    }
151
152    #[test]
153    fn next_seq_is_monotonic() {
154        let mut s = SummaryStore::new("h");
155        assert_eq!(s.next_seq(), 1);
156        s.push(rec("abc-0007", "t", "b"), 100);
157        assert_eq!(s.next_seq(), 8);
158    }
159
160    #[test]
161    fn lexical_search_ranks_by_overlap() {
162        let mut s = SummaryStore::new("h");
163        s.push(
164            rec("a-1", "graph traversal edges", "co-access learning"),
165            100,
166        );
167        s.push(rec("a-2", "billing webhook", "stripe meter events"), 100);
168        let hits = s.search_lexical("graph edges", 5);
169        assert_eq!(hits.first().unwrap().0, 0, "graph summary ranks first");
170        assert!(s.search_lexical("nonexistentterm", 5).is_empty());
171    }
172}