Skip to main content

microclaw_engine/internal/storage/
memory.rs

1use std::path::{Path, PathBuf};
2
3use microclaw_core::redact;
4
5pub struct MemoryManager {
6    data_dir: PathBuf,
7}
8
9impl MemoryManager {
10    pub fn new(data_dir: &str) -> Self {
11        MemoryManager {
12            data_dir: PathBuf::from(data_dir).join("groups"),
13        }
14    }
15
16    fn global_memory_path(&self) -> PathBuf {
17        self.data_dir.join("AGENTS.md")
18    }
19
20    fn chat_memory_path(&self, channel: &str, chat_id: i64) -> PathBuf {
21        self.data_dir
22            .join(channel.trim())
23            .join(chat_id.to_string())
24            .join("AGENTS.md")
25    }
26
27    fn bot_memory_path(&self, channel: &str) -> PathBuf {
28        self.data_dir.join(channel.trim()).join("AGENTS.md")
29    }
30
31    /// Per-chat user-model file. Mirrors Hermes' USER.md split from
32    /// MEMORY.md: a single curated narrative about who the user is, kept
33    /// separate from atomic memories so it can be loaded as one coherent
34    /// block at the top of the system prompt.
35    fn chat_user_model_path(&self, channel: &str, chat_id: i64) -> PathBuf {
36        self.data_dir
37            .join(channel.trim())
38            .join(chat_id.to_string())
39            .join("USER.md")
40    }
41
42    pub fn read_global_memory(&self) -> Option<String> {
43        let path = self.global_memory_path();
44        std::fs::read_to_string(path).ok()
45    }
46
47    pub fn read_chat_user_model(&self, channel: &str, chat_id: i64) -> Option<String> {
48        let path = self.chat_user_model_path(channel, chat_id);
49        std::fs::read_to_string(path).ok()
50    }
51
52    /// Remove the per-chat USER.md file. Returns Ok(true) when a file was
53    /// removed, Ok(false) when there was nothing to remove. Used by the
54    /// `/user clear` slash command so the curator can rebuild from scratch.
55    pub fn clear_chat_user_model(&self, channel: &str, chat_id: i64) -> std::io::Result<bool> {
56        let path = self.chat_user_model_path(channel, chat_id);
57        match std::fs::remove_file(&path) {
58            Ok(()) => Ok(true),
59            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
60            Err(e) => Err(e),
61        }
62    }
63
64    pub fn write_chat_user_model(
65        &self,
66        channel: &str,
67        chat_id: i64,
68        content: &str,
69    ) -> std::io::Result<()> {
70        let path = self.chat_user_model_path(channel, chat_id);
71        if let Some(parent) = path.parent() {
72            std::fs::create_dir_all(parent)?;
73        }
74        // Redact PII / credentials before USER.md hits disk. The reflector
75        // LLM was asked to never invent facts, but raw conversation excerpts
76        // include things like API keys and emails the model might have
77        // verbatim-quoted into the narrative.
78        std::fs::write(path, redact::redact(content))
79    }
80
81    pub fn read_chat_memory(&self, channel: &str, chat_id: i64) -> Option<String> {
82        let path = self.chat_memory_path(channel, chat_id);
83        std::fs::read_to_string(path).ok()
84    }
85
86    pub fn read_bot_memory(&self, channel: &str) -> Option<String> {
87        let path = self.bot_memory_path(channel);
88        std::fs::read_to_string(path).ok()
89    }
90
91    #[allow(dead_code)]
92    pub fn write_global_memory(&self, content: &str) -> std::io::Result<()> {
93        let path = self.global_memory_path();
94        if let Some(parent) = path.parent() {
95            std::fs::create_dir_all(parent)?;
96        }
97        std::fs::write(path, redact::redact(content))
98    }
99
100    #[allow(dead_code)]
101    pub fn write_chat_memory(
102        &self,
103        channel: &str,
104        chat_id: i64,
105        content: &str,
106    ) -> std::io::Result<()> {
107        let path = self.chat_memory_path(channel, chat_id);
108        if let Some(parent) = path.parent() {
109            std::fs::create_dir_all(parent)?;
110        }
111        std::fs::write(path, redact::redact(content))
112    }
113
114    #[allow(dead_code)]
115    pub fn write_bot_memory(&self, channel: &str, content: &str) -> std::io::Result<()> {
116        let path = self.bot_memory_path(channel);
117        if let Some(parent) = path.parent() {
118            std::fs::create_dir_all(parent)?;
119        }
120        std::fs::write(path, redact::redact(content))
121    }
122
123    pub fn build_memory_context(&self, channel: &str, chat_id: i64) -> String {
124        let mut context = String::new();
125
126        if let Some(global) = self.read_global_memory() {
127            if !global.trim().is_empty() {
128                context.push_str("<global_memory>\n");
129                context.push_str(&global);
130                context.push_str("\n</global_memory>\n\n");
131            }
132        }
133
134        if let Some(bot) = self.read_bot_memory(channel) {
135            if !bot.trim().is_empty() {
136                context.push_str("<bot_memory>\n");
137                context.push_str(&bot);
138                context.push_str("\n</bot_memory>\n\n");
139            }
140        }
141
142        if let Some(chat) = self.read_chat_memory(channel, chat_id) {
143            if !chat.trim().is_empty() {
144                context.push_str("<chat_memory>\n");
145                context.push_str(&chat);
146                context.push_str("\n</chat_memory>\n\n");
147            }
148        }
149
150        context
151    }
152
153    #[allow(dead_code)]
154    pub fn groups_dir(&self) -> &Path {
155        &self.data_dir
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn test_memory_manager() -> (MemoryManager, std::path::PathBuf) {
164        let dir = std::env::temp_dir().join(format!("microclaw_mem_test_{}", uuid::Uuid::new_v4()));
165        let mm = MemoryManager::new(dir.to_str().unwrap());
166        (mm, dir)
167    }
168
169    fn cleanup(dir: &std::path::Path) {
170        let _ = std::fs::remove_dir_all(dir);
171    }
172
173    #[test]
174    fn test_global_memory_path() {
175        let (mm, dir) = test_memory_manager();
176        let path = mm.global_memory_path();
177        assert!(path.ends_with("groups/AGENTS.md"));
178        cleanup(&dir);
179    }
180
181    #[test]
182    fn test_chat_memory_path() {
183        let (mm, dir) = test_memory_manager();
184        let path = mm.chat_memory_path("telegram", 12345);
185        assert!(path.ends_with(
186            std::path::Path::new("groups")
187                .join("telegram")
188                .join("12345")
189                .join("AGENTS.md")
190        ));
191        cleanup(&dir);
192    }
193
194    #[test]
195    fn test_bot_memory_path() {
196        let (mm, dir) = test_memory_manager();
197        let path = mm.bot_memory_path("feishu.ops");
198        assert!(path.ends_with(
199            std::path::Path::new("groups")
200                .join("feishu.ops")
201                .join("AGENTS.md")
202        ));
203        cleanup(&dir);
204    }
205
206    #[test]
207    fn test_writes_redact_credentials_before_persist() {
208        let (mm, dir) = test_memory_manager();
209        // USER.md path: secrets get masked before hitting disk.
210        mm.write_chat_user_model(
211            "telegram",
212            7,
213            "User shared sk-proj-ABCDEFGHIJKLMNOP1234567890 and email a@b.com",
214        )
215        .expect("write");
216        let stored = mm.read_chat_user_model("telegram", 7).expect("read");
217        assert!(!stored.contains("sk-proj-ABCDEFGHIJKLMNOP"));
218        assert!(stored.contains("sk-<redacted>"));
219        assert!(!stored.contains("a@b.com"));
220        assert!(stored.contains("<redacted>@b.com"));
221
222        // Same guarantee for chat AGENTS.md.
223        mm.write_chat_memory("telegram", 7, "ghp_abcdefghij1234567890ABCDE leaked")
224            .expect("write");
225        let mem = mm.read_chat_memory("telegram", 7).expect("read");
226        assert!(!mem.contains("ghp_abcdefghij"));
227        assert!(mem.contains("gh<redacted>"));
228
229        cleanup(&dir);
230    }
231
232    #[test]
233    fn test_clear_chat_user_model_returns_true_when_present_false_when_absent() {
234        let (mm, dir) = test_memory_manager();
235        // Absent → false, no error.
236        assert!(matches!(mm.clear_chat_user_model("telegram", 5), Ok(false)));
237        // Write then clear → true, file is gone.
238        mm.write_chat_user_model("telegram", 5, "narrative")
239            .unwrap();
240        assert!(mm.read_chat_user_model("telegram", 5).is_some());
241        assert!(matches!(mm.clear_chat_user_model("telegram", 5), Ok(true)));
242        assert!(mm.read_chat_user_model("telegram", 5).is_none());
243        cleanup(&dir);
244    }
245
246    #[test]
247    fn test_chat_user_model_path_and_round_trip() {
248        let (mm, dir) = test_memory_manager();
249        let path = mm.chat_user_model_path("telegram", 42);
250        assert!(path.ends_with(
251            std::path::Path::new("groups")
252                .join("telegram")
253                .join("42")
254                .join("USER.md")
255        ));
256        // Empty round-trip: read returns None when file does not exist.
257        assert!(mm.read_chat_user_model("telegram", 42).is_none());
258        // Write then read back.
259        mm.write_chat_user_model("telegram", 42, "Senior Rust engineer.")
260            .expect("write");
261        let got = mm
262            .read_chat_user_model("telegram", 42)
263            .expect("read after write");
264        assert_eq!(got, "Senior Rust engineer.");
265        cleanup(&dir);
266    }
267
268    #[test]
269    fn test_read_nonexistent_memory() {
270        let (mm, dir) = test_memory_manager();
271        assert!(mm.read_global_memory().is_none());
272        assert!(mm.read_chat_memory("telegram", 100).is_none());
273        cleanup(&dir);
274    }
275
276    #[test]
277    fn test_write_and_read_global_memory() {
278        let (mm, dir) = test_memory_manager();
279        mm.write_global_memory("global notes").unwrap();
280        let content = mm.read_global_memory().unwrap();
281        assert_eq!(content, "global notes");
282        cleanup(&dir);
283    }
284
285    #[test]
286    fn test_write_and_read_chat_memory() {
287        let (mm, dir) = test_memory_manager();
288        mm.write_chat_memory("telegram", 42, "chat 42 notes")
289            .unwrap();
290        let content = mm.read_chat_memory("telegram", 42).unwrap();
291        assert_eq!(content, "chat 42 notes");
292
293        // Different chat should be empty
294        assert!(mm.read_chat_memory("telegram", 99).is_none());
295        cleanup(&dir);
296    }
297
298    #[test]
299    fn test_write_and_read_bot_memory() {
300        let (mm, dir) = test_memory_manager();
301        mm.write_bot_memory("feishu", "bot notes").unwrap();
302        let content = mm.read_bot_memory("feishu").unwrap();
303        assert_eq!(content, "bot notes");
304        cleanup(&dir);
305    }
306
307    #[test]
308    fn test_build_memory_context_empty() {
309        let (mm, dir) = test_memory_manager();
310        let ctx = mm.build_memory_context("telegram", 100);
311        assert!(ctx.is_empty());
312        cleanup(&dir);
313    }
314
315    #[test]
316    fn test_build_memory_context_with_global_only() {
317        let (mm, dir) = test_memory_manager();
318        mm.write_global_memory("I am global memory").unwrap();
319        let ctx = mm.build_memory_context("telegram", 100);
320        assert!(ctx.contains("<global_memory>"));
321        assert!(ctx.contains("I am global memory"));
322        assert!(ctx.contains("</global_memory>"));
323        assert!(!ctx.contains("<chat_memory>"));
324        cleanup(&dir);
325    }
326
327    #[test]
328    fn test_build_memory_context_with_both() {
329        let (mm, dir) = test_memory_manager();
330        mm.write_global_memory("global stuff").unwrap();
331        mm.write_bot_memory("telegram", "bot stuff").unwrap();
332        mm.write_chat_memory("telegram", 100, "chat stuff").unwrap();
333        let ctx = mm.build_memory_context("telegram", 100);
334        assert!(ctx.contains("<global_memory>"));
335        assert!(ctx.contains("global stuff"));
336        assert!(ctx.contains("<bot_memory>"));
337        assert!(ctx.contains("bot stuff"));
338        assert!(ctx.contains("<chat_memory>"));
339        assert!(ctx.contains("chat stuff"));
340        cleanup(&dir);
341    }
342
343    #[test]
344    fn test_build_memory_context_ignores_whitespace_only() {
345        let (mm, dir) = test_memory_manager();
346        mm.write_global_memory("   \n  ").unwrap();
347        let ctx = mm.build_memory_context("telegram", 100);
348        // Whitespace-only content should be ignored
349        assert!(ctx.is_empty());
350        cleanup(&dir);
351    }
352
353    #[test]
354    fn test_groups_dir() {
355        let (mm, dir) = test_memory_manager();
356        assert!(mm.groups_dir().ends_with("groups"));
357        cleanup(&dir);
358    }
359}