lean_ctx/core/session_summary/
store.rs1use 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 #[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 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 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 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}