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#[derive(Debug, Clone, Copy)]
264enum MemoryPhase {
265 Extract,
267 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
545pub 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
562pub 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
752async 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 ¬es,
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 ¬es,
804 )
805 .await
806 .unwrap_or_else(|| {
807 render_memory_summary(
808 &classified.preferences,
809 &classified.repository_facts,
810 ¬es,
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
1076enum FactsInput<'a> {
1078 Preclassified(&'a [GroundedFactRecord]),
1080 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
1701fn 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
2025macro_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 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;