1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use tracing::info;
5
6use crate::internal::storage::db::Database;
7use crate::internal::storage::db::Memory;
8use crate::memory_backend::MemoryBackend;
9use microclaw_core::llm_types::ToolDefinition;
10
11use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
12
13pub struct StructuredMemorySearchTool {
16 memory_backend: Arc<MemoryBackend>,
17}
18
19impl StructuredMemorySearchTool {
20 pub fn new(db: Arc<Database>, memory_backend: Arc<MemoryBackend>) -> Self {
21 let _ = db;
22 Self { memory_backend }
23 }
24
25 fn filter_visible_memories(chat_id: i64, memories: Vec<Memory>) -> Vec<Memory> {
26 memories
27 .into_iter()
28 .filter(|m| m.chat_id.is_none() || m.chat_id == Some(chat_id))
29 .collect()
30 }
31}
32
33#[async_trait]
34impl Tool for StructuredMemorySearchTool {
35 fn name(&self) -> &str {
36 "structured_memory_search"
37 }
38
39 fn definition(&self) -> ToolDefinition {
40 ToolDefinition {
41 name: "structured_memory_search".into(),
42 description: "Search structured memories extracted from past conversations. Returns memories whose content contains the query string. Leave query empty to list recent visible memories.".into(),
43 input_schema: schema_object(
44 json!({
45 "query": {
46 "type": "string",
47 "description": "Keyword(s) to search for in memory content; leave empty to list recent visible memories"
48 },
49 "limit": {
50 "type": "integer",
51 "description": "Maximum number of results to return (default 10, max 50)"
52 },
53 "include_archived": {
54 "type": "boolean",
55 "description": "Whether to include archived memories in results (default false)"
56 }
57 }),
58 &[],
59 ),
60 }
61 }
62
63 async fn execute(&self, input: serde_json::Value) -> ToolResult {
64 let query = input
65 .get("query")
66 .and_then(|v| v.as_str())
67 .map(|q| q.trim().to_string())
68 .unwrap_or_default();
69 let limit = input
70 .get("limit")
71 .and_then(|v| v.as_u64())
72 .map(|n| n.min(50) as usize)
73 .unwrap_or(10);
74 let include_archived = input
75 .get("include_archived")
76 .and_then(|v| v.as_bool())
77 .unwrap_or(false);
78
79 let chat_id = auth_context_from_input(&input)
80 .map(|a| a.caller_chat_id)
81 .unwrap_or(0);
82
83 info!(
84 "structured_memory_search: query={query:?} chat_id={chat_id} limit={limit} include_archived={include_archived}"
85 );
86
87 let result = if query.is_empty() {
88 let mut memories = match self
89 .memory_backend
90 .get_all_memories_for_chat(Some(chat_id))
91 .await
92 {
93 Ok(m) => m,
94 Err(e) => return ToolResult::error(format!("Search failed: {e}")),
95 };
96 let mut global = match self.memory_backend.get_all_memories_for_chat(None).await {
97 Ok(m) => m,
98 Err(e) => return ToolResult::error(format!("Search failed: {e}")),
99 };
100 memories.append(&mut global);
101 memories.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
102 if !include_archived {
103 memories.retain(|m| !m.is_archived);
104 }
105 memories.truncate(limit);
106 Ok(memories)
107 } else {
108 self.memory_backend
109 .search_memories_with_options(chat_id, &query, limit, include_archived, true)
110 .await
111 };
112
113 match result {
114 Ok(memories) if memories.is_empty() => {
115 let message = if query.is_empty() {
116 "No visible memories found.".to_string()
117 } else {
118 "No memories found matching that query.".to_string()
119 };
120 ToolResult::success(message)
121 }
122 Ok(memories) => {
123 let original_count = memories.len();
124 let memories = Self::filter_visible_memories(chat_id, memories);
125 if memories.is_empty() {
126 let message = if query.is_empty() {
127 "No visible memories found.".to_string()
128 } else {
129 "No visible memories found matching that query.".to_string()
130 };
131 return ToolResult::success(message);
132 }
133 let filtered_count = original_count.saturating_sub(memories.len());
134 if filtered_count > 0 {
135 info!(
136 "structured_memory_search: filtered {} cross-chat memories for chat_id={}",
137 filtered_count, chat_id
138 );
139 }
140 let lines: Vec<String> = memories
141 .iter()
142 .map(|m| {
143 let scope = if m.chat_id.is_none() {
144 "global"
145 } else {
146 "chat"
147 };
148 format!("[id={}] [{}] [{}] {}", m.id, m.category, scope, m.content)
149 })
150 .collect();
151 ToolResult::success(lines.join("\n"))
152 }
153 Err(e) => ToolResult::error(format!("Search failed: {e}")),
154 }
155 }
156}
157
158pub struct StructuredMemoryDeleteTool {
161 memory_backend: Arc<MemoryBackend>,
162}
163
164impl StructuredMemoryDeleteTool {
165 pub fn new(db: Arc<Database>, memory_backend: Arc<MemoryBackend>) -> Self {
166 let _ = db;
167 Self { memory_backend }
168 }
169}
170
171#[async_trait]
172impl Tool for StructuredMemoryDeleteTool {
173 fn name(&self) -> &str {
174 "structured_memory_delete"
175 }
176
177 fn definition(&self) -> ToolDefinition {
178 ToolDefinition {
179 name: "structured_memory_delete".into(),
180 description: "Archive a structured memory by its id (soft delete). Use structured_memory_search first to find the id. You can only archive memories that belong to the current chat or global memories if you are a control chat.".into(),
181 input_schema: schema_object(
182 json!({
183 "id": {
184 "type": "integer",
185 "description": "The id of the memory to delete"
186 }
187 }),
188 &["id"],
189 ),
190 }
191 }
192
193 async fn execute(&self, input: serde_json::Value) -> ToolResult {
194 let id = match input.get("id").and_then(|v| v.as_i64()) {
195 Some(id) => id,
196 None => return ToolResult::error("Missing 'id' parameter".into()),
197 };
198
199 let mem = match self.memory_backend.get_memory_by_id(id).await {
201 Ok(Some(m)) => m,
202 Ok(None) => return ToolResult::error(format!("Memory id={id} not found")),
203 Err(e) => return ToolResult::error(format!("DB error: {e}")),
204 };
205
206 if let Some(auth) = auth_context_from_input(&input) {
208 match mem.chat_id {
209 Some(mem_chat_id) => {
210 if let Err(e) = authorize_chat_access(&input, mem_chat_id) {
211 return ToolResult::error(format!(
212 "{e} (memory id={id}, owner_chat_id={mem_chat_id})"
213 ));
214 }
215 }
216 None => {
217 if !auth.is_control_chat() {
219 return ToolResult::error(format!(
220 "Permission denied: only control chats can delete global memories (caller: {}, memory id={id}, owner_scope=global)",
221 auth.caller_chat_id
222 ));
223 }
224 }
225 }
226 }
227
228 info!("structured_memory_delete: id={id}");
229
230 match self.memory_backend.archive_memory(id).await {
231 Ok(true) => ToolResult::success(format!("Memory id={id} archived.")),
232 Ok(false) => ToolResult::error(format!("Memory id={id} not found")),
233 Err(e) => ToolResult::error(format!("Delete failed: {e}")),
234 }
235 }
236}
237
238pub struct StructuredMemoryUpdateTool {
241 memory_backend: Arc<MemoryBackend>,
242}
243
244impl StructuredMemoryUpdateTool {
245 pub fn new(db: Arc<Database>, memory_backend: Arc<MemoryBackend>) -> Self {
246 let _ = db;
247 Self { memory_backend }
248 }
249}
250
251#[async_trait]
252impl Tool for StructuredMemoryUpdateTool {
253 fn name(&self) -> &str {
254 "structured_memory_update"
255 }
256
257 fn definition(&self) -> ToolDefinition {
258 ToolDefinition {
259 name: "structured_memory_update".into(),
260 description: "Update the content or category of an existing structured memory. Use this to correct outdated or wrong memories instead of creating a duplicate.".into(),
261 input_schema: schema_object(
262 json!({
263 "id": {
264 "type": "integer",
265 "description": "The id of the memory to update"
266 },
267 "content": {
268 "type": "string",
269 "description": "New content for the memory (max 300 characters)"
270 },
271 "category": {
272 "type": "string",
273 "description": "Category: PROFILE, KNOWLEDGE, or EVENT",
274 "enum": ["PROFILE", "KNOWLEDGE", "EVENT"]
275 }
276 }),
277 &["id", "content"],
278 ),
279 }
280 }
281
282 async fn execute(&self, input: serde_json::Value) -> ToolResult {
283 let id = match input.get("id").and_then(|v| v.as_i64()) {
284 Some(id) => id,
285 None => return ToolResult::error("Missing 'id' parameter".into()),
286 };
287 let content = match input.get("content").and_then(|v| v.as_str()) {
288 Some(c) if !c.trim().is_empty() => c.trim().to_string(),
289 _ => return ToolResult::error("Missing or empty 'content' parameter".into()),
290 };
291 if content.len() > 300 {
292 return ToolResult::error("Content exceeds 300 character limit".into());
293 }
294
295 let mem = match self.memory_backend.get_memory_by_id(id).await {
297 Ok(Some(m)) => m,
298 Ok(None) => return ToolResult::error(format!("Memory id={id} not found")),
299 Err(e) => return ToolResult::error(format!("DB error: {e}")),
300 };
301
302 if let Some(auth) = auth_context_from_input(&input) {
304 match mem.chat_id {
305 Some(mem_chat_id) => {
306 if let Err(e) = authorize_chat_access(&input, mem_chat_id) {
307 return ToolResult::error(format!(
308 "{e} (memory id={id}, owner_chat_id={mem_chat_id})"
309 ));
310 }
311 }
312 None => {
313 if !auth.is_control_chat() {
314 return ToolResult::error(format!(
315 "Permission denied: only control chats can update global memories (caller: {}, memory id={id}, owner_scope=global)",
316 auth.caller_chat_id
317 ));
318 }
319 }
320 }
321 }
322
323 let category = input
324 .get("category")
325 .and_then(|v| v.as_str())
326 .unwrap_or(&mem.category)
327 .to_string();
328
329 let valid_categories = ["PROFILE", "KNOWLEDGE", "EVENT"];
330 if !valid_categories.contains(&category.as_str()) {
331 return ToolResult::error(format!(
332 "Invalid category '{category}'. Must be one of: PROFILE, KNOWLEDGE, EVENT"
333 ));
334 }
335
336 info!("structured_memory_update: id={id}");
337
338 match self
339 .memory_backend
340 .update_memory_content(id, &content, &category)
341 .await
342 {
343 Ok(true) => ToolResult::success(format!("Memory id={id} updated.")),
344 Ok(false) => ToolResult::error(format!("Memory id={id} not found")),
345 Err(e) => ToolResult::error(format!("Update failed: {e}")),
346 }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use serde_json::json;
354
355 fn test_db() -> Arc<Database> {
356 let dir = std::env::temp_dir().join(format!("mc_smem_{}", uuid::Uuid::new_v4()));
357 std::fs::create_dir_all(&dir).unwrap();
358 Arc::new(Database::new(dir.to_str().unwrap()).unwrap())
359 }
360
361 fn test_backend(db: Arc<Database>) -> Arc<MemoryBackend> {
362 Arc::new(MemoryBackend::local_only(db))
363 }
364
365 #[tokio::test]
366 async fn test_search_returns_results() {
367 let db = test_db();
368 db.insert_memory(Some(100), "User loves Rust programming", "PROFILE")
369 .unwrap();
370 db.insert_memory(Some(100), "User likes coffee", "PROFILE")
371 .unwrap();
372 let tool = StructuredMemorySearchTool::new(db.clone(), test_backend(db));
373 let result = tool
374 .execute(json!({
375 "query": "rust",
376 "__microclaw_auth": {"caller_chat_id": 100, "control_chat_ids": []}
377 }))
378 .await;
379 assert!(!result.is_error);
380 assert!(result.content.contains("Rust"));
381 assert!(!result.content.contains("coffee"));
382 }
383
384 #[tokio::test]
385 async fn test_search_empty_query_lists_no_visible_memories() {
386 let db = test_db();
387 let tool = StructuredMemorySearchTool::new(db.clone(), test_backend(db));
388 let result = tool.execute(json!({"query": " "})).await;
389 assert!(!result.is_error);
390 assert!(result.content.contains("No visible memories"));
391 }
392
393 #[tokio::test]
394 async fn test_delete_own_chat_memory() {
395 let db = test_db();
396 let id = db.insert_memory(Some(100), "to delete", "EVENT").unwrap();
397 let tool = StructuredMemoryDeleteTool::new(db.clone(), test_backend(db.clone()));
398 let result = tool
399 .execute(json!({
400 "id": id,
401 "__microclaw_auth": {"caller_chat_id": 100, "control_chat_ids": []}
402 }))
403 .await;
404 assert!(!result.is_error, "{}", result.content);
405 let mem = db.get_memory_by_id(id).unwrap().unwrap();
406 assert!(mem.is_archived);
407 }
408
409 #[tokio::test]
410 async fn test_delete_other_chat_denied() {
411 let db = test_db();
412 let id = db.insert_memory(Some(200), "other chat", "EVENT").unwrap();
413 let tool = StructuredMemoryDeleteTool::new(db.clone(), test_backend(db));
414 let result = tool
415 .execute(json!({
416 "id": id,
417 "__microclaw_auth": {"caller_chat_id": 100, "control_chat_ids": []}
418 }))
419 .await;
420 assert!(result.is_error);
421 assert!(result.content.contains("Permission denied"));
422 }
423
424 #[tokio::test]
425 async fn test_search_empty_query_lists_recent_visible_memories() {
426 let db = test_db();
427 db.insert_memory(Some(100), "chat memory", "PROFILE")
428 .unwrap();
429 db.insert_memory(None, "global memory", "KNOWLEDGE")
430 .unwrap();
431 let tool = StructuredMemorySearchTool::new(db.clone(), test_backend(db));
432 let result = tool
433 .execute(json!({
434 "limit": 10,
435 "__microclaw_auth": {"caller_chat_id": 100, "control_chat_ids": []}
436 }))
437 .await;
438 assert!(!result.is_error, "{}", result.content);
439 assert!(result.content.contains("chat memory"));
440 assert!(result.content.contains("global memory"));
441 }
442
443 #[tokio::test]
444 async fn test_update_memory() {
445 let db = test_db();
446 let id = db
447 .insert_memory(Some(100), "User lives in Tokyo", "PROFILE")
448 .unwrap();
449 let tool = StructuredMemoryUpdateTool::new(db.clone(), test_backend(db.clone()));
450 let result = tool
451 .execute(json!({
452 "id": id,
453 "content": "User lives in Osaka",
454 "__microclaw_auth": {"caller_chat_id": 100, "control_chat_ids": []}
455 }))
456 .await;
457 assert!(!result.is_error, "{}", result.content);
458 let mem = db.get_memory_by_id(id).unwrap().unwrap();
459 assert_eq!(mem.content, "User lives in Osaka");
460 }
461
462 #[tokio::test]
463 async fn test_update_content_too_long() {
464 let db = test_db();
465 let id = db.insert_memory(Some(100), "short", "EVENT").unwrap();
466 let tool = StructuredMemoryUpdateTool::new(db.clone(), test_backend(db));
467 let long = "x".repeat(301);
468 let result = tool
469 .execute(json!({
470 "id": id,
471 "content": long,
472 "__microclaw_auth": {"caller_chat_id": 100, "control_chat_ids": []}
473 }))
474 .await;
475 assert!(result.is_error);
476 assert!(result.content.contains("300 character"));
477 }
478}