Skip to main content

vtcode_memory/
query.rs

1//! Read-only queries across sessions for analytics and long-term learning.
2
3use std::path::Path;
4
5use crate::error::SessionStoreError;
6use crate::sessions_root;
7
8/// Lightweight summary of a single session, read from its `manifest.json`.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
10pub struct SessionSummary {
11    /// Session identifier (directory name).
12    pub session_id: String,
13    /// Number of completed turns.
14    pub turn_count: u64,
15    /// Total events recorded.
16    pub event_count: u64,
17    /// Lifecycle status.
18    pub status: String,
19    /// RFC3339 last-update timestamp (used for ordering).
20    pub updated_at: String,
21}
22
23/// A single grounded fact drawn from a session's memory envelope.
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
25pub struct FactRecord {
26    /// The fact text.
27    pub fact: String,
28    /// Session the fact originated from.
29    pub session_id: String,
30}
31
32/// One result returned from a memory search.
33///
34/// Mirrors the shape used by the grok-build memory subsystem so that
35/// higher-level consumers (tool bridge, context injection) can share
36/// formatting logic once a richer backend is available.
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
38pub struct MemorySearchResult {
39    /// Stable identifier for this chunk (session_id + fact index).
40    pub chunk_id: String,
41    /// Source memory file path.
42    pub path: String,
43    /// 0-based start line in the source file (0 for derived facts).
44    pub start_line: usize,
45    /// 0-based end line in the source file (0 for derived facts).
46    pub end_line: usize,
47    /// Relevance score (higher = more relevant).
48    pub score: f64,
49    /// Text snippet from the chunk.
50    pub snippet: String,
51    /// Source scope: `"session"` for per-session memory files.
52    pub source: String,
53    /// Unix timestamp (seconds) when the source memory was created.
54    pub created_at: Option<i64>,
55}
56
57/// List up to `n` most-recently-updated sessions.
58#[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
82/// Cross-session long-term-learning query: collect grounded facts from every
83/// session's derived memory envelope. This is how the agent learns across
84/// sessions without loading any history into context.
85pub 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
116/// Cross-session memory search: scan every session's derived memory envelope
117/// for facts matching `query`. Returns up to `max_results` results with
118/// score >= `min_score`, sorted by descending relevance.
119///
120/// Scoring is based on the number of case-insensitive query matches found
121/// in each fact. A simple BMH-style substring count keeps this zero-dependency
122/// while still surfacing the most-relevant chunks first.
123pub 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
185/// Return the configured default for `max_results` in search queries.
186pub fn default_search_max_results() -> usize {
187    6
188}
189
190/// Return the configured default for `min_score` in search queries.
191pub 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}