Skip to main content

microclaw_engine/tools/
memory.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use std::sync::Arc;
5use tracing::info;
6
7use crate::internal::storage::db::Database;
8use crate::internal::storage::memory_quality;
9use crate::memory_backend::MemoryBackend;
10use microclaw_core::llm_types::ToolDefinition;
11
12use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
13
14pub struct ReadMemoryTool {
15    groups_dir: PathBuf,
16    db: Arc<Database>,
17}
18
19impl ReadMemoryTool {
20    pub fn new(data_dir: &str, db: Arc<Database>) -> Self {
21        ReadMemoryTool {
22            groups_dir: PathBuf::from(data_dir).join("groups"),
23            db,
24        }
25    }
26}
27
28fn memory_channel_from_auth(input: &serde_json::Value) -> String {
29    auth_context_from_input(input)
30        .map(|a| a.caller_channel)
31        .filter(|s| !s.trim().is_empty())
32        .unwrap_or_else(|| "web".to_string())
33}
34
35fn chat_id_from_input_or_auth(input: &serde_json::Value) -> Option<i64> {
36    input
37        .get("chat_id")
38        .and_then(|v| v.as_i64())
39        .or_else(|| auth_context_from_input(input).map(|a| a.caller_chat_id))
40}
41
42fn memory_channel_for_chat(
43    db: &Database,
44    input: &serde_json::Value,
45    chat_id: i64,
46) -> Result<String, String> {
47    if let Some(channel) = db
48        .get_chat_channel(chat_id)
49        .map_err(|e| format!("Failed to resolve chat channel for {chat_id}: {e}"))?
50        .map(|s| s.trim().to_string())
51        .filter(|s| !s.is_empty())
52    {
53        Ok(channel)
54    } else {
55        Ok(memory_channel_from_auth(input))
56    }
57}
58
59fn latest_sender_for_chat(db: &Database, chat_id: i64) -> Option<String> {
60    db.get_recent_messages(chat_id, 20)
61        .ok()?
62        .into_iter()
63        .rev()
64        .find(|m| !m.is_from_bot && !m.content.trim_start().starts_with('/'))
65        .map(|m| m.sender_name.trim().to_string())
66        .filter(|s| !s.is_empty())
67}
68
69fn find_person_section_range(lines: &[&str], header: &str) -> Option<(usize, usize)> {
70    let start = lines.iter().position(|line| line.trim() == header)?;
71    let end = lines
72        .iter()
73        .enumerate()
74        .skip(start + 1)
75        .find(|(_, line)| line.trim_start().starts_with("## Person:"))
76        .map(|(idx, _)| idx)
77        .unwrap_or(lines.len());
78    Some((start, end))
79}
80
81fn upsert_chat_person_memory(existing: &str, sender: &str, content: &str) -> String {
82    let sender = sender.trim();
83    let content = content.trim();
84    if sender.is_empty() || content.is_empty() {
85        return content.to_string();
86    }
87
88    let section_header = format!("## Person: {sender}");
89    let section_block = format!("{section_header}\n{content}\n");
90    let existing_trimmed = existing.trim();
91    if existing_trimmed.is_empty() {
92        return section_block;
93    }
94
95    let lines: Vec<&str> = existing_trimmed.lines().collect();
96    if let Some((start, end)) = find_person_section_range(&lines, &section_header) {
97        let mut out = String::new();
98        for line in &lines[..start] {
99            out.push_str(line);
100            out.push('\n');
101        }
102        out.push_str(&section_block);
103        for line in &lines[end..] {
104            out.push_str(line);
105            out.push('\n');
106        }
107        return out;
108    }
109
110    let mut out = String::new();
111    out.push_str(existing_trimmed);
112    if !existing_trimmed.ends_with('\n') {
113        out.push('\n');
114    }
115    out.push('\n');
116    out.push_str(&section_block);
117    out
118}
119
120#[async_trait]
121impl Tool for ReadMemoryTool {
122    fn name(&self) -> &str {
123        "read_memory"
124    }
125
126    fn definition(&self) -> ToolDefinition {
127        ToolDefinition {
128            name: "read_memory".into(),
129            description: "Read internal AGENTS.md memory context for reasoning. Use scope 'global' for memories shared across all chats, 'bot' for the current bot/account, or 'chat' for chat-specific memories. Do not echo raw memory blocks/IDs directly to users; summarize in natural language.".into(),
130            input_schema: schema_object(
131                json!({
132                    "scope": {
133                        "type": "string",
134                        "description": "Memory scope: 'global', 'bot', or 'chat'",
135                        "enum": ["global", "bot", "chat"]
136                    },
137                    "chat_id": {
138                        "type": "integer",
139                        "description": "Chat ID (required for scope 'chat')"
140                    }
141                }),
142                &["scope"],
143            ),
144        }
145    }
146
147    async fn execute(&self, input: serde_json::Value) -> ToolResult {
148        let scope = match input.get("scope").and_then(|v| v.as_str()) {
149            Some(s) => s,
150            None => return ToolResult::error("Missing 'scope' parameter".into()),
151        };
152
153        let path = match scope {
154            "global" => self.groups_dir.join("AGENTS.md"),
155            "bot" => {
156                let channel = memory_channel_from_auth(&input);
157                self.groups_dir.join(channel).join("AGENTS.md")
158            }
159            "chat" => {
160                let chat_id = match chat_id_from_input_or_auth(&input) {
161                    Some(id) => id,
162                    None => return ToolResult::error("Missing 'chat_id' for chat scope".into()),
163                };
164                if let Err(e) = authorize_chat_access(&input, chat_id) {
165                    return ToolResult::error(e);
166                }
167                let channel = match memory_channel_for_chat(&self.db, &input, chat_id) {
168                    Ok(v) => v,
169                    Err(e) => return ToolResult::error(e),
170                };
171                self.groups_dir
172                    .join(channel)
173                    .join(chat_id.to_string())
174                    .join("AGENTS.md")
175            }
176            _ => return ToolResult::error("scope must be 'global', 'bot', or 'chat'".into()),
177        };
178
179        info!("Reading memory: {}", path.display());
180
181        match std::fs::read_to_string(&path) {
182            Ok(content) => {
183                if content.trim().is_empty() {
184                    ToolResult::success("Memory file is empty.".into())
185                } else {
186                    ToolResult::success(content)
187                }
188            }
189            Err(_) => ToolResult::success("No memory file found (not yet created).".into()),
190        }
191    }
192}
193
194pub struct WriteMemoryTool {
195    groups_dir: PathBuf,
196    db: Arc<Database>,
197    memory_backend: Arc<MemoryBackend>,
198}
199
200impl WriteMemoryTool {
201    pub fn new(data_dir: &str, db: Arc<Database>, memory_backend: Arc<MemoryBackend>) -> Self {
202        WriteMemoryTool {
203            groups_dir: PathBuf::from(data_dir).join("groups"),
204            db,
205            memory_backend,
206        }
207    }
208}
209
210impl WriteMemoryTool {
211    /// Mirror a write into the structured memory store. The row holds the
212    /// normalized (180-char) digest of the content, and global/bot writes
213    /// resend the whole file, so the digest repeats across saves; an existing
214    /// live row with the same digest is refreshed instead of appending a
215    /// duplicate (#505). Returns the row id and whether it was refreshed.
216    async fn upsert_memory_row(
217        &self,
218        chat_id: Option<i64>,
219        normalized: &str,
220    ) -> Option<(i64, bool)> {
221        let existing = self
222            .memory_backend
223            .get_all_memories_for_chat(chat_id)
224            .await
225            .unwrap_or_default();
226        if let Some(dup) = existing
227            .iter()
228            .find(|m| !m.is_archived && m.content.eq_ignore_ascii_case(normalized))
229        {
230            let updated = self
231                .memory_backend
232                .update_memory_with_metadata(
233                    dup.id,
234                    normalized,
235                    &dup.category,
236                    dup.confidence.max(WRITE_MEMORY_CONFIDENCE),
237                    &dup.source,
238                )
239                .await;
240            return matches!(updated, Ok(true)).then_some((dup.id, true));
241        }
242        self.memory_backend
243            .insert_memory_with_metadata(
244                chat_id,
245                normalized,
246                "KNOWLEDGE",
247                "write_memory_tool",
248                WRITE_MEMORY_CONFIDENCE,
249            )
250            .await
251            .ok()
252            .map(|id| (id, false))
253    }
254}
255
256const WRITE_MEMORY_CONFIDENCE: f64 = 0.85;
257
258#[async_trait]
259impl Tool for WriteMemoryTool {
260    fn name(&self) -> &str {
261        "write_memory"
262    }
263
264    fn definition(&self) -> ToolDefinition {
265        ToolDefinition {
266            name: "write_memory".into(),
267            description: "Write to the AGENTS.md memory file. Use this to remember important information about the user or conversation. Use scope 'global' for memories shared across all chats, 'bot' for the current bot/account, or 'chat' for chat-specific memories. In chat scope, memory is stored per person when sender identity is available. Pass `ttl_days` for facts that should expire (e.g. \"working from Tokyo this week\", \"travel until 2026-05-20\") so the memory is auto-pruned later.".into(),
268            input_schema: schema_object(
269                json!({
270                    "scope": {
271                        "type": "string",
272                        "description": "Memory scope: 'global', 'bot', or 'chat'",
273                        "enum": ["global", "bot", "chat"]
274                    },
275                    "chat_id": {
276                        "type": "integer",
277                        "description": "Chat ID (required for scope 'chat')"
278                    },
279                    "content": {
280                        "type": "string",
281                        "description": "The content to write to the memory file (global/bot replace full file; chat updates the latest sender section when sender identity is available)"
282                    },
283                    "ttl_days": {
284                        "type": "number",
285                        "description": "Optional. If set, the structured memory row is auto-pruned after this many days. Use for time-bounded facts; omit for durable knowledge."
286                    }
287                }),
288                &["scope", "content"],
289            ),
290        }
291    }
292
293    async fn execute(&self, input: serde_json::Value) -> ToolResult {
294        let scope = match input.get("scope").and_then(|v| v.as_str()) {
295            Some(s) => s,
296            None => return ToolResult::error("Missing 'scope' parameter".into()),
297        };
298        let content = match input.get("content").and_then(|v| v.as_str()) {
299            Some(c) => c,
300            None => return ToolResult::error("Missing 'content' parameter".into()),
301        };
302
303        let (path, memory_chat_id) = match scope {
304            "global" => {
305                if let Some(auth) = auth_context_from_input(&input) {
306                    if !auth.is_control_chat() {
307                        return ToolResult::error(format!(
308                            "Permission denied: chat {} cannot write global memory",
309                            auth.caller_chat_id
310                        ));
311                    }
312                }
313                (self.groups_dir.join("AGENTS.md"), None)
314            }
315            "bot" => {
316                let channel = memory_channel_from_auth(&input);
317                (self.groups_dir.join(channel).join("AGENTS.md"), None)
318            }
319            "chat" => {
320                let chat_id = match chat_id_from_input_or_auth(&input) {
321                    Some(id) => id,
322                    None => return ToolResult::error("Missing 'chat_id' for chat scope".into()),
323                };
324                if let Err(e) = authorize_chat_access(&input, chat_id) {
325                    return ToolResult::error(e);
326                }
327                let channel = match memory_channel_for_chat(&self.db, &input, chat_id) {
328                    Ok(v) => v,
329                    Err(e) => return ToolResult::error(e),
330                };
331                (
332                    self.groups_dir
333                        .join(channel)
334                        .join(chat_id.to_string())
335                        .join("AGENTS.md"),
336                    Some(chat_id),
337                )
338            }
339            _ => return ToolResult::error("scope must be 'global', 'bot', or 'chat'".into()),
340        };
341
342        info!("Writing memory: {}", path.display());
343
344        if let Some(parent) = path.parent() {
345            if let Err(e) = std::fs::create_dir_all(parent) {
346                return ToolResult::error(format!("Failed to create directory: {e}"));
347            }
348        }
349
350        let write_content = if scope == "chat" {
351            let chat_id = memory_chat_id.unwrap_or_default();
352            let sender = latest_sender_for_chat(&self.db, chat_id);
353            let existing = std::fs::read_to_string(&path).unwrap_or_default();
354            if let Some(sender) = sender {
355                upsert_chat_person_memory(&existing, &sender, content)
356            } else {
357                content.to_string()
358            }
359        } else {
360            content.to_string()
361        };
362
363        match std::fs::write(&path, &write_content) {
364            Ok(()) => {
365                let memory_content = content.trim().to_string();
366                let ttl_days = input
367                    .get("ttl_days")
368                    .and_then(|v| v.as_f64())
369                    .filter(|v| v.is_finite() && *v > 0.0);
370                if !memory_content.is_empty() {
371                    if let Some(normalized) =
372                        memory_quality::normalize_memory_content(&memory_content, 180)
373                    {
374                        if memory_quality::memory_quality_ok(&normalized) {
375                            if let Some((memory_id, refreshed)) =
376                                self.upsert_memory_row(memory_chat_id, &normalized).await
377                            {
378                                let expires_at = ttl_days.map(|days| {
379                                    let secs = (days * 86_400.0) as i64;
380                                    (chrono::Utc::now() + chrono::Duration::seconds(secs))
381                                        .to_rfc3339()
382                                });
383                                // A re-save without ttl_days re-asserts the fact as
384                                // durable, so a refreshed row drops its old expiry.
385                                if expires_at.is_some() || refreshed {
386                                    let _ = self
387                                        .db
388                                        .set_memory_expires_at(memory_id, expires_at.as_deref());
389                                }
390                            }
391                        }
392                    }
393                }
394
395                let suffix = match ttl_days {
396                    Some(d) => format!(" (expires in {d} day(s))"),
397                    None => String::new(),
398                };
399                ToolResult::success(format!("Memory saved to {} scope.{}", scope, suffix))
400            }
401            Err(e) => ToolResult::error(format!("Failed to write memory: {e}")),
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use serde_json::json;
410    use std::sync::Arc;
411
412    use crate::internal::storage::db::{Database, StoredMessage};
413
414    fn test_dir() -> std::path::PathBuf {
415        std::env::temp_dir().join(format!("microclaw_memtool_{}", uuid::Uuid::new_v4()))
416    }
417
418    fn test_db(dir: &std::path::Path) -> Arc<Database> {
419        let runtime = dir.join("runtime");
420        std::fs::create_dir_all(&runtime).unwrap();
421        Arc::new(Database::new(runtime.to_str().unwrap()).unwrap())
422    }
423
424    fn test_backend(db: Arc<Database>) -> Arc<MemoryBackend> {
425        Arc::new(MemoryBackend::local_only(db))
426    }
427
428    fn store_user_message(db: &Database, chat_id: i64, sender_name: &str, content: &str) {
429        let msg = StoredMessage {
430            id: format!("{}-{}", sender_name, uuid::Uuid::new_v4()),
431            chat_id,
432            sender_name: sender_name.to_string(),
433            content: content.to_string(),
434            is_from_bot: false,
435            timestamp: chrono::Utc::now().to_rfc3339(),
436        };
437        db.store_message(&msg).unwrap();
438    }
439
440    fn live_rows(db: &Database, chat_id: Option<i64>) -> Vec<crate::internal::storage::db::Memory> {
441        db.get_all_memories_for_chat(chat_id)
442            .unwrap()
443            .into_iter()
444            .filter(|m| !m.is_archived)
445            .collect()
446    }
447
448    #[tokio::test]
449    async fn test_write_memory_repeat_global_doc_keeps_one_row() {
450        let dir = test_dir();
451        let db = test_db(&dir);
452        let tool =
453            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
454        // Whole-document rewrites that only change past the 180-char digest.
455        let header = "# Household memory\n\nThe family prefers vegetarian dinners on weekdays, \
456                      chores rotate weekly between the two kids, and the shared calendar is the \
457                      source of truth for appointments and school events.";
458        for tail in [
459            "",
460            "\n- bins go out on Tuesday",
461            "\n- bins go out on Wednesday",
462        ] {
463            let result = tool
464                .execute(json!({"scope": "global", "content": format!("{header}{tail}")}))
465                .await;
466            assert!(!result.is_error, "{}", result.content);
467        }
468        assert_eq!(live_rows(&db, None).len(), 1);
469        let _ = std::fs::remove_dir_all(&dir);
470    }
471
472    #[tokio::test]
473    async fn test_write_memory_distinct_facts_and_chats_stay_separate() {
474        let dir = test_dir();
475        let db = test_db(&dir);
476        let tool =
477            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
478        for (chat_id, content) in [
479            (300, "User prefers concise answers in English"),
480            (300, "User is learning Rust and async programming"),
481            (300, "User prefers concise answers in English"),
482            (301, "User prefers concise answers in English"),
483        ] {
484            tool.execute(json!({"scope": "chat", "chat_id": chat_id, "content": content}))
485                .await;
486        }
487        assert_eq!(live_rows(&db, Some(300)).len(), 2);
488        assert_eq!(live_rows(&db, Some(301)).len(), 1);
489        let _ = std::fs::remove_dir_all(&dir);
490    }
491
492    #[tokio::test]
493    async fn test_write_memory_does_not_revive_archived_row() {
494        let dir = test_dir();
495        let db = test_db(&dir);
496        let tool =
497            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
498        let input = json!({"scope": "chat", "chat_id": 302, "content": "User works night shifts on weekends"});
499        tool.execute(input.clone()).await;
500        let first = live_rows(&db, Some(302))[0].id;
501        db.archive_memory(first).unwrap();
502        tool.execute(input).await;
503        let live = live_rows(&db, Some(302));
504        assert_eq!(live.len(), 1);
505        assert_ne!(live[0].id, first);
506        let _ = std::fs::remove_dir_all(&dir);
507    }
508
509    #[tokio::test]
510    async fn test_write_memory_resave_updates_expiry() {
511        let dir = test_dir();
512        let db = test_db(&dir);
513        let tool =
514            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
515        let content = "User is working from Tokyo this week";
516        tool.execute(json!({"scope": "chat", "chat_id": 303, "content": content, "ttl_days": 7}))
517            .await;
518        assert!(live_rows(&db, Some(303))[0].expires_at.is_some());
519        tool.execute(json!({"scope": "chat", "chat_id": 303, "content": content}))
520            .await;
521        let live = live_rows(&db, Some(303));
522        assert_eq!(live.len(), 1);
523        assert!(live[0].expires_at.is_none());
524        let _ = std::fs::remove_dir_all(&dir);
525    }
526
527    #[tokio::test]
528    async fn test_read_memory_global_not_exists() {
529        let dir = test_dir();
530        let db = test_db(&dir);
531        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
532        let result = tool.execute(json!({"scope": "global"})).await;
533        assert!(!result.is_error);
534        assert!(result.content.contains("No memory file found"));
535        let _ = std::fs::remove_dir_all(&dir);
536    }
537
538    #[tokio::test]
539    async fn test_write_and_read_memory_global() {
540        let dir = test_dir();
541        let db = test_db(&dir);
542        let write_tool =
543            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
544        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db.clone());
545
546        let result = write_tool
547            .execute(json!({"scope": "global", "content": "user prefers Rust"}))
548            .await;
549        assert!(!result.is_error);
550        assert!(result.content.contains("Memory saved"));
551
552        let result = read_tool.execute(json!({"scope": "global"})).await;
553        assert!(!result.is_error);
554        assert_eq!(result.content, "user prefers Rust");
555        let mems = db.get_all_memories_for_chat(None).unwrap();
556        assert_eq!(mems.len(), 1);
557        assert_eq!(mems[0].content, "user prefers Rust");
558
559        let _ = std::fs::remove_dir_all(&dir);
560    }
561
562    #[tokio::test]
563    async fn test_write_and_read_memory_chat() {
564        let dir = test_dir();
565        let db = test_db(&dir);
566        db.resolve_or_create_chat_id("web", "42", Some("web-42"), "web")
567            .unwrap();
568        let write_tool =
569            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
570        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db.clone());
571
572        let result = write_tool
573            .execute(json!({"scope": "chat", "chat_id": 42, "content": "chat 42 notes"}))
574            .await;
575        assert!(!result.is_error);
576
577        let result = read_tool
578            .execute(json!({"scope": "chat", "chat_id": 42}))
579            .await;
580        assert!(!result.is_error);
581        assert_eq!(result.content, "chat 42 notes");
582        let mems = db.get_all_memories_for_chat(Some(42)).unwrap();
583        assert_eq!(mems.len(), 1);
584        assert_eq!(mems[0].content, "chat 42 notes");
585
586        let _ = std::fs::remove_dir_all(&dir);
587    }
588
589    #[tokio::test]
590    async fn test_read_memory_chat_missing_chat_id() {
591        let dir = test_dir();
592        let db = test_db(&dir);
593        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
594        let result = tool.execute(json!({"scope": "chat"})).await;
595        assert!(result.is_error);
596        assert!(result.content.contains("Missing 'chat_id'"));
597        let _ = std::fs::remove_dir_all(&dir);
598    }
599
600    #[tokio::test]
601    async fn test_write_memory_chat_uses_auth_chat_id_when_missing() {
602        let dir = test_dir();
603        let db = test_db(&dir);
604        db.resolve_or_create_chat_id("web", "42", Some("web-42"), "web")
605            .unwrap();
606        let tool =
607            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
608        let result = tool
609            .execute(json!({
610                "scope": "chat",
611                "content": "from auth chat id",
612                "__microclaw_auth": {
613                    "caller_channel": "web",
614                    "caller_chat_id": 42,
615                    "control_chat_ids": []
616                }
617            }))
618            .await;
619        assert!(!result.is_error, "{}", result.content);
620        let content =
621            std::fs::read_to_string(dir.join("groups").join("web").join("42").join("AGENTS.md"))
622                .unwrap();
623        assert_eq!(content, "from auth chat id");
624        let _ = std::fs::remove_dir_all(&dir);
625    }
626
627    #[tokio::test]
628    async fn test_write_memory_chat_upserts_per_sender() {
629        let dir = test_dir();
630        let db = test_db(&dir);
631        db.resolve_or_create_chat_id("web", "42", Some("web-42"), "web")
632            .unwrap();
633        let tool =
634            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
635
636        store_user_message(&db, 42, "alice", "remember profile");
637        let r1 = tool
638            .execute(json!({"scope": "chat", "chat_id": 42, "content": "昵称: 老板"}))
639            .await;
640        assert!(!r1.is_error, "{}", r1.content);
641
642        store_user_message(&db, 42, "bob", "remember profile");
643        let r2 = tool
644            .execute(json!({"scope": "chat", "chat_id": 42, "content": "昵称: Bob哥"}))
645            .await;
646        assert!(!r2.is_error, "{}", r2.content);
647
648        store_user_message(&db, 42, "alice", "update profile");
649        let r3 = tool
650            .execute(json!({"scope": "chat", "chat_id": 42, "content": "昵称: 大老板"}))
651            .await;
652        assert!(!r3.is_error, "{}", r3.content);
653
654        let content =
655            std::fs::read_to_string(dir.join("groups").join("web").join("42").join("AGENTS.md"))
656                .unwrap();
657        assert!(content.contains("## Person: alice"));
658        assert!(content.contains("## Person: bob"));
659        assert!(content.contains("昵称: 大老板"));
660        assert!(content.contains("昵称: Bob哥"));
661        assert!(!content.contains("昵称: 老板"));
662
663        let _ = std::fs::remove_dir_all(&dir);
664    }
665
666    #[tokio::test]
667    async fn test_write_memory_missing_scope() {
668        let dir = test_dir();
669        let db = test_db(&dir);
670        let tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
671        let result = tool.execute(json!({"content": "data"})).await;
672        assert!(result.is_error);
673        assert!(result.content.contains("Missing 'scope'"));
674        let _ = std::fs::remove_dir_all(&dir);
675    }
676
677    #[tokio::test]
678    async fn test_read_memory_invalid_scope() {
679        let dir = test_dir();
680        let db = test_db(&dir);
681        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
682        let result = tool.execute(json!({"scope": "invalid"})).await;
683        assert!(result.is_error);
684        assert!(result
685            .content
686            .contains("must be 'global', 'bot', or 'chat'"));
687        let _ = std::fs::remove_dir_all(&dir);
688    }
689
690    #[tokio::test]
691    async fn test_write_and_read_memory_bot_scope() {
692        let dir = test_dir();
693        let db = test_db(&dir);
694        let write_tool =
695            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
696        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
697
698        let write = write_tool
699            .execute(json!({
700                "scope": "bot",
701                "content": "bot identity",
702                "__microclaw_auth": {
703                    "caller_channel": "feishu.ops",
704                    "caller_chat_id": 42,
705                    "control_chat_ids": []
706                }
707            }))
708            .await;
709        assert!(!write.is_error, "{}", write.content);
710
711        let read = read_tool
712            .execute(json!({
713                "scope": "bot",
714                "__microclaw_auth": {
715                    "caller_channel": "feishu.ops",
716                    "caller_chat_id": 42,
717                    "control_chat_ids": []
718                }
719            }))
720            .await;
721        assert!(!read.is_error, "{}", read.content);
722        assert_eq!(read.content, "bot identity");
723        let _ = std::fs::remove_dir_all(&dir);
724    }
725
726    #[tokio::test]
727    async fn test_read_memory_empty_file() {
728        let dir = test_dir();
729        let db = test_db(&dir);
730        let write_tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
731        let db2 = test_db(&dir);
732        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db2);
733
734        write_tool
735            .execute(json!({"scope": "global", "content": "   "}))
736            .await;
737
738        let result = read_tool.execute(json!({"scope": "global"})).await;
739        assert!(!result.is_error);
740        assert!(result.content.contains("empty"));
741
742        let _ = std::fs::remove_dir_all(&dir);
743    }
744
745    #[tokio::test]
746    async fn test_write_memory_global_denied_for_non_control_chat() {
747        let dir = test_dir();
748        let db = test_db(&dir);
749        let tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
750        let result = tool
751            .execute(json!({
752                "scope": "global",
753                "content": "secret",
754                "__microclaw_auth": {
755                    "caller_chat_id": 100,
756                    "control_chat_ids": []
757                }
758            }))
759            .await;
760        assert!(result.is_error);
761        assert!(result.content.contains("Permission denied"));
762        let _ = std::fs::remove_dir_all(&dir);
763    }
764
765    #[tokio::test]
766    async fn test_write_memory_global_allowed_for_control_chat() {
767        let dir = test_dir();
768        let db = test_db(&dir);
769        let tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
770        let result = tool
771            .execute(json!({
772                "scope": "global",
773                "content": "global ok",
774                "__microclaw_auth": {
775                    "caller_chat_id": 100,
776                    "control_chat_ids": [100]
777                }
778            }))
779            .await;
780        assert!(!result.is_error, "{}", result.content);
781        let content = std::fs::read_to_string(dir.join("groups").join("AGENTS.md")).unwrap();
782        assert_eq!(content, "global ok");
783        let _ = std::fs::remove_dir_all(&dir);
784    }
785
786    #[tokio::test]
787    async fn test_read_memory_chat_permission_denied() {
788        let dir = test_dir();
789        let db = test_db(&dir);
790        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
791        let result = tool
792            .execute(json!({
793                "scope": "chat",
794                "chat_id": 200,
795                "__microclaw_auth": {
796                    "caller_chat_id": 100,
797                    "control_chat_ids": []
798                }
799            }))
800            .await;
801        assert!(result.is_error);
802        assert!(result.content.contains("Permission denied"));
803        let _ = std::fs::remove_dir_all(&dir);
804    }
805
806    #[tokio::test]
807    async fn test_read_memory_chat_allowed_for_control_chat_cross_chat() {
808        let dir = test_dir();
809        let db = test_db(&dir);
810        db.resolve_or_create_chat_id("web", "200", Some("web-200"), "web")
811            .unwrap();
812        let write_tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
813        let db2 = test_db(&dir);
814        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db2);
815        write_tool
816            .execute(json!({"scope": "chat", "chat_id": 200, "content": "chat200"}))
817            .await;
818        let result = read_tool
819            .execute(json!({
820                "scope": "chat",
821                "chat_id": 200,
822                "__microclaw_auth": {
823                    "caller_chat_id": 100,
824                    "control_chat_ids": [100]
825                }
826            }))
827            .await;
828        assert!(!result.is_error, "{}", result.content);
829        assert_eq!(result.content, "chat200");
830        let _ = std::fs::remove_dir_all(&dir);
831    }
832}