1use std::collections::HashMap;
4use std::path::Path;
5use std::sync::Mutex;
6use std::time::SystemTime;
7
8use chrono::{DateTime, Utc};
9use lru::LruCache;
10
11use crate::error::SessionStoreError;
12use crate::sessions_root;
13
14const MANIFEST_CACHE_CAPACITY: usize = 200;
15const MANIFEST_CACHE_NONZERO_CAPACITY: std::num::NonZeroUsize =
16 std::num::NonZeroUsize::new(MANIFEST_CACHE_CAPACITY).unwrap();
17static MANIFEST_CACHE: std::sync::OnceLock<Mutex<LruCache<String, CachedManifest>>> = std::sync::OnceLock::new();
18
19#[derive(Debug, Clone)]
20struct CachedManifest {
21 summary: SessionSummary,
22 signature: ManifestSignature,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26struct ManifestSignature {
27 modified: Option<SystemTime>,
28 len: u64,
29}
30
31fn manifest_signature(path: &Path) -> Option<ManifestSignature> {
32 let metadata = std::fs::symlink_metadata(path).ok()?;
33 Some(ManifestSignature {
34 modified: metadata.modified().ok(),
35 len: metadata.len(),
36 })
37}
38
39pub(crate) fn invalidate_manifest_cache(path: &Path) {
41 if let Some(cache) = MANIFEST_CACHE.get()
42 && let Ok(mut cache) = cache.lock()
43 {
44 let key = path.to_string_lossy();
45 cache.pop(key.as_ref());
46 }
47}
48
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
51pub struct SessionSummary {
52 pub session_id: String,
54 pub turn_count: u64,
56 pub event_count: u64,
58 pub status: String,
60 pub updated_at: String,
62}
63
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
66pub struct FactRecord {
67 pub fact: String,
69 pub session_id: String,
71}
72
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
79pub struct MemorySearchResult {
80 chunk_id: String,
82 path: String,
84 start_line: usize,
86 end_line: usize,
88 score: f64,
90 snippet: String,
92 source: String,
94 created_at: Option<i64>,
96}
97
98#[must_use]
100pub fn recent_sessions(workspace: &Path, n: usize) -> Vec<SessionSummary> {
101 let root = sessions_root(workspace);
102 if !root.exists() {
103 return Vec::new();
104 }
105 let mut out = Vec::new();
106 let entries = match std::fs::read_dir(&root) {
107 Ok(e) => e,
108 Err(_) => return Vec::new(),
109 };
110 let mut cache = MANIFEST_CACHE
111 .get_or_init(|| Mutex::new(LruCache::new(MANIFEST_CACHE_NONZERO_CAPACITY)))
112 .lock()
113 .unwrap_or_else(std::sync::PoisonError::into_inner);
114 for entry in entries.filter_map(Result::ok) {
115 let manifest = entry.path().join("manifest.json");
116 let key = manifest.to_string_lossy().into_owned();
117 let signature = manifest_signature(&manifest);
118 if let Some(cached) = cache.get(&key)
119 && signature == Some(cached.signature)
120 {
121 out.push(cached.summary.clone());
122 continue;
123 }
124 if let Ok(bytes) = std::fs::read(&manifest)
125 && let Ok(s) = serde_json::from_slice::<SessionSummary>(&bytes)
126 {
127 if let Some(signature) = signature.or_else(|| manifest_signature(&manifest)) {
128 cache.put(key, CachedManifest { summary: s.clone(), signature });
129 }
130 out.push(s);
131 }
132 }
133 out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
134 out.truncate(n);
135 out
136}
137
138#[must_use]
144pub fn live_sessions(workspace: &Path) -> Vec<SessionSummary> {
145 recent_sessions(workspace, usize::MAX)
146 .into_iter()
147 .filter(|summary| crate::retention::session_dir_is_live(&crate::session_dir(workspace, &summary.session_id)))
148 .collect()
149}
150
151pub fn query_facts(workspace: &Path, limit: usize) -> Result<Vec<FactRecord>, SessionStoreError> {
155 let root = sessions_root(workspace);
156 if !root.exists() {
157 return Ok(Vec::new());
158 }
159 let mut facts: Vec<FactRecord> = Vec::new();
160 let entries = std::fs::read_dir(&root).map_err(|e| SessionStoreError::io(root.clone(), e))?;
161 for entry in entries.filter_map(Result::ok) {
162 let memory = entry.path().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().into_owned();
170 if let Some(arr) = value.get("grounded_facts").and_then(|v| v.as_array()) {
171 for item in arr {
172 if let Some(fact) = item.get("fact").and_then(|f| f.as_str()) {
173 facts.push(FactRecord {
174 fact: fact.to_string(),
175 session_id: session_id.clone(),
176 });
177 }
178 }
179 }
180 }
181 facts.truncate(limit);
182 Ok(facts)
183}
184
185pub fn search_memory(
192 workspace: &Path,
193 query: &str,
194 max_results: usize,
195 min_score: f64,
196) -> Result<Vec<MemorySearchResult>, SessionStoreError> {
197 if query.is_empty() {
198 return Ok(Vec::new());
199 }
200
201 let root = sessions_root(workspace);
202 if !root.exists() {
203 return Ok(Vec::new());
204 }
205
206 let query_terms = tokenize(query);
207 if query_terms.is_empty() {
208 return Ok(Vec::new());
209 }
210 let mut results: Vec<MemorySearchResult> = Vec::new();
211 let session_source = String::from("session");
212 let mut documents = Vec::new();
213
214 let entries = std::fs::read_dir(&root).map_err(|e| SessionStoreError::io(root.clone(), e))?;
215 for entry in entries.filter_map(Result::ok) {
216 let session_dir = entry.path();
217 let memory = session_dir.join(crate::DERIVED_DIR).join("memory.json");
218 let Ok(bytes) = std::fs::read(&memory) else {
219 continue;
220 };
221 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
222 continue;
223 };
224 let session_id = entry.file_name().to_string_lossy().into_owned();
225 let memory_path = memory.to_string_lossy().into_owned();
226 let created_at = value
227 .get("created_at")
228 .and_then(parse_timestamp)
229 .or_else(|| value.get("updated_at").and_then(parse_timestamp));
230
231 if let Some(arr) = value.get("grounded_facts").and_then(|v| v.as_array()) {
232 for (idx, item) in arr.iter().enumerate() {
233 let Some(fact) = item.get("fact").and_then(|f| f.as_str()) else {
234 continue;
235 };
236 let mut terms = tokenize(fact);
237 terms.sort_unstable();
240 documents.push((
241 format!("{session_id}:{idx}"),
242 memory_path.clone(),
243 fact.to_owned(),
244 terms,
245 created_at,
246 ));
247 }
248 }
249 }
250
251 let document_count = documents.len();
252 if document_count == 0 {
253 return Ok(Vec::new());
254 }
255 let average_length =
256 documents.iter().map(|(_, _, _, terms, _)| terms.len() as f64).sum::<f64>() / document_count as f64;
257 let mut document_frequency: HashMap<String, usize> = HashMap::new();
258 for (_, _, _, terms, _) in &documents {
259 let mut previous: Option<&str> = None;
261 for term in terms {
262 let term = term.as_str();
263 if previous != Some(term) {
264 previous = Some(term);
265 *document_frequency.entry(term.to_owned()).or_insert(0) += 1;
266 }
267 }
268 }
269 let k1 = 1.2;
270 let b = 0.75;
271 let now = Utc::now();
272 for (chunk_id, path, fact, terms, created_at) in documents {
273 let length = terms.len() as f64;
274 let mut score = 0.0;
275 for query_term in &query_terms {
276 let start = terms.partition_point(|term| term.as_str() < query_term.as_str());
278 let end = terms.partition_point(|term| term.as_str() <= query_term.as_str());
279 let frequency = end - start;
280 if frequency == 0 {
281 continue;
282 }
283 let Some(&frequency_in_documents) = document_frequency.get(query_term) else {
284 continue;
285 };
286 let idf = (((document_count - frequency_in_documents) as f64 + 0.5)
287 / (frequency_in_documents as f64 + 0.5)
288 + 1.0)
289 .ln();
290 let denominator = frequency as f64 + k1 * (1.0 - b + b * length / average_length.max(1.0));
291 score += idf * (frequency as f64 * (k1 + 1.0)) / denominator;
292 }
293 if score <= 0.0 {
294 continue;
295 }
296 score *= recency_multiplier(created_at, now);
301 if score < min_score {
302 continue;
303 }
304 results.push(MemorySearchResult {
305 chunk_id,
306 path,
307 start_line: 0,
308 end_line: 0,
309 score,
310 snippet: fact,
311 source: session_source.clone(),
312 created_at,
313 });
314 }
315
316 results.sort_by(|a, b| {
317 b.score
318 .partial_cmp(&a.score)
319 .unwrap_or(std::cmp::Ordering::Equal)
320 .then_with(|| a.chunk_id.cmp(&b.chunk_id))
321 });
322 results.truncate(max_results);
323 Ok(results)
324}
325
326fn parse_timestamp(value: &serde_json::Value) -> Option<i64> {
327 if let Some(timestamp) = value.as_i64() {
328 return DateTime::<Utc>::from_timestamp(timestamp, 0).map(|_| timestamp);
329 }
330 let text = value.as_str()?.trim();
331 if let Ok(timestamp) = text.parse::<i64>() {
332 return DateTime::<Utc>::from_timestamp(timestamp, 0).map(|_| timestamp);
333 }
334 DateTime::parse_from_rfc3339(text).ok().map(|timestamp| timestamp.timestamp())
335}
336
337fn recency_multiplier(created_at: Option<i64>, now: DateTime<Utc>) -> f64 {
338 let Some(created_at) = created_at.and_then(|timestamp| DateTime::<Utc>::from_timestamp(timestamp, 0)) else {
339 return 1.0;
340 };
341 let age_days = (now - created_at).num_seconds().max(0) as f64 / 86_400.0;
342 1.0 + 0.15 * 0.5_f64.powf(age_days / 30.0)
343}
344
345pub fn default_search_max_results() -> usize {
347 6
348}
349
350pub fn default_search_min_score() -> f64 {
352 0.0
353}
354
355fn count_substring_matches(text: &str, lowered_query: &str) -> usize {
356 if lowered_query.is_empty() {
357 return 0;
358 }
359 let lowered = text.to_ascii_lowercase();
360 let mut count = 0;
361 let mut start = 0;
362 while let Some(pos) = lowered[start..].find(lowered_query) {
363 count += 1;
364 start += pos + lowered_query.len();
365 }
366 count
367}
368
369fn tokenize(text: &str) -> Vec<String> {
370 let mut tokens = Vec::new();
371 let mut current = String::new();
372 for character in text.chars() {
373 if character.is_alphanumeric() {
374 current.extend(character.to_lowercase());
375 } else if !current.is_empty() {
376 tokens.push(std::mem::take(&mut current));
377 }
378 }
379 if !current.is_empty() {
380 tokens.push(current);
381 }
382 tokens
383}
384
385#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
392pub struct SessionMemoryView {
393 pub summary: String,
395 pub facts: Vec<String>,
397}
398
399pub fn write_session_memory_view(
407 workspace: &Path,
408 session_id: &str,
409 view: &SessionMemoryView,
410) -> Result<(), SessionStoreError> {
411 let dir = crate::session_dir(workspace, session_id);
412 crate::ensure_private_directory(&dir)?;
413 crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
414
415 let envelope = serde_json::json!({
416 "session_id": session_id,
417 "schema_version": 3,
418 "summary": view.summary,
419 "grounded_facts": view
420 .facts
421 .iter()
422 .map(|fact| serde_json::json!({ "fact": fact }))
423 .collect::<Vec<_>>(),
424 "touched_files": [],
425 "generated_at": Utc::now().to_rfc3339(),
426 });
427 let bytes = serde_json::to_vec_pretty(&envelope)
428 .map_err(|error| SessionStoreError::io(dir.clone(), std::io::Error::other(error)))?;
429 let dest = dir.join(crate::DERIVED_DIR).join("memory.json");
430 vtcode_commons::VtCodePaths::write_private_file_atomic(&dest, &bytes)
431 .map_err(|error| SessionStoreError::io(dest, std::io::Error::other(error)))?;
432 invalidate_manifest_cache(&dir.join("manifest.json"));
433 Ok(())
434}
435
436pub fn session_memory_facts(
441 workspace: &Path,
442 session_id: &str,
443 limit: usize,
444) -> Result<Vec<FactRecord>, SessionStoreError> {
445 let memory = crate::session_dir(workspace, session_id)
446 .join(crate::DERIVED_DIR)
447 .join("memory.json");
448 let Ok(bytes) = std::fs::read(&memory) else {
449 return Ok(Vec::new());
450 };
451 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
452 return Ok(Vec::new());
453 };
454 let mut facts = Vec::new();
455 if let Some(arr) = value.get("grounded_facts").and_then(|v| v.as_array()) {
456 for item in arr {
457 if let Some(fact) = item.get("fact").and_then(|f| f.as_str()) {
458 facts.push(FactRecord {
459 fact: fact.to_string(),
460 session_id: session_id.to_string(),
461 });
462 if facts.len() >= limit {
463 break;
464 }
465 }
466 }
467 }
468 Ok(facts)
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use tempfile::TempDir;
475
476 #[test]
477 fn count_substring_matches_counts_overlapping() {
478 assert_eq!(count_substring_matches("aaaa", "aa"), 2);
479 assert_eq!(count_substring_matches("ababa", "aba"), 1);
480 assert_eq!(count_substring_matches("hello world", "ll"), 1);
481 assert_eq!(count_substring_matches("", "x"), 0);
482 }
483
484 #[test]
485 fn search_memory_returns_matching_facts() {
486 let dir = TempDir::new().expect("tempdir");
487 let sess = crate::session_dir(dir.path(), "s1");
488 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
489 let memory = serde_json::json!({
490 "grounded_facts": [
491 {"fact": "the widget is blue"},
492 {"fact": "the server runs on port 8080"},
493 {"fact": "use PostgreSQL for persistence"},
494 ]
495 });
496 std::fs::write(sess.join(crate::DERIVED_DIR).join("memory.json"), serde_json::to_string(&memory).expect("ser"))
497 .expect("write");
498
499 let results = search_memory(dir.path(), "blue", 10, 0.0).expect("search");
500 assert_eq!(results.len(), 1);
501 assert_eq!(results[0].snippet, "the widget is blue");
502 assert_eq!(results[0].chunk_id, "s1:0");
503 assert!(results[0].score > 0.0);
504 }
505
506 #[test]
507 fn search_memory_scores_multiple_matches() {
508 let dir = TempDir::new().expect("tempdir");
509 let sess = crate::session_dir(dir.path(), "s2");
510 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
511 let memory = serde_json::json!({
512 "grounded_facts": [
513 {"fact": "rust uses rustc and cargo"},
514 {"fact": "cargo is the rust build tool"},
515 ]
516 });
517 std::fs::write(sess.join(crate::DERIVED_DIR).join("memory.json"), serde_json::to_string(&memory).expect("ser"))
518 .expect("write");
519
520 let results = search_memory(dir.path(), "cargo", 10, 0.0).expect("search");
521 assert_eq!(results.len(), 2);
522 assert!(results.iter().all(|r| r.score > 0.0));
523 }
524
525 #[test]
526 fn search_memory_respects_min_score() {
527 let dir = TempDir::new().expect("tempdir");
528 let sess = crate::session_dir(dir.path(), "s3");
529 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
530 let memory = serde_json::json!({
531 "grounded_facts": [
532 {"fact": "alpha beta gamma"},
533 ]
534 });
535 std::fs::write(sess.join(crate::DERIVED_DIR).join("memory.json"), serde_json::to_string(&memory).expect("ser"))
536 .expect("write");
537
538 let results = search_memory(dir.path(), "beta", 10, 2.0).expect("search");
539 assert!(results.is_empty());
540 }
541
542 #[test]
543 fn search_memory_empty_query_returns_empty() {
544 let dir = TempDir::new().expect("tempdir");
545 let results = search_memory(dir.path(), "", 10, 0.0).expect("search");
546 assert!(results.is_empty());
547 }
548
549 #[test]
550 fn search_memory_sorts_by_score_descending() {
551 let dir = TempDir::new().expect("tempdir");
552 for i in 0..3 {
553 let sess = crate::session_dir(dir.path(), &format!("s{i}"));
554 std::fs::create_dir_all(sess.join(crate::DERIVED_DIR)).expect("mkdir");
555 let memory = serde_json::json!({
556 "grounded_facts": [
557 {"fact": format!("fact {i} appears twice twice")},
558 ]
559 });
560 std::fs::write(
561 sess.join(crate::DERIVED_DIR).join("memory.json"),
562 serde_json::to_string(&memory).expect("ser"),
563 )
564 .expect("write");
565 }
566
567 let results = search_memory(dir.path(), "twice", 10, 0.0).expect("search");
568 assert_eq!(results.len(), 3);
569 assert!(results.windows(2).all(|w| w[0].score >= w[1].score));
570 }
571
572 #[test]
573 fn search_memory_uses_bm25_term_coverage_and_deterministic_ties() {
574 let dir = TempDir::new().expect("tempdir");
575 for (session_id, facts) in [
576 ("s1", vec!["rust cargo tool", "unrelated note"]),
577 ("s2", vec!["cargo build tool"]),
578 ] {
579 let session = crate::session_dir(dir.path(), session_id);
580 std::fs::create_dir_all(session.join(crate::DERIVED_DIR)).expect("mkdir");
581 let memory = serde_json::json!({
582 "grounded_facts": facts.into_iter().map(|fact| serde_json::json!({"fact": fact})).collect::<Vec<_>>()
583 });
584 std::fs::write(
585 session.join(crate::DERIVED_DIR).join("memory.json"),
586 serde_json::to_string(&memory).expect("serialize"),
587 )
588 .expect("write");
589 }
590
591 let results = search_memory(dir.path(), "rust cargo", 10, 0.0).expect("search");
592 assert_eq!(results.first().map(|result| result.chunk_id.as_str()), Some("s1:0"));
593 assert!(results[0].score > results[1].score);
594 }
595
596 #[test]
597 fn search_memory_applies_recency_to_valid_rfc3339_timestamps() {
598 let dir = TempDir::new().expect("tempdir");
599 for (session_id, timestamp) in [("old", "2020-01-01T00:00:00Z"), ("new", "2099-01-01T00:00:00Z")] {
600 let session = crate::session_dir(dir.path(), session_id);
601 std::fs::create_dir_all(session.join(crate::DERIVED_DIR)).expect("mkdir");
602 let memory = serde_json::json!({
603 "created_at": timestamp,
604 "grounded_facts": [{"fact": "shared search fact"}]
605 });
606 std::fs::write(
607 session.join(crate::DERIVED_DIR).join("memory.json"),
608 serde_json::to_vec(&memory).expect("serialize"),
609 )
610 .expect("write");
611 }
612
613 let results = search_memory(dir.path(), "shared", 10, 0.0).expect("search");
614 assert_eq!(results.len(), 2);
615 assert_eq!(results[0].chunk_id, "new:0");
616 assert!(results[0].score > results[1].score);
617 }
618
619 #[test]
620 fn search_memory_does_not_boost_missing_or_invalid_timestamps() {
621 let dir = TempDir::new().expect("tempdir");
622 for (session_id, timestamp) in [
623 ("missing", serde_json::Value::Null),
624 ("invalid", serde_json::json!("not-a-date")),
625 ] {
626 let session = crate::session_dir(dir.path(), session_id);
627 std::fs::create_dir_all(session.join(crate::DERIVED_DIR)).expect("mkdir");
628 let mut memory = serde_json::json!({"grounded_facts": [{"fact": "neutral search fact"}]});
629 if !timestamp.is_null() {
630 memory["created_at"] = timestamp;
631 }
632 std::fs::write(
633 session.join(crate::DERIVED_DIR).join("memory.json"),
634 serde_json::to_vec(&memory).expect("serialize"),
635 )
636 .expect("write");
637 }
638
639 let results = search_memory(dir.path(), "neutral", 10, 0.0).expect("search");
640 assert_eq!(results.len(), 2);
641 assert!((results[0].score - results[1].score).abs() < f64::EPSILON);
642 assert_eq!(results[0].created_at, None);
643 assert_eq!(results[1].created_at, None);
644 }
645
646 #[test]
647 fn recency_multiplier_matches_thirty_day_half_life() {
648 let now = DateTime::parse_from_rfc3339("2026-01-31T00:00:00Z")
649 .expect("timestamp")
650 .with_timezone(&Utc);
651 let thirty_days_ago = now - chrono::Duration::days(30);
652 let multiplier = recency_multiplier(Some(thirty_days_ago.timestamp()), now);
653 assert!((multiplier - 1.075).abs() < 1e-12);
654 assert!((recency_multiplier(None, now) - 1.0).abs() < f64::EPSILON);
655 }
656
657 #[test]
658 fn recent_sessions_invalidates_manifest_cache_after_replacement() {
659 let dir = TempDir::new().expect("tempdir");
660 let session = crate::session_dir(dir.path(), "cache-session");
661 std::fs::create_dir_all(&session).expect("mkdir");
662 let manifest = serde_json::json!({
663 "session_id": "cache-session",
664 "schema_version": 1,
665 "created_at": "2026-01-01T00:00:00Z",
666 "updated_at": "2026-01-01T00:00:00Z",
667 "turn_count": 1,
668 "event_count": 1,
669 "status": "active"
670 });
671 let path = session.join("manifest.json");
672 std::fs::write(&path, serde_json::to_vec(&manifest).expect("serialize")).expect("write");
673 assert_eq!(recent_sessions(dir.path(), 1)[0].updated_at, "2026-01-01T00:00:00Z");
674
675 let mut replaced = manifest;
676 replaced["updated_at"] = serde_json::json!("2099-01-01T00:00:00Z");
677 std::fs::write(&path, serde_json::to_vec(&replaced).expect("serialize")).expect("replace");
678 assert_eq!(recent_sessions(dir.path(), 1)[0].updated_at, "2099-01-01T00:00:00Z");
679 }
680
681 #[test]
682 fn session_memory_view_round_trips_through_queries() {
683 let dir = TempDir::new().expect("tempdir");
684 let view = SessionMemoryView {
685 summary: "Implemented the frobnicator".to_string(),
686 facts: vec![
687 "Uses tokio for async runtime".to_string(),
688 "Prefers anyhow errors".to_string(),
689 ],
690 };
691 write_session_memory_view(dir.path(), "view-session", &view).expect("write view");
692
693 let facts = session_memory_facts(dir.path(), "view-session", 10).expect("read facts");
694 assert_eq!(facts.len(), 2);
695 assert_eq!(facts[0].session_id, "view-session");
696 assert_eq!(facts[0].fact, "Uses tokio for async runtime");
697
698 let all = query_facts(dir.path(), 10).expect("query facts");
699 assert_eq!(all.len(), 2);
700 assert!(all.iter().all(|record| record.session_id == "view-session"));
701
702 let results = search_memory(dir.path(), "tokio", 5, 0.0).expect("search");
703 assert_eq!(results.len(), 1);
704 assert!(results[0].snippet.contains("tokio"));
705 }
706
707 #[test]
708 fn search_memory_ranks_higher_term_frequency_first() {
709 let dir = TempDir::new().expect("tempdir");
710 for (session_id, fact) in [("once", "cargo alpha beta"), ("twice", "cargo cargo beta")] {
711 let session = crate::session_dir(dir.path(), session_id);
712 std::fs::create_dir_all(session.join(crate::DERIVED_DIR)).expect("mkdir");
713 let memory = serde_json::json!({
714 "grounded_facts": [{"fact": fact}]
715 });
716 std::fs::write(
717 session.join(crate::DERIVED_DIR).join("memory.json"),
718 serde_json::to_vec(&memory).expect("serialize"),
719 )
720 .expect("write");
721 }
722
723 let results = search_memory(dir.path(), "cargo", 10, 0.0).expect("search");
724 assert_eq!(results.len(), 2);
725 assert_eq!(results[0].chunk_id, "twice:0");
728 assert_eq!(results[1].chunk_id, "once:0");
729 assert!(results[0].score > results[1].score);
730 }
731
732 #[test]
733 fn session_memory_facts_respects_limit_and_missing_files() {
734 let dir = TempDir::new().expect("tempdir");
735 assert!(
736 session_memory_facts(dir.path(), "missing-session", 5)
737 .expect("missing")
738 .is_empty()
739 );
740
741 let view = SessionMemoryView {
742 summary: String::new(),
743 facts: (0..5).map(|index| format!("fact-{index}")).collect(),
744 };
745 write_session_memory_view(dir.path(), "limited", &view).expect("write");
746 let limited = session_memory_facts(dir.path(), "limited", 3).expect("read");
747 assert_eq!(limited.len(), 3);
748 assert_eq!(limited[2].fact, "fact-2");
749 }
750}