1use async_trait::async_trait;
18
19use crate::app::memory::{self};
20use mermaid_domain::MemoryScope;
21use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
22
23use super::super::ctx::ExecContext;
24use super::ToolExecutor;
25
26pub struct MemoryTool;
27
28fn scope_from_args(args: &serde_json::Value) -> MemoryScope {
29 if args
30 .get("global")
31 .and_then(|v| v.as_bool())
32 .unwrap_or(false)
33 {
34 MemoryScope::Global
35 } else if args
36 .get("shared")
37 .and_then(|v| v.as_bool())
38 .unwrap_or(false)
39 {
40 MemoryScope::ProjectShared
41 } else {
42 MemoryScope::ProjectPrivate
43 }
44}
45
46fn str_arg(args: &serde_json::Value, key: &str) -> Option<String> {
47 args.get(key)
48 .and_then(|v| v.as_str())
49 .map(str::trim)
50 .filter(|s| !s.is_empty())
51 .map(str::to_string)
52}
53
54fn tags_arg(args: &serde_json::Value) -> Vec<String> {
55 args.get("tags")
56 .and_then(|v| v.as_array())
57 .map(|a| {
58 a.iter()
59 .filter_map(|t| t.as_str())
60 .map(str::to_string)
61 .collect()
62 })
63 .unwrap_or_default()
64}
65
66#[async_trait]
67impl ToolExecutor for MemoryTool {
68 fn name(&self) -> &'static str {
69 "memory"
70 }
71
72 fn schema(&self) -> ToolDefinition {
73 ToolDefinition {
74 name: "memory".to_string(),
75 description: "Durable, cross-session memory of facts, one fact per file. The memory index is always in your context; this tool changes or searches it. \
76 `action=remember` saves a new fact; `action=update` replaces one fact's body (pass its `id`); `action=forget` deletes a fact (pass its `id`); `action=search` finds facts by keyword (pass `query`) across names, descriptions, and bodies. \
77 Default scope is project-private (machine-local, not committed); `shared=true` writes to the repo's .mermaid/memory, and `global=true` applies across all projects. \
78 Never store secrets, tokens, or PII."
79 .to_string(),
80 input_schema: serde_json::json!({
81 "type": "object",
82 "properties": {
83 "action": {
84 "type": "string",
85 "enum": ["remember", "update", "forget", "search"],
86 "description": "What to do."
87 },
88 "query": {
89 "type": "string",
90 "description": "Keyword(s) to search for across memory names, descriptions, and bodies. Required for search."
91 },
92 "name": {
93 "type": "string",
94 "description": "Short title for the fact; also the filename. Required for remember."
95 },
96 "content": {
97 "type": "string",
98 "description": "The fact itself (Markdown). Required for remember and update."
99 },
100 "description": {
101 "type": "string",
102 "description": "One-line summary shown in the always-loaded index. Defaults to the first line of content."
103 },
104 "tags": {
105 "type": "array",
106 "items": { "type": "string" },
107 "description": "Optional keywords."
108 },
109 "shared": {
110 "type": "boolean",
111 "description": "Store in the committed project-shared scope (.mermaid/memory) instead of private."
112 },
113 "global": {
114 "type": "boolean",
115 "description": "Store in the global, cross-project scope."
116 },
117 "id": {
118 "type": "string",
119 "description": "Name/id of the fact to update or forget (as shown in the index)."
120 }
121 },
122 "required": ["action"]
123 }),
124 }
125 }
126
127 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
128 let start = std::time::Instant::now();
129 let action = str_arg(&args, "action").unwrap_or_default();
130
131 if action == "search" {
135 let Some(query) = str_arg(&args, "query") else {
136 return ToolOutcome::error(
137 "memory search requires 'query'",
138 Some(start.elapsed().as_secs_f64()),
139 );
140 };
141 return run_search(&ctx.workdir, &query, start);
142 }
143
144 if let Some(blocked) = super::policy_gate::gate_external(
147 &ctx,
148 "memory",
149 mermaid_runtime::ToolCategory::Memory,
150 format!("memory {action}"),
151 &args,
152 )
153 .await
154 {
155 return blocked;
156 }
157
158 match action.as_str() {
159 "remember" => run_remember(&ctx.workdir, &args, start),
160 "update" => run_update(&ctx.workdir, &args, start),
161 "forget" => run_forget(&ctx.workdir, &args, start),
162 other => ToolOutcome::error(
163 format!(
164 "memory: unknown action '{other}' (expected remember, update, forget, or search)"
165 ),
166 Some(start.elapsed().as_secs_f64()),
167 ),
168 }
169 }
170}
171
172fn run_remember(
174 workdir: &std::path::Path,
175 args: &serde_json::Value,
176 start: std::time::Instant,
177) -> ToolOutcome {
178 let secs = || start.elapsed().as_secs_f64();
179 let Some(name) = str_arg(args, "name") else {
180 return ToolOutcome::error("memory remember requires 'name'", Some(secs()));
181 };
182 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
183 return ToolOutcome::error("memory remember requires 'content'", Some(secs()));
184 };
185 let scope = scope_from_args(args);
186 let description = str_arg(args, "description").unwrap_or_else(|| {
187 content
188 .lines()
189 .find(|l| !l.trim().is_empty())
190 .unwrap_or("")
191 .to_string()
192 });
193 let tags = tags_arg(args);
194 match memory::write_memory(workdir, scope, &name, &description, &tags, content) {
195 Ok(path) => finish(start, "remember", &name, scope, &path),
196 Err(e) => ToolOutcome::error(format!("memory remember failed: {e}"), Some(secs())),
197 }
198}
199
200fn run_update(
203 workdir: &std::path::Path,
204 args: &serde_json::Value,
205 start: std::time::Instant,
206) -> ToolOutcome {
207 let secs = || start.elapsed().as_secs_f64();
208 let Some(id) = str_arg(args, "id").or_else(|| str_arg(args, "name")) else {
209 return ToolOutcome::error("memory update requires 'id'", Some(secs()));
210 };
211 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
212 return ToolOutcome::error("memory update requires 'content'", Some(secs()));
213 };
214 let Some(existing) = memory::find(workdir, &id) else {
215 return ToolOutcome::error(
216 format!("memory update: no memory named '{id}'"),
217 Some(secs()),
218 );
219 };
220 let description = str_arg(args, "description").unwrap_or_else(|| existing.description.clone());
221 let tags = tags_arg(args);
222 match memory::write_memory(
223 workdir,
224 existing.scope,
225 &existing.name,
226 &description,
227 &tags,
228 content,
229 ) {
230 Ok(path) => {
231 if path != existing.path {
235 let _ = std::fs::remove_file(&existing.path);
236 }
237 finish(start, "update", &existing.name, existing.scope, &path)
238 },
239 Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), Some(secs())),
240 }
241}
242
243fn run_forget(
245 workdir: &std::path::Path,
246 args: &serde_json::Value,
247 start: std::time::Instant,
248) -> ToolOutcome {
249 let secs = || start.elapsed().as_secs_f64();
250 let Some(id) = str_arg(args, "id").or_else(|| str_arg(args, "name")) else {
251 return ToolOutcome::error("memory forget requires 'id'", Some(secs()));
252 };
253 match memory::delete_memory(workdir, &id) {
254 Ok(Some(path)) => ToolOutcome::success(
255 format!("Forgot memory '{id}' ({})", path.display()),
256 format!("forgot {id}"),
257 secs(),
258 )
259 .with_metadata(ToolRunMetadata {
260 detail: ToolMetadata::Custom {
261 name: "memory".to_string(),
262 data: serde_json::json!({
263 "action": "forget",
264 "id": id,
265 "path": path.display().to_string(),
266 }),
267 },
268 ..ToolRunMetadata::default()
269 }),
270 Ok(None) => ToolOutcome::error(
271 format!("memory forget: no memory named '{id}'"),
272 Some(secs()),
273 ),
274 Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), Some(secs())),
275 }
276}
277
278fn finish(
280 start: std::time::Instant,
281 action: &str,
282 name: &str,
283 scope: MemoryScope,
284 path: &std::path::Path,
285) -> ToolOutcome {
286 let verb = if action == "remember" {
287 "Remembered"
288 } else {
289 "Updated"
290 };
291 ToolOutcome::success(
292 format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
293 format!("{action} {name} [{}]", scope.as_str()),
294 start.elapsed().as_secs_f64(),
295 )
296 .with_metadata(ToolRunMetadata {
297 detail: ToolMetadata::Custom {
298 name: "memory".to_string(),
299 data: serde_json::json!({
300 "action": action,
301 "name": name,
302 "scope": scope.as_str(),
303 "path": path.display().to_string(),
304 }),
305 },
306 ..ToolRunMetadata::default()
307 })
308}
309
310fn run_search(workdir: &std::path::Path, query: &str, start: std::time::Instant) -> ToolOutcome {
313 let hits = memory::search(workdir, query);
314 let secs = start.elapsed().as_secs_f64();
315 if hits.is_empty() {
316 return ToolOutcome::success(
317 format!("No memory matched '{query}'."),
318 format!("search '{query}': 0 hits"),
319 secs,
320 );
321 }
322 let mut body = format!("{} memory hit(s) for '{query}':\n", hits.len());
323 for hit in &hits {
324 body.push_str(&format!(
325 "- {} [{}] ({}) — {}\n",
326 hit.entry.name,
327 hit.entry.scope.as_str(),
328 hit.entry.path.display(),
329 hit.snippet,
330 ));
331 }
332 ToolOutcome::success(body, format!("search '{query}': {} hits", hits.len()), secs)
333}