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