vtcode_core/persistent_memory/
fact_extraction.rs1use super::*;
2
3#[inline]
9pub fn normalize_whitespace(text: &str) -> String {
10 vtcode_commons::formatting::collapse_whitespace(text)
11}
12
13pub fn truncate_for_fact(text: &str, max_chars: usize) -> String {
14 let trimmed = text.trim();
15 if trimmed.chars().count() <= max_chars {
16 return trimmed.to_string();
17 }
18 vtcode_commons::formatting::truncate_text(trimmed, max_chars.saturating_sub(3), "...")
19}
20
21pub fn maybe_extract_tool_fact(message: &Message) -> Option<GroundedFactRecord> {
22 if message.role != MessageRole::Tool {
23 return None;
24 }
25 let tool_name = message.origin_tool.as_deref().unwrap_or("tool");
26 let text = message.content.as_text();
27 let raw = text.trim();
28 if raw.is_empty() {
29 return None;
30 }
31
32 let candidate = serde_json::from_str::<serde_json::Value>(raw)
33 .ok()
34 .and_then(|value| {
35 if value.get("error").is_some() || value.get("success") == Some(&serde_json::Value::Bool(false)) {
36 return None;
37 }
38 for key in ["summary", "message", "result", "output", "stdout"] {
39 if let Some(v) = value.get(key) {
40 if let Some(text) = v.as_str() {
41 let normalized = normalize_whitespace(text);
42 if !normalized.is_empty() {
43 return Some(normalized);
44 }
45 } else if !v.is_null() {
46 let normalized = normalize_whitespace(&v.to_string());
47 if !normalized.is_empty() {
48 return Some(normalized);
49 }
50 }
51 }
52 }
53 let compact = normalize_whitespace(&value.to_string());
54 (!compact.is_empty()).then_some(compact)
55 })
56 .or_else(|| {
57 let lowered = raw.to_ascii_lowercase();
58 if lowered.contains("error")
59 || lowered.contains("failed")
60 || lowered.contains("denied")
61 || lowered.contains("timeout")
62 {
63 return None;
64 }
65 Some(normalize_whitespace(raw))
66 })?;
67
68 Some(GroundedFactRecord {
69 fact: truncate_for_fact(&candidate, 180),
70 source: format!("tool:{tool_name}"),
71 })
72}
73
74pub fn maybe_extract_user_fact(message: &Message) -> Option<GroundedFactRecord> {
75 if message.role != MessageRole::User {
76 return None;
77 }
78 let text = normalize_whitespace(message.content.as_text().as_ref());
79 if text.is_empty() {
80 return None;
81 }
82 let candidate_text = strip_user_memory_candidate_prefixes(&text);
83 let (candidate_text, looks_authored_note) = strip_user_memory_note_marker(candidate_text)
84 .map(|fact| (fact, true))
85 .unwrap_or((candidate_text, false));
86 let looks_durable_self_fact = SELF_FACT_PREFIXES
87 .iter()
88 .any(|p| candidate_text.to_ascii_lowercase().starts_with(*p));
89 (looks_authored_note || looks_durable_self_fact).then(|| GroundedFactRecord {
90 fact: truncate_for_fact(candidate_text, 180),
91 source: "user_assertion".to_string(),
92 })
93}
94
95fn strip_user_memory_candidate_prefixes(text: &str) -> &str {
96 let mut trimmed = text.trim();
97 loop {
98 let lowered = trimmed.to_ascii_lowercase();
99 let Some(prefix) = STRIP_PREFIXES.iter().find(|p| lowered.starts_with(**p)) else {
100 return trimmed;
101 };
102 trimmed = trimmed
103 .get(prefix.len()..)
104 .unwrap_or("")
105 .trim_start_matches([',', ':', '-', ' '])
106 .trim_start();
107 }
108}
109
110fn strip_user_memory_note_marker(text: &str) -> Option<&str> {
111 let lowered = text.to_ascii_lowercase();
112 CLEANUP_NOTE_PREFIXES.iter().find_map(|prefix| {
113 lowered.starts_with(prefix).then(|| {
114 text.get(prefix.len()..)
115 .unwrap_or("")
116 .trim_start_matches([',', ':', '-', ' '])
117 .trim_start()
118 })
119 })
120}
121
122pub fn dedup_latest_facts(history: &[Message], limit: usize) -> Vec<GroundedFactRecord> {
123 let mut facts = Vec::new();
124 for message in history {
125 if let Some(fact) = maybe_extract_tool_fact(message).or_else(|| maybe_extract_user_fact(message)) {
126 let normalized = normalize_whitespace(&fact.fact).to_ascii_lowercase();
127 if let Some(existing_idx) = facts.iter().position(|entry: &GroundedFactRecord| {
128 normalize_whitespace(&entry.fact).to_ascii_lowercase() == normalized
129 }) {
130 facts.remove(existing_idx);
131 }
132 facts.push(fact);
133 }
134 }
135
136 let keep_from = facts.len().saturating_sub(limit);
137 facts.into_iter().skip(keep_from).collect()
138}