Skip to main content

vv_agent/memory/session/
mod.rs

1mod config;
2mod entry;
3mod parse;
4mod prompt;
5mod state;
6mod storage;
7
8use std::panic::{catch_unwind, AssertUnwindSafe};
9use std::path::PathBuf;
10
11use crate::memory::token_utils::estimate_tokens;
12use crate::types::Message;
13
14pub use config::{SessionMemoryConfig, SessionMemoryExtractionCallback};
15pub use entry::SessionMemoryEntry;
16use entry::{entry_key, SESSION_MEMORY_CATEGORIES};
17use prompt::{build_extraction_prompt, should_skip_message};
18pub use state::SessionMemoryState;
19
20#[derive(Debug, Clone)]
21pub struct SessionMemory {
22    pub config: SessionMemoryConfig,
23    pub state: SessionMemoryState,
24    pub(super) workspace: Option<PathBuf>,
25    pub(super) storage_scope: Option<String>,
26}
27
28impl SessionMemory {
29    pub fn new(config: SessionMemoryConfig) -> Self {
30        Self::with_workspace(config, None, None)
31    }
32
33    pub fn with_workspace(
34        config: SessionMemoryConfig,
35        workspace: Option<PathBuf>,
36        storage_scope: Option<String>,
37    ) -> Self {
38        Self {
39            config,
40            state: SessionMemoryState::default(),
41            workspace,
42            storage_scope: storage_scope.map(|scope| scope.trim().to_string()),
43        }
44    }
45
46    pub fn should_extract(&self, current_tokens: u64, message_count: usize) -> bool {
47        if self.config.extraction_callback.is_none() || current_tokens == 0 || message_count == 0 {
48            return false;
49        }
50
51        if !self.state.initialized {
52            return current_tokens >= self.config.min_tokens_before_extraction
53                && message_count >= self.config.min_text_messages;
54        }
55
56        let growth_threshold = ((self.config.min_tokens_before_extraction as f64)
57            * self.config.growth_ratio)
58            .floor()
59            .max(1.0) as u64;
60        let growth = if current_tokens >= self.state.tokens_at_last_extraction {
61            current_tokens - self.state.tokens_at_last_extraction
62        } else {
63            current_tokens
64        };
65        growth >= growth_threshold
66    }
67
68    pub fn extract(
69        &mut self,
70        messages: &[Message],
71        current_cycle: i32,
72        current_tokens: u64,
73    ) -> usize {
74        let Some(callback) = self.config.extraction_callback.as_ref().cloned() else {
75            return 0;
76        };
77        if messages.is_empty() {
78            return 0;
79        }
80
81        let start_index = if self.state.last_extracted_message_index >= 0
82            && (self.state.last_extracted_message_index as usize) < messages.len()
83        {
84            self.state.last_extracted_message_index as usize + 1
85        } else {
86            0
87        };
88        let new_messages = messages
89            .iter()
90            .enumerate()
91            .filter_map(|(index, message)| {
92                (index >= start_index && !should_skip_message(message)).then_some(message)
93            })
94            .collect::<Vec<_>>();
95
96        if new_messages.is_empty() {
97            self.record_extraction(messages.len() as i32 - 1, current_tokens);
98            return 0;
99        }
100
101        let prompt = build_extraction_prompt(&new_messages);
102        let raw_result = catch_unwind(AssertUnwindSafe(|| {
103            callback(
104                &prompt,
105                self.config.extraction_backend.as_deref(),
106                self.config.extraction_model.as_deref(),
107            )
108        }));
109        let Some(raw_result) = raw_result.ok().flatten() else {
110            return 0;
111        };
112
113        let entries = self.parse_extraction_result(&raw_result, current_cycle);
114        let merged_count = self.merge_entries(entries);
115        self.prune_to_budget();
116        self.record_extraction(messages.len() as i32 - 1, current_tokens);
117        self.save();
118        merged_count
119    }
120
121    pub fn render_as_system_context(&self) -> String {
122        if self.state.entries.is_empty() {
123            return String::new();
124        }
125
126        let mut parts = vec!["<Session Memory>".to_string()];
127        for category in SESSION_MEMORY_CATEGORIES {
128            let entries = self
129                .state
130                .entries
131                .iter()
132                .filter(|entry| entry.category == *category)
133                .collect::<Vec<_>>();
134            if entries.is_empty() {
135                continue;
136            }
137            parts.push(format!("## {category}"));
138            for entry in entries {
139                parts.push(format!("- {}", entry.content));
140            }
141        }
142        parts.push("</Session Memory>".to_string());
143        parts.join("\n")
144    }
145
146    pub fn on_compaction(&mut self, current_tokens: Option<u64>) {
147        self.state.last_extracted_message_index = -1;
148        if let Some(current_tokens) = current_tokens {
149            self.state.tokens_at_last_extraction = current_tokens;
150        }
151        self.save();
152    }
153
154    pub fn load(&mut self) {
155        let Some(path) = self.storage_path() else {
156            return;
157        };
158        let Ok(content) = std::fs::read_to_string(path) else {
159            return;
160        };
161        let Ok(state) = serde_json::from_str::<SessionMemoryState>(&content) else {
162            return;
163        };
164        self.state = state;
165    }
166
167    pub fn save(&self) {
168        let Some(path) = self.storage_path() else {
169            return;
170        };
171        let Some(parent) = path.parent() else {
172            return;
173        };
174        if std::fs::create_dir_all(parent).is_err() {
175            return;
176        }
177        let Ok(content) = serde_json::to_string_pretty(&self.state) else {
178            return;
179        };
180        let _ = std::fs::write(path, content);
181    }
182
183    pub fn merge_entries(&mut self, entries: Vec<SessionMemoryEntry>) -> usize {
184        let mut merged = 0;
185        for entry in entries {
186            let key = entry_key(&entry);
187            if let Some(existing) = self
188                .state
189                .entries
190                .iter_mut()
191                .find(|candidate| entry_key(candidate) == key)
192            {
193                existing.importance = existing.importance.max(entry.importance);
194                existing.source_cycle = existing.source_cycle.max(entry.source_cycle);
195                continue;
196            }
197            self.state.entries.push(entry);
198            merged += 1;
199        }
200        merged
201    }
202
203    pub fn prune_to_budget(&mut self) {
204        if self.config.max_tokens == 0 || self.state.entries.is_empty() {
205            return;
206        }
207        let mut current_tokens =
208            estimate_tokens(&self.render_as_system_context(), &self.config.token_model);
209        while current_tokens > self.config.max_tokens && !self.state.entries.is_empty() {
210            let Some((drop_index, _)) = self
211                .state
212                .entries
213                .iter()
214                .enumerate()
215                .min_by_key(|(index, entry)| (entry.importance, entry.source_cycle, *index as i32))
216            else {
217                break;
218            };
219            self.state.entries.remove(drop_index);
220            current_tokens =
221                estimate_tokens(&self.render_as_system_context(), &self.config.token_model);
222        }
223    }
224
225    fn record_extraction(&mut self, last_message_index: i32, current_tokens: u64) {
226        self.state.last_extracted_message_index = last_message_index;
227        self.state.tokens_at_last_extraction = current_tokens;
228        self.state.initialized = true;
229    }
230}