vv_agent/memory/session/
mod.rs1mod 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}