Skip to main content

vtcode_memory/
query.rs

1//! Read-only queries across sessions for analytics and long-term learning.
2
3use 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
39/// Invalidate a cached manifest after an atomic replacement.
40pub(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/// Lightweight summary of a single session, read from its `manifest.json`.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
51pub struct SessionSummary {
52    /// Session identifier (directory name).
53    pub session_id: String,
54    /// Number of completed turns.
55    pub turn_count: u64,
56    /// Total events recorded.
57    pub event_count: u64,
58    /// Lifecycle status.
59    pub status: String,
60    /// RFC3339 last-update timestamp (used for ordering).
61    pub updated_at: String,
62}
63
64/// A single grounded fact drawn from a session's memory envelope.
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
66pub struct FactRecord {
67    /// The fact text.
68    pub fact: String,
69    /// Session the fact originated from.
70    pub session_id: String,
71}
72
73/// One result returned from a memory search.
74///
75/// Mirrors the shape used by the grok-build memory subsystem so that
76/// higher-level consumers (tool bridge, context injection) can share
77/// formatting logic once a richer backend is available.
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
79pub struct MemorySearchResult {
80    /// Stable identifier for this chunk (session_id + fact index).
81    chunk_id: String,
82    /// Source memory file path.
83    path: String,
84    /// 0-based start line in the source file (0 for derived facts).
85    start_line: usize,
86    /// 0-based end line in the source file (0 for derived facts).
87    end_line: usize,
88    /// Relevance score (higher = more relevant).
89    score: f64,
90    /// Text snippet from the chunk.
91    snippet: String,
92    /// Source scope: `"session"` for per-session memory files.
93    source: String,
94    /// Unix timestamp (seconds) when the source memory was created.
95    created_at: Option<i64>,
96}
97
98/// List up to `n` most-recently-updated sessions.
99#[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/// List summaries of sessions with a live process attached, most-recently-updated first.
139///
140/// Liveness is the flock-backed `session.lock` check, not the manifest
141/// `status` string, so crashed sessions that never emitted `thread.completed`
142/// are excluded. Used by peer-messaging discovery (`list_sessions`).
143#[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
151/// Cross-session long-term-learning query: collect grounded facts from every
152/// session's derived memory envelope. This is how the agent learns across
153/// sessions without loading any history into context.
154pub 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
185/// Cross-session memory search: scan every session's derived memory envelope
186/// for facts matching `query`. Returns up to `max_results` results with
187/// score >= `min_score`, sorted by descending relevance.
188///
189/// Scoring uses BM25 (`k1=1.2`, `b=0.75`) over tokenized facts. Ties are
190/// deterministic by the stable `session_id:index` chunk identifier.
191pub 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                // Sorted once: run-length dedup below and binary-search term
238                // counts in scoring replace two per-document hash tables.
239                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        // Terms are pre-sorted: consecutive duplicates form one run.
260        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            // Terms are pre-sorted: count occurrences via binary search.
277            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        // A mild recency preference helps current session facts win close BM25
297        // matches without allowing recency to create a result that did not
298        // match the query. Missing or malformed timestamps deliberately keep
299        // the neutral multiplier of 1.0.
300        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
345/// Return the configured default for `max_results` in search queries.
346pub fn default_search_max_results() -> usize {
347    6
348}
349
350/// Return the configured default for `min_score` in search queries.
351pub 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/// Minimal per-session memory view persisted into `derived/memory.json`.
386///
387/// The serialized shape is compatible with the legacy
388/// `SessionMemoryEnvelope` layout (`grounded_facts: [{fact: …}]`) so
389/// `query_facts`, `search_memory`, and the legacy migration importer all read
390/// the same file format.
391#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
392pub struct SessionMemoryView {
393    /// Short session summary (may be empty when unknown).
394    pub summary: String,
395    /// Grounded facts extracted from the session, in extraction order.
396    pub facts: Vec<String>,
397}
398
399/// Persist a session's derived memory view to
400/// `<session>/derived/memory.json` (atomic, private permissions).
401///
402/// This is the live producer for the per-session memory file: cross-session
403/// queries (`query_facts`, `search_memory`) and the batch memory pipeline
404/// observe sessions through it. Fails when the session directory cannot be
405/// created or the file cannot be written; it never mutates `events.jsonl`.
406pub 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
436/// Read one session's grounded facts from its derived memory view.
437///
438/// Returns an empty list when the view has not been written yet; unlike
439/// [`query_facts`] this never scans sibling sessions.
440pub 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        // Same document length, same idf, no timestamps: BM25 increases
726        // monotonically in term frequency, so the twice-mentioned term ranks first.
727        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}