microclaw_engine/internal/storage/
memory.rs1use 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 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 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 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 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 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 assert!(matches!(mm.clear_chat_user_model("telegram", 5), Ok(false)));
237 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 assert!(mm.read_chat_user_model("telegram", 42).is_none());
258 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 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 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}