Skip to main content

vtcode_core/persistent_memory/
fact_extraction.rs

1use super::*;
2
3/// Collapse whitespace runs into single spaces, trimming the ends.
4///
5/// Delegates to the canonical [`vtcode_commons::formatting::collapse_whitespace`];
6/// kept as a named function because persistent-memory callers rely on the
7/// `normalize_whitespace` spelling and the `persistent_memory` re-export.
8#[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}