Skip to main content

vtcode_core/persistent_memory/
mod.rs

1use anyhow::{Context, Result, anyhow, bail};
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::collections::BTreeSet;
6use std::fmt::Write;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9use tokio::time::{Duration, sleep};
10
11use crate::config::loader::VTCodeConfig;
12use crate::config::types::AgentConfig as RuntimeAgentConfig;
13use crate::config::{ConfigManager, PersistentMemoryConfig};
14use crate::llm::factory::infer_provider_from_model;
15use crate::llm::provider::{LLMProvider, LLMRequest, Message, MessageRole};
16use crate::llm::{
17    LightweightFeature, collect_single_response, create_provider_for_model_route, resolve_lightweight_route,
18};
19use vtcode_commons::VtCodePaths;
20
21mod batch;
22mod fact_extraction;
23mod legacy_migration;
24mod llm_ops;
25mod lock;
26mod reader;
27mod rendering;
28
29pub use batch::{BatchMemoryReport, batch_parameters_from_config, run_batch_memory_extraction};
30pub use fact_extraction::{
31    dedup_latest_facts, maybe_extract_tool_fact, maybe_extract_user_fact, normalize_whitespace, truncate_for_fact,
32};
33use legacy_migration::{
34    migrate_legacy_persistent_memory_dir_if_needed, persistent_memory_base_dir, persistent_memory_project_name,
35    sanitize_project_name,
36};
37use llm_ops::{classify_facts_strict, plan_memory_operation, summarize_memory};
38use lock::MemoryLock;
39use reader::{
40    MemoryNoteSummary, classify_fact, collect_all_memory_matches, collect_cleanup_candidates, collect_memory_matches,
41    count_pending_rollout_summaries, count_pending_rollout_summaries_async, decode_topic_source, encode_topic_source,
42    list_pending_rollout_files_async, list_rollout_markdown_files, list_rollout_markdown_files_async,
43    normalize_memory_query, parse_fact_line, parse_topic_file, read_note_summaries, read_rollout_records,
44    read_topic_records,
45};
46use rendering::{
47    render_memory_index, render_memory_summary, render_memory_summary_bullets, render_rollout_summary,
48    render_topic_file, unique_rollout_id,
49};
50
51// Re-exported at crate-visible-but-restricted scope solely so that
52// `persistent_memory_tests` (a descendant module of `persistent_memory`, see
53// `#[cfg(test)] mod persistent_memory_tests;` below) can reach these
54// otherwise-internal submodule items through its `use super::*;`. They are
55// not referenced by any non-test code in this module, so the imports are
56// gated behind `#[cfg(test)]` to avoid unused-import warnings in normal
57// builds.
58#[cfg(test)]
59use legacy_migration::migrate_legacy_memory_dir;
60#[cfg(test)]
61use llm_ops::{
62    MemoryModelRoute, MemoryPhase, classify_facts_with_provider, plan_memory_operation_with_provider,
63    resolve_memory_model_routes, summarize_memory_with_provider,
64};
65#[cfg(test)]
66use lock::{LOCK_STALE_AFTER_SECS, lock_age};
67
68pub const MEMORY_FILENAME: &str = "MEMORY.md";
69pub const MEMORY_SUMMARY_FILENAME: &str = "memory_summary.md";
70pub const ROLLOUT_SUMMARIES_DIRNAME: &str = "rollout_summaries";
71pub const NOTES_DIRNAME: &str = "notes";
72
73const MEMORY_LOCK_FILENAME: &str = ".memory.lock";
74const PREFERENCES_FILENAME: &str = "preferences.md";
75const REPOSITORY_FACTS_FILENAME: &str = "repository-facts.md";
76const DEFAULT_FACT_LIMIT: usize = 24;
77const MEMORY_HIGHLIGHT_LIMIT: usize = 10;
78const TOPIC_FACT_LIMIT: usize = 32;
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct GroundedFactRecord {
82    pub fact: String,
83    pub source: String,
84}
85
86#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
87pub struct PersistentMemoryStatus {
88    pub enabled: bool,
89    pub auto_write: bool,
90    pub directory: PathBuf,
91    pub summary_file: PathBuf,
92    pub memory_file: PathBuf,
93    pub preferences_file: PathBuf,
94    pub repository_facts_file: PathBuf,
95    pub notes_dir: PathBuf,
96    pub rollout_summaries_dir: PathBuf,
97    pub summary_exists: bool,
98    pub registry_exists: bool,
99    pub pending_rollout_summaries: usize,
100    pub cleanup_status: MemoryCleanupStatus,
101}
102
103#[derive(Debug, Clone, Serialize)]
104pub struct PersistentMemoryExcerpt {
105    pub status: PersistentMemoryStatus,
106    pub contents: String,
107    pub truncated: bool,
108    pub bytes_read: usize,
109    pub lines_read: usize,
110    /// Rough token estimate of the returned excerpt (0 when no token budget
111    /// is configured; whitespace-based approximation, not exact tokenization).
112    pub tokens_estimated: usize,
113}
114
115#[derive(Debug, Clone, Serialize)]
116pub struct PersistentMemoryWriteReport {
117    pub directory: PathBuf,
118    pub summary_file: PathBuf,
119    pub memory_file: PathBuf,
120    pub rollout_summary_file: Option<PathBuf>,
121    pub created_files: Vec<PathBuf>,
122    pub added_facts: usize,
123    pub pending_rollout_summaries: usize,
124}
125
126#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
127pub struct PersistentMemoryMatch {
128    pub source: String,
129    pub fact: String,
130}
131
132#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
133pub struct PersistentMemoryForgetReport {
134    pub directory: PathBuf,
135    pub summary_file: PathBuf,
136    pub memory_file: PathBuf,
137    pub removed_facts: usize,
138    pub pending_rollout_summaries: usize,
139}
140
141#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
142pub struct MemoryCleanupStatus {
143    pub needed: bool,
144    pub suspicious_facts: usize,
145    pub suspicious_summary_lines: usize,
146}
147
148#[derive(Debug, Clone, Serialize)]
149pub struct PersistentMemoryCleanupReport {
150    pub directory: PathBuf,
151    pub summary_file: PathBuf,
152    pub memory_file: PathBuf,
153    pub rewritten_facts: usize,
154    pub removed_rollout_files: usize,
155}
156
157pub fn extract_memory_highlights(contents: &str, limit: usize) -> Vec<String> {
158    if limit == 0 {
159        return Vec::new();
160    }
161    let mut highlights = Vec::with_capacity(limit);
162    for line in contents.lines() {
163        let trimmed = line.trim();
164        if trimmed.is_empty() || trimmed.starts_with('#') {
165            continue;
166        }
167        let normalized = trimmed
168            .strip_prefix("- ")
169            .or_else(|| trimmed.strip_prefix("* "))
170            .or_else(|| trimmed.strip_prefix("+ "))
171            .unwrap_or(trimmed);
172        if normalized.is_empty() || highlights.iter().any(|e| e == normalized) {
173            continue;
174        }
175        highlights.push(normalized.to_string());
176        if highlights.len() >= limit {
177            break;
178        }
179    }
180    highlights
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(rename_all = "snake_case")]
185pub enum MemoryOpKind {
186    Remember,
187    Forget,
188    AskMissing,
189    Noop,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193pub struct MemoryOpCandidate {
194    pub id: usize,
195    pub source: String,
196    pub fact: String,
197}
198
199#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(rename_all = "snake_case")]
201pub enum MemoryPlannedTopic {
202    Preferences,
203    RepositoryFacts,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
207pub struct MemoryPlannedFact {
208    pub topic: MemoryPlannedTopic,
209    pub fact: String,
210    #[serde(default)]
211    pub source: String,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215pub struct MemoryMissingField {
216    pub field: String,
217    pub prompt: String,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct MemoryOpPlan {
222    pub kind: MemoryOpKind,
223    #[serde(default)]
224    pub facts: Vec<MemoryPlannedFact>,
225    #[serde(default)]
226    pub selected_ids: Vec<usize>,
227    #[serde(default)]
228    pub missing: Option<MemoryMissingField>,
229    #[serde(default)]
230    pub message: Option<String>,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234#[repr(usize)]
235enum MemoryTopic {
236    Preferences = 0,
237    RepositoryFacts = 1,
238}
239
240impl MemoryTopic {
241    fn title(self) -> &'static str {
242        ["Preferences", "Repository Facts"][self as usize]
243    }
244
245    fn description(self) -> &'static str {
246        [
247            "Durable user preferences and workflow notes.",
248            "Grounded repository facts and recurring tooling notes.",
249        ][self as usize]
250    }
251
252    fn slug(self) -> &'static str {
253        ["preferences", "repository_facts"][self as usize]
254    }
255
256    fn from_slug(value: &str) -> Option<Self> {
257        match value {
258            "preferences" => Some(Self::Preferences),
259            "repository_facts" => Some(Self::RepositoryFacts),
260            _ => None,
261        }
262    }
263}
264
265#[derive(Debug, Clone)]
266struct PersistentMemoryFiles {
267    directory: PathBuf,
268    private_global: bool,
269    summary_file: PathBuf,
270    memory_file: PathBuf,
271    preferences_file: PathBuf,
272    repository_facts_file: PathBuf,
273    notes_dir: PathBuf,
274    rollout_summaries_dir: PathBuf,
275    lock_file: PathBuf,
276}
277
278impl PersistentMemoryFiles {
279    fn new(directory: PathBuf, private_global: bool) -> Self {
280        Self {
281            summary_file: directory.join(MEMORY_SUMMARY_FILENAME),
282            memory_file: directory.join(MEMORY_FILENAME),
283            preferences_file: directory.join(PREFERENCES_FILENAME),
284            repository_facts_file: directory.join(REPOSITORY_FACTS_FILENAME),
285            notes_dir: directory.join(NOTES_DIRNAME),
286            rollout_summaries_dir: directory.join(ROLLOUT_SUMMARIES_DIRNAME),
287            lock_file: directory.join(MEMORY_LOCK_FILENAME),
288            directory,
289            private_global,
290        }
291    }
292}
293
294#[derive(Debug, Clone)]
295struct ClassifiedFacts {
296    preferences: Vec<GroundedFactRecord>,
297    repository_facts: Vec<GroundedFactRecord>,
298}
299
300impl ClassifiedFacts {
301    fn total(&self) -> usize {
302        self.preferences.len() + self.repository_facts.len()
303    }
304}
305
306/// Resolves the persistent memory directory for a project.
307///
308/// **Blocking**: This function may perform filesystem I/O (directory migration).
309/// Callers in async contexts must wrap this in `tokio::task::spawn_blocking`.
310pub fn resolve_persistent_memory_dir(
311    config: &PersistentMemoryConfig,
312    workspace_root: &Path,
313) -> Result<Option<PathBuf>> {
314    let project_name = persistent_memory_project_name(workspace_root);
315    let directory = persistent_memory_base_dir(config)?
316        .join("projects")
317        .join(sanitize_project_name(&project_name))
318        .join("memory");
319    migrate_legacy_persistent_memory_dir_if_needed(config, &project_name, &directory)?;
320    Ok(Some(directory))
321}
322
323/// Returns the current persistent memory status.
324///
325/// **Blocking**: This function performs filesystem I/O (directory migration,
326/// file existence checks, reading topic files). Callers in async contexts
327/// must wrap this in `tokio::task::spawn_blocking`.
328pub fn persistent_memory_status(
329    config: &PersistentMemoryConfig,
330    workspace_root: &Path,
331) -> Result<PersistentMemoryStatus> {
332    let directory = resolve_persistent_memory_dir(config, workspace_root)?
333        .ok_or_else(|| anyhow!("persistent memory path policy returned no storage directory"))?;
334    let files = PersistentMemoryFiles::new(directory, config.directory_override.is_none());
335    let pending_rollout_summaries = count_pending_rollout_summaries(&files.rollout_summaries_dir)?;
336    let cleanup_status = detect_memory_cleanup_status(&files)?;
337
338    Ok(PersistentMemoryStatus {
339        enabled: config.enabled,
340        auto_write: config.auto_write,
341        summary_exists: is_regular_file(&files.summary_file),
342        registry_exists: is_regular_file(&files.memory_file),
343        pending_rollout_summaries,
344        cleanup_status,
345        directory: files.directory,
346        summary_file: files.summary_file,
347        memory_file: files.memory_file,
348        preferences_file: files.preferences_file,
349        repository_facts_file: files.repository_facts_file,
350        notes_dir: files.notes_dir,
351        rollout_summaries_dir: files.rollout_summaries_dir,
352    })
353}
354
355pub async fn read_persistent_memory_excerpt(
356    config: &PersistentMemoryConfig,
357    workspace_root: &Path,
358) -> Result<Option<PersistentMemoryExcerpt>> {
359    if !config.enabled {
360        return Ok(None);
361    }
362
363    let config_clone = config.clone();
364    let workspace_root = workspace_root.to_path_buf();
365    let status = tokio::task::spawn_blocking(move || persistent_memory_status(&config_clone, &workspace_root))
366        .await
367        .context("Persistent memory status task panicked")??;
368    if !tokio::fs::try_exists(&status.summary_file).await.unwrap_or(false) {
369        return Ok(None);
370    }
371
372    let raw = String::from_utf8(vtcode_commons::fs::read_private_file_no_follow(&status.summary_file).await?)
373        .with_context(|| format!("Failed to decode persistent memory summary {}", status.summary_file.display()))?;
374
375    let (contents, truncated, bytes_read, lines_read) =
376        truncate_memory_excerpt(&raw, config.startup_line_limit, config.startup_byte_limit);
377    // Apply the token budget last (it sees the already line/byte-truncated
378    // text) so the injected excerpt always fits the configured prompt budget.
379    let (contents, truncated, tokens_estimated) = if config.startup_token_budget > 0 {
380        let trimmed = crate::llm::utils::truncate_to_token_limit(&contents, config.startup_token_budget);
381        let truncated = truncated || trimmed != contents;
382        // Estimate AFTER truncation so the count describes the excerpt that is
383        // actually injected, not the pre-truncation text.
384        let tokens = trimmed.split_whitespace().count();
385        (trimmed, truncated, tokens)
386    } else {
387        (contents, truncated, 0)
388    };
389
390    Ok(Some(PersistentMemoryExcerpt {
391        status,
392        contents,
393        truncated,
394        bytes_read,
395        lines_read,
396        tokens_estimated,
397    }))
398}
399
400pub async fn read_persistent_memory_excerpt_for_config(
401    vt_cfg: Option<&VTCodeConfig>,
402    workspace_root: &Path,
403) -> Result<Option<PersistentMemoryExcerpt>> {
404    let config = effective_persistent_memory_config(vt_cfg);
405    read_persistent_memory_excerpt(&config, workspace_root).await
406}
407
408pub async fn finalize_persistent_memory(
409    runtime_config: &RuntimeAgentConfig,
410    vt_cfg: Option<&VTCodeConfig>,
411    history: &[Message],
412    session_id: &str,
413) -> Result<Option<PersistentMemoryWriteReport>> {
414    let config = effective_generated_memory_config(vt_cfg);
415    if !config.enabled || !config.auto_write {
416        return Ok(None);
417    }
418
419    // Live producer for the per-session derived memory view: without this,
420    // cross-session queries (`vtcode_memory::query_facts` / `search_memory`)
421    // only ever observe legacy-migrated sessions. Cheap file I/O — run before
422    // the LLM-backed global persist so it completes even when finalization is
423    // cut short.
424    write_session_derived_view(runtime_config.workspace.as_path(), session_id, history);
425
426    let cfg_status = config.clone();
427    let ws_status = runtime_config.workspace.clone();
428    if tokio::task::spawn_blocking(move || persistent_memory_status(&cfg_status, ws_status.as_path()))
429        .await
430        .context("Persistent memory status task panicked")??
431        .cleanup_status
432        .needed
433    {
434        return Ok(None);
435    }
436
437    let facts = dedup_latest_facts(history, DEFAULT_FACT_LIMIT);
438    persist_memory_internal(
439        &config,
440        runtime_config.workspace.as_path(),
441        Some(runtime_config),
442        vt_cfg,
443        FactsInput::Candidates(&facts),
444        true,
445        false,
446    )
447    .await
448}
449
450/// Build the per-session memory view: prefer the compaction envelope's
451/// grounded facts when one was persisted, else re-run the lightweight
452/// extraction over the session history.
453fn session_memory_view(
454    workspace_root: &Path,
455    session_id: &str,
456    history: &[Message],
457) -> vtcode_memory::SessionMemoryView {
458    if let Some(envelope) = crate::compaction::memory_envelope::load_latest_memory_envelope(workspace_root, session_id)
459        && !envelope.grounded_facts.is_empty()
460    {
461        return vtcode_memory::SessionMemoryView {
462            summary: envelope.summary,
463            facts: envelope.grounded_facts.into_iter().map(|record| record.fact).collect(),
464        };
465    }
466    vtcode_memory::SessionMemoryView {
467        summary: String::new(),
468        facts: dedup_latest_facts(history, DEFAULT_FACT_LIMIT)
469            .into_iter()
470            .map(|record| record.fact)
471            .collect(),
472    }
473}
474
475/// Best-effort write of the derived memory view into the session store.
476fn write_session_derived_view(workspace_root: &Path, session_id: &str, history: &[Message]) {
477    let view = session_memory_view(workspace_root, session_id, history);
478    if let Err(error) = vtcode_memory::write_session_memory_view(workspace_root, session_id, &view) {
479        tracing::warn!(target: "vtcode.memory", session_id, error = %error, "failed to persist derived session memory view");
480    }
481}
482
483pub async fn rebuild_persistent_memory_summary(
484    runtime_config: &RuntimeAgentConfig,
485    vt_cfg: Option<&VTCodeConfig>,
486) -> Result<Option<PersistentMemoryWriteReport>> {
487    let config = effective_persistent_memory_config(vt_cfg);
488    if !config.enabled {
489        return Ok(None);
490    }
491    let cfg_rb = config.clone();
492    let ws_rb = runtime_config.workspace.clone();
493    if tokio::task::spawn_blocking(move || persistent_memory_status(&cfg_rb, ws_rb.as_path()))
494        .await
495        .context("Persistent memory status task panicked")??
496        .cleanup_status
497        .needed
498    {
499        bail!("persistent memory cleanup is required before rebuilding the summary");
500    }
501
502    persist_memory_internal(
503        &config,
504        runtime_config.workspace.as_path(),
505        Some(runtime_config),
506        vt_cfg,
507        FactsInput::Candidates(&[]),
508        false,
509        true,
510    )
511    .await
512}
513
514pub async fn rebuild_generated_memory_files(config: &PersistentMemoryConfig, workspace_root: &Path) -> Result<()> {
515    let cfg = config.clone();
516    let ws = workspace_root.to_path_buf();
517    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
518        .await
519        .context("Persistent memory directory resolution task panicked")??
520        .context("persistent memory directory should resolve")?;
521    let files = PersistentMemoryFiles::new(directory, config.directory_override.is_none());
522    let mut created_files = Vec::new();
523    ensure_memory_layout(&files, &mut created_files).await?;
524    let _lock = MemoryLock::acquire(&files.lock_file).await?;
525    // Consolidation is best-effort; log errors but don't fail initialization
526    if let Err(e) = consolidate_memory_files(None, None, workspace_root, &files).await {
527        tracing::warn!("Failed to consolidate memory files during init: {}", e);
528    }
529    Ok(())
530}
531
532pub async fn scaffold_persistent_memory(
533    config: &PersistentMemoryConfig,
534    workspace_root: &Path,
535) -> Result<Option<PersistentMemoryStatus>> {
536    let cfg = config.clone();
537    let ws = workspace_root.to_path_buf();
538    let status = tokio::task::spawn_blocking(move || persistent_memory_status(&cfg, &ws))
539        .await
540        .context("Persistent memory status task panicked")??;
541    let files = PersistentMemoryFiles::new(status.directory.clone(), config.directory_override.is_none());
542    let mut created_files = Vec::new();
543    ensure_memory_layout(&files, &mut created_files).await?;
544    let cfg2 = config.clone();
545    let ws2 = workspace_root.to_path_buf();
546    let final_status = tokio::task::spawn_blocking(move || persistent_memory_status(&cfg2, &ws2))
547        .await
548        .context("Persistent memory status task panicked")??;
549    Ok(Some(final_status))
550}
551
552/// Write classified facts to all memory files (topic files, index, summary).
553async fn write_classified_memory(
554    files: &PersistentMemoryFiles,
555    classified: &ClassifiedFacts,
556    notes: &[MemoryNoteSummary],
557    runtime_config: Option<&RuntimeAgentConfig>,
558    vt_cfg: Option<&VTCodeConfig>,
559    workspace_root: &Path,
560) -> Result<Vec<PathBuf>> {
561    let mut created_files = Vec::new();
562    async fn write_memory_file(
563        path: &Path,
564        contents: String,
565        created_files: &mut Vec<PathBuf>,
566        private_global: bool,
567    ) -> Result<()> {
568        if !tokio::fs::try_exists(path).await.unwrap_or(false) {
569            created_files.push(path.to_path_buf());
570        }
571        if private_global {
572            vtcode_commons::fs::write_private_file_atomic(path, contents.as_bytes())
573                .await
574                .with_context(|| format!("Failed to write private memory file {}", path.display()))
575        } else {
576            tokio::fs::write(path, contents)
577                .await
578                .with_context(|| format!("Failed to write {}", path.display()))
579        }
580    }
581    write_memory_file(
582        &files.preferences_file,
583        render_topic_file(MemoryTopic::Preferences, &classified.preferences),
584        &mut created_files,
585        files.private_global,
586    )
587    .await?;
588    write_memory_file(
589        &files.repository_facts_file,
590        render_topic_file(MemoryTopic::RepositoryFacts, &classified.repository_facts),
591        &mut created_files,
592        files.private_global,
593    )
594    .await?;
595    write_memory_file(
596        &files.memory_file,
597        render_memory_index(&classified.preferences, &classified.repository_facts, notes, 0),
598        &mut created_files,
599        files.private_global,
600    )
601    .await?;
602    let summary = summarize_memory(
603        runtime_config,
604        vt_cfg,
605        workspace_root,
606        &classified.preferences,
607        &classified.repository_facts,
608        notes,
609    )
610    .await
611    .unwrap_or_else(|| render_memory_summary(&classified.preferences, &classified.repository_facts, notes));
612    write_memory_file(&files.summary_file, summary, &mut created_files, files.private_global).await?;
613    Ok(created_files)
614}
615
616pub async fn cleanup_persistent_memory(
617    runtime_config: &RuntimeAgentConfig,
618    vt_cfg: Option<&VTCodeConfig>,
619    include_summary_only_signals: bool,
620) -> Result<Option<PersistentMemoryCleanupReport>> {
621    let config = effective_persistent_memory_config(vt_cfg);
622    if !config.enabled {
623        return Ok(None);
624    }
625
626    let cfg_dir = config.clone();
627    let ws_dir = runtime_config.workspace.clone();
628    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg_dir, ws_dir.as_path()))
629        .await
630        .context("Persistent memory directory resolution task panicked")??
631        .context("persistent memory directory should resolve when enabled")?;
632    let files = PersistentMemoryFiles::new(directory, config.directory_override.is_none());
633    let mut created_files = Vec::new();
634    ensure_memory_layout(&files, &mut created_files).await?;
635
636    let status = detect_memory_cleanup_status_async(&files).await?;
637    if !status.needed && !include_summary_only_signals {
638        return Ok(Some(PersistentMemoryCleanupReport {
639            directory: files.directory,
640            summary_file: files.summary_file,
641            memory_file: files.memory_file,
642            rewritten_facts: 0,
643            removed_rollout_files: 0,
644        }));
645    }
646
647    let _lock = MemoryLock::acquire(&files.lock_file).await?;
648    let (candidates, notes) =
649        tokio::try_join!(collect_cleanup_candidates(&files), read_note_summaries(&files.notes_dir))?;
650    let classified = if candidates.is_empty() {
651        ClassifiedFacts {
652            preferences: Vec::new(),
653            repository_facts: Vec::new(),
654        }
655    } else {
656        classify_facts_strict(Some(runtime_config), vt_cfg, runtime_config.workspace.as_path(), &candidates).await?
657    };
658
659    let removed_rollout_files = remove_rollout_markdown_files(&files.rollout_summaries_dir).await?;
660    // Write is critical; propagate errors
661    write_classified_memory(
662        &files,
663        &classified,
664        &notes,
665        Some(runtime_config),
666        vt_cfg,
667        runtime_config.workspace.as_path(),
668    )
669    .await?;
670
671    Ok(Some(PersistentMemoryCleanupReport {
672        directory: files.directory,
673        summary_file: files.summary_file,
674        memory_file: files.memory_file,
675        rewritten_facts: classified.total(),
676        removed_rollout_files,
677    }))
678}
679
680pub async fn list_persistent_memory_candidates(
681    config: &PersistentMemoryConfig,
682    workspace_root: &Path,
683) -> Result<Option<Vec<PersistentMemoryMatch>>> {
684    if !config.enabled {
685        return Ok(None);
686    }
687
688    let cfg = config.clone();
689    let ws = workspace_root.to_path_buf();
690    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
691        .await
692        .context("Persistent memory directory resolution task panicked")??
693        .context("persistent memory directory should resolve when enabled")?;
694    if !tokio::fs::try_exists(&directory).await.unwrap_or(false) {
695        return Ok(Some(Vec::new()));
696    }
697
698    let files = PersistentMemoryFiles::new(directory, config.directory_override.is_none());
699    collect_all_memory_matches(&files).await.map(Some)
700}
701
702pub async fn find_persistent_memory_matches(
703    config: &PersistentMemoryConfig,
704    workspace_root: &Path,
705    query: &str,
706) -> Result<Option<Vec<PersistentMemoryMatch>>> {
707    if !config.enabled {
708        return Ok(None);
709    }
710    let Some(normalized_query) = normalize_memory_query(query) else {
711        return Ok(Some(Vec::new()));
712    };
713    let cfg = config.clone();
714    let ws = workspace_root.to_path_buf();
715    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
716        .await
717        .context("Persistent memory directory resolution task panicked")??
718        .context("persistent memory directory should resolve when enabled")?;
719    if !tokio::fs::try_exists(&directory).await.unwrap_or(false) {
720        return Ok(Some(Vec::new()));
721    }
722
723    let files = PersistentMemoryFiles::new(directory, config.directory_override.is_none());
724    collect_memory_matches(&files, &normalized_query).await.map(Some)
725}
726
727/// Plan a user-approved persistent-memory save.
728///
729/// `prior_assistant_reply` is reference material for deictic requests such as
730/// "remember it" and must contain only the immediately preceding assistant
731/// reply. The caller remains responsible for limiting it to those requests and
732/// for obtaining confirmation before calling [`persist_remembered_memory_plan`].
733pub async fn plan_remember_persistent_memory(
734    runtime_config: &RuntimeAgentConfig,
735    vt_cfg: Option<&VTCodeConfig>,
736    request: &str,
737    supplemental_answer: Option<&str>,
738    prior_assistant_reply: Option<&str>,
739) -> Result<Option<MemoryOpPlan>> {
740    let config = effective_persistent_memory_config(vt_cfg);
741    if !config.enabled {
742        return Ok(None);
743    }
744
745    let plan = plan_memory_operation(
746        runtime_config,
747        vt_cfg,
748        runtime_config.workspace.as_path(),
749        MemoryOpKind::Remember,
750        request,
751        supplemental_answer,
752        prior_assistant_reply,
753        &[],
754    )
755    .await?;
756    Ok(Some(plan))
757}
758
759pub async fn persist_remembered_memory_plan(
760    runtime_config: &RuntimeAgentConfig,
761    vt_cfg: Option<&VTCodeConfig>,
762    plan: &MemoryOpPlan,
763) -> Result<Option<PersistentMemoryWriteReport>> {
764    let config = effective_persistent_memory_config(vt_cfg);
765    if !config.enabled || plan.kind != MemoryOpKind::Remember {
766        return Ok(None);
767    }
768
769    let facts = memory_plan_facts(plan)?;
770    persist_memory_internal(
771        &config,
772        runtime_config.workspace.as_path(),
773        Some(runtime_config),
774        vt_cfg,
775        FactsInput::Preclassified(&facts),
776        true,
777        false,
778    )
779    .await
780}
781
782pub async fn plan_forget_persistent_memory(
783    runtime_config: &RuntimeAgentConfig,
784    vt_cfg: Option<&VTCodeConfig>,
785    request: &str,
786    candidates: &[MemoryOpCandidate],
787) -> Result<Option<MemoryOpPlan>> {
788    let config = effective_persistent_memory_config(vt_cfg);
789    if !config.enabled {
790        return Ok(None);
791    }
792
793    let plan = plan_memory_operation(
794        runtime_config,
795        vt_cfg,
796        runtime_config.workspace.as_path(),
797        MemoryOpKind::Forget,
798        request,
799        None,
800        None,
801        candidates,
802    )
803    .await?;
804    Ok(Some(plan))
805}
806
807pub async fn forget_planned_persistent_memory_matches(
808    runtime_config: &RuntimeAgentConfig,
809    vt_cfg: Option<&VTCodeConfig>,
810    candidates: &[MemoryOpCandidate],
811    plan: &MemoryOpPlan,
812) -> Result<Option<PersistentMemoryForgetReport>> {
813    let config = effective_persistent_memory_config(vt_cfg);
814    if !config.enabled || plan.kind != MemoryOpKind::Forget {
815        return Ok(None);
816    }
817
818    let selected = selected_memory_candidates(candidates, &plan.selected_ids)?;
819    let cfg_dir = config.clone();
820    let ws_dir = runtime_config.workspace.clone();
821    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg_dir, ws_dir.as_path()))
822        .await
823        .context("Persistent memory directory resolution task panicked")??
824        .context("persistent memory directory should resolve when enabled")?;
825    let files = PersistentMemoryFiles::new(directory, config.directory_override.is_none());
826    if !tokio::fs::try_exists(&files.directory).await.unwrap_or(false) {
827        return Ok(Some(PersistentMemoryForgetReport {
828            directory: files.directory,
829            summary_file: files.summary_file,
830            memory_file: files.memory_file,
831            removed_facts: 0,
832            pending_rollout_summaries: 0,
833        }));
834    }
835
836    let _lock = MemoryLock::acquire(&files.lock_file).await?;
837    let mut removed_facts = 0usize;
838    removed_facts += rewrite_topic_without_selected(
839        &files.preferences_file,
840        MemoryTopic::Preferences,
841        &selected,
842        files.private_global,
843    )
844    .await?;
845    removed_facts += rewrite_topic_without_selected(
846        &files.repository_facts_file,
847        MemoryTopic::RepositoryFacts,
848        &selected,
849        files.private_global,
850    )
851    .await?;
852
853    let rollout_files = list_rollout_markdown_files_async(&files.rollout_summaries_dir).await?;
854    for path in rollout_files {
855        removed_facts += scrub_rollout_file_by_selection(&path, &selected, files.private_global).await?;
856    }
857
858    if removed_facts > 0 {
859        // Consolidation is best-effort; log errors but don't fail the forget operation
860        if let Err(e) =
861            consolidate_memory_files(Some(runtime_config), vt_cfg, runtime_config.workspace.as_path(), &files).await
862        {
863            tracing::warn!("Failed to consolidate memory files after forget: {}", e);
864        }
865    }
866
867    Ok(Some(PersistentMemoryForgetReport {
868        directory: files.directory,
869        summary_file: files.summary_file,
870        memory_file: files.memory_file,
871        removed_facts,
872        pending_rollout_summaries: count_pending_rollout_summaries_async(&files.rollout_summaries_dir).await?,
873    }))
874}
875
876/// Distinguishes how facts are provided to the persistence layer.
877enum FactsInput<'a> {
878    /// Facts already classified into topics (no LLM call needed).
879    Preclassified(&'a [GroundedFactRecord]),
880    /// Raw candidate facts that must be classified via LLM.
881    Candidates(&'a [GroundedFactRecord]),
882}
883
884impl FactsInput<'_> {
885    fn as_slice(&self) -> &[GroundedFactRecord] {
886        match self {
887            FactsInput::Preclassified(facts) => facts,
888            FactsInput::Candidates(facts) => facts,
889        }
890    }
891}
892
893async fn persist_memory_internal(
894    config: &PersistentMemoryConfig,
895    workspace_root: &Path,
896    runtime_config: Option<&RuntimeAgentConfig>,
897    vt_cfg: Option<&VTCodeConfig>,
898    facts_input: FactsInput<'_>,
899    write_rollout: bool,
900    force_rebuild: bool,
901) -> Result<Option<PersistentMemoryWriteReport>> {
902    let cfg = config.clone();
903    let ws = workspace_root.to_path_buf();
904    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
905        .await
906        .context("Persistent memory directory resolution task panicked")??
907        .context("persistent memory directory should resolve when enabled")?;
908    let files = PersistentMemoryFiles::new(directory, config.directory_override.is_none());
909    let mut created_files = Vec::new();
910    ensure_memory_layout(&files, &mut created_files).await?;
911
912    let facts_slice = facts_input.as_slice();
913    if detect_memory_cleanup_status_async(&files).await?.needed && (write_rollout || !facts_slice.is_empty()) {
914        bail!("persistent memory cleanup is required before mutating memory");
915    }
916
917    let _lock = MemoryLock::acquire(&files.lock_file).await?;
918    let existing_lines = read_existing_memory_lines(&files.directory).await?;
919    let deduped_records: Vec<GroundedFactRecord> = facts_slice
920        .iter()
921        .filter(|f| !existing_lines.contains(&normalize_whitespace(&f.fact).to_ascii_lowercase()))
922        .cloned()
923        .collect();
924
925    let classified = match facts_input {
926        FactsInput::Preclassified(_) => classified_facts_from_records(&deduped_records),
927        FactsInput::Candidates(_) if deduped_records.is_empty() => ClassifiedFacts {
928            preferences: Vec::new(),
929            repository_facts: Vec::new(),
930        },
931        FactsInput::Candidates(_) => {
932            classify_facts_strict(runtime_config, vt_cfg, workspace_root, &deduped_records).await?
933        }
934    };
935
936    let staged_rollout = if write_rollout && classified.total() > 0 {
937        Some(
938            write_rollout_summary_pending(&files.rollout_summaries_dir, &classified, files.private_global)
939                .await
940                .with_context(|| {
941                    format!("Failed to write rollout summary under {}", files.rollout_summaries_dir.display())
942                })?,
943        )
944    } else {
945        None
946    };
947
948    let pending_before = list_pending_rollout_files_async(&files.rollout_summaries_dir).await?;
949    let (summary_exists, memory_exists) =
950        tokio::try_join!(tokio::fs::try_exists(&files.summary_file), tokio::fs::try_exists(&files.memory_file),)?;
951    let should_consolidate =
952        force_rebuild || staged_rollout.is_some() || !pending_before.is_empty() || !summary_exists || !memory_exists;
953    if !should_consolidate {
954        return Ok(None);
955    }
956
957    let consolidated = consolidate_memory_files(runtime_config, vt_cfg, workspace_root, &files).await?;
958    created_files.extend(consolidated.created_files);
959    created_files.sort();
960    created_files.dedup();
961
962    Ok(Some(PersistentMemoryWriteReport {
963        directory: files.directory,
964        summary_file: files.summary_file,
965        memory_file: files.memory_file,
966        rollout_summary_file: staged_rollout.map(finalize_rollout_summary_path),
967        created_files,
968        added_facts: consolidated.added_facts,
969        pending_rollout_summaries: count_pending_rollout_summaries_async(&files.rollout_summaries_dir).await?,
970    }))
971}
972
973fn classified_facts_from_records(records: &[GroundedFactRecord]) -> ClassifiedFacts {
974    let mut preferences = Vec::new();
975    let mut repository_facts = Vec::new();
976    for fact in records {
977        let topic = decode_topic_source(&fact.source).0.unwrap_or_else(|| classify_fact(fact));
978        match topic {
979            MemoryTopic::Preferences => preferences.push(fact.clone()),
980            MemoryTopic::RepositoryFacts => repository_facts.push(fact.clone()),
981        }
982    }
983    ClassifiedFacts {
984        preferences: merge_topic_facts(preferences),
985        repository_facts: merge_topic_facts(repository_facts),
986    }
987}
988
989async fn ensure_memory_layout(files: &PersistentMemoryFiles, created_files: &mut Vec<PathBuf>) -> Result<()> {
990    async fn ensure_file(
991        path: &Path,
992        contents: String,
993        created_files: &mut Vec<PathBuf>,
994        private_global: bool,
995    ) -> Result<()> {
996        if private_global {
997            match tokio::fs::symlink_metadata(path).await {
998                Ok(metadata) if metadata.file_type().is_symlink() => {
999                    bail!("Refusing symlinked persistent memory file {}", path.display());
1000                }
1001                Ok(metadata) if !metadata.is_file() => {
1002                    bail!("Persistent memory path is not a regular file: {}", path.display());
1003                }
1004                Ok(_) => return Ok(()),
1005                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1006                Err(error) => {
1007                    return Err(error).with_context(|| format!("Failed to inspect {}", path.display()));
1008                }
1009            }
1010        } else if tokio::fs::try_exists(path).await.unwrap_or(false) {
1011            return Ok(());
1012        }
1013        if private_global {
1014            vtcode_commons::fs::write_private_file_atomic(path, contents.as_bytes())
1015                .await
1016                .with_context(|| format!("Failed to write private memory file {}", path.display()))?;
1017        } else {
1018            tokio::fs::write(path, contents)
1019                .await
1020                .with_context(|| format!("Failed to write {}", path.display()))?;
1021        }
1022        created_files.push(path.to_path_buf());
1023        Ok(())
1024    }
1025    for (dir, desc) in [
1026        (&files.directory, "persistent memory"),
1027        (&files.rollout_summaries_dir, "rollout summaries"),
1028        (&files.notes_dir, "notes"),
1029    ] {
1030        if files.private_global {
1031            VtCodePaths::ensure_user_dir(dir).with_context(|| format!("Failed to create {desc} {}", dir.display()))?;
1032        } else {
1033            tokio::fs::create_dir_all(dir)
1034                .await
1035                .with_context(|| format!("Failed to create {desc} {}", dir.display()))?;
1036        }
1037    }
1038    ensure_file(
1039        &files.preferences_file,
1040        render_topic_file(MemoryTopic::Preferences, &[]),
1041        created_files,
1042        files.private_global,
1043    )
1044    .await?;
1045    ensure_file(
1046        &files.repository_facts_file,
1047        render_topic_file(MemoryTopic::RepositoryFacts, &[]),
1048        created_files,
1049        files.private_global,
1050    )
1051    .await?;
1052    ensure_file(&files.memory_file, render_memory_index(&[], &[], &[], 0), created_files, files.private_global).await?;
1053    ensure_file(&files.summary_file, render_memory_summary(&[], &[], &[]), created_files, files.private_global).await?;
1054    Ok(())
1055}
1056
1057fn is_regular_file(path: &Path) -> bool {
1058    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
1059}
1060
1061fn truncate_memory_excerpt(contents: &str, line_limit: usize, byte_limit: usize) -> (String, bool, usize, usize) {
1062    let all_lines = contents.lines().collect::<Vec<_>>();
1063    let mut selected = String::new();
1064    let mut bytes_read = 0usize;
1065    let mut lines_read = 0usize;
1066    let mut truncated = false;
1067    for (index, line) in all_lines.iter().enumerate() {
1068        if lines_read >= line_limit {
1069            truncated = true;
1070            break;
1071        }
1072        let line_bytes = line.len();
1073        let trailing_newline = usize::from(index + 1 < all_lines.len());
1074        if bytes_read + line_bytes + trailing_newline > byte_limit {
1075            truncated = true;
1076            break;
1077        }
1078        selected.push_str(line);
1079        selected.push('\n');
1080        bytes_read += line_bytes + trailing_newline;
1081        lines_read += 1;
1082    }
1083    if !truncated && contents.len() > bytes_read {
1084        truncated = true;
1085    }
1086    (selected.trim_end().to_string(), truncated, bytes_read, lines_read)
1087}
1088
1089async fn read_existing_memory_lines(directory: &Path) -> Result<BTreeSet<String>> {
1090    let mut lines = BTreeSet::new();
1091    if !tokio::fs::try_exists(directory).await.unwrap_or(false) {
1092        return Ok(lines);
1093    }
1094    let mut stack = vec![directory.to_path_buf()];
1095    while let Some(next_dir) = stack.pop() {
1096        let mut entries = tokio::fs::read_dir(&next_dir)
1097            .await
1098            .with_context(|| format!("Failed to list {}", next_dir.display()))?;
1099        while let Some(entry) = entries.next_entry().await? {
1100            let path = entry.path();
1101            if entry.metadata().await?.is_dir() {
1102                stack.push(path);
1103                continue;
1104            }
1105            if path.extension().and_then(|v| v.to_str()) != Some("md") {
1106                continue;
1107            }
1108            let content = String::from_utf8(vtcode_commons::fs::read_private_file_no_follow(&path).await?)
1109                .with_context(|| format!("failed to decode note file at {}", path.display()))?;
1110            for line in content.lines() {
1111                if let Some((_, fact)) = parse_fact_line(line) {
1112                    lines.insert(normalize_whitespace(&fact).to_ascii_lowercase());
1113                }
1114            }
1115        }
1116    }
1117    Ok(lines)
1118}
1119
1120const CLEANUP_REMEMBER_MARKERS: &[&str] = &[
1121    "save to memory",
1122    "remember that",
1123    "remember my",
1124    "remember ",
1125    "add to memory",
1126    "store in memory",
1127];
1128const CLEANUP_FORGET_MARKERS: &[&str] = &["forget ", "remove from memory", "delete from memory"];
1129const STRIP_PREFIXES: &[&str] = &[
1130    "please ",
1131    "please, ",
1132    "can you ",
1133    "could you ",
1134    "would you ",
1135    "vt code, ",
1136    "vt code ",
1137];
1138const CLEANUP_NOTE_PREFIXES: &[&str] = &["note that ", "important:"];
1139const SELF_FACT_PREFIXES: &[&str] = &[
1140    "my name is ",
1141    "i prefer ",
1142    "my preferred ",
1143    "my pronouns are ",
1144    "my timezone is ",
1145];
1146
1147fn detect_memory_cleanup_status(files: &PersistentMemoryFiles) -> Result<MemoryCleanupStatus> {
1148    if !files.directory.exists() {
1149        return Ok(MemoryCleanupStatus {
1150            needed: false,
1151            suspicious_facts: 0,
1152            suspicious_summary_lines: 0,
1153        });
1154    }
1155    let mut suspicious_facts = 0usize;
1156    for path in [
1157        &files.preferences_file,
1158        &files.repository_facts_file,
1159        &files.memory_file,
1160    ] {
1161        suspicious_facts += count_suspicious_facts_in_file(path)?;
1162    }
1163    suspicious_facts += count_suspicious_rollout_facts(&files.rollout_summaries_dir)?;
1164    let suspicious_summary_lines = count_suspicious_summary_lines(&files.summary_file)?;
1165    Ok(MemoryCleanupStatus {
1166        needed: suspicious_facts > 0 || suspicious_summary_lines > 0,
1167        suspicious_facts,
1168        suspicious_summary_lines,
1169    })
1170}
1171
1172async fn detect_memory_cleanup_status_async(files: &PersistentMemoryFiles) -> Result<MemoryCleanupStatus> {
1173    let files = files.clone();
1174    tokio::task::spawn_blocking(move || detect_memory_cleanup_status(&files))
1175        .await
1176        .context("Persistent memory cleanup status task panicked")?
1177}
1178
1179fn count_suspicious_facts_in_file(path: &Path) -> Result<usize> {
1180    if !path.exists() {
1181        return Ok(0);
1182    }
1183    let content = String::from_utf8(VtCodePaths::read_file_no_follow(path)?)
1184        .with_context(|| format!("Failed to decode {} as UTF-8", path.display()))?;
1185    Ok(parse_topic_file(&content).into_iter().filter(is_legacy_polluted_fact).count())
1186}
1187
1188fn count_suspicious_rollout_facts(rollout_dir: &Path) -> Result<usize> {
1189    if !rollout_dir.exists() {
1190        return Ok(0);
1191    }
1192    let mut count = 0usize;
1193    for entry in std::fs::read_dir(rollout_dir).with_context(|| format!("Failed to list {}", rollout_dir.display()))? {
1194        let path = entry?.path();
1195        if path.extension().and_then(|v| v.to_str()) == Some("md") {
1196            count += count_suspicious_facts_in_file(&path)?;
1197        }
1198    }
1199    Ok(count)
1200}
1201
1202fn count_suspicious_summary_lines(path: &Path) -> Result<usize> {
1203    if !path.exists() {
1204        return Ok(0);
1205    }
1206    let content = String::from_utf8(VtCodePaths::read_file_no_follow(path)?)
1207        .with_context(|| format!("Failed to decode {} as UTF-8", path.display()))?;
1208    Ok(content
1209        .lines()
1210        .map(str::trim)
1211        .filter(|l| l.starts_with("- "))
1212        .map(|l| l.trim_start_matches("- ").trim())
1213        .filter(|l| looks_like_legacy_prompt(l) || looks_like_serialized_payload(l))
1214        .count())
1215}
1216
1217#[cold]
1218fn is_legacy_polluted_fact(fact: &GroundedFactRecord) -> bool {
1219    looks_like_legacy_prompt(&fact.fact) || looks_like_serialized_payload(&fact.fact)
1220}
1221
1222#[cold]
1223fn looks_like_legacy_prompt(text: &str) -> bool {
1224    let mut lowered = normalize_whitespace(text).to_ascii_lowercase();
1225    while let Some(stripped) = STRIP_PREFIXES.iter().find_map(|p| lowered.strip_prefix(p)) {
1226        lowered = stripped.trim_start().to_string();
1227    }
1228    CLEANUP_REMEMBER_MARKERS
1229        .iter()
1230        .chain(CLEANUP_FORGET_MARKERS.iter())
1231        .any(|m| lowered.starts_with(m))
1232}
1233
1234#[cold]
1235fn looks_like_serialized_payload(text: &str) -> bool {
1236    let t = text.trim();
1237    t.starts_with('{')
1238        || t.starts_with('[')
1239        || t.contains("\"query\":")
1240        || t.contains("\"matches\":")
1241        || t.contains("\"path\":")
1242        || t.contains("</parameter>")
1243        || t.contains("</invoke>")
1244        || t.contains("<</invoke>")
1245}
1246
1247async fn write_rollout_summary_pending(
1248    rollout_dir: &Path,
1249    classified: &ClassifiedFacts,
1250    private_global: bool,
1251) -> Result<PathBuf> {
1252    if private_global {
1253        VtCodePaths::ensure_user_dir(rollout_dir)
1254            .with_context(|| format!("Failed to create {}", rollout_dir.display()))?;
1255    } else {
1256        tokio::fs::create_dir_all(rollout_dir)
1257            .await
1258            .with_context(|| format!("Failed to create {}", rollout_dir.display()))?;
1259    }
1260    let path = rollout_dir.join(format!("{}.pending.md", unique_rollout_id()));
1261    let contents = render_rollout_summary(classified);
1262    if private_global {
1263        vtcode_commons::fs::write_private_file_atomic(&path, contents.as_bytes())
1264            .await
1265            .with_context(|| format!("Failed to write private memory rollout summary {}", path.display()))?;
1266    } else {
1267        tokio::fs::write(&path, contents)
1268            .await
1269            .with_context(|| format!("Failed to write {}", path.display()))?;
1270    }
1271    Ok(path)
1272}
1273
1274fn finalize_rollout_summary_path(path: PathBuf) -> PathBuf {
1275    match path.file_name().and_then(|v| v.to_str()) {
1276        Some(name) => path.with_file_name(name.trim_end_matches(".pending.md").to_string() + ".md"),
1277        None => path,
1278    }
1279}
1280
1281struct ConsolidationResult {
1282    created_files: Vec<PathBuf>,
1283    added_facts: usize,
1284}
1285
1286async fn consolidate_memory_files(
1287    runtime_config: Option<&RuntimeAgentConfig>,
1288    vt_cfg: Option<&VTCodeConfig>,
1289    workspace_root: &Path,
1290    files: &PersistentMemoryFiles,
1291) -> Result<ConsolidationResult> {
1292    let (pending_files, prefs_existing, repo_existing, rollout, notes) = tokio::try_join!(
1293        list_pending_rollout_files_async(&files.rollout_summaries_dir),
1294        read_topic_records(&files.preferences_file, MemoryTopic::Preferences),
1295        read_topic_records(&files.repository_facts_file, MemoryTopic::RepositoryFacts),
1296        read_rollout_records(&files.rollout_summaries_dir),
1297        read_note_summaries(&files.notes_dir),
1298    )?;
1299    let classified = ClassifiedFacts {
1300        preferences: merge_topic_facts(prefs_existing.into_iter().chain(rollout.0).collect()),
1301        repository_facts: merge_topic_facts(repo_existing.into_iter().chain(rollout.1).collect()),
1302    };
1303    let created_files =
1304        write_classified_memory(files, &classified, &notes, runtime_config, vt_cfg, workspace_root).await?;
1305    let mut added_facts = 0usize;
1306    for p in &pending_files {
1307        if let Ok(c) = String::from_utf8(vtcode_commons::fs::read_private_file_no_follow(p).await?) {
1308            added_facts += c.lines().filter_map(parse_fact_line).count();
1309        }
1310    }
1311    for pending in &pending_files {
1312        let finalized = finalize_rollout_summary_path(pending.clone());
1313        if !tokio::fs::try_exists(&finalized).await.unwrap_or(false) {
1314            tokio::fs::rename(pending, &finalized)
1315                .await
1316                .with_context(|| format!("Failed to finalize rollout summary {}", pending.display()))?;
1317        } else {
1318            tokio::fs::remove_file(pending)
1319                .await
1320                .with_context(|| format!("Failed to remove {}", pending.display()))?;
1321        }
1322    }
1323    Ok(ConsolidationResult { created_files, added_facts })
1324}
1325
1326fn merge_topic_facts(records: Vec<GroundedFactRecord>) -> Vec<GroundedFactRecord> {
1327    let mut facts = Vec::new();
1328    for fact in records {
1329        let normalized = normalize_whitespace(&fact.fact).to_ascii_lowercase();
1330        if let Some(i) = facts
1331            .iter()
1332            .position(|e: &GroundedFactRecord| normalize_whitespace(&e.fact).to_ascii_lowercase() == normalized)
1333        {
1334            facts.remove(i);
1335        }
1336        facts.push(fact);
1337    }
1338    let skip = facts.len().saturating_sub(TOPIC_FACT_LIMIT);
1339    facts.into_iter().skip(skip).collect()
1340}
1341
1342fn normalized_selection_key(source: &str, fact: &str) -> String {
1343    format!(
1344        "{}::{}",
1345        normalize_whitespace(source).to_ascii_lowercase(),
1346        normalize_whitespace(fact).to_ascii_lowercase()
1347    )
1348}
1349
1350fn selection_key_for_record(record: &GroundedFactRecord) -> String {
1351    let (_topic, source) = decode_topic_source(&record.source);
1352    normalized_selection_key(&source, &record.fact)
1353}
1354
1355fn selection_keys(selected: &[MemoryOpCandidate]) -> BTreeSet<String> {
1356    selected.iter().map(|e| normalized_selection_key(&e.source, &e.fact)).collect()
1357}
1358
1359async fn rewrite_topic_without_selected(
1360    path: &Path,
1361    topic: MemoryTopic,
1362    selected: &[MemoryOpCandidate],
1363    private_global: bool,
1364) -> Result<usize> {
1365    if !tokio::fs::try_exists(path).await.unwrap_or(false) {
1366        return Ok(0);
1367    }
1368    let keys = selection_keys(selected);
1369    let facts = read_topic_records(path, topic).await?;
1370    let removed = facts.iter().filter(|f| keys.contains(&selection_key_for_record(f))).count();
1371    if removed == 0 {
1372        return Ok(0);
1373    }
1374    let kept: Vec<_> = facts
1375        .into_iter()
1376        .filter(|f| !keys.contains(&selection_key_for_record(f)))
1377        .collect();
1378    let contents = render_topic_file(topic, &kept);
1379    if private_global {
1380        vtcode_commons::fs::write_private_file_atomic(path, contents.as_bytes())
1381            .await
1382            .with_context(|| format!("Failed to write private memory file {}", path.display()))?;
1383    } else {
1384        tokio::fs::write(path, contents)
1385            .await
1386            .with_context(|| format!("Failed to write {}", path.display()))?;
1387    }
1388    Ok(removed)
1389}
1390
1391async fn scrub_rollout_file_by_selection(
1392    path: &Path,
1393    selected: &[MemoryOpCandidate],
1394    private_global: bool,
1395) -> Result<usize> {
1396    let contents = String::from_utf8(vtcode_commons::fs::read_private_file_no_follow(path).await?)
1397        .with_context(|| format!("Failed to decode {} as UTF-8", path.display()))?;
1398    let keys = selection_keys(selected);
1399    let mut removed = 0usize;
1400    let mut filtered = Vec::new();
1401    for line in contents.lines() {
1402        let keep = parse_fact_line(line).is_none_or(|(source, fact)| {
1403            let m = keys.contains(&selection_key_for_record(&GroundedFactRecord { source, fact }));
1404            if m {
1405                removed += 1;
1406            }
1407            !m
1408        });
1409        if keep {
1410            filtered.push(line);
1411        }
1412    }
1413    if removed == 0 {
1414        return Ok(0);
1415    }
1416    let mut rewritten = filtered.join("\n");
1417    if contents.ends_with('\n') {
1418        rewritten.push('\n');
1419    }
1420    if private_global {
1421        vtcode_commons::fs::write_private_file_atomic(path, rewritten.as_bytes())
1422            .await
1423            .with_context(|| format!("Failed to write private memory rollout summary {}", path.display()))?;
1424    } else {
1425        tokio::fs::write(path, rewritten)
1426            .await
1427            .with_context(|| format!("Failed to write {}", path.display()))?;
1428    }
1429    Ok(removed)
1430}
1431
1432async fn remove_rollout_markdown_files(rollout_dir: &Path) -> Result<usize> {
1433    let files = list_rollout_markdown_files(rollout_dir)?;
1434    let count = files.len();
1435    for p in files {
1436        tokio::fs::remove_file(&p)
1437            .await
1438            .with_context(|| format!("Failed to remove {}", p.display()))?;
1439    }
1440    Ok(count)
1441}
1442
1443fn memory_plan_facts(plan: &MemoryOpPlan) -> Result<Vec<GroundedFactRecord>> {
1444    if plan.kind != MemoryOpKind::Remember {
1445        bail!("memory plan is not a remember operation");
1446    }
1447    Ok(plan
1448        .facts
1449        .iter()
1450        .map(|f| {
1451            let topic = match f.topic {
1452                MemoryPlannedTopic::Preferences => MemoryTopic::Preferences,
1453                MemoryPlannedTopic::RepositoryFacts => MemoryTopic::RepositoryFacts,
1454            };
1455            let source = if f.source.trim().is_empty() {
1456                "manual_memory".to_string()
1457            } else {
1458                normalize_whitespace(&f.source)
1459            };
1460            GroundedFactRecord {
1461                fact: truncate_for_fact(&normalize_whitespace(&f.fact), 180),
1462                source: encode_topic_source(topic, &source),
1463            }
1464        })
1465        .filter(|f| !f.fact.is_empty())
1466        .collect())
1467}
1468
1469fn selected_memory_candidates(
1470    candidates: &[MemoryOpCandidate],
1471    selected_ids: &[usize],
1472) -> Result<Vec<MemoryOpCandidate>> {
1473    let selected: Vec<_> = selected_ids
1474        .iter()
1475        .filter_map(|id| candidates.iter().find(|c| c.id == *id).cloned())
1476        .collect();
1477    if selected_ids.len() != selected.len() {
1478        bail!("memory plan selected a missing candidate");
1479    }
1480    Ok(selected)
1481}
1482
1483fn effective_persistent_memory_config(vt_cfg: Option<&VTCodeConfig>) -> PersistentMemoryConfig {
1484    let mut config = vt_cfg.map(|cfg| cfg.agent.persistent_memory.clone()).unwrap_or_default();
1485    if let Some(cfg) = vt_cfg {
1486        config.enabled = cfg.persistent_memory_enabled();
1487    }
1488    config
1489}
1490
1491fn effective_generated_memory_config(vt_cfg: Option<&VTCodeConfig>) -> PersistentMemoryConfig {
1492    let mut config = effective_persistent_memory_config(vt_cfg);
1493    if let Some(cfg) = vt_cfg {
1494        config.enabled = cfg.should_generate_memories();
1495    }
1496    config
1497}
1498
1499#[cfg(test)]
1500mod persistent_memory_tests;