1use std::path::Path;
4use std::sync::Mutex;
5
6use lru::LruCache;
7
8use crate::error::SessionStoreError;
9use crate::sessions_root;
10
11const MANIFEST_CACHE_CAPACITY: usize = 200;
12const MANIFEST_CACHE_NONZERO_CAPACITY: std::num::NonZeroUsize =
13 std::num::NonZeroUsize::new(MANIFEST_CACHE_CAPACITY).unwrap();
14static MANIFEST_CACHE: std::sync::OnceLock<Mutex<LruCache<String, SessionSummary>>> = std::sync::OnceLock::new();
15
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
18pub struct SessionSummary {
19 pub session_id: String,
21 pub turn_count: u64,
23 pub event_count: u64,
25 pub status: String,
27 pub updated_at: String,
29}
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
33pub struct FactRecord {
34 pub fact: String,
36 pub session_id: String,
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
46pub struct MemorySearchResult {
47 pub chunk_id: String,
49 pub path: String,
51 pub start_line: usize,
53 pub end_line: usize,
55 pub score: f64,
57 pub snippet: String,
59 pub source: String,
61 pub created_at: Option<i64>,
63}
64
65#[must_use]
67pub fn recent_sessions(workspace: &Path, n: usize) -> Vec<SessionSummary> {
68 let root = sessions_root(workspace);
69 if !root.exists() {
70 return Vec::new();
71 }
72 let mut out = Vec::new();
73 let entries = match std::fs::read_dir(&root) {
74 Ok(e) => e,
75 Err(_) => return Vec::new(),
76 };
77 let mut cache = MANIFEST_CACHE
78 .get_or_init(|| Mutex::new(LruCache::new(MANIFEST_CACHE_NONZERO_CAPACITY)))
79 .lock()
80 .unwrap_or_else(std::sync::PoisonError::into_inner);
81 for entry in entries.filter_map(Result::ok) {
82 let manifest = entry.path().join("manifest.json");
83 let key = manifest.to_string_lossy().to_string();
84 if let Some(s) = cache.get(&key) {
85 out.push(s.clone());
86 continue;
87 }
88 if let Ok(bytes) = std::fs::read(&manifest)
89 && let Ok(s) = serde_json::from_slice::<SessionSummary>(&bytes)
90 {
91 cache.put(key, s.clone());
92 out.push(s);
93 }
94 }
95 out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
96 out.truncate(n);
97 out
98}
99
100pub fn query_facts(workspace: &Path, limit: usize) -> Result<Vec<FactRecord>, SessionStoreError> {
104 let root = sessions_root(workspace);
105 if !root.exists() {
106 return Ok(Vec::new());
107 }
108 let mut facts: Vec<FactRecord> = Vec::new();
109 let entries = std::fs::read_dir(&root).map_err(|e| SessionStoreError::io(root.clone(), e))?;
110 for entry in entries.filter_map(Result::ok) {
111 let memory = entry.path().join(crate::DERIVED_DIR).join("memory.json");
112 let Ok(bytes) = std::fs::read(&memory) else {
113 continue;
114 };
115 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
116 continue;
117 };
118 let session_id = entry.file_name().to_string_lossy().to_string();
119 if let Some(arr) = value.get("grounded_facts").and_then(|v| v.as_array()) {
120 for item in arr {
121 if let Some(fact) = item.get("fact").and_then(|f| f.as_str()) {
122 facts.push(FactRecord {
123 fact: fact.to_string(),
124 session_id: session_id.clone(),
125 });
126 }
127 }
128 }
129 }
130 facts.truncate(limit);
131 Ok(facts)
132}
133
134pub fn search_memory(
142 workspace: &Path,
143 query: &str,
144 max_results: usize,
145 min_score: f64,
146) -> Result<Vec<MemorySearchResult>, SessionStoreError> {
147 if query.is_empty() {
148 return Ok(Vec::new());
149 }
150
151 let root = sessions_root(workspace);
152 if !root.exists() {
153 return Ok(Vec::new());
154 }
155
156 let lowered = query.to_ascii_lowercase();
157 let mut results: Vec<MemorySearchResult> = Vec::new();
158
159 let entries = std::fs::read_dir(&root).map_err(|e| SessionStoreError::io(root.clone(), e))?;
160 for entry in entries.filter_map(Result::ok) {
161 let session_dir = entry.path();
162 let memory = session_dir.join(crate::DERIVED_DIR).join("memory.json");
163 let Ok(bytes) = std::fs::read(&memory) else {
164 continue;
165 };
166 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
167 continue;
168 };
169 let session_id = entry.file_name().to_string_lossy().to_string();
170 let created_at = value
171 .get("created_at")
172 .and_then(|v| v.as_i64())
173 .or_else(|| value.get("updated_at").and_then(|v| v.as_i64()));
174
175 if let Some(arr) = value.get("grounded_facts").and_then(|v| v.as_array()) {
176 for (idx, item) in arr.iter().enumerate() {
177 let Some(fact) = item.get("fact").and_then(|f| f.as_str()) else {
178 continue;
179 };
180 let score = count_substring_matches(fact, &lowered) as f64;
181 if score <= 0.0 || score < min_score {
182 continue;
183 }
184 results.push(MemorySearchResult {
185 chunk_id: format!("{session_id}:{idx}"),
186 path: memory.to_string_lossy().to_string(),
187 start_line: 0,
188 end_line: 0,
189 score,
190 snippet: fact.to_string(),
191 source: "session".to_string(),
192 created_at,
193 });
194 }
195 }
196 }
197
198 results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
199 results.truncate(max_results);
200 Ok(results)
201}
202
203pub fn default_search_max_results() -> usize {
205 6
206}
207
208pub fn default_search_min_score() -> f64 {
210 0.0
211}
212
213fn count_substring_matches(text: &str, lowered_query: &str) -> usize {
214 if lowered_query.is_empty() {
215 return 0;
216 }
217 let lowered = text.to_ascii_lowercase();
218 let mut count = 0;
219 let mut start = 0;
220 while let Some(pos) = lowered[start..].find(lowered_query) {
221 count += 1;
222 start += pos + lowered_query.len();
223 }
224 count
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use tempfile::TempDir;
231
232 #[test]
233 fn count_substring_matches_counts_overlapping() {
234 assert_eq!(count_substring_matches("aaaa", "aa"), 2);
235 assert_eq!(count_substring_matches("ababa", "aba"), 1);
236 assert_eq!(count_substring_matches("hello world", "ll"), 1);
237 assert_eq!(count_substring_matches("", "x"), 0);
238 }
239
240 #[test]
241 fn search_memory_returns_matching_facts() {
242 let dir = TempDir::new().expect("tempdir");
243 let sess = crate::session_dir(dir.path(), "s1");
244 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
245 let memory = serde_json::json!({
246 "grounded_facts": [
247 {"fact": "the widget is blue"},
248 {"fact": "the server runs on port 8080"},
249 {"fact": "use PostgreSQL for persistence"},
250 ]
251 });
252 std::fs::write(sess.join(crate::DERIVED_DIR).join("memory.json"), serde_json::to_string(&memory).expect("ser"))
253 .expect("write");
254
255 let results = search_memory(dir.path(), "blue", 10, 0.0).expect("search");
256 assert_eq!(results.len(), 1);
257 assert_eq!(results[0].snippet, "the widget is blue");
258 assert_eq!(results[0].chunk_id, "s1:0");
259 assert!((results[0].score - 1.0).abs() < f64::EPSILON);
260 }
261
262 #[test]
263 fn search_memory_scores_multiple_matches() {
264 let dir = TempDir::new().expect("tempdir");
265 let sess = crate::session_dir(dir.path(), "s2");
266 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
267 let memory = serde_json::json!({
268 "grounded_facts": [
269 {"fact": "rust uses rustc and cargo"},
270 {"fact": "cargo is the rust build tool"},
271 ]
272 });
273 std::fs::write(sess.join(crate::DERIVED_DIR).join("memory.json"), serde_json::to_string(&memory).expect("ser"))
274 .expect("write");
275
276 let results = search_memory(dir.path(), "cargo", 10, 0.0).expect("search");
277 assert_eq!(results.len(), 2);
278 assert!(results.iter().all(|r| (r.score - 1.0).abs() < f64::EPSILON));
279 }
280
281 #[test]
282 fn search_memory_respects_min_score() {
283 let dir = TempDir::new().expect("tempdir");
284 let sess = crate::session_dir(dir.path(), "s3");
285 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
286 let memory = serde_json::json!({
287 "grounded_facts": [
288 {"fact": "alpha beta gamma"},
289 ]
290 });
291 std::fs::write(sess.join(crate::DERIVED_DIR).join("memory.json"), serde_json::to_string(&memory).expect("ser"))
292 .expect("write");
293
294 let results = search_memory(dir.path(), "beta", 10, 2.0).expect("search");
295 assert!(results.is_empty());
296 }
297
298 #[test]
299 fn search_memory_empty_query_returns_empty() {
300 let dir = TempDir::new().expect("tempdir");
301 let results = search_memory(dir.path(), "", 10, 0.0).expect("search");
302 assert!(results.is_empty());
303 }
304
305 #[test]
306 fn search_memory_sorts_by_score_descending() {
307 let dir = TempDir::new().expect("tempdir");
308 for i in 0..3 {
309 let sess = crate::session_dir(dir.path(), &format!("s{i}"));
310 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
311 let memory = serde_json::json!({
312 "grounded_facts": [
313 {"fact": format!("fact {i} appears twice twice")},
314 ]
315 });
316 std::fs::write(
317 sess.join(crate::DERIVED_DIR).join("memory.json"),
318 serde_json::to_string(&memory).expect("ser"),
319 )
320 .expect("write");
321 }
322
323 let results = search_memory(dir.path(), "twice", 10, 0.0).expect("search");
324 assert_eq!(results.len(), 3);
325 assert!(results.windows(2).all(|w| w[0].score >= w[1].score));
326 }
327}