rustyclaw_core/
memory_consolidation.rs1use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::path::Path;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ConsolidationResult {
19 pub performed: bool,
21 pub messages_consolidated: usize,
23 pub memory_size: usize,
25 pub history_size: usize,
27 pub error: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ConsolidationConfig {
34 #[serde(default = "default_true")]
36 pub enabled: bool,
37
38 #[serde(default = "default_message_threshold")]
40 pub message_threshold: usize,
41
42 #[serde(default = "default_memory_max_size")]
44 pub memory_max_size: usize,
45
46 #[serde(default = "default_memory_path")]
48 pub memory_path: String,
49
50 #[serde(default = "default_history_path")]
52 pub history_path: String,
53}
54
55fn default_true() -> bool {
56 true
57}
58
59fn default_message_threshold() -> usize {
60 20
61}
62
63fn default_memory_max_size() -> usize {
64 50 * 1024 }
66
67fn default_memory_path() -> String {
68 "MEMORY.md".to_string()
69}
70
71fn default_history_path() -> String {
72 "HISTORY.md".to_string()
73}
74
75impl Default for ConsolidationConfig {
76 fn default() -> Self {
77 Self {
78 enabled: true,
79 message_threshold: default_message_threshold(),
80 memory_max_size: default_memory_max_size(),
81 memory_path: default_memory_path(),
82 history_path: default_history_path(),
83 }
84 }
85}
86
87pub struct MemoryConsolidation {
93 config: ConsolidationConfig,
94 messages_since_consolidation: usize,
96}
97
98impl MemoryConsolidation {
99 pub fn new(config: ConsolidationConfig) -> Self {
101 Self {
102 config,
103 messages_since_consolidation: 0,
104 }
105 }
106
107 pub fn should_consolidate(&self) -> bool {
109 self.config.enabled && self.messages_since_consolidation >= self.config.message_threshold
110 }
111
112 pub fn record_message(&mut self) {
114 self.messages_since_consolidation += 1;
115 }
116
117 pub fn reset_counter(&mut self) {
119 self.messages_since_consolidation = 0;
120 }
121
122 pub fn message_count(&self) -> usize {
124 self.messages_since_consolidation
125 }
126
127 pub fn append_history(&self, workspace: &Path, entry: &str) -> Result<usize, String> {
131 let history_path = workspace.join(&self.config.history_path);
132
133 if let Some(parent) = history_path.parent() {
135 fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
136 }
137
138 let timestamp = Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
139 let formatted = format!("\n[{}] {}\n", timestamp, entry.trim());
140
141 let mut file = OpenOptions::new()
142 .create(true)
143 .append(true)
144 .open(&history_path)
145 .map_err(|e| format!("Failed to open HISTORY.md: {}", e))?;
146
147 file.write_all(formatted.as_bytes())
148 .map_err(|e| format!("Failed to write to HISTORY.md: {}", e))?;
149
150 let metadata = fs::metadata(&history_path)
151 .map_err(|e| format!("Failed to read HISTORY.md metadata: {}", e))?;
152
153 Ok(metadata.len() as usize)
154 }
155
156 pub fn update_memory(&self, workspace: &Path, content: &str) -> Result<usize, String> {
160 let memory_path = workspace.join(&self.config.memory_path);
161
162 if let Some(parent) = memory_path.parent() {
164 fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
165 }
166
167 fs::write(&memory_path, content)
168 .map_err(|e| format!("Failed to write MEMORY.md: {}", e))?;
169
170 let size = content.len();
171
172 if size > self.config.memory_max_size {
173 eprintln!(
174 "Warning: MEMORY.md is {} bytes, exceeds recommended max of {} bytes",
175 size, self.config.memory_max_size
176 );
177 }
178
179 Ok(size)
180 }
181
182 pub fn read_memory(&self, workspace: &Path) -> Result<String, String> {
184 let memory_path = workspace.join(&self.config.memory_path);
185
186 if !memory_path.exists() {
187 return Ok(String::new());
188 }
189
190 fs::read_to_string(&memory_path).map_err(|e| format!("Failed to read MEMORY.md: {}", e))
191 }
192
193 pub fn read_history(&self, workspace: &Path) -> Result<String, String> {
195 let history_path = workspace.join(&self.config.history_path);
196
197 if !history_path.exists() {
198 return Ok(String::new());
199 }
200
201 fs::read_to_string(&history_path).map_err(|e| format!("Failed to read HISTORY.md: {}", e))
202 }
203
204 pub fn search_history(
206 &self,
207 workspace: &Path,
208 pattern: &str,
209 max_results: usize,
210 ) -> Result<Vec<HistoryEntry>, String> {
211 let history = self.read_history(workspace)?;
212 let pattern_lower = pattern.to_lowercase();
213
214 let mut results = Vec::new();
215 let mut current_entry: Option<HistoryEntry> = None;
216
217 for line in history.lines() {
218 if line.starts_with('[') && line.contains(']') {
220 if let Some(entry) = current_entry.take() {
222 if entry.text.to_lowercase().contains(&pattern_lower) {
223 results.push(entry);
224 if results.len() >= max_results {
225 break;
226 }
227 }
228 }
229
230 if let Some(end_bracket) = line.find(']') {
232 let timestamp_str = &line[1..end_bracket];
233 let text = line[end_bracket + 1..].trim().to_string();
234
235 current_entry = Some(HistoryEntry {
236 timestamp: timestamp_str.to_string(),
237 text,
238 });
239 }
240 } else if let Some(ref mut entry) = current_entry {
241 entry.text.push('\n');
243 entry.text.push_str(line);
244 }
245 }
246
247 if let Some(entry) = current_entry {
249 if entry.text.to_lowercase().contains(&pattern_lower) && results.len() < max_results {
250 results.push(entry);
251 }
252 }
253
254 Ok(results)
255 }
256
257 pub fn config(&self) -> &ConsolidationConfig {
259 &self.config
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct HistoryEntry {
266 pub timestamp: String,
268 pub text: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct SaveMemoryArgs {
279 pub history_entry: String,
281
282 #[serde(default)]
286 pub memory_update: Option<String>,
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use tempfile::tempdir;
293
294 #[test]
295 fn test_append_history() {
296 let dir = tempdir().unwrap();
297 let config = ConsolidationConfig::default();
298 let consolidation = MemoryConsolidation::new(config);
299
300 let size = consolidation
301 .append_history(dir.path(), "Test entry 1")
302 .unwrap();
303 assert!(size > 0);
304
305 let size2 = consolidation
306 .append_history(dir.path(), "Test entry 2")
307 .unwrap();
308 assert!(size2 > size);
309
310 let history = consolidation.read_history(dir.path()).unwrap();
311 assert!(history.contains("Test entry 1"));
312 assert!(history.contains("Test entry 2"));
313 }
314
315 #[test]
316 fn test_update_memory() {
317 let dir = tempdir().unwrap();
318 let config = ConsolidationConfig::default();
319 let consolidation = MemoryConsolidation::new(config);
320
321 let content = "# Memory\n\nSome important facts.";
322 let size = consolidation.update_memory(dir.path(), content).unwrap();
323 assert_eq!(size, content.len());
324
325 let read_back = consolidation.read_memory(dir.path()).unwrap();
326 assert_eq!(read_back, content);
327 }
328
329 #[test]
330 fn test_search_history() {
331 let dir = tempdir().unwrap();
332 let config = ConsolidationConfig::default();
333 let consolidation = MemoryConsolidation::new(config);
334
335 consolidation
336 .append_history(dir.path(), "Meeting with Alice about project")
337 .unwrap();
338 consolidation
339 .append_history(dir.path(), "Fixed bug in parser")
340 .unwrap();
341 consolidation
342 .append_history(dir.path(), "Called Alice, discussed timeline")
343 .unwrap();
344
345 let results = consolidation
346 .search_history(dir.path(), "Alice", 10)
347 .unwrap();
348 assert_eq!(results.len(), 2);
349 }
350
351 #[test]
352 fn test_consolidation_threshold() {
353 let config = ConsolidationConfig {
354 message_threshold: 5,
355 ..Default::default()
356 };
357 let mut consolidation = MemoryConsolidation::new(config);
358
359 for _ in 0..4 {
360 consolidation.record_message();
361 assert!(!consolidation.should_consolidate());
362 }
363
364 consolidation.record_message();
365 assert!(consolidation.should_consolidate());
366
367 consolidation.reset_counter();
368 assert!(!consolidation.should_consolidate());
369 }
370}