Skip to main content

rustyclaw_core/
memory_consolidation.rs

1//! Two-layer memory consolidation, inspired by nanobot.
2//!
3//! This module implements LLM-driven memory consolidation with two layers:
4//! - **MEMORY.md**: Long-term facts, curated by the LLM
5//! - **HISTORY.md**: Grep-searchable timestamped log
6//!
7//! The LLM calls `save_memory` to consolidate conversation history, deciding
8//! what facts to keep in MEMORY.md and what to log in HISTORY.md.
9
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::path::Path;
15
16/// Result of a memory consolidation operation.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ConsolidationResult {
19    /// Whether consolidation was performed.
20    pub performed: bool,
21    /// Number of messages consolidated.
22    pub messages_consolidated: usize,
23    /// New MEMORY.md size in bytes.
24    pub memory_size: usize,
25    /// New HISTORY.md size in bytes.
26    pub history_size: usize,
27    /// Error message if consolidation failed.
28    pub error: Option<String>,
29}
30
31/// Configuration for memory consolidation.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ConsolidationConfig {
34    /// Enable automatic consolidation.
35    #[serde(default = "default_true")]
36    pub enabled: bool,
37
38    /// Trigger consolidation after this many messages.
39    #[serde(default = "default_message_threshold")]
40    pub message_threshold: usize,
41
42    /// Maximum size for MEMORY.md in bytes before warning.
43    #[serde(default = "default_memory_max_size")]
44    pub memory_max_size: usize,
45
46    /// Path to MEMORY.md (relative to workspace).
47    #[serde(default = "default_memory_path")]
48    pub memory_path: String,
49
50    /// Path to HISTORY.md (relative to workspace).
51    #[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 // 50KB
65}
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
87/// Memory consolidation controller.
88///
89/// Manages the two-layer memory system:
90/// - MEMORY.md: Long-term facts (LLM-maintained)
91/// - HISTORY.md: Timestamped log (append-only)
92pub struct MemoryConsolidation {
93    config: ConsolidationConfig,
94    /// Messages since last consolidation.
95    messages_since_consolidation: usize,
96}
97
98impl MemoryConsolidation {
99    /// Create a new consolidation controller.
100    pub fn new(config: ConsolidationConfig) -> Self {
101        Self {
102            config,
103            messages_since_consolidation: 0,
104        }
105    }
106
107    /// Check if consolidation should be triggered.
108    pub fn should_consolidate(&self) -> bool {
109        self.config.enabled && self.messages_since_consolidation >= self.config.message_threshold
110    }
111
112    /// Increment the message counter.
113    pub fn record_message(&mut self) {
114        self.messages_since_consolidation += 1;
115    }
116
117    /// Reset the message counter after consolidation.
118    pub fn reset_counter(&mut self) {
119        self.messages_since_consolidation = 0;
120    }
121
122    /// Get the current message count.
123    pub fn message_count(&self) -> usize {
124        self.messages_since_consolidation
125    }
126
127    /// Save a history entry (append to HISTORY.md).
128    ///
129    /// This is called by the `save_memory` tool to log timestamped entries.
130    pub fn append_history(&self, workspace: &Path, entry: &str) -> Result<usize, String> {
131        let history_path = workspace.join(&self.config.history_path);
132
133        // Create parent directories if needed
134        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    /// Update MEMORY.md (full replacement).
157    ///
158    /// This is called by the `save_memory` tool with the LLM's curated content.
159    pub fn update_memory(&self, workspace: &Path, content: &str) -> Result<usize, String> {
160        let memory_path = workspace.join(&self.config.memory_path);
161
162        // Create parent directories if needed
163        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    /// Read current MEMORY.md content.
183    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    /// Read current HISTORY.md content.
194    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    /// Search HISTORY.md using grep-style pattern matching.
205    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            // Check if this is a new entry (starts with timestamp)
219            if line.starts_with('[') && line.contains(']') {
220                // Save previous entry if it matched
221                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                // Parse new entry
231                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                // Continuation of current entry
242                entry.text.push('\n');
243                entry.text.push_str(line);
244            }
245        }
246
247        // Don't forget the last entry
248        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    /// Get configuration reference.
258    pub fn config(&self) -> &ConsolidationConfig {
259        &self.config
260    }
261}
262
263/// A single entry from HISTORY.md.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct HistoryEntry {
266    /// Timestamp string from the entry.
267    pub timestamp: String,
268    /// Entry text content.
269    pub text: String,
270}
271
272/// Arguments for the save_memory tool.
273///
274/// The LLM provides both pieces in a single call:
275/// - `history_entry`: A summary to append to HISTORY.md
276/// - `memory_update`: The full new content for MEMORY.md (or None to skip)
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct SaveMemoryArgs {
279    /// Timestamped summary to append to HISTORY.md.
280    pub history_entry: String,
281
282    /// Full updated MEMORY.md content (optional).
283    /// If provided, replaces the entire file.
284    /// If None, MEMORY.md is not modified.
285    #[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}