Skip to main content

vtcode_core/
persistent_memory.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, get_config_dir};
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,
18    resolve_lightweight_route,
19};
20
21pub const MEMORY_FILENAME: &str = "MEMORY.md";
22pub const MEMORY_SUMMARY_FILENAME: &str = "memory_summary.md";
23pub const ROLLOUT_SUMMARIES_DIRNAME: &str = "rollout_summaries";
24pub const NOTES_DIRNAME: &str = "notes";
25
26const MEMORY_LOCK_FILENAME: &str = ".memory.lock";
27const PREFERENCES_FILENAME: &str = "preferences.md";
28const REPOSITORY_FACTS_FILENAME: &str = "repository-facts.md";
29const DEFAULT_FACT_LIMIT: usize = 24;
30const MEMORY_HIGHLIGHT_LIMIT: usize = 10;
31const TOPIC_FACT_LIMIT: usize = 32;
32const LOCK_RETRY_ATTEMPTS: usize = 40;
33const LOCK_RETRY_DELAY_MS: u64 = 50;
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct GroundedFactRecord {
37    pub fact: String,
38    pub source: String,
39}
40
41#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
42pub struct PersistentMemoryStatus {
43    pub enabled: bool,
44    pub auto_write: bool,
45    pub directory: PathBuf,
46    pub summary_file: PathBuf,
47    pub memory_file: PathBuf,
48    pub preferences_file: PathBuf,
49    pub repository_facts_file: PathBuf,
50    pub notes_dir: PathBuf,
51    pub rollout_summaries_dir: PathBuf,
52    pub summary_exists: bool,
53    pub registry_exists: bool,
54    pub pending_rollout_summaries: usize,
55    pub cleanup_status: MemoryCleanupStatus,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct PersistentMemoryExcerpt {
60    pub status: PersistentMemoryStatus,
61    pub contents: String,
62    pub truncated: bool,
63    pub bytes_read: usize,
64    pub lines_read: usize,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct PersistentMemoryWriteReport {
69    pub directory: PathBuf,
70    pub summary_file: PathBuf,
71    pub memory_file: PathBuf,
72    pub rollout_summary_file: Option<PathBuf>,
73    pub created_files: Vec<PathBuf>,
74    pub added_facts: usize,
75    pub pending_rollout_summaries: usize,
76}
77
78#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
79pub struct PersistentMemoryMatch {
80    pub source: String,
81    pub fact: String,
82}
83
84#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
85pub struct PersistentMemoryForgetReport {
86    pub directory: PathBuf,
87    pub summary_file: PathBuf,
88    pub memory_file: PathBuf,
89    pub removed_facts: usize,
90    pub pending_rollout_summaries: usize,
91}
92
93#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
94pub struct MemoryCleanupStatus {
95    pub needed: bool,
96    pub suspicious_facts: usize,
97    pub suspicious_summary_lines: usize,
98}
99
100#[derive(Debug, Clone, Serialize)]
101pub struct PersistentMemoryCleanupReport {
102    pub directory: PathBuf,
103    pub summary_file: PathBuf,
104    pub memory_file: PathBuf,
105    pub rewritten_facts: usize,
106    pub removed_rollout_files: usize,
107}
108
109pub fn extract_memory_highlights(contents: &str, limit: usize) -> Vec<String> {
110    if limit == 0 {
111        return Vec::new();
112    }
113    let mut highlights = Vec::with_capacity(limit);
114    for line in contents.lines() {
115        let trimmed = line.trim();
116        if trimmed.is_empty() || trimmed.starts_with('#') {
117            continue;
118        }
119        let normalized = trimmed
120            .strip_prefix("- ")
121            .or_else(|| trimmed.strip_prefix("* "))
122            .or_else(|| trimmed.strip_prefix("+ "))
123            .unwrap_or(trimmed);
124        if normalized.is_empty() || highlights.iter().any(|e| e == normalized) {
125            continue;
126        }
127        highlights.push(normalized.to_string());
128        if highlights.len() >= limit {
129            break;
130        }
131    }
132    highlights
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(rename_all = "snake_case")]
137pub enum MemoryOpKind {
138    Remember,
139    Forget,
140    AskMissing,
141    Noop,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct MemoryOpCandidate {
146    pub id: usize,
147    pub source: String,
148    pub fact: String,
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum MemoryPlannedTopic {
154    Preferences,
155    RepositoryFacts,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159pub struct MemoryPlannedFact {
160    pub topic: MemoryPlannedTopic,
161    pub fact: String,
162    #[serde(default)]
163    pub source: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167pub struct MemoryMissingField {
168    pub field: String,
169    pub prompt: String,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
173pub struct MemoryOpPlan {
174    pub kind: MemoryOpKind,
175    #[serde(default)]
176    pub facts: Vec<MemoryPlannedFact>,
177    #[serde(default)]
178    pub selected_ids: Vec<usize>,
179    #[serde(default)]
180    pub missing: Option<MemoryMissingField>,
181    #[serde(default)]
182    pub message: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186#[repr(usize)]
187enum MemoryTopic {
188    Preferences = 0,
189    RepositoryFacts = 1,
190}
191
192impl MemoryTopic {
193    fn title(self) -> &'static str {
194        ["Preferences", "Repository Facts"][self as usize]
195    }
196
197    fn description(self) -> &'static str {
198        [
199            "Durable user preferences and workflow notes.",
200            "Grounded repository facts and recurring tooling notes.",
201        ][self as usize]
202    }
203
204    fn slug(self) -> &'static str {
205        ["preferences", "repository_facts"][self as usize]
206    }
207
208    fn from_slug(value: &str) -> Option<Self> {
209        match value {
210            "preferences" => Some(Self::Preferences),
211            "repository_facts" => Some(Self::RepositoryFacts),
212            _ => None,
213        }
214    }
215}
216
217#[derive(Debug)]
218struct PersistentMemoryFiles {
219    directory: PathBuf,
220    summary_file: PathBuf,
221    memory_file: PathBuf,
222    preferences_file: PathBuf,
223    repository_facts_file: PathBuf,
224    notes_dir: PathBuf,
225    rollout_summaries_dir: PathBuf,
226    lock_file: PathBuf,
227}
228
229impl PersistentMemoryFiles {
230    fn new(directory: PathBuf) -> Self {
231        Self {
232            summary_file: directory.join(MEMORY_SUMMARY_FILENAME),
233            memory_file: directory.join(MEMORY_FILENAME),
234            preferences_file: directory.join(PREFERENCES_FILENAME),
235            repository_facts_file: directory.join(REPOSITORY_FACTS_FILENAME),
236            notes_dir: directory.join(NOTES_DIRNAME),
237            rollout_summaries_dir: directory.join(ROLLOUT_SUMMARIES_DIRNAME),
238            lock_file: directory.join(MEMORY_LOCK_FILENAME),
239            directory,
240        }
241    }
242}
243
244#[derive(Debug, Clone)]
245struct ClassifiedFacts {
246    preferences: Vec<GroundedFactRecord>,
247    repository_facts: Vec<GroundedFactRecord>,
248}
249
250impl ClassifiedFacts {
251    fn total(&self) -> usize {
252        self.preferences.len() + self.repository_facts.len()
253    }
254}
255
256#[derive(Debug, Deserialize)]
257struct MemorySummaryResponse {
258    #[serde(default)]
259    bullets: Vec<String>,
260}
261
262/// Distinguishes memory LLM call phases for model override routing.
263#[derive(Debug, Clone, Copy)]
264enum MemoryPhase {
265    /// Per-thread fact extraction and classification.
266    Extract,
267    /// Global consolidation and summary generation.
268    Consolidate,
269}
270
271#[derive(Debug, Clone)]
272struct MemoryModelRoute {
273    provider_name: String,
274    model: String,
275    temperature: f32,
276}
277
278#[derive(Debug, Clone)]
279struct ResolvedMemoryRoutes {
280    primary: MemoryModelRoute,
281    fallback: Option<MemoryModelRoute>,
282    warning: Option<String>,
283}
284
285#[derive(Debug, Deserialize)]
286struct MemoryClassificationItem {
287    id: usize,
288    topic: MemoryPlannedTopic,
289    #[serde(default)]
290    fact: Option<String>,
291}
292
293#[derive(Debug, Deserialize)]
294struct MemoryClassificationPlan {
295    #[serde(default)]
296    keep: Vec<MemoryClassificationItem>,
297}
298
299pub fn normalize_whitespace(text: &str) -> String {
300    text.split_whitespace().fold(String::new(), |mut acc, s| {
301        if !acc.is_empty() {
302            acc.push(' ');
303        }
304        acc.push_str(s);
305        acc
306    })
307}
308
309pub fn truncate_for_fact(text: &str, max_chars: usize) -> String {
310    let trimmed = text.trim();
311    if trimmed.chars().count() <= max_chars {
312        return trimmed.to_string();
313    }
314    format!(
315        "{}...",
316        trimmed
317            .chars()
318            .take(max_chars.saturating_sub(3))
319            .collect::<String>()
320    )
321}
322
323fn build_memory_json_request(
324    provider: &(impl LLMProvider + ?Sized),
325    route: &MemoryModelRoute,
326    prompt: String,
327    schema_name: &str,
328    schema: &serde_json::Value,
329) -> Result<LLMRequest> {
330    let supports_native_json = provider.supports_structured_output(&route.model);
331    let prompt = if supports_native_json {
332        prompt
333    } else {
334        let schema = serde_json::to_string_pretty(schema)
335            .context("failed to serialize persistent memory JSON schema")?;
336        format!(
337            "{prompt}\n\nReturn JSON only. Do not add markdown fences or explanatory text. The response must be a single JSON object that matches this schema:\n{schema}"
338        )
339    };
340
341    Ok(LLMRequest {
342        model: route.model.clone(),
343        temperature: Some(route.temperature),
344        output_format: supports_native_json.then(|| {
345            json!({
346                "type": "json_schema",
347                "json_schema": {
348                    "name": schema_name,
349                    "schema": schema,
350                }
351            })
352        }),
353        messages: vec![Message::user(prompt)],
354        ..Default::default()
355    })
356}
357
358fn parse_memory_json_response<T>(text: &str, context: &str) -> Result<T>
359where
360    T: DeserializeOwned,
361{
362    let trimmed = text.trim();
363    if trimmed.is_empty() {
364        bail!("{context} returned empty content");
365    }
366    if let Ok(parsed) = serde_json::from_str::<T>(trimmed) {
367        return Ok(parsed);
368    }
369    extract_first_json_block(trimmed)
370        .and_then(|json_block| serde_json::from_str::<T>(json_block).ok())
371        .with_context(|| format!("failed to parse {context} response"))
372}
373
374fn extract_first_json_block(text: &str) -> Option<&str> {
375    let (start, opening) = text
376        .char_indices()
377        .find(|(_, ch)| matches!(ch, '{' | '['))?;
378    let mut stack = vec![opening];
379    let mut in_string = false;
380    let mut escaped = false;
381
382    for (offset, ch) in text[start + opening.len_utf8()..].char_indices() {
383        if in_string {
384            if escaped {
385                escaped = false;
386            } else if ch == '\\' {
387                escaped = true;
388            } else if ch == '"' {
389                in_string = false;
390            }
391            continue;
392        }
393
394        match ch {
395            '"' => in_string = true,
396            '{' | '[' => stack.push(ch),
397            '}' => {
398                if stack.pop() != Some('{') {
399                    return None;
400                }
401                if stack.is_empty() {
402                    let end = start + opening.len_utf8() + offset + ch.len_utf8();
403                    return Some(&text[start..end]);
404                }
405            }
406            ']' => {
407                if stack.pop() != Some('[') {
408                    return None;
409                }
410                if stack.is_empty() {
411                    let end = start + opening.len_utf8() + offset + ch.len_utf8();
412                    return Some(&text[start..end]);
413                }
414            }
415            _ => {}
416        }
417    }
418
419    None
420}
421
422pub fn maybe_extract_tool_fact(message: &Message) -> Option<GroundedFactRecord> {
423    if message.role != MessageRole::Tool {
424        return None;
425    }
426    let tool_name = message.origin_tool.as_deref().unwrap_or("tool");
427    let text = message.content.as_text();
428    let raw = text.trim();
429    if raw.is_empty() {
430        return None;
431    }
432
433    let candidate = serde_json::from_str::<serde_json::Value>(raw)
434        .ok()
435        .and_then(|value| {
436            if value.get("error").is_some()
437                || value.get("success") == Some(&serde_json::Value::Bool(false))
438            {
439                return None;
440            }
441            for key in ["summary", "message", "result", "output", "stdout"] {
442                if let Some(v) = value.get(key) {
443                    if let Some(text) = v.as_str() {
444                        let normalized = normalize_whitespace(text);
445                        if !normalized.is_empty() {
446                            return Some(normalized);
447                        }
448                    } else if !v.is_null() {
449                        let normalized = normalize_whitespace(&v.to_string());
450                        if !normalized.is_empty() {
451                            return Some(normalized);
452                        }
453                    }
454                }
455            }
456            let compact = normalize_whitespace(&value.to_string());
457            (!compact.is_empty()).then_some(compact)
458        })
459        .or_else(|| {
460            let lowered = raw.to_ascii_lowercase();
461            if lowered.contains("error")
462                || lowered.contains("failed")
463                || lowered.contains("denied")
464                || lowered.contains("timeout")
465            {
466                return None;
467            }
468            Some(normalize_whitespace(raw))
469        })?;
470
471    Some(GroundedFactRecord {
472        fact: truncate_for_fact(&candidate, 180),
473        source: format!("tool:{tool_name}"),
474    })
475}
476
477pub fn maybe_extract_user_fact(message: &Message) -> Option<GroundedFactRecord> {
478    if message.role != MessageRole::User {
479        return None;
480    }
481    let text = normalize_whitespace(message.content.as_text().as_ref());
482    if text.is_empty() {
483        return None;
484    }
485    let candidate_text = strip_user_memory_candidate_prefixes(&text);
486    let (candidate_text, looks_authored_note) = strip_user_memory_note_marker(candidate_text)
487        .map(|fact| (fact, true))
488        .unwrap_or((candidate_text, false));
489    let looks_durable_self_fact = SELF_FACT_PREFIXES
490        .iter()
491        .any(|p| candidate_text.to_ascii_lowercase().starts_with(*p));
492    (looks_authored_note || looks_durable_self_fact).then(|| GroundedFactRecord {
493        fact: truncate_for_fact(candidate_text, 180),
494        source: "user_assertion".to_string(),
495    })
496}
497
498fn strip_user_memory_candidate_prefixes(text: &str) -> &str {
499    let mut trimmed = text.trim();
500    loop {
501        let lowered = trimmed.to_ascii_lowercase();
502        let Some(prefix) = STRIP_PREFIXES.iter().find(|p| lowered.starts_with(**p)) else {
503            return trimmed;
504        };
505        trimmed = trimmed
506            .get(prefix.len()..)
507            .unwrap_or("")
508            .trim_start_matches([',', ':', '-', ' '])
509            .trim_start();
510    }
511}
512
513fn strip_user_memory_note_marker(text: &str) -> Option<&str> {
514    let lowered = text.to_ascii_lowercase();
515    CLEANUP_NOTE_PREFIXES.iter().find_map(|prefix| {
516        lowered.starts_with(prefix).then(|| {
517            text.get(prefix.len()..)
518                .unwrap_or("")
519                .trim_start_matches([',', ':', '-', ' '])
520                .trim_start()
521        })
522    })
523}
524
525pub fn dedup_latest_facts(history: &[Message], limit: usize) -> Vec<GroundedFactRecord> {
526    let mut facts = Vec::new();
527    for message in history {
528        if let Some(fact) =
529            maybe_extract_tool_fact(message).or_else(|| maybe_extract_user_fact(message))
530        {
531            let normalized = normalize_whitespace(&fact.fact).to_ascii_lowercase();
532            if let Some(existing_idx) = facts.iter().position(|entry: &GroundedFactRecord| {
533                normalize_whitespace(&entry.fact).to_ascii_lowercase() == normalized
534            }) {
535                facts.remove(existing_idx);
536            }
537            facts.push(fact);
538        }
539    }
540
541    let keep_from = facts.len().saturating_sub(limit);
542    facts.into_iter().skip(keep_from).collect()
543}
544
545/// Resolves the persistent memory directory for a project.
546///
547/// **Blocking**: This function may perform filesystem I/O (directory migration).
548/// Callers in async contexts must wrap this in `tokio::task::spawn_blocking`.
549pub fn resolve_persistent_memory_dir(
550    config: &PersistentMemoryConfig,
551    workspace_root: &Path,
552) -> Result<Option<PathBuf>> {
553    let project_name = persistent_memory_project_name(workspace_root);
554    let directory = persistent_memory_base_dir(config)?
555        .join("projects")
556        .join(sanitize_project_name(&project_name))
557        .join("memory");
558    migrate_legacy_persistent_memory_dir_if_needed(config, &project_name, &directory)?;
559    Ok(Some(directory))
560}
561
562/// Returns the current persistent memory status.
563///
564/// **Blocking**: This function performs filesystem I/O (directory migration,
565/// file existence checks, reading topic files). Callers in async contexts
566/// must wrap this in `tokio::task::spawn_blocking`.
567pub fn persistent_memory_status(
568    config: &PersistentMemoryConfig,
569    workspace_root: &Path,
570) -> Result<PersistentMemoryStatus> {
571    let directory = resolve_persistent_memory_dir(config, workspace_root)?.unwrap_or_else(|| {
572        dirs::home_dir()
573            .map(|home| home.join(".vtcode"))
574            .unwrap_or_else(|| PathBuf::from(".vtcode"))
575            .join("projects")
576            .join("workspace")
577            .join("memory")
578    });
579    let files = PersistentMemoryFiles::new(directory);
580    let pending_rollout_summaries = count_pending_rollout_summaries(&files.rollout_summaries_dir)?;
581    let cleanup_status = detect_memory_cleanup_status(&files)?;
582
583    Ok(PersistentMemoryStatus {
584        enabled: config.enabled,
585        auto_write: config.auto_write,
586        summary_exists: files.summary_file.exists(),
587        registry_exists: files.memory_file.exists(),
588        pending_rollout_summaries,
589        cleanup_status,
590        directory: files.directory,
591        summary_file: files.summary_file,
592        memory_file: files.memory_file,
593        preferences_file: files.preferences_file,
594        repository_facts_file: files.repository_facts_file,
595        notes_dir: files.notes_dir,
596        rollout_summaries_dir: files.rollout_summaries_dir,
597    })
598}
599
600pub async fn read_persistent_memory_excerpt(
601    config: &PersistentMemoryConfig,
602    workspace_root: &Path,
603) -> Result<Option<PersistentMemoryExcerpt>> {
604    if !config.enabled {
605        return Ok(None);
606    }
607
608    let config_clone = config.clone();
609    let workspace_root = workspace_root.to_path_buf();
610    let status = tokio::task::spawn_blocking(move || {
611        persistent_memory_status(&config_clone, &workspace_root)
612    })
613    .await
614    .context("Persistent memory status task panicked")??;
615    if !status.summary_file.exists() {
616        return Ok(None);
617    }
618
619    let raw = tokio::fs::read_to_string(&status.summary_file)
620        .await
621        .with_context(|| {
622            format!(
623                "Failed to read persistent memory summary {}",
624                status.summary_file.display()
625            )
626        })?;
627
628    let (contents, truncated, bytes_read, lines_read) =
629        truncate_memory_excerpt(&raw, config.startup_line_limit, config.startup_byte_limit);
630
631    Ok(Some(PersistentMemoryExcerpt {
632        status,
633        contents,
634        truncated,
635        bytes_read,
636        lines_read,
637    }))
638}
639
640pub async fn read_persistent_memory_excerpt_for_config(
641    vt_cfg: Option<&VTCodeConfig>,
642    workspace_root: &Path,
643) -> Result<Option<PersistentMemoryExcerpt>> {
644    let config = effective_persistent_memory_config(vt_cfg);
645    read_persistent_memory_excerpt(&config, workspace_root).await
646}
647
648pub async fn finalize_persistent_memory(
649    runtime_config: &RuntimeAgentConfig,
650    vt_cfg: Option<&VTCodeConfig>,
651    history: &[Message],
652) -> Result<Option<PersistentMemoryWriteReport>> {
653    let config = effective_generated_memory_config(vt_cfg);
654    if !config.enabled || !config.auto_write {
655        return Ok(None);
656    }
657    let cfg_status = config.clone();
658    let ws_status = runtime_config.workspace.clone();
659    if tokio::task::spawn_blocking(move || {
660        persistent_memory_status(&cfg_status, ws_status.as_path())
661    })
662    .await
663    .context("Persistent memory status task panicked")??
664    .cleanup_status
665    .needed
666    {
667        return Ok(None);
668    }
669
670    let facts = dedup_latest_facts(history, DEFAULT_FACT_LIMIT);
671    persist_memory_internal(
672        &config,
673        runtime_config.workspace.as_path(),
674        Some(runtime_config),
675        vt_cfg,
676        FactsInput::Candidates(&facts),
677        true,
678        false,
679    )
680    .await
681}
682
683pub async fn rebuild_persistent_memory_summary(
684    runtime_config: &RuntimeAgentConfig,
685    vt_cfg: Option<&VTCodeConfig>,
686) -> Result<Option<PersistentMemoryWriteReport>> {
687    let config = effective_persistent_memory_config(vt_cfg);
688    if !config.enabled {
689        return Ok(None);
690    }
691    let cfg_rb = config.clone();
692    let ws_rb = runtime_config.workspace.clone();
693    if tokio::task::spawn_blocking(move || persistent_memory_status(&cfg_rb, ws_rb.as_path()))
694        .await
695        .context("Persistent memory status task panicked")??
696        .cleanup_status
697        .needed
698    {
699        bail!("persistent memory cleanup is required before rebuilding the summary");
700    }
701
702    persist_memory_internal(
703        &config,
704        runtime_config.workspace.as_path(),
705        Some(runtime_config),
706        vt_cfg,
707        FactsInput::Candidates(&[]),
708        false,
709        true,
710    )
711    .await
712}
713
714pub async fn rebuild_generated_memory_files(
715    config: &PersistentMemoryConfig,
716    workspace_root: &Path,
717) -> Result<()> {
718    let cfg = config.clone();
719    let ws = workspace_root.to_path_buf();
720    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
721        .await
722        .context("Persistent memory directory resolution task panicked")??
723        .expect("persistent memory directory should resolve");
724    let files = PersistentMemoryFiles::new(directory);
725    let mut created_files = Vec::new();
726    ensure_memory_layout(&files, &mut created_files).await?;
727    let _lock = MemoryLock::acquire(&files.lock_file).await?;
728    let _ = consolidate_memory_files(None, None, workspace_root, &files).await?;
729    Ok(())
730}
731
732pub async fn scaffold_persistent_memory(
733    config: &PersistentMemoryConfig,
734    workspace_root: &Path,
735) -> Result<Option<PersistentMemoryStatus>> {
736    let cfg = config.clone();
737    let ws = workspace_root.to_path_buf();
738    let status = tokio::task::spawn_blocking(move || persistent_memory_status(&cfg, &ws))
739        .await
740        .context("Persistent memory status task panicked")??;
741    let files = PersistentMemoryFiles::new(status.directory.clone());
742    let mut created_files = Vec::new();
743    ensure_memory_layout(&files, &mut created_files).await?;
744    let cfg2 = config.clone();
745    let ws2 = workspace_root.to_path_buf();
746    let final_status = tokio::task::spawn_blocking(move || persistent_memory_status(&cfg2, &ws2))
747        .await
748        .context("Persistent memory status task panicked")??;
749    Ok(Some(final_status))
750}
751
752/// Write classified facts to all memory files (topic files, index, summary).
753async fn write_classified_memory(
754    files: &PersistentMemoryFiles,
755    classified: &ClassifiedFacts,
756    runtime_config: Option<&RuntimeAgentConfig>,
757    vt_cfg: Option<&VTCodeConfig>,
758    workspace_root: &Path,
759) -> Result<Vec<PathBuf>> {
760    let notes = read_note_summaries(&files.notes_dir).await?;
761    let mut created_files = Vec::new();
762    async fn write_if_missing(
763        path: &Path,
764        contents: String,
765        created_files: &mut Vec<PathBuf>,
766    ) -> Result<()> {
767        if !path.exists() {
768            created_files.push(path.to_path_buf());
769        }
770        tokio::fs::write(path, contents)
771            .await
772            .with_context(|| format!("Failed to write {}", path.display()))
773    }
774    write_if_missing(
775        &files.preferences_file,
776        render_topic_file(MemoryTopic::Preferences, &classified.preferences),
777        &mut created_files,
778    )
779    .await?;
780    write_if_missing(
781        &files.repository_facts_file,
782        render_topic_file(MemoryTopic::RepositoryFacts, &classified.repository_facts),
783        &mut created_files,
784    )
785    .await?;
786    write_if_missing(
787        &files.memory_file,
788        render_memory_index(
789            &classified.preferences,
790            &classified.repository_facts,
791            &notes,
792            0,
793        ),
794        &mut created_files,
795    )
796    .await?;
797    let summary = summarize_memory(
798        runtime_config,
799        vt_cfg,
800        workspace_root,
801        &classified.preferences,
802        &classified.repository_facts,
803        &notes,
804    )
805    .await
806    .unwrap_or_else(|| {
807        render_memory_summary(
808            &classified.preferences,
809            &classified.repository_facts,
810            &notes,
811        )
812    });
813    write_if_missing(&files.summary_file, summary, &mut created_files).await?;
814    Ok(created_files)
815}
816
817pub async fn cleanup_persistent_memory(
818    runtime_config: &RuntimeAgentConfig,
819    vt_cfg: Option<&VTCodeConfig>,
820    include_summary_only_signals: bool,
821) -> Result<Option<PersistentMemoryCleanupReport>> {
822    let config = effective_persistent_memory_config(vt_cfg);
823    if !config.enabled {
824        return Ok(None);
825    }
826
827    let cfg_dir = config.clone();
828    let ws_dir = runtime_config.workspace.clone();
829    let directory = tokio::task::spawn_blocking(move || {
830        resolve_persistent_memory_dir(&cfg_dir, ws_dir.as_path())
831    })
832    .await
833    .context("Persistent memory directory resolution task panicked")??
834    .expect("persistent memory directory should resolve when enabled");
835    let files = PersistentMemoryFiles::new(directory);
836    let mut created_files = Vec::new();
837    ensure_memory_layout(&files, &mut created_files).await?;
838
839    let status = detect_memory_cleanup_status(&files)?;
840    if !status.needed && !include_summary_only_signals {
841        return Ok(Some(PersistentMemoryCleanupReport {
842            directory: files.directory,
843            summary_file: files.summary_file,
844            memory_file: files.memory_file,
845            rewritten_facts: 0,
846            removed_rollout_files: 0,
847        }));
848    }
849
850    let _lock = MemoryLock::acquire(&files.lock_file).await?;
851    let candidates = collect_cleanup_candidates(&files).await?;
852    let classified = if candidates.is_empty() {
853        ClassifiedFacts {
854            preferences: Vec::new(),
855            repository_facts: Vec::new(),
856        }
857    } else {
858        classify_facts_strict(
859            Some(runtime_config),
860            vt_cfg,
861            runtime_config.workspace.as_path(),
862            &candidates,
863        )
864        .await?
865    };
866
867    let removed_rollout_files = remove_rollout_markdown_files(&files.rollout_summaries_dir).await?;
868    let _ = write_classified_memory(
869        &files,
870        &classified,
871        Some(runtime_config),
872        vt_cfg,
873        runtime_config.workspace.as_path(),
874    )
875    .await?;
876
877    Ok(Some(PersistentMemoryCleanupReport {
878        directory: files.directory,
879        summary_file: files.summary_file,
880        memory_file: files.memory_file,
881        rewritten_facts: classified.total(),
882        removed_rollout_files,
883    }))
884}
885
886pub async fn list_persistent_memory_candidates(
887    config: &PersistentMemoryConfig,
888    workspace_root: &Path,
889) -> Result<Option<Vec<PersistentMemoryMatch>>> {
890    if !config.enabled {
891        return Ok(None);
892    }
893
894    let cfg = config.clone();
895    let ws = workspace_root.to_path_buf();
896    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
897        .await
898        .context("Persistent memory directory resolution task panicked")??
899        .expect("persistent memory directory should resolve when enabled");
900    if !directory.exists() {
901        return Ok(Some(Vec::new()));
902    }
903
904    let files = PersistentMemoryFiles::new(directory);
905    collect_all_memory_matches(&files).await.map(Some)
906}
907
908pub async fn find_persistent_memory_matches(
909    config: &PersistentMemoryConfig,
910    workspace_root: &Path,
911    query: &str,
912) -> Result<Option<Vec<PersistentMemoryMatch>>> {
913    if !config.enabled {
914        return Ok(None);
915    }
916    let Some(normalized_query) = normalize_memory_query(query) else {
917        return Ok(Some(Vec::new()));
918    };
919    let cfg = config.clone();
920    let ws = workspace_root.to_path_buf();
921    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
922        .await
923        .context("Persistent memory directory resolution task panicked")??
924        .expect("persistent memory directory should resolve when enabled");
925    if !directory.exists() {
926        return Ok(Some(Vec::new()));
927    }
928
929    let files = PersistentMemoryFiles::new(directory);
930    collect_memory_matches(&files, &normalized_query)
931        .await
932        .map(Some)
933}
934
935pub async fn plan_remember_persistent_memory(
936    runtime_config: &RuntimeAgentConfig,
937    vt_cfg: Option<&VTCodeConfig>,
938    request: &str,
939    supplemental_answer: Option<&str>,
940) -> Result<Option<MemoryOpPlan>> {
941    let config = effective_persistent_memory_config(vt_cfg);
942    if !config.enabled {
943        return Ok(None);
944    }
945
946    let plan = plan_memory_operation(
947        runtime_config,
948        vt_cfg,
949        runtime_config.workspace.as_path(),
950        MemoryOpKind::Remember,
951        request,
952        supplemental_answer,
953        &[],
954    )
955    .await?;
956    Ok(Some(plan))
957}
958
959pub async fn persist_remembered_memory_plan(
960    runtime_config: &RuntimeAgentConfig,
961    vt_cfg: Option<&VTCodeConfig>,
962    plan: &MemoryOpPlan,
963) -> Result<Option<PersistentMemoryWriteReport>> {
964    let config = effective_persistent_memory_config(vt_cfg);
965    if !config.enabled || plan.kind != MemoryOpKind::Remember {
966        return Ok(None);
967    }
968
969    let facts = memory_plan_facts(plan)?;
970    persist_memory_internal(
971        &config,
972        runtime_config.workspace.as_path(),
973        Some(runtime_config),
974        vt_cfg,
975        FactsInput::Preclassified(&facts),
976        true,
977        false,
978    )
979    .await
980}
981
982pub async fn plan_forget_persistent_memory(
983    runtime_config: &RuntimeAgentConfig,
984    vt_cfg: Option<&VTCodeConfig>,
985    request: &str,
986    candidates: &[MemoryOpCandidate],
987) -> Result<Option<MemoryOpPlan>> {
988    let config = effective_persistent_memory_config(vt_cfg);
989    if !config.enabled {
990        return Ok(None);
991    }
992
993    let plan = plan_memory_operation(
994        runtime_config,
995        vt_cfg,
996        runtime_config.workspace.as_path(),
997        MemoryOpKind::Forget,
998        request,
999        None,
1000        candidates,
1001    )
1002    .await?;
1003    Ok(Some(plan))
1004}
1005
1006pub async fn forget_planned_persistent_memory_matches(
1007    runtime_config: &RuntimeAgentConfig,
1008    vt_cfg: Option<&VTCodeConfig>,
1009    candidates: &[MemoryOpCandidate],
1010    plan: &MemoryOpPlan,
1011) -> Result<Option<PersistentMemoryForgetReport>> {
1012    let config = effective_persistent_memory_config(vt_cfg);
1013    if !config.enabled || plan.kind != MemoryOpKind::Forget {
1014        return Ok(None);
1015    }
1016
1017    let selected = selected_memory_candidates(candidates, &plan.selected_ids)?;
1018    let cfg_dir = config.clone();
1019    let ws_dir = runtime_config.workspace.clone();
1020    let directory = tokio::task::spawn_blocking(move || {
1021        resolve_persistent_memory_dir(&cfg_dir, ws_dir.as_path())
1022    })
1023    .await
1024    .context("Persistent memory directory resolution task panicked")??
1025    .expect("persistent memory directory should resolve when enabled");
1026    let files = PersistentMemoryFiles::new(directory);
1027    if !files.directory.exists() {
1028        return Ok(Some(PersistentMemoryForgetReport {
1029            directory: files.directory,
1030            summary_file: files.summary_file,
1031            memory_file: files.memory_file,
1032            removed_facts: 0,
1033            pending_rollout_summaries: 0,
1034        }));
1035    }
1036
1037    let _lock = MemoryLock::acquire(&files.lock_file).await?;
1038    let mut removed_facts = 0usize;
1039    removed_facts += rewrite_topic_without_selected(
1040        &files.preferences_file,
1041        MemoryTopic::Preferences,
1042        &selected,
1043    )
1044    .await?;
1045    removed_facts += rewrite_topic_without_selected(
1046        &files.repository_facts_file,
1047        MemoryTopic::RepositoryFacts,
1048        &selected,
1049    )
1050    .await?;
1051
1052    let rollout_files = list_rollout_markdown_files(&files.rollout_summaries_dir)?;
1053    for path in rollout_files {
1054        removed_facts += scrub_rollout_file_by_selection(&path, &selected).await?;
1055    }
1056
1057    if removed_facts > 0 {
1058        let _ = consolidate_memory_files(
1059            Some(runtime_config),
1060            vt_cfg,
1061            runtime_config.workspace.as_path(),
1062            &files,
1063        )
1064        .await?;
1065    }
1066
1067    Ok(Some(PersistentMemoryForgetReport {
1068        directory: files.directory,
1069        summary_file: files.summary_file,
1070        memory_file: files.memory_file,
1071        removed_facts,
1072        pending_rollout_summaries: count_pending_rollout_summaries(&files.rollout_summaries_dir)?,
1073    }))
1074}
1075
1076/// Distinguishes how facts are provided to the persistence layer.
1077enum FactsInput<'a> {
1078    /// Facts already classified into topics (no LLM call needed).
1079    Preclassified(&'a [GroundedFactRecord]),
1080    /// Raw candidate facts that must be classified via LLM.
1081    Candidates(&'a [GroundedFactRecord]),
1082}
1083
1084impl FactsInput<'_> {
1085    fn as_slice(&self) -> &[GroundedFactRecord] {
1086        match self {
1087            FactsInput::Preclassified(facts) => facts,
1088            FactsInput::Candidates(facts) => facts,
1089        }
1090    }
1091}
1092
1093async fn persist_memory_internal(
1094    config: &PersistentMemoryConfig,
1095    workspace_root: &Path,
1096    runtime_config: Option<&RuntimeAgentConfig>,
1097    vt_cfg: Option<&VTCodeConfig>,
1098    facts_input: FactsInput<'_>,
1099    write_rollout: bool,
1100    force_rebuild: bool,
1101) -> Result<Option<PersistentMemoryWriteReport>> {
1102    let cfg = config.clone();
1103    let ws = workspace_root.to_path_buf();
1104    let directory = tokio::task::spawn_blocking(move || resolve_persistent_memory_dir(&cfg, &ws))
1105        .await
1106        .context("Persistent memory directory resolution task panicked")??
1107        .expect("persistent memory directory should resolve when enabled");
1108    let files = PersistentMemoryFiles::new(directory);
1109    let mut created_files = Vec::new();
1110    ensure_memory_layout(&files, &mut created_files).await?;
1111
1112    let facts_slice = facts_input.as_slice();
1113    if detect_memory_cleanup_status(&files)?.needed && (write_rollout || !facts_slice.is_empty()) {
1114        bail!("persistent memory cleanup is required before mutating memory");
1115    }
1116
1117    let _lock = MemoryLock::acquire(&files.lock_file).await?;
1118    let existing_lines = read_existing_memory_lines(&files.directory).await?;
1119    let deduped_records: Vec<GroundedFactRecord> = facts_slice
1120        .iter()
1121        .filter(|f| !existing_lines.contains(&normalize_whitespace(&f.fact).to_ascii_lowercase()))
1122        .cloned()
1123        .collect();
1124
1125    let classified = match facts_input {
1126        FactsInput::Preclassified(_) => classified_facts_from_records(&deduped_records),
1127        FactsInput::Candidates(_) if deduped_records.is_empty() => ClassifiedFacts {
1128            preferences: Vec::new(),
1129            repository_facts: Vec::new(),
1130        },
1131        FactsInput::Candidates(_) => {
1132            classify_facts_strict(runtime_config, vt_cfg, workspace_root, &deduped_records).await?
1133        }
1134    };
1135
1136    let staged_rollout = if write_rollout && classified.total() > 0 {
1137        Some(
1138            write_rollout_summary_pending(&files.rollout_summaries_dir, &classified)
1139                .await
1140                .with_context(|| {
1141                    format!(
1142                        "Failed to write rollout summary under {}",
1143                        files.rollout_summaries_dir.display()
1144                    )
1145                })?,
1146        )
1147    } else {
1148        None
1149    };
1150
1151    let pending_before = list_pending_rollout_files(&files.rollout_summaries_dir)?;
1152    let should_consolidate = force_rebuild
1153        || staged_rollout.is_some()
1154        || !pending_before.is_empty()
1155        || !files.summary_file.exists()
1156        || !files.memory_file.exists();
1157    if !should_consolidate {
1158        return Ok(None);
1159    }
1160
1161    let consolidated =
1162        consolidate_memory_files(runtime_config, vt_cfg, workspace_root, &files).await?;
1163    created_files.extend(consolidated.created_files);
1164    created_files.sort();
1165    created_files.dedup();
1166
1167    Ok(Some(PersistentMemoryWriteReport {
1168        directory: files.directory,
1169        summary_file: files.summary_file,
1170        memory_file: files.memory_file,
1171        rollout_summary_file: staged_rollout.map(finalize_rollout_summary_path),
1172        created_files,
1173        added_facts: consolidated.added_facts,
1174        pending_rollout_summaries: count_pending_rollout_summaries(&files.rollout_summaries_dir)?,
1175    }))
1176}
1177
1178fn classified_facts_from_records(records: &[GroundedFactRecord]) -> ClassifiedFacts {
1179    let mut preferences = Vec::new();
1180    let mut repository_facts = Vec::new();
1181    for fact in records {
1182        let topic = decode_topic_source(&fact.source)
1183            .0
1184            .unwrap_or_else(|| classify_fact(fact));
1185        match topic {
1186            MemoryTopic::Preferences => preferences.push(fact.clone()),
1187            MemoryTopic::RepositoryFacts => repository_facts.push(fact.clone()),
1188        }
1189    }
1190    ClassifiedFacts {
1191        preferences: merge_topic_facts(preferences),
1192        repository_facts: merge_topic_facts(repository_facts),
1193    }
1194}
1195
1196async fn ensure_memory_layout(
1197    files: &PersistentMemoryFiles,
1198    created_files: &mut Vec<PathBuf>,
1199) -> Result<()> {
1200    async fn ensure_file(
1201        path: &Path,
1202        contents: String,
1203        created_files: &mut Vec<PathBuf>,
1204    ) -> Result<()> {
1205        if path.exists() {
1206            return Ok(());
1207        }
1208        tokio::fs::write(path, contents)
1209            .await
1210            .with_context(|| format!("Failed to write {}", path.display()))?;
1211        created_files.push(path.to_path_buf());
1212        Ok(())
1213    }
1214    for (dir, desc) in [
1215        (&files.directory, "persistent memory"),
1216        (&files.rollout_summaries_dir, "rollout summaries"),
1217        (&files.notes_dir, "notes"),
1218    ] {
1219        tokio::fs::create_dir_all(dir)
1220            .await
1221            .with_context(|| format!("Failed to create {desc} {}", dir.display()))?;
1222    }
1223    ensure_file(
1224        &files.preferences_file,
1225        render_topic_file(MemoryTopic::Preferences, &[]),
1226        created_files,
1227    )
1228    .await?;
1229    ensure_file(
1230        &files.repository_facts_file,
1231        render_topic_file(MemoryTopic::RepositoryFacts, &[]),
1232        created_files,
1233    )
1234    .await?;
1235    ensure_file(
1236        &files.memory_file,
1237        render_memory_index(&[], &[], &[], 0),
1238        created_files,
1239    )
1240    .await?;
1241    ensure_file(
1242        &files.summary_file,
1243        render_memory_summary(&[], &[], &[]),
1244        created_files,
1245    )
1246    .await?;
1247    Ok(())
1248}
1249
1250fn persistent_memory_base_dir(config: &PersistentMemoryConfig) -> Result<PathBuf> {
1251    if let Some(override_dir) = config.directory_override.as_deref() {
1252        if let Some(stripped) = override_dir.strip_prefix("~/") {
1253            return Ok(dirs::home_dir()
1254                .context("Could not resolve home directory")?
1255                .join(stripped));
1256        }
1257        return Ok(PathBuf::from(override_dir));
1258    }
1259    dirs::home_dir()
1260        .map(|home| home.join(".vtcode"))
1261        .context("Could not resolve VT Code home directory")
1262}
1263
1264fn persistent_memory_project_name(workspace_root: &Path) -> String {
1265    ConfigManager::current_project_name(workspace_root)
1266        .or_else(|| {
1267            workspace_root
1268                .file_name()
1269                .and_then(|v| v.to_str())
1270                .map(|v| v.to_string())
1271        })
1272        .unwrap_or_else(|| "workspace".to_string())
1273}
1274
1275fn migrate_legacy_persistent_memory_dir_if_needed(
1276    config: &PersistentMemoryConfig,
1277    project_name: &str,
1278    target_dir: &Path,
1279) -> Result<()> {
1280    if config.directory_override.is_some() {
1281        return Ok(());
1282    }
1283    let Some(legacy_dir) = legacy_persistent_memory_dir(project_name)? else {
1284        return Ok(());
1285    };
1286    if legacy_dir == target_dir || !legacy_dir.exists() {
1287        return Ok(());
1288    }
1289    migrate_legacy_memory_dir(&legacy_dir, target_dir)
1290}
1291
1292fn migrate_legacy_memory_dir(legacy_dir: &Path, target_dir: &Path) -> Result<()> {
1293    if target_dir.exists() && memory_directory_has_stored_content(target_dir)? {
1294        if !memory_directory_has_stored_content(legacy_dir)? {
1295            remove_empty_legacy_memory_hierarchy(legacy_dir)?;
1296        }
1297        return Ok(());
1298    }
1299    if target_dir.exists() {
1300        std::fs::remove_dir_all(target_dir)
1301            .with_context(|| format!("Failed to clear {}", target_dir.display()))?;
1302    }
1303    let target_parent = target_dir
1304        .parent()
1305        .context("Persistent memory directory is missing a parent")?;
1306    std::fs::create_dir_all(target_parent)
1307        .with_context(|| format!("Failed to create {}", target_parent.display()))?;
1308    std::fs::rename(legacy_dir, target_dir).with_context(|| {
1309        format!(
1310            "Failed to migrate persistent memory from {} to {}",
1311            legacy_dir.display(),
1312            target_dir.display()
1313        )
1314    })?;
1315    remove_empty_legacy_memory_hierarchy(legacy_dir)?;
1316    Ok(())
1317}
1318
1319fn legacy_persistent_memory_dir(project_name: &str) -> Result<Option<PathBuf>> {
1320    let Some(legacy_base) = get_config_dir() else {
1321        return Ok(None);
1322    };
1323    let current_base = dirs::home_dir()
1324        .map(|home| home.join(".vtcode"))
1325        .context("Could not resolve VT Code home directory")?;
1326    if legacy_base == current_base {
1327        return Ok(None);
1328    }
1329    Ok(Some(
1330        legacy_base
1331            .join("projects")
1332            .join(sanitize_project_name(project_name))
1333            .join("memory"),
1334    ))
1335}
1336
1337fn memory_directory_has_stored_content(directory: &Path) -> Result<bool> {
1338    if !directory.exists() {
1339        return Ok(false);
1340    }
1341    for path in [
1342        directory.join(PREFERENCES_FILENAME),
1343        directory.join(REPOSITORY_FACTS_FILENAME),
1344    ] {
1345        if !path.exists() {
1346            continue;
1347        }
1348        let contents = std::fs::read_to_string(&path)
1349            .with_context(|| format!("Failed to read {}", path.display()))?;
1350        if !parse_topic_file(&contents).is_empty() {
1351            return Ok(true);
1352        }
1353    }
1354    let rollout_dir = directory.join(ROLLOUT_SUMMARIES_DIRNAME);
1355    if !rollout_dir.exists() {
1356        return Ok(false);
1357    }
1358    for entry in std::fs::read_dir(&rollout_dir)
1359        .with_context(|| format!("Failed to list {}", rollout_dir.display()))?
1360    {
1361        let path = entry?.path();
1362        if path.extension().and_then(|v| v.to_str()) != Some("md") {
1363            continue;
1364        }
1365        let contents = std::fs::read_to_string(&path)
1366            .with_context(|| format!("Failed to read {}", path.display()))?;
1367        if !parse_topic_file(&contents).is_empty() {
1368            return Ok(true);
1369        }
1370    }
1371    Ok(false)
1372}
1373
1374fn remove_empty_legacy_memory_hierarchy(legacy_memory_dir: &Path) -> Result<()> {
1375    let mut current = legacy_memory_dir.parent();
1376    for _ in 0..3 {
1377        let Some(path) = current else { break };
1378        match std::fs::remove_dir(path) {
1379            Ok(()) => current = path.parent(),
1380            Err(err) if err.kind() == std::io::ErrorKind::NotFound => current = path.parent(),
1381            Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
1382            Err(err) => {
1383                return Err(err).with_context(|| format!("Failed to remove {}", path.display()));
1384            }
1385        }
1386    }
1387    Ok(())
1388}
1389
1390#[cold]
1391fn sanitize_project_name(project_name: &str) -> String {
1392    let sanitized: String = project_name
1393        .chars()
1394        .map(|ch| match ch {
1395            '/' | '\\' | ':' => '_',
1396            other => other,
1397        })
1398        .collect();
1399    let trimmed = sanitized.trim();
1400    if trimmed.is_empty() {
1401        "workspace".to_string()
1402    } else {
1403        trimmed.to_string()
1404    }
1405}
1406
1407fn truncate_memory_excerpt(
1408    contents: &str,
1409    line_limit: usize,
1410    byte_limit: usize,
1411) -> (String, bool, usize, usize) {
1412    let all_lines = contents.lines().collect::<Vec<_>>();
1413    let mut selected = String::new();
1414    let mut bytes_read = 0usize;
1415    let mut lines_read = 0usize;
1416    let mut truncated = false;
1417    for (index, line) in all_lines.iter().enumerate() {
1418        if lines_read >= line_limit {
1419            truncated = true;
1420            break;
1421        }
1422        let line_bytes = line.len();
1423        let trailing_newline = usize::from(index + 1 < all_lines.len());
1424        if bytes_read + line_bytes + trailing_newline > byte_limit {
1425            truncated = true;
1426            break;
1427        }
1428        selected.push_str(line);
1429        selected.push('\n');
1430        bytes_read += line_bytes + trailing_newline;
1431        lines_read += 1;
1432    }
1433    if !truncated && contents.len() > bytes_read {
1434        truncated = true;
1435    }
1436    (
1437        selected.trim_end().to_string(),
1438        truncated,
1439        bytes_read,
1440        lines_read,
1441    )
1442}
1443
1444async fn read_existing_memory_lines(directory: &Path) -> Result<BTreeSet<String>> {
1445    let mut lines = BTreeSet::new();
1446    if !directory.exists() {
1447        return Ok(lines);
1448    }
1449    let mut stack = vec![directory.to_path_buf()];
1450    while let Some(next_dir) = stack.pop() {
1451        let mut entries = tokio::fs::read_dir(&next_dir)
1452            .await
1453            .with_context(|| format!("Failed to list {}", next_dir.display()))?;
1454        while let Some(entry) = entries.next_entry().await? {
1455            let path = entry.path();
1456            if entry.metadata().await?.is_dir() {
1457                stack.push(path);
1458                continue;
1459            }
1460            if path.extension().and_then(|v| v.to_str()) != Some("md") {
1461                continue;
1462            }
1463            let content = tokio::fs::read_to_string(&path)
1464                .await
1465                .with_context(|| format!("failed to read note file at {}", path.display()))?;
1466            for line in content.lines() {
1467                if let Some((_, fact)) = parse_fact_line(line) {
1468                    lines.insert(normalize_whitespace(&fact).to_ascii_lowercase());
1469                }
1470            }
1471        }
1472    }
1473    Ok(lines)
1474}
1475
1476const CLEANUP_REMEMBER_MARKERS: &[&str] = &[
1477    "save to memory",
1478    "remember that",
1479    "remember my",
1480    "remember ",
1481    "add to memory",
1482    "store in memory",
1483];
1484const CLEANUP_FORGET_MARKERS: &[&str] = &["forget ", "remove from memory", "delete from memory"];
1485const STRIP_PREFIXES: &[&str] = &[
1486    "please ",
1487    "please, ",
1488    "can you ",
1489    "could you ",
1490    "would you ",
1491    "vt code, ",
1492    "vt code ",
1493];
1494const CLEANUP_NOTE_PREFIXES: &[&str] = &["note that ", "important:"];
1495const SELF_FACT_PREFIXES: &[&str] = &[
1496    "my name is ",
1497    "i prefer ",
1498    "my preferred ",
1499    "my pronouns are ",
1500    "my timezone is ",
1501];
1502
1503fn detect_memory_cleanup_status(files: &PersistentMemoryFiles) -> Result<MemoryCleanupStatus> {
1504    if !files.directory.exists() {
1505        return Ok(MemoryCleanupStatus {
1506            needed: false,
1507            suspicious_facts: 0,
1508            suspicious_summary_lines: 0,
1509        });
1510    }
1511    let mut suspicious_facts = 0usize;
1512    for path in [
1513        &files.preferences_file,
1514        &files.repository_facts_file,
1515        &files.memory_file,
1516    ] {
1517        suspicious_facts += count_suspicious_facts_in_file(path)?;
1518    }
1519    suspicious_facts += count_suspicious_rollout_facts(&files.rollout_summaries_dir)?;
1520    let suspicious_summary_lines = count_suspicious_summary_lines(&files.summary_file)?;
1521    Ok(MemoryCleanupStatus {
1522        needed: suspicious_facts > 0 || suspicious_summary_lines > 0,
1523        suspicious_facts,
1524        suspicious_summary_lines,
1525    })
1526}
1527
1528fn count_suspicious_facts_in_file(path: &Path) -> Result<usize> {
1529    if !path.exists() {
1530        return Ok(0);
1531    }
1532    let content = std::fs::read_to_string(path)
1533        .with_context(|| format!("Failed to read {}", path.display()))?;
1534    Ok(parse_topic_file(&content)
1535        .into_iter()
1536        .filter(is_legacy_polluted_fact)
1537        .count())
1538}
1539
1540fn count_suspicious_rollout_facts(rollout_dir: &Path) -> Result<usize> {
1541    if !rollout_dir.exists() {
1542        return Ok(0);
1543    }
1544    let mut count = 0usize;
1545    for entry in std::fs::read_dir(rollout_dir)
1546        .with_context(|| format!("Failed to list {}", rollout_dir.display()))?
1547    {
1548        let path = entry?.path();
1549        if path.extension().and_then(|v| v.to_str()) == Some("md") {
1550            count += count_suspicious_facts_in_file(&path)?;
1551        }
1552    }
1553    Ok(count)
1554}
1555
1556fn count_suspicious_summary_lines(path: &Path) -> Result<usize> {
1557    if !path.exists() {
1558        return Ok(0);
1559    }
1560    let content = std::fs::read_to_string(path)
1561        .with_context(|| format!("Failed to read {}", path.display()))?;
1562    Ok(content
1563        .lines()
1564        .map(str::trim)
1565        .filter(|l| l.starts_with("- "))
1566        .map(|l| l.trim_start_matches("- ").trim())
1567        .filter(|l| looks_like_legacy_prompt(l) || looks_like_serialized_payload(l))
1568        .count())
1569}
1570
1571#[cold]
1572fn is_legacy_polluted_fact(fact: &GroundedFactRecord) -> bool {
1573    looks_like_legacy_prompt(&fact.fact) || looks_like_serialized_payload(&fact.fact)
1574}
1575
1576#[cold]
1577fn looks_like_legacy_prompt(text: &str) -> bool {
1578    let mut lowered = normalize_whitespace(text).to_ascii_lowercase();
1579    while let Some(stripped) = STRIP_PREFIXES.iter().find_map(|p| lowered.strip_prefix(p)) {
1580        lowered = stripped.trim_start().to_string();
1581    }
1582    CLEANUP_REMEMBER_MARKERS
1583        .iter()
1584        .chain(CLEANUP_FORGET_MARKERS.iter())
1585        .any(|m| lowered.starts_with(m))
1586}
1587
1588#[cold]
1589fn looks_like_serialized_payload(text: &str) -> bool {
1590    let t = text.trim();
1591    t.starts_with('{')
1592        || t.starts_with('[')
1593        || t.contains("\"query\":")
1594        || t.contains("\"matches\":")
1595        || t.contains("\"path\":")
1596        || t.contains("</parameter>")
1597        || t.contains("</invoke>")
1598        || t.contains("<</invoke>")
1599}
1600
1601fn normalize_memory_query(query: &str) -> Option<String> {
1602    let normalized = normalize_whitespace(query).to_ascii_lowercase();
1603    (!normalized.is_empty()).then_some(normalized)
1604}
1605
1606async fn collect_memory_matches(
1607    files: &PersistentMemoryFiles,
1608    normalized_query: &str,
1609) -> Result<Vec<PersistentMemoryMatch>> {
1610    Ok(collect_all_memory_matches(files)
1611        .await?
1612        .into_iter()
1613        .filter(|r| {
1614            let nf = normalize_whitespace(&r.fact).to_ascii_lowercase();
1615            let ns = normalize_whitespace(&r.source).to_ascii_lowercase();
1616            nf.contains(normalized_query) || ns.contains(normalized_query)
1617        })
1618        .collect())
1619}
1620
1621async fn collect_all_memory_matches(
1622    files: &PersistentMemoryFiles,
1623) -> Result<Vec<PersistentMemoryMatch>> {
1624    let prefs = read_topic_records(&files.preferences_file, MemoryTopic::Preferences).await?;
1625    let repo =
1626        read_topic_records(&files.repository_facts_file, MemoryTopic::RepositoryFacts).await?;
1627    let rollout = read_rollout_records(&files.rollout_summaries_dir).await?;
1628    let notes = read_note_summaries(&files.notes_dir).await?;
1629
1630    let mut matches = Vec::new();
1631    for r in prefs
1632        .into_iter()
1633        .chain(repo)
1634        .chain(rollout.0)
1635        .chain(rollout.1)
1636    {
1637        let (_, src) = decode_topic_source(&r.source);
1638        matches.push(PersistentMemoryMatch {
1639            source: src,
1640            fact: r.fact,
1641        });
1642    }
1643    for n in notes {
1644        for h in n.highlights {
1645            matches.push(PersistentMemoryMatch {
1646                source: n.relative_path.clone(),
1647                fact: h,
1648            });
1649        }
1650    }
1651
1652    let mut deduped = Vec::new();
1653    for r in matches {
1654        let nf = normalize_whitespace(&r.fact).to_ascii_lowercase();
1655        if let Some(i) = deduped.iter().position(|e: &PersistentMemoryMatch| {
1656            normalize_whitespace(&e.fact).to_ascii_lowercase() == nf
1657        }) {
1658            deduped.remove(i);
1659        }
1660        deduped.push(r);
1661    }
1662    Ok(deduped)
1663}
1664
1665async fn collect_cleanup_candidates(
1666    files: &PersistentMemoryFiles,
1667) -> Result<Vec<GroundedFactRecord>> {
1668    let prefs = read_topic_records(&files.preferences_file, MemoryTopic::Preferences).await?;
1669    let repo =
1670        read_topic_records(&files.repository_facts_file, MemoryTopic::RepositoryFacts).await?;
1671    let rollout = read_rollout_records(&files.rollout_summaries_dir).await?;
1672    Ok(prefs
1673        .into_iter()
1674        .chain(repo)
1675        .chain(rollout.0)
1676        .chain(rollout.1)
1677        .collect())
1678}
1679
1680async fn write_rollout_summary_pending(
1681    rollout_dir: &Path,
1682    classified: &ClassifiedFacts,
1683) -> Result<PathBuf> {
1684    tokio::fs::create_dir_all(rollout_dir)
1685        .await
1686        .with_context(|| format!("Failed to create {}", rollout_dir.display()))?;
1687    let path = rollout_dir.join(format!("{}.pending.md", unique_rollout_id()));
1688    tokio::fs::write(&path, render_rollout_summary(classified))
1689        .await
1690        .with_context(|| format!("Failed to write {}", path.display()))?;
1691    Ok(path)
1692}
1693
1694fn finalize_rollout_summary_path(path: PathBuf) -> PathBuf {
1695    match path.file_name().and_then(|v| v.to_str()) {
1696        Some(name) => path.with_file_name(name.trim_end_matches(".pending.md").to_string() + ".md"),
1697        None => path,
1698    }
1699}
1700
1701/// List `.md` files under `dir`, optionally filtering by a predicate on the file name.
1702fn list_md_files(dir: &Path, filter: impl Fn(&str) -> bool) -> Result<Vec<PathBuf>> {
1703    fn walk(dir: &Path, files: &mut Vec<PathBuf>, filter: &impl Fn(&str) -> bool) -> Result<()> {
1704        if !dir.exists() {
1705            return Ok(());
1706        }
1707        for entry in
1708            std::fs::read_dir(dir).with_context(|| format!("Failed to list {}", dir.display()))?
1709        {
1710            let path = entry?.path();
1711            if path.is_dir() {
1712                walk(&path, files, filter)?;
1713            } else if path.extension().and_then(|v| v.to_str()) == Some("md")
1714                && filter(path.file_name().and_then(|v| v.to_str()).unwrap_or(""))
1715            {
1716                files.push(path);
1717            }
1718        }
1719        Ok(())
1720    }
1721    let mut files = Vec::new();
1722    walk(dir, &mut files, &filter)?;
1723    files.sort();
1724    Ok(files)
1725}
1726
1727fn list_pending_rollout_files(rollout_dir: &Path) -> Result<Vec<PathBuf>> {
1728    list_md_files(rollout_dir, |n| n.ends_with(".pending.md"))
1729}
1730
1731fn list_rollout_markdown_files(rollout_dir: &Path) -> Result<Vec<PathBuf>> {
1732    list_md_files(rollout_dir, |_| true)
1733}
1734
1735fn list_note_markdown_files(notes_dir: &Path) -> Result<Vec<PathBuf>> {
1736    list_md_files(notes_dir, |_| true)
1737}
1738
1739fn count_pending_rollout_summaries(rollout_dir: &Path) -> Result<usize> {
1740    Ok(list_md_files(rollout_dir, |n| n.ends_with(".pending.md"))?.len())
1741}
1742
1743async fn read_note_summaries(notes_dir: &Path) -> Result<Vec<MemoryNoteSummary>> {
1744    let mut notes = Vec::new();
1745    for path in list_note_markdown_files(notes_dir)? {
1746        let content = tokio::fs::read_to_string(&path)
1747            .await
1748            .with_context(|| format!("Failed to read {}", path.display()))?;
1749        let relative = path
1750            .strip_prefix(notes_dir)
1751            .with_context(|| format!("Failed to relativize {}", path.display()))?
1752            .to_string_lossy()
1753            .replace('\\', "/");
1754        notes.push(MemoryNoteSummary {
1755            relative_path: format!("{NOTES_DIRNAME}/{relative}"),
1756            highlights: extract_memory_highlights(&content, 3),
1757        });
1758    }
1759    Ok(notes)
1760}
1761
1762struct ConsolidationResult {
1763    created_files: Vec<PathBuf>,
1764    added_facts: usize,
1765}
1766
1767#[derive(Debug, Clone, PartialEq, Eq)]
1768struct MemoryNoteSummary {
1769    relative_path: String,
1770    highlights: Vec<String>,
1771}
1772
1773async fn consolidate_memory_files(
1774    runtime_config: Option<&RuntimeAgentConfig>,
1775    vt_cfg: Option<&VTCodeConfig>,
1776    workspace_root: &Path,
1777    files: &PersistentMemoryFiles,
1778) -> Result<ConsolidationResult> {
1779    let pending_files = list_pending_rollout_files(&files.rollout_summaries_dir)?;
1780    let prefs_existing =
1781        read_topic_records(&files.preferences_file, MemoryTopic::Preferences).await?;
1782    let repo_existing =
1783        read_topic_records(&files.repository_facts_file, MemoryTopic::RepositoryFacts).await?;
1784    let rollout = read_rollout_records(&files.rollout_summaries_dir).await?;
1785    let classified = ClassifiedFacts {
1786        preferences: merge_topic_facts(prefs_existing.into_iter().chain(rollout.0).collect()),
1787        repository_facts: merge_topic_facts(repo_existing.into_iter().chain(rollout.1).collect()),
1788    };
1789    let created_files =
1790        write_classified_memory(files, &classified, runtime_config, vt_cfg, workspace_root).await?;
1791    let mut added_facts = 0usize;
1792    for p in &pending_files {
1793        if let Ok(c) = tokio::fs::read_to_string(p).await {
1794            added_facts += c.lines().filter_map(parse_fact_line).count();
1795        }
1796    }
1797    for pending in &pending_files {
1798        let finalized = finalize_rollout_summary_path(pending.clone());
1799        if !finalized.exists() {
1800            tokio::fs::rename(pending, &finalized)
1801                .await
1802                .with_context(|| {
1803                    format!("Failed to finalize rollout summary {}", pending.display())
1804                })?;
1805        } else {
1806            tokio::fs::remove_file(pending)
1807                .await
1808                .with_context(|| format!("Failed to remove {}", pending.display()))?;
1809        }
1810    }
1811    Ok(ConsolidationResult {
1812        created_files,
1813        added_facts,
1814    })
1815}
1816
1817async fn read_topic_records(path: &Path, topic: MemoryTopic) -> Result<Vec<GroundedFactRecord>> {
1818    if !path.exists() {
1819        return Ok(Vec::new());
1820    }
1821    let contents = tokio::fs::read_to_string(path)
1822        .await
1823        .with_context(|| format!("Failed to read {}", path.display()))?;
1824    Ok(parse_topic_file(&contents)
1825        .into_iter()
1826        .map(|r| GroundedFactRecord {
1827            fact: r.fact,
1828            source: encode_topic_source(topic, &r.source),
1829        })
1830        .collect())
1831}
1832
1833async fn read_rollout_records(
1834    rollout_dir: &Path,
1835) -> Result<(Vec<GroundedFactRecord>, Vec<GroundedFactRecord>)> {
1836    if !rollout_dir.exists() {
1837        return Ok((Vec::new(), Vec::new()));
1838    }
1839    let mut prefs = Vec::new();
1840    let mut repo_facts = Vec::new();
1841    let mut entries = tokio::fs::read_dir(rollout_dir)
1842        .await
1843        .with_context(|| format!("Failed to list {}", rollout_dir.display()))?;
1844    while let Some(entry) = entries.next_entry().await? {
1845        let path = entry.path();
1846        if path.extension().and_then(|v| v.to_str()) != Some("md") {
1847            continue;
1848        }
1849        let contents = tokio::fs::read_to_string(&path).await?;
1850        for record in parse_topic_file(&contents) {
1851            let (topic, _) = decode_topic_source(&record.source);
1852            match topic.unwrap_or_else(|| classify_fact(&record)) {
1853                MemoryTopic::Preferences => prefs.push(record),
1854                MemoryTopic::RepositoryFacts => repo_facts.push(record),
1855            }
1856        }
1857    }
1858    Ok((prefs, repo_facts))
1859}
1860
1861fn merge_topic_facts(records: Vec<GroundedFactRecord>) -> Vec<GroundedFactRecord> {
1862    let mut facts = Vec::new();
1863    for fact in records {
1864        let normalized = normalize_whitespace(&fact.fact).to_ascii_lowercase();
1865        if let Some(i) = facts.iter().position(|e: &GroundedFactRecord| {
1866            normalize_whitespace(&e.fact).to_ascii_lowercase() == normalized
1867        }) {
1868            facts.remove(i);
1869        }
1870        facts.push(fact);
1871    }
1872    let skip = facts.len().saturating_sub(TOPIC_FACT_LIMIT);
1873    facts.into_iter().skip(skip).collect()
1874}
1875
1876fn normalized_selection_key(source: &str, fact: &str) -> String {
1877    format!(
1878        "{}::{}",
1879        normalize_whitespace(source).to_ascii_lowercase(),
1880        normalize_whitespace(fact).to_ascii_lowercase()
1881    )
1882}
1883
1884fn selection_key_for_record(record: &GroundedFactRecord) -> String {
1885    let (_topic, source) = decode_topic_source(&record.source);
1886    normalized_selection_key(&source, &record.fact)
1887}
1888
1889fn selection_keys(selected: &[MemoryOpCandidate]) -> BTreeSet<String> {
1890    selected
1891        .iter()
1892        .map(|e| normalized_selection_key(&e.source, &e.fact))
1893        .collect()
1894}
1895
1896async fn rewrite_topic_without_selected(
1897    path: &Path,
1898    topic: MemoryTopic,
1899    selected: &[MemoryOpCandidate],
1900) -> Result<usize> {
1901    if !path.exists() {
1902        return Ok(0);
1903    }
1904    let keys = selection_keys(selected);
1905    let facts = read_topic_records(path, topic).await?;
1906    let removed = facts
1907        .iter()
1908        .filter(|f| keys.contains(&selection_key_for_record(f)))
1909        .count();
1910    if removed == 0 {
1911        return Ok(0);
1912    }
1913    let kept: Vec<_> = facts
1914        .into_iter()
1915        .filter(|f| !keys.contains(&selection_key_for_record(f)))
1916        .collect();
1917    tokio::fs::write(path, render_topic_file(topic, &kept))
1918        .await
1919        .with_context(|| format!("Failed to write {}", path.display()))?;
1920    Ok(removed)
1921}
1922
1923async fn scrub_rollout_file_by_selection(
1924    path: &Path,
1925    selected: &[MemoryOpCandidate],
1926) -> Result<usize> {
1927    let contents = tokio::fs::read_to_string(path)
1928        .await
1929        .with_context(|| format!("Failed to read {}", path.display()))?;
1930    let keys = selection_keys(selected);
1931    let mut removed = 0usize;
1932    let mut filtered = Vec::new();
1933    for line in contents.lines() {
1934        let keep = parse_fact_line(line).is_none_or(|(source, fact)| {
1935            let m = keys.contains(&selection_key_for_record(&GroundedFactRecord {
1936                source,
1937                fact,
1938            }));
1939            if m {
1940                removed += 1;
1941            }
1942            !m
1943        });
1944        if keep {
1945            filtered.push(line);
1946        }
1947    }
1948    if removed == 0 {
1949        return Ok(0);
1950    }
1951    let mut rewritten = filtered.join("\n");
1952    if contents.ends_with('\n') {
1953        rewritten.push('\n');
1954    }
1955    tokio::fs::write(path, rewritten)
1956        .await
1957        .with_context(|| format!("Failed to write {}", path.display()))?;
1958    Ok(removed)
1959}
1960
1961async fn remove_rollout_markdown_files(rollout_dir: &Path) -> Result<usize> {
1962    let files = list_rollout_markdown_files(rollout_dir)?;
1963    let count = files.len();
1964    for p in files {
1965        tokio::fs::remove_file(&p)
1966            .await
1967            .with_context(|| format!("Failed to remove {}", p.display()))?;
1968    }
1969    Ok(count)
1970}
1971
1972fn parse_topic_file(contents: &str) -> Vec<GroundedFactRecord> {
1973    contents
1974        .lines()
1975        .filter_map(parse_fact_line)
1976        .map(|(source, fact)| GroundedFactRecord { source, fact })
1977        .collect()
1978}
1979
1980fn parse_fact_line(line: &str) -> Option<(String, String)> {
1981    let trimmed = line.trim();
1982    let remainder = trimmed.strip_prefix("- [")?;
1983    let (source, fact) = remainder.split_once("] ")?;
1984    let fact = fact.trim();
1985    if fact.is_empty() {
1986        return None;
1987    }
1988    Some((source.trim().to_string(), fact.to_string()))
1989}
1990
1991fn classify_fact(fact: &GroundedFactRecord) -> MemoryTopic {
1992    if fact.source == "user_assertion" {
1993        MemoryTopic::Preferences
1994    } else {
1995        MemoryTopic::RepositoryFacts
1996    }
1997}
1998
1999fn encode_topic_source(topic: MemoryTopic, source: &str) -> String {
2000    format!("{}:{}", topic.slug(), source)
2001}
2002
2003fn decode_topic_source(source: &str) -> (Option<MemoryTopic>, String) {
2004    match source.split_once(':') {
2005        Some((topic, rest)) => (MemoryTopic::from_slug(topic), rest.trim().to_string()),
2006        None => (None, source.to_string()),
2007    }
2008}
2009
2010async fn classify_facts_strict(
2011    runtime_config: Option<&RuntimeAgentConfig>,
2012    vt_cfg: Option<&VTCodeConfig>,
2013    workspace_root: &Path,
2014    candidates: &[GroundedFactRecord],
2015) -> Result<ClassifiedFacts> {
2016    if candidates.is_empty() {
2017        return Ok(ClassifiedFacts {
2018            preferences: Vec::new(),
2019            repository_facts: Vec::new(),
2020        });
2021    }
2022    classify_facts_with_llm(runtime_config, vt_cfg, workspace_root, candidates).await
2023}
2024
2025/// Try a memory LLM operation with primary route, falling back to the fallback route on error.
2026/// This macro expands to the full routing/fallback pattern used by all memory LLM calls.
2027macro_rules! try_with_memory_routes {
2028    ($runtime_config:expr, $vt_cfg:expr, $workspace_root:expr, $phase:expr, $provider_fn:expr) => {
2029        async {
2030            let __rt_cfg: &RuntimeAgentConfig = $runtime_config;
2031            let __routes = resolve_memory_model_routes(__rt_cfg, $vt_cfg, $phase);
2032            log_memory_route_warning(&__routes);
2033
2034            let __provider = create_memory_provider(&__routes.primary, __rt_cfg, $vt_cfg)?;
2035            match $provider_fn(__provider.as_ref(), &__routes.primary).await {
2036                Ok(result) => Ok(result),
2037                Err(__primary_err) => {
2038                    let Some(__fallback) = __routes.fallback.as_ref() else {
2039                        return Err(__primary_err);
2040                    };
2041
2042                    tracing::warn!(
2043                        model = %__routes.primary.model,
2044                        fallback_model = %__fallback.model,
2045                        error = %__primary_err,
2046                        "persistent memory LLM call failed on lightweight route; retrying with main model"
2047                    );
2048                    let __provider = create_memory_provider(__fallback, __rt_cfg, $vt_cfg)?;
2049                    $provider_fn(__provider.as_ref(), __fallback).await
2050                }
2051            }
2052        }
2053    };
2054}
2055
2056async fn classify_facts_with_llm(
2057    runtime_config: Option<&RuntimeAgentConfig>,
2058    vt_cfg: Option<&VTCodeConfig>,
2059    workspace_root: &Path,
2060    candidates: &[GroundedFactRecord],
2061) -> Result<ClassifiedFacts> {
2062    let rt_cfg = runtime_config
2063        .ok_or_else(|| anyhow!("runtime config is required for persistent memory LLM routing"))?;
2064    try_with_memory_routes!(
2065        rt_cfg,
2066        vt_cfg,
2067        workspace_root,
2068        MemoryPhase::Extract,
2069        |provider, route| {
2070            classify_facts_with_provider(provider, route, workspace_root, candidates)
2071        }
2072    )
2073    .await
2074}
2075
2076async fn classify_facts_with_provider(
2077    provider: &(impl LLMProvider + ?Sized),
2078    route: &MemoryModelRoute,
2079    workspace_root: &Path,
2080    candidates: &[GroundedFactRecord],
2081) -> Result<ClassifiedFacts> {
2082    let payload = candidates
2083        .iter()
2084        .enumerate()
2085        .map(|(index, fact)| {
2086            json!({
2087                "id": index,
2088                "source": fact.source,
2089                "fact": fact.fact,
2090            })
2091        })
2092        .collect::<Vec<_>>();
2093
2094    let schema = json!({
2095        "type": "object",
2096        "properties": {
2097            "keep": {
2098                "type": "array",
2099                "items": {
2100                    "type": "object",
2101                    "properties": {
2102                        "id": {"type": "integer"},
2103                        "topic": {
2104                            "type": "string",
2105                            "enum": ["preferences", "repository_facts"]
2106                        },
2107                        "fact": {"type": "string"}
2108                    },
2109                    "required": ["id", "topic", "fact"],
2110                    "additionalProperties": false
2111                }
2112            }
2113        },
2114        "required": ["keep"],
2115        "additionalProperties": false
2116    });
2117    let request = build_memory_json_request(
2118        provider,
2119        route,
2120        format!(
2121            "Classify VT Code memory evidence. Keep only durable reusable preferences or repository facts. Rewrite each kept fact into one concise canonical sentence. Drop transient, conversational, or noisy entries by omitting them.\n\nWorkspace: {}\nCandidates:\n{}",
2122            workspace_root.display(),
2123            serde_json::to_string_pretty(&payload)
2124                .context("failed to serialize memory classification payload")?
2125        ),
2126        "memory_classification",
2127        &schema,
2128    )?;
2129
2130    let response = collect_single_response(provider, request)
2131        .await
2132        .context("persistent memory classification LLM request failed")?;
2133    let content = response
2134        .content
2135        .context("persistent memory classification returned no content")?;
2136    let parsed = parse_memory_json_response::<MemoryClassificationPlan>(
2137        content.trim(),
2138        "persistent memory classification",
2139    )?;
2140
2141    let mut preferences = Vec::new();
2142    let mut repository_facts = Vec::new();
2143    for item in parsed.keep {
2144        let candidate = candidates.get(item.id).ok_or_else(|| {
2145            anyhow!(
2146                "memory classification referenced unknown candidate id {}",
2147                item.id
2148            )
2149        })?;
2150        let normalized_fact = normalize_whitespace(item.fact.as_deref().unwrap_or(&candidate.fact));
2151        if normalized_fact.is_empty() || looks_like_legacy_prompt(&normalized_fact) {
2152            continue;
2153        }
2154        let topic = match item.topic {
2155            MemoryPlannedTopic::Preferences => MemoryTopic::Preferences,
2156            MemoryPlannedTopic::RepositoryFacts => MemoryTopic::RepositoryFacts,
2157        };
2158        let record = GroundedFactRecord {
2159            fact: truncate_for_fact(&normalized_fact, 180),
2160            source: {
2161                let (_existing_topic, display_source) = decode_topic_source(&candidate.source);
2162                encode_topic_source(topic, &display_source)
2163            },
2164        };
2165        match topic {
2166            MemoryTopic::Preferences => preferences.push(record),
2167            MemoryTopic::RepositoryFacts => repository_facts.push(record),
2168        };
2169    }
2170
2171    Ok(ClassifiedFacts {
2172        preferences,
2173        repository_facts,
2174    })
2175}
2176
2177async fn summarize_memory(
2178    runtime_config: Option<&RuntimeAgentConfig>,
2179    vt_cfg: Option<&VTCodeConfig>,
2180    workspace_root: &Path,
2181    preferences: &[GroundedFactRecord],
2182    repository_facts: &[GroundedFactRecord],
2183    notes: &[MemoryNoteSummary],
2184) -> Option<String> {
2185    let runtime_config = runtime_config?;
2186    try_with_memory_routes!(
2187        runtime_config,
2188        vt_cfg,
2189        workspace_root,
2190        MemoryPhase::Consolidate,
2191        |provider, route| {
2192            summarize_memory_with_provider(
2193                provider,
2194                route,
2195                workspace_root,
2196                preferences,
2197                repository_facts,
2198                notes,
2199            )
2200        }
2201    )
2202    .await
2203    .ok()
2204}
2205
2206async fn summarize_memory_with_provider(
2207    provider: &(impl LLMProvider + ?Sized),
2208    route: &MemoryModelRoute,
2209    workspace_root: &Path,
2210    preferences: &[GroundedFactRecord],
2211    repository_facts: &[GroundedFactRecord],
2212    notes: &[MemoryNoteSummary],
2213) -> Result<String> {
2214    let schema = json!({
2215        "type": "object",
2216        "properties": {
2217            "bullets": {
2218                "type": "array",
2219                "items": {"type": "string"}
2220            }
2221        },
2222        "required": ["bullets"],
2223        "additionalProperties": false
2224    });
2225    let request = build_memory_json_request(
2226        provider,
2227        route,
2228        format!(
2229            "Write a concise VT Code persistent memory summary for startup injection. Return 4-10 short bullets only. Focus on stable preferences, repository facts, and durable user-authored notes.\n\nWorkspace: {}\nPreferences:\n{}\n\nRepository facts:\n{}\n\nNotes:\n{}",
2230            workspace_root.display(),
2231            facts_for_prompt(preferences),
2232            facts_for_prompt(repository_facts),
2233            notes_for_prompt(notes),
2234        ),
2235        "memory_summary",
2236        &schema,
2237    )?;
2238
2239    let response = collect_single_response(provider, request)
2240        .await
2241        .context("persistent memory summary LLM request failed")?
2242        .content
2243        .context("persistent memory summary returned no content")?;
2244    let parsed = parse_memory_json_response::<MemorySummaryResponse>(
2245        response.trim(),
2246        "persistent memory summary",
2247    )?;
2248    let bullets = parsed
2249        .bullets
2250        .into_iter()
2251        .map(|bullet| normalize_whitespace(&bullet))
2252        .filter(|bullet| !bullet.is_empty())
2253        .take(MEMORY_HIGHLIGHT_LIMIT)
2254        .collect::<Vec<_>>();
2255    if bullets.is_empty() {
2256        bail!("persistent memory summary returned no bullets");
2257    }
2258
2259    Ok(render_memory_summary_bullets(&bullets))
2260}
2261
2262async fn plan_memory_operation(
2263    runtime_config: &RuntimeAgentConfig,
2264    vt_cfg: Option<&VTCodeConfig>,
2265    workspace_root: &Path,
2266    expected_kind: MemoryOpKind,
2267    request: &str,
2268    supplemental_answer: Option<&str>,
2269    candidates: &[MemoryOpCandidate],
2270) -> Result<MemoryOpPlan> {
2271    try_with_memory_routes!(
2272        runtime_config,
2273        vt_cfg,
2274        workspace_root,
2275        MemoryPhase::Extract,
2276        |provider, route| {
2277            plan_memory_operation_with_provider(
2278                provider,
2279                route,
2280                workspace_root,
2281                expected_kind.clone(),
2282                request,
2283                supplemental_answer,
2284                candidates,
2285            )
2286        }
2287    )
2288    .await
2289}
2290
2291async fn plan_memory_operation_with_provider(
2292    provider: &(impl LLMProvider + ?Sized),
2293    route: &MemoryModelRoute,
2294    workspace_root: &Path,
2295    expected_kind: MemoryOpKind,
2296    request: &str,
2297    supplemental_answer: Option<&str>,
2298    candidates: &[MemoryOpCandidate],
2299) -> Result<MemoryOpPlan> {
2300    let payload = serde_json::to_string_pretty(candidates)
2301        .context("failed to serialize memory operation candidates")?;
2302    let supplemental = supplemental_answer.unwrap_or("").trim();
2303    let schema = json!({
2304        "type": "object",
2305        "properties": {
2306            "kind": {
2307                "type": "string",
2308                "enum": ["remember", "forget", "ask_missing", "noop"]
2309            },
2310            "facts": {
2311                "type": "array",
2312                "items": {
2313                    "type": "object",
2314                    "properties": {
2315                        "topic": {
2316                            "type": "string",
2317                            "enum": ["preferences", "repository_facts"]
2318                        },
2319                        "fact": {"type": "string"},
2320                        "source": {"type": "string"}
2321                    },
2322                    "required": ["topic", "fact"],
2323                    "additionalProperties": false
2324                }
2325            },
2326            "selected_ids": {
2327                "type": "array",
2328                "items": {"type": "integer"}
2329            },
2330            "missing": {
2331                "type": ["object", "null"],
2332                "properties": {
2333                    "field": {"type": "string"},
2334                    "prompt": {"type": "string"}
2335                },
2336                "required": ["field", "prompt"],
2337                "additionalProperties": false
2338            },
2339            "message": {"type": ["string", "null"]}
2340        },
2341        "required": ["kind", "facts", "selected_ids", "missing", "message"],
2342        "additionalProperties": false
2343    });
2344    let llm_request = build_memory_json_request(
2345        provider,
2346        route,
2347        format!(
2348            "Plan a VT Code persistent memory operation.\n\nExpected operation: {:?}\nWorkspace: {}\nUser request: {}\nSupplemental answer: {}\nCurrent candidates:\n{}\n\nRules:\n- Never echo the raw request back as a saved fact.\n- For remember: extract only durable canonical facts. If a required value is missing, return ask_missing.\n- For forget: choose only ids from Current candidates. Do not invent ids.\n- For ask_missing: include one concise field label and one concise human-facing prompt.\n- For noop: do not include facts or selected ids.\n- Saved facts must be standalone sentences, not imperative prompts.",
2349            expected_kind,
2350            workspace_root.display(),
2351            request.trim(),
2352            if supplemental.is_empty() {
2353                "(none)"
2354            } else {
2355                supplemental
2356            },
2357            payload
2358        ),
2359        "memory_operation_plan",
2360        &schema,
2361    )?;
2362
2363    let response = collect_single_response(provider, llm_request)
2364        .await
2365        .context("persistent memory planner LLM request failed")?;
2366    let content = response
2367        .content
2368        .context("persistent memory planner returned no content")?;
2369    let plan =
2370        parse_memory_json_response::<MemoryOpPlan>(content.trim(), "persistent memory planner")?;
2371    validate_memory_op_plan(&plan, expected_kind, candidates)?;
2372    Ok(plan)
2373}
2374
2375fn validate_memory_op_plan(
2376    plan: &MemoryOpPlan,
2377    expected_kind: MemoryOpKind,
2378    candidates: &[MemoryOpCandidate],
2379) -> Result<()> {
2380    match plan.kind {
2381        MemoryOpKind::Remember => {
2382            if expected_kind != MemoryOpKind::Remember {
2383                bail!("memory planner returned remember for a non-remember request");
2384            }
2385            if plan.facts.is_empty() {
2386                bail!("memory planner returned remember with no facts");
2387            }
2388            if plan
2389                .facts
2390                .iter()
2391                .any(|f| normalize_whitespace(&f.fact).is_empty())
2392            {
2393                bail!("memory planner returned an empty fact");
2394            }
2395        }
2396        MemoryOpKind::Forget => {
2397            if expected_kind != MemoryOpKind::Forget {
2398                bail!("memory planner returned forget for a non-forget request");
2399            }
2400            let valid_ids: BTreeSet<_> = candidates.iter().map(|c| c.id).collect();
2401            if plan.selected_ids.iter().any(|id| !valid_ids.contains(id)) {
2402                bail!("memory planner selected an unknown memory candidate");
2403            }
2404        }
2405        MemoryOpKind::AskMissing => {
2406            let m = plan
2407                .missing
2408                .as_ref()
2409                .ok_or_else(|| anyhow!("memory planner returned ask_missing without a prompt"))?;
2410            if normalize_whitespace(&m.field).is_empty()
2411                || normalize_whitespace(&m.prompt).is_empty()
2412            {
2413                bail!("memory planner returned an incomplete missing-field request");
2414            }
2415        }
2416        MemoryOpKind::Noop => {}
2417    }
2418    if matches!(plan.kind, MemoryOpKind::AskMissing | MemoryOpKind::Noop)
2419        && (!plan.facts.is_empty() || !plan.selected_ids.is_empty())
2420    {
2421        bail!("memory planner returned extra mutations for a non-mutating plan");
2422    }
2423    Ok(())
2424}
2425
2426fn memory_plan_facts(plan: &MemoryOpPlan) -> Result<Vec<GroundedFactRecord>> {
2427    if plan.kind != MemoryOpKind::Remember {
2428        bail!("memory plan is not a remember operation");
2429    }
2430    Ok(plan
2431        .facts
2432        .iter()
2433        .map(|f| {
2434            let topic = match f.topic {
2435                MemoryPlannedTopic::Preferences => MemoryTopic::Preferences,
2436                MemoryPlannedTopic::RepositoryFacts => MemoryTopic::RepositoryFacts,
2437            };
2438            let source = if f.source.trim().is_empty() {
2439                "manual_memory".to_string()
2440            } else {
2441                normalize_whitespace(&f.source)
2442            };
2443            GroundedFactRecord {
2444                fact: truncate_for_fact(&normalize_whitespace(&f.fact), 180),
2445                source: encode_topic_source(topic, &source),
2446            }
2447        })
2448        .filter(|f| !f.fact.is_empty())
2449        .collect())
2450}
2451
2452fn selected_memory_candidates(
2453    candidates: &[MemoryOpCandidate],
2454    selected_ids: &[usize],
2455) -> Result<Vec<MemoryOpCandidate>> {
2456    let selected: Vec<_> = selected_ids
2457        .iter()
2458        .filter_map(|id| candidates.iter().find(|c| c.id == *id).cloned())
2459        .collect();
2460    if selected_ids.len() != selected.len() {
2461        bail!("memory plan selected a missing candidate");
2462    }
2463    Ok(selected)
2464}
2465
2466fn effective_persistent_memory_config(vt_cfg: Option<&VTCodeConfig>) -> PersistentMemoryConfig {
2467    let mut config = vt_cfg
2468        .map(|cfg| cfg.agent.persistent_memory.clone())
2469        .unwrap_or_default();
2470    if let Some(cfg) = vt_cfg {
2471        config.enabled = cfg.persistent_memory_enabled();
2472    }
2473    config
2474}
2475
2476fn effective_generated_memory_config(vt_cfg: Option<&VTCodeConfig>) -> PersistentMemoryConfig {
2477    let mut config = effective_persistent_memory_config(vt_cfg);
2478    if let Some(cfg) = vt_cfg {
2479        config.enabled = cfg.should_generate_memories();
2480    }
2481    config
2482}
2483
2484fn facts_for_prompt(facts: &[GroundedFactRecord]) -> String {
2485    if facts.is_empty() {
2486        return "- none".to_string();
2487    }
2488    facts
2489        .iter()
2490        .map(|f| {
2491            let (_, s) = decode_topic_source(&f.source);
2492            format!("- [{}] {}", s, f.fact)
2493        })
2494        .collect::<Vec<_>>()
2495        .join("\n")
2496}
2497
2498fn notes_for_prompt(notes: &[MemoryNoteSummary]) -> String {
2499    if notes.is_empty() {
2500        return "- none".to_string();
2501    }
2502    notes
2503        .iter()
2504        .map(|n| {
2505            let preview = if n.highlights.is_empty() {
2506                "no extracted highlights".to_string()
2507            } else {
2508                n.highlights.join("; ")
2509            };
2510            format!("- [{}] {}", n.relative_path, preview)
2511        })
2512        .collect::<Vec<_>>()
2513        .join("\n")
2514}
2515
2516fn resolve_memory_model_routes(
2517    runtime_config: &RuntimeAgentConfig,
2518    vt_cfg: Option<&VTCodeConfig>,
2519    phase: MemoryPhase,
2520) -> ResolvedMemoryRoutes {
2521    // Check for a phase-specific model override from MemoriesConfig.
2522    let model_override = vt_cfg.and_then(|cfg| {
2523        let memories = &cfg.agent.persistent_memory.memories;
2524        match phase {
2525            MemoryPhase::Extract => memories.extract_model.as_deref(),
2526            MemoryPhase::Consolidate => memories.consolidation_model.as_deref(),
2527        }
2528    });
2529
2530    let resolution = resolve_lightweight_route(
2531        runtime_config,
2532        vt_cfg,
2533        LightweightFeature::Memory,
2534        model_override,
2535    );
2536    let primary = memory_model_route_from_resolution(&resolution.primary, runtime_config, vt_cfg);
2537    let fallback = resolution
2538        .fallback
2539        .as_ref()
2540        .map(|r| memory_model_route_from_resolution(r, runtime_config, vt_cfg));
2541    ResolvedMemoryRoutes {
2542        primary,
2543        fallback,
2544        warning: resolution.warning,
2545    }
2546}
2547
2548fn memory_model_route_from_resolution(
2549    route: &crate::llm::ModelRoute,
2550    runtime_config: &RuntimeAgentConfig,
2551    vt_cfg: Option<&VTCodeConfig>,
2552) -> MemoryModelRoute {
2553    let temperature = if route.model == runtime_config.model
2554        && route
2555            .provider_name
2556            .eq_ignore_ascii_case(&runtime_provider_name(runtime_config))
2557    {
2558        0.0
2559    } else {
2560        vt_cfg
2561            .map(|cfg| cfg.agent.small_model.temperature)
2562            .unwrap_or(0.0)
2563    };
2564    MemoryModelRoute {
2565        provider_name: route.provider_name.clone(),
2566        model: route.model.clone(),
2567        temperature,
2568    }
2569}
2570
2571#[cold]
2572fn log_memory_route_warning(routes: &ResolvedMemoryRoutes) {
2573    if let Some(warning) = &routes.warning {
2574        tracing::warn!(warning = %warning, "persistent memory route adjusted");
2575    }
2576}
2577
2578fn create_memory_provider(
2579    route: &MemoryModelRoute,
2580    runtime_config: &RuntimeAgentConfig,
2581    vt_cfg: Option<&VTCodeConfig>,
2582) -> Result<Box<dyn LLMProvider>> {
2583    create_provider_for_model_route(
2584        &crate::llm::ModelRoute {
2585            provider_name: route.provider_name.clone(),
2586            model: route.model.clone(),
2587        },
2588        runtime_config,
2589        vt_cfg,
2590    )
2591    .context("Failed to initialize persistent memory LLM provider")
2592}
2593
2594fn runtime_provider_name(runtime_config: &RuntimeAgentConfig) -> String {
2595    if !runtime_config.provider.trim().is_empty() {
2596        return runtime_config.provider.to_lowercase();
2597    }
2598    infer_provider_from_model(&runtime_config.model)
2599        .map(|p| p.to_string().to_lowercase())
2600        .unwrap_or_else(|| "gemini".to_string())
2601}
2602
2603fn render_topic_file(topic: MemoryTopic, facts: &[GroundedFactRecord]) -> String {
2604    let mut out = format!("# {}\n\n{}\n", topic.title(), topic.description());
2605    if facts.is_empty() {
2606        out.push_str("\n- No saved facts yet.\n");
2607    } else {
2608        out.push('\n');
2609        for f in facts {
2610            let (_, src) = decode_topic_source(&f.source);
2611            let _ = writeln!(out, "- [{}] {}", src.trim(), f.fact);
2612        }
2613    }
2614    out
2615}
2616
2617fn render_memory_index(
2618    preferences: &[GroundedFactRecord],
2619    repository_facts: &[GroundedFactRecord],
2620    notes: &[MemoryNoteSummary],
2621    pending_rollouts: usize,
2622) -> String {
2623    let mut highlights: Vec<_> = preferences
2624        .iter()
2625        .chain(repository_facts.iter())
2626        .cloned()
2627        .collect();
2628    let skip = highlights.len().saturating_sub(MEMORY_HIGHLIGHT_LIMIT);
2629    highlights = highlights.into_iter().skip(skip).collect();
2630    let mut out = String::from("# VT Code Memory Registry\n\n## Files\n");
2631    out.push_str("- `memory_summary.md`: Startup-injected summary for future sessions.\n");
2632    out.push_str("- `preferences.md`: Durable user preferences and workflow notes.\n");
2633    out.push_str(
2634        "- `repository-facts.md`: Grounded repository facts and recurring tooling notes.\n",
2635    );
2636    out.push_str("- `notes/`: User-authored durable notes available to the native memory tool.\n");
2637    out.push_str("- `rollout_summaries/`: Per-session evidence summaries.\n");
2638    let _ = write!(
2639        out,
2640        "\n## Rollout Status\n- Pending rollout summaries: {pending_rollouts}\n"
2641    );
2642    out.push_str("\n## Highlights\n");
2643    if highlights.is_empty() {
2644        out.push_str("- No persistent notes yet.\n");
2645    } else {
2646        for f in &highlights {
2647            let (_, src) = decode_topic_source(&f.source);
2648            let _ = writeln!(out, "- [{}] {}", src.trim(), f.fact);
2649        }
2650    }
2651    if !notes.is_empty() {
2652        out.push_str("\n## Note Files\n");
2653        for n in notes {
2654            let _ = write!(out, "- `{}`", n.relative_path);
2655            if let Some(first) = n.highlights.first() {
2656                let _ = write!(out, ": {first}");
2657            }
2658            out.push('\n');
2659        }
2660    }
2661    out
2662}
2663
2664fn render_memory_summary(
2665    preferences: &[GroundedFactRecord],
2666    repository_facts: &[GroundedFactRecord],
2667    notes: &[MemoryNoteSummary],
2668) -> String {
2669    let mut bullets: Vec<_> = preferences
2670        .iter()
2671        .chain(repository_facts.iter())
2672        .map(|f| f.fact.clone())
2673        .collect();
2674    bullets.extend(notes.iter().filter_map(|n| {
2675        n.highlights
2676            .first()
2677            .map(|h| format!("Note ({}): {}", n.relative_path, h))
2678    }));
2679    let skip = bullets.len().saturating_sub(MEMORY_HIGHLIGHT_LIMIT);
2680    bullets = bullets.into_iter().skip(skip).collect();
2681    if bullets.is_empty() {
2682        bullets.push("No durable memory notes have been consolidated yet.".to_string());
2683    }
2684    render_memory_summary_bullets(&bullets)
2685}
2686
2687fn render_memory_summary_bullets(bullets: &[String]) -> String {
2688    let mut out = String::from("# VT Code Memory Summary\n");
2689    for b in bullets {
2690        let _ = writeln!(out, "- {}", b.trim());
2691    }
2692    out
2693}
2694
2695fn render_rollout_summary(classified: &ClassifiedFacts) -> String {
2696    let mut out = format!(
2697        "# Rollout Summary\n\n- Generated: {}\n",
2698        chrono::Utc::now().to_rfc3339()
2699    );
2700    if classified.total() == 0 {
2701        out.push_str("\n- No durable facts captured.\n");
2702    } else {
2703        out.push('\n');
2704        for f in classified
2705            .preferences
2706            .iter()
2707            .chain(&classified.repository_facts)
2708        {
2709            let _ = writeln!(out, "- [{}] {}", f.source, f.fact);
2710        }
2711    }
2712    out
2713}
2714
2715fn unique_rollout_id() -> String {
2716    let millis = SystemTime::now()
2717        .duration_since(UNIX_EPOCH)
2718        .map(|d| d.as_millis())
2719        .unwrap_or(0);
2720    format!("rollout-{millis}")
2721}
2722
2723struct MemoryLock {
2724    path: PathBuf,
2725}
2726
2727impl MemoryLock {
2728    async fn acquire(path: &Path) -> Result<Self> {
2729        for _ in 0..LOCK_RETRY_ATTEMPTS {
2730            match tokio::fs::OpenOptions::new()
2731                .create_new(true)
2732                .write(true)
2733                .open(path)
2734                .await
2735            {
2736                Ok(_) => {
2737                    return Ok(Self {
2738                        path: path.to_path_buf(),
2739                    });
2740                }
2741                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
2742                    sleep(Duration::from_millis(LOCK_RETRY_DELAY_MS)).await
2743                }
2744                Err(err) => {
2745                    return Err(err)
2746                        .with_context(|| format!("Failed to acquire {}", path.display()));
2747                }
2748            }
2749        }
2750        Err(anyhow::anyhow!(
2751            "Timed out waiting for persistent memory lock {}",
2752            path.display()
2753        ))
2754    }
2755}
2756
2757impl Drop for MemoryLock {
2758    fn drop(&mut self) {
2759        let _ = std::fs::remove_file(&self.path);
2760    }
2761}
2762
2763#[cfg(test)]
2764mod persistent_memory_tests;