Skip to main content

vtcode_core/context/
conversation_memory.rs

1//! Conversation memory for vibe coding support
2//!
3//! Tracks entities mentioned across conversation turns to enable pronoun resolution
4//! and contextual understanding.
5
6use hashbrown::HashMap;
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use std::fmt::Write;
10use std::path::PathBuf;
11use vtcode_commons::utils::current_timestamp;
12
13/// Maximum number of conversation turns to remember
14const MAX_MEMORY_TURNS: usize = 50;
15
16/// Maximum entity mentions to track
17const MAX_ENTITY_MENTIONS: usize = 200;
18
19/// Type of entity mention
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum MentionType {
22    /// Direct mention (explicit naming)
23    Direct,
24    /// Pronoun reference (it, that, this)
25    Pronoun,
26    /// Implicit (inferred from context)
27    Implicit,
28}
29
30/// History of an entity's mentions
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct MentionHistory {
33    pub entity: String,
34    pub first_mention: u64,
35    pub last_mention: u64,
36    pub mention_count: usize,
37    pub context_snippets: Vec<String>,
38}
39
40impl MentionHistory {
41    /// Create new mention history
42    pub fn new(entity: String) -> Self {
43        let now = current_timestamp();
44        Self {
45            entity,
46            first_mention: now,
47            last_mention: now,
48            mention_count: 1,
49            context_snippets: Vec::new(),
50        }
51    }
52
53    /// Record a mention
54    pub fn record_mention(&mut self, _turn: usize) {
55        self.last_mention = current_timestamp();
56        self.mention_count += 1;
57    }
58}
59
60/// A single entity mention in the timeline
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EntityMention {
63    pub turn: usize,
64    pub entity: String,
65    pub mention_type: MentionType,
66    pub file_context: Option<PathBuf>,
67}
68
69/// A user message for context
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct UserMessage {
72    pub turn: usize,
73    pub content: String,
74    pub entities: Vec<String>,
75}
76
77/// Pronoun reference waiting to be resolved
78#[derive(Debug, Clone)]
79pub struct PronounReference {
80    pub pronoun: String,
81    pub turn: usize,
82    pub context: String,
83}
84
85/// Conversation memory system
86pub struct ConversationMemory {
87    /// Mentioned entities with history
88    mentioned_entities: HashMap<String, MentionHistory>,
89
90    /// Timeline of entity mentions
91    entity_timeline: VecDeque<EntityMention>,
92
93    /// Recent user messages
94    recent_user_messages: VecDeque<UserMessage>,
95
96    /// Recent file contexts (files mentioned or edited)
97    recent_file_contexts: VecDeque<PathBuf>,
98
99    /// Unresolved pronouns
100    #[expect(dead_code)]
101    unresolved_pronouns: Vec<PronounReference>,
102
103    /// Resolved reference mappings
104    resolved_references: HashMap<String, String>,
105
106    /// Current turn number
107    current_turn: usize,
108}
109
110impl Default for ConversationMemory {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl ConversationMemory {
117    /// Create a new conversation memory
118    pub fn new() -> Self {
119        Self {
120            mentioned_entities: HashMap::new(),
121            entity_timeline: VecDeque::with_capacity(MAX_ENTITY_MENTIONS),
122            recent_user_messages: VecDeque::with_capacity(MAX_MEMORY_TURNS),
123            recent_file_contexts: VecDeque::with_capacity(20),
124            unresolved_pronouns: Vec::new(),
125            resolved_references: HashMap::new(),
126            current_turn: 0,
127        }
128    }
129
130    /// Extract entities from a message
131    pub fn extract_entities(&mut self, message: &str, turn: usize) {
132        self.current_turn = turn;
133
134        let entities = self.extract_nouns_and_identifiers(message);
135        let mut extracted = Vec::new();
136
137        for entity in entities {
138            self.record_entity_mention(&entity, turn, MentionType::Direct);
139            extracted.push(entity);
140        }
141
142        // Store user message
143        self.recent_user_messages.push_back(UserMessage {
144            turn,
145            content: message.to_string(),
146            entities: extracted,
147        });
148
149        // Keep bounded
150        while self.recent_user_messages.len() > MAX_MEMORY_TURNS {
151            self.recent_user_messages.pop_front();
152        }
153    }
154
155    /// Extract nouns and identifiers from text
156    fn extract_nouns_and_identifiers(&self, text: &str) -> Vec<String> {
157        let mut entities = Vec::new();
158
159        const ENTITY_STOPWORDS: &[&str] = &[
160            "update",
161            "fix",
162            "test",
163            "look",
164            "add",
165            "remove",
166            "create",
167            "delete",
168            "refactor",
169            "implement",
170            "check",
171            "review",
172        ];
173
174        // Simple extraction: capitalize words, camelCase, PascalCase
175        for word in text.split_whitespace() {
176            let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '.');
177            let candidate = cleaned.split('.').next().unwrap_or(cleaned);
178            let candidate_lower = candidate.to_ascii_lowercase();
179
180            // Skip empty and short words
181            if candidate.len() < 3 {
182                continue;
183            }
184
185            // Skip common action words that are not entities.
186            if ENTITY_STOPWORDS.contains(&candidate_lower.as_str()) {
187                continue;
188            }
189
190            // Capitalized words (likely proper nouns)
191            if candidate
192                .chars()
193                .next()
194                .map(|c| c.is_uppercase())
195                .unwrap_or(false)
196            {
197                entities.push(candidate.to_string());
198                continue;
199            }
200
201            // camelCase or PascalCase (likely identifiers)
202            let has_mixed_case = candidate.chars().any(|c| c.is_uppercase())
203                && candidate.chars().any(|c| c.is_lowercase());
204
205            if has_mixed_case {
206                entities.push(candidate.to_string());
207            }
208        }
209
210        entities
211    }
212
213    /// Record an entity mention
214    fn record_entity_mention(&mut self, entity: &str, turn: usize, mention_type: MentionType) {
215        let entity_lower = entity.to_lowercase();
216
217        // Update or create history
218        self.mentioned_entities
219            .entry(entity_lower.clone())
220            .and_modify(|history| history.record_mention(turn))
221            .or_insert_with(|| MentionHistory::new(entity.to_string()));
222
223        // Add to timeline
224        self.entity_timeline.push_back(EntityMention {
225            turn,
226            entity: entity.to_string(),
227            mention_type,
228            file_context: None,
229        });
230
231        // Keep bounded
232        while self.entity_timeline.len() > MAX_ENTITY_MENTIONS {
233            self.entity_timeline.pop_front();
234        }
235    }
236
237    /// Get recent entities (N most recent)
238    pub fn get_recent_entities(&self, count: usize) -> Vec<String> {
239        self.entity_timeline
240            .iter()
241            .rev()
242            .take(count)
243            .map(|mention| mention.entity.clone())
244            .collect()
245    }
246
247    /// Resolve a pronoun to an entity
248    pub fn resolve_pronoun(&self, pronoun: &str, turn: usize) -> Option<String> {
249        let pronoun_lower = pronoun.to_lowercase();
250
251        match pronoun_lower.as_str() {
252            "it" => {
253                // Look back at last mentioned entity
254                self.entity_timeline
255                    .iter()
256                    .rev()
257                    .find(|m| m.turn < turn)
258                    .map(|m| m.entity.clone())
259            }
260            "that" | "this" => {
261                // Look at last direct mention
262                self.entity_timeline
263                    .iter()
264                    .rev()
265                    .filter(|m| m.turn < turn)
266                    .find(|m| matches!(m.mention_type, MentionType::Direct))
267                    .map(|m| m.entity.clone())
268            }
269            "those" | "these" => {
270                // Multiple entities - return most recent direct mention
271                self.entity_timeline
272                    .iter()
273                    .rev()
274                    .filter(|m| m.turn < turn && matches!(m.mention_type, MentionType::Direct))
275                    .take(2)
276                    .map(|m| m.entity.clone())
277                    .next()
278            }
279            _ => None,
280        }
281    }
282
283    /// Get all mentioned entities
284    pub fn mentioned_entities(&self) -> &HashMap<String, MentionHistory> {
285        &self.mentioned_entities
286    }
287
288    /// Get entity mention count
289    pub fn mention_count(&self, entity: &str) -> usize {
290        self.mentioned_entities
291            .get(&entity.to_lowercase())
292            .map(|h| h.mention_count)
293            .unwrap_or(0)
294    }
295
296    /// Add file context
297    pub fn add_file_context(&mut self, file: PathBuf) {
298        self.recent_file_contexts.push_back(file);
299
300        // Keep bounded
301        while self.recent_file_contexts.len() > 20 {
302            self.recent_file_contexts.pop_front();
303        }
304    }
305
306    /// Get recent file contexts
307    pub fn recent_file_contexts(&self, count: usize) -> Vec<&PathBuf> {
308        self.recent_file_contexts.iter().rev().take(count).collect()
309    }
310
311    /// Check if entity was recently mentioned
312    pub fn was_recently_mentioned(&self, entity: &str, within_turns: usize) -> bool {
313        let cutoff_turn = self.current_turn.saturating_sub(within_turns);
314
315        self.entity_timeline
316            .iter()
317            .rev()
318            .any(|m| m.entity.eq_ignore_ascii_case(entity) && m.turn >= cutoff_turn)
319    }
320
321    /// Get context summary for recent conversation
322    pub fn get_context_summary(&self, turns: usize) -> String {
323        let messages: Vec<_> = self.recent_user_messages.iter().rev().take(turns).collect();
324
325        if messages.is_empty() {
326            return String::from("No recent context available");
327        }
328
329        let mut summary = String::from("Recent conversation:\n");
330        for msg in messages.iter().rev() {
331            let _ = writeln!(summary, "Turn {}: {}", msg.turn, msg.content);
332        }
333
334        summary
335    }
336
337    /// Clear old data to free memory
338    pub fn clear_old_data(&mut self, keep_turns: usize) {
339        let cutoff_turn = self.current_turn.saturating_sub(keep_turns);
340
341        // Remove old entity timeline entries
342        self.entity_timeline.retain(|m| m.turn >= cutoff_turn);
343
344        // Remove old user messages
345        self.recent_user_messages.retain(|m| m.turn >= cutoff_turn);
346
347        // Clear resolved references from old turns
348        self.resolved_references.clear();
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_extract_entities() {
358        let mut memory = ConversationMemory::new();
359
360        memory.extract_entities("Update the Sidebar component in App.tsx", 1);
361
362        assert_eq!(memory.mentioned_entities.len(), 2); // Sidebar, App
363        assert!(memory.mentioned_entities.contains_key("sidebar"));
364        assert!(memory.mentioned_entities.contains_key("app"));
365    }
366
367    #[test]
368    fn test_pronoun_resolution_it() {
369        let mut memory = ConversationMemory::new();
370
371        // Turn 1: Mention Sidebar
372        memory.extract_entities("The Sidebar is too wide", 1);
373
374        // Turn 2: Reference with "it"
375        let resolved = memory.resolve_pronoun("it", 2);
376
377        assert!(resolved.is_some());
378        assert_eq!(resolved.unwrap(), "Sidebar");
379    }
380
381    #[test]
382    fn test_pronoun_resolution_that() {
383        let mut memory = ConversationMemory::new();
384
385        memory.extract_entities("Look at the Button component", 1);
386
387        let resolved = memory.resolve_pronoun("that", 2);
388
389        assert!(resolved.is_some());
390        assert_eq!(resolved.unwrap(), "Button");
391    }
392
393    #[test]
394    fn test_recent_entities() {
395        let mut memory = ConversationMemory::new();
396
397        memory.extract_entities("Update Sidebar", 1);
398        memory.extract_entities("Fix Button", 2);
399        memory.extract_entities("Test Form", 3);
400
401        let recent = memory.get_recent_entities(2);
402
403        assert_eq!(recent.len(), 2);
404        assert_eq!(recent[0], "Form");
405        assert_eq!(recent[1], "Button");
406    }
407
408    #[test]
409    fn test_mention_count() {
410        let mut memory = ConversationMemory::new();
411
412        memory.extract_entities("Update Sidebar", 1);
413        memory.extract_entities("The Sidebar is nice", 2);
414        memory.extract_entities("Sidebar needs work", 3);
415
416        assert_eq!(memory.mention_count("sidebar"), 3);
417        assert_eq!(memory.mention_count("Button"), 0);
418    }
419
420    #[test]
421    fn test_context_summary() {
422        let mut memory = ConversationMemory::new();
423
424        memory.extract_entities("First message", 1);
425        memory.extract_entities("Second message", 2);
426
427        let summary = memory.get_context_summary(2);
428
429        assert!(summary.contains("First message"));
430        assert!(summary.contains("Second message"));
431    }
432}