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, §ion_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(§ion_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(§ion_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 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 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 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}