Skip to main content

vtcode_core/persistent_memory/
batch.rs

1//! Batch memory extraction across past sessions.
2//!
3//! Codex-style two-phase pipeline completion: per-session grounded facts have
4//! already been extracted into each session's `derived/memory.json` view (see
5//! [`super::finalize_persistent_memory`]); this module re-reads those views
6//! from the recent sessions, dedupes them, and feeds them through the normal
7//! classify → consolidate path under the global [`MemoryLock`](super::MemoryLock)
8//! so the user-level `memory_summary.md` reflects the whole session history,
9//! not only the most recent one.
10
11use std::collections::HashSet;
12use std::path::Path;
13use std::sync::Arc;
14
15use anyhow::{Context, Result, bail};
16use tokio::sync::Semaphore;
17
18use super::{FactsInput, GroundedFactRecord, PersistentMemoryWriteReport, RuntimeAgentConfig, VTCodeConfig};
19
20/// Facts kept per session during batch collection. Mirrors the eviction
21/// summary's grounded-fact cap so no single session dominates the batch.
22const FACTS_PER_SESSION: usize = 32;
23
24/// Report for one batch extraction run.
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct BatchMemoryReport {
27    /// Sessions scanned (most recent first, bounded by `max_sessions`).
28    pub sessions_scanned: usize,
29    /// Sessions that contributed at least one fact.
30    pub sessions_with_facts: usize,
31    /// Unique candidate facts fed into classification.
32    pub candidate_facts: usize,
33    /// Sessions skipped because they were still marked active.
34    pub active_sessions_skipped: usize,
35    /// Underlying write report when consolidation ran.
36    pub write_report: Option<PersistentMemoryWriteReport>,
37}
38
39/// Extract memory candidates from the most recent `max_sessions` sessions and
40/// consolidate them into the persistent memory store.
41///
42/// Session reads are parallelized with a `concurrency`-sized semaphore; the
43/// classify + consolidate LLM work runs once over the merged candidates under
44/// the global memory lock. Safe to run while a session finalization is in
45/// flight: the lock serializes the mutating phase.
46pub async fn run_batch_memory_extraction(
47    runtime_config: &RuntimeAgentConfig,
48    vt_cfg: Option<&VTCodeConfig>,
49    workspace_root: &Path,
50    max_sessions: usize,
51    concurrency: usize,
52) -> Result<BatchMemoryReport> {
53    if max_sessions == 0 {
54        bail!("batch memory extraction requires max_sessions > 0");
55    }
56    let config = super::effective_generated_memory_config(vt_cfg);
57    if !config.enabled {
58        bail!(
59            "persistent memory generation is disabled; enable `[agent.persistent_memory] enabled` (and memories) first"
60        );
61    }
62
63    let summaries = vtcode_memory::recent_sessions(workspace_root, max_sessions);
64    let semaphore = Arc::new(Semaphore::new(concurrency.max(1)));
65    let mut handles = Vec::with_capacity(summaries.len());
66    for summary in summaries {
67        let permit = semaphore
68            .clone()
69            .acquire_owned()
70            .await
71            .context("batch memory extraction semaphore closed")?;
72        let workspace = workspace_root.to_path_buf();
73        let session_id = summary.session_id.clone();
74        let active = summary.status == "active";
75        handles.push(tokio::task::spawn_blocking(move || {
76            let _permit = permit;
77            match vtcode_memory::session_memory_facts(&workspace, &session_id, FACTS_PER_SESSION) {
78                Ok(facts) => (session_id, active, facts),
79                Err(error) => {
80                    tracing::warn!(session_id = %session_id, error = %error, "batch memory: failed to read session facts; skipping");
81                    (session_id, active, Vec::new())
82                }
83            }
84        }));
85    }
86
87    let mut seen: HashSet<String> = HashSet::new();
88    let mut candidates: Vec<GroundedFactRecord> = Vec::new();
89    let mut sessions_scanned = 0usize;
90    let mut sessions_with_facts = 0usize;
91    let mut active_sessions_skipped = 0usize;
92    for handle in handles {
93        let Ok((session_id, active, facts)) = handle.await else {
94            continue;
95        };
96        sessions_scanned += 1;
97        if active {
98            // A live session's view is still being written; skip it to avoid
99            // ingesting a partial snapshot.
100            active_sessions_skipped += 1;
101            continue;
102        }
103        let mut contributed = false;
104        for fact in facts {
105            let normalized = super::normalize_whitespace(&fact.fact).to_ascii_lowercase();
106            if normalized.is_empty() || !seen.insert(normalized) {
107                continue;
108            }
109            candidates.push(GroundedFactRecord {
110                fact: fact.fact,
111                source: format!("session:{session_id}"),
112            });
113            contributed = true;
114        }
115        if contributed {
116            sessions_with_facts += 1;
117        }
118    }
119
120    let candidate_count = candidates.len();
121    let write_report = if candidate_count == 0 {
122        None
123    } else {
124        super::persist_memory_internal(
125            &config,
126            workspace_root,
127            Some(runtime_config),
128            vt_cfg,
129            FactsInput::Candidates(&candidates),
130            true,
131            false,
132        )
133        .await
134        .context("batch memory consolidation")?
135    };
136
137    Ok(BatchMemoryReport {
138        sessions_scanned,
139        sessions_with_facts,
140        candidate_facts: candidate_count,
141        active_sessions_skipped,
142        write_report,
143    })
144}
145
146/// Resolve the batch parameters from config with a sanity clamp.
147pub fn batch_parameters(config: &crate::config::PersistentMemoryConfig) -> (usize, usize) {
148    let sessions = config.memories.batch_sessions.max(1);
149    let concurrency = config.memories.batch_concurrency.clamp(1, 16);
150    (sessions, concurrency)
151}
152
153/// Resolve batch parameters from an optional loaded config, applying the
154/// documented defaults when no config is available.
155pub fn batch_parameters_from_config(config: Option<&crate::config::PersistentMemoryConfig>) -> (usize, usize) {
156    match config {
157        Some(config) => batch_parameters(config),
158        None => batch_parameters(&crate::config::PersistentMemoryConfig::default()),
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn batch_parameters_clamp_degenerate_config() {
168        let mut config = crate::config::PersistentMemoryConfig::default();
169        config.memories.batch_sessions = 0;
170        config.memories.batch_concurrency = 999;
171        let (sessions, concurrency) = batch_parameters(&config);
172        assert_eq!(sessions, 1);
173        assert_eq!(concurrency, 16);
174    }
175
176    #[test]
177    fn batch_parameters_use_defaults() {
178        let config = crate::config::PersistentMemoryConfig::default();
179        let (sessions, concurrency) = batch_parameters(&config);
180        assert_eq!(sessions, 50);
181        assert_eq!(concurrency, 8);
182    }
183}