1use 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
13const MAX_MEMORY_TURNS: usize = 50;
15
16const MAX_ENTITY_MENTIONS: usize = 200;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum MentionType {
22 Direct,
24 Pronoun,
26 Implicit,
28}
29
30#[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 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 pub fn record_mention(&mut self, _turn: usize) {
55 self.last_mention = current_timestamp();
56 self.mention_count += 1;
57 }
58}
59
60#[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#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct UserMessage {
72 pub turn: usize,
73 pub content: String,
74 pub entities: Vec<String>,
75}
76
77#[derive(Debug, Clone)]
79pub struct PronounReference {
80 pub pronoun: String,
81 pub turn: usize,
82 pub context: String,
83}
84
85pub struct ConversationMemory {
87 mentioned_entities: HashMap<String, MentionHistory>,
89
90 entity_timeline: VecDeque<EntityMention>,
92
93 recent_user_messages: VecDeque<UserMessage>,
95
96 recent_file_contexts: VecDeque<PathBuf>,
98
99 #[expect(dead_code)]
101 unresolved_pronouns: Vec<PronounReference>,
102
103 resolved_references: HashMap<String, String>,
105
106 current_turn: usize,
108}
109
110impl Default for ConversationMemory {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116impl ConversationMemory {
117 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 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 self.recent_user_messages.push_back(UserMessage {
144 turn,
145 content: message.to_string(),
146 entities: extracted,
147 });
148
149 while self.recent_user_messages.len() > MAX_MEMORY_TURNS {
151 self.recent_user_messages.pop_front();
152 }
153 }
154
155 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 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 if candidate.len() < 3 {
182 continue;
183 }
184
185 if ENTITY_STOPWORDS.contains(&candidate_lower.as_str()) {
187 continue;
188 }
189
190 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 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 fn record_entity_mention(&mut self, entity: &str, turn: usize, mention_type: MentionType) {
215 let entity_lower = entity.to_lowercase();
216
217 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 self.entity_timeline.push_back(EntityMention {
225 turn,
226 entity: entity.to_string(),
227 mention_type,
228 file_context: None,
229 });
230
231 while self.entity_timeline.len() > MAX_ENTITY_MENTIONS {
233 self.entity_timeline.pop_front();
234 }
235 }
236
237 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 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 self.entity_timeline
255 .iter()
256 .rev()
257 .find(|m| m.turn < turn)
258 .map(|m| m.entity.clone())
259 }
260 "that" | "this" => {
261 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 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 pub fn mentioned_entities(&self) -> &HashMap<String, MentionHistory> {
285 &self.mentioned_entities
286 }
287
288 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 pub fn add_file_context(&mut self, file: PathBuf) {
298 self.recent_file_contexts.push_back(file);
299
300 while self.recent_file_contexts.len() > 20 {
302 self.recent_file_contexts.pop_front();
303 }
304 }
305
306 pub fn recent_file_contexts(&self, count: usize) -> Vec<&PathBuf> {
308 self.recent_file_contexts.iter().rev().take(count).collect()
309 }
310
311 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 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 pub fn clear_old_data(&mut self, keep_turns: usize) {
339 let cutoff_turn = self.current_turn.saturating_sub(keep_turns);
340
341 self.entity_timeline.retain(|m| m.turn >= cutoff_turn);
343
344 self.recent_user_messages.retain(|m| m.turn >= cutoff_turn);
346
347 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); 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 memory.extract_entities("The Sidebar is too wide", 1);
373
374 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}