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