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: "Manage your durable, cross-session memory of semantic facts: preferences, project conventions, decisions and their rationale, and hard-won gotchas. The memory index is always in your context; use this tool to change or search it. \
76 Before saving, apply the signal gate: will a future agent act better because this fact exists? If not, write nothing. The highest-signal facts are user-stated preferences and decisions, project conventions, and gotchas that cost real time — weight what the user explicitly said over what you inferred. \
77 `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. \
78 Keep each fact atomic — one idea per memory — and never merge or re-summarize the whole corpus. \
79 Default scope is project-private (machine-local, not committed); set `shared=true` for team facts committed to the repo's .mermaid/memory, or `global=true` for facts that apply across all projects. \
80 Save durable knowledge, not transient task state, and never store secrets, tokens, or PII."
81 .to_string(),
82 input_schema: serde_json::json!({
83 "type": "object",
84 "properties": {
85 "action": {
86 "type": "string",
87 "enum": ["remember", "update", "forget", "search"],
88 "description": "What to do."
89 },
90 "query": {
91 "type": "string",
92 "description": "Keyword(s) to search for across memory names, descriptions, and bodies. Required for search."
93 },
94 "name": {
95 "type": "string",
96 "description": "Short title for the fact; also the filename. Required for remember."
97 },
98 "content": {
99 "type": "string",
100 "description": "The fact itself (Markdown). Required for remember and update."
101 },
102 "description": {
103 "type": "string",
104 "description": "One-line summary shown in the always-loaded index. Defaults to the first line of content."
105 },
106 "tags": {
107 "type": "array",
108 "items": { "type": "string" },
109 "description": "Optional keywords."
110 },
111 "shared": {
112 "type": "boolean",
113 "description": "Store in the committed project-shared scope (.mermaid/memory) instead of private."
114 },
115 "global": {
116 "type": "boolean",
117 "description": "Store in the global, cross-project scope."
118 },
119 "id": {
120 "type": "string",
121 "description": "Name/id of the fact to update or forget (as shown in the index)."
122 }
123 },
124 "required": ["action"]
125 }),
126 }
127 }
128
129 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
130 let start = std::time::Instant::now();
131 let action = str_arg(&args, "action").unwrap_or_default();
132
133 if action == "search" {
137 let Some(query) = str_arg(&args, "query") else {
138 return ToolOutcome::error(
139 "memory search requires 'query'",
140 start.elapsed().as_secs_f64(),
141 );
142 };
143 return run_search(&ctx.workdir, &query, start);
144 }
145
146 if let Some(blocked) = super::policy_gate::gate_external(
149 &ctx,
150 "memory",
151 mermaid_runtime::ToolCategory::Memory,
152 format!("memory {action}"),
153 &args,
154 )
155 .await
156 {
157 return blocked;
158 }
159
160 match action.as_str() {
161 "remember" => run_remember(&ctx.workdir, &args, start),
162 "update" => run_update(&ctx.workdir, &args, start),
163 "forget" => run_forget(&ctx.workdir, &args, start),
164 other => ToolOutcome::error(
165 format!(
166 "memory: unknown action '{other}' (expected remember, update, forget, or search)"
167 ),
168 start.elapsed().as_secs_f64(),
169 ),
170 }
171 }
172}
173
174fn run_remember(
176 workdir: &std::path::Path,
177 args: &serde_json::Value,
178 start: std::time::Instant,
179) -> ToolOutcome {
180 let secs = || start.elapsed().as_secs_f64();
181 let Some(name) = str_arg(args, "name") else {
182 return ToolOutcome::error("memory remember requires 'name'", secs());
183 };
184 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
185 return ToolOutcome::error("memory remember requires 'content'", secs());
186 };
187 let scope = scope_from_args(args);
188 let description = str_arg(args, "description").unwrap_or_else(|| {
189 content
190 .lines()
191 .find(|l| !l.trim().is_empty())
192 .unwrap_or("")
193 .to_string()
194 });
195 let tags = tags_arg(args);
196 match memory::write_memory(workdir, scope, &name, &description, &tags, content) {
197 Ok(path) => finish(start, "remember", &name, scope, &path),
198 Err(e) => ToolOutcome::error(format!("memory remember failed: {e}"), secs()),
199 }
200}
201
202fn run_update(
205 workdir: &std::path::Path,
206 args: &serde_json::Value,
207 start: std::time::Instant,
208) -> ToolOutcome {
209 let secs = || start.elapsed().as_secs_f64();
210 let Some(id) = str_arg(args, "id").or_else(|| str_arg(args, "name")) else {
211 return ToolOutcome::error("memory update requires 'id'", secs());
212 };
213 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
214 return ToolOutcome::error("memory update requires 'content'", secs());
215 };
216 let Some(existing) = memory::find(workdir, &id) else {
217 return ToolOutcome::error(format!("memory update: no memory named '{id}'"), secs());
218 };
219 let description = str_arg(args, "description").unwrap_or_else(|| existing.description.clone());
220 let tags = tags_arg(args);
221 match memory::write_memory(
222 workdir,
223 existing.scope,
224 &existing.name,
225 &description,
226 &tags,
227 content,
228 ) {
229 Ok(path) => {
230 if path != existing.path {
234 let _ = std::fs::remove_file(&existing.path);
235 }
236 finish(start, "update", &existing.name, existing.scope, &path)
237 },
238 Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), secs()),
239 }
240}
241
242fn run_forget(
244 workdir: &std::path::Path,
245 args: &serde_json::Value,
246 start: std::time::Instant,
247) -> ToolOutcome {
248 let secs = || start.elapsed().as_secs_f64();
249 let Some(id) = str_arg(args, "id").or_else(|| str_arg(args, "name")) else {
250 return ToolOutcome::error("memory forget requires 'id'", secs());
251 };
252 match memory::delete_memory(workdir, &id) {
253 Ok(Some(path)) => ToolOutcome::success(
254 format!("Forgot memory '{id}' ({})", path.display()),
255 format!("forgot {id}"),
256 secs(),
257 )
258 .with_metadata(ToolRunMetadata {
259 detail: ToolMetadata::Custom {
260 name: "memory".to_string(),
261 data: serde_json::json!({
262 "action": "forget",
263 "id": id,
264 "path": path.display().to_string(),
265 }),
266 },
267 ..ToolRunMetadata::default()
268 }),
269 Ok(None) => ToolOutcome::error(format!("memory forget: no memory named '{id}'"), secs()),
270 Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), secs()),
271 }
272}
273
274fn finish(
276 start: std::time::Instant,
277 action: &str,
278 name: &str,
279 scope: MemoryScope,
280 path: &std::path::Path,
281) -> ToolOutcome {
282 let verb = if action == "remember" {
283 "Remembered"
284 } else {
285 "Updated"
286 };
287 ToolOutcome::success(
288 format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
289 format!("{action} {name} [{}]", scope.as_str()),
290 start.elapsed().as_secs_f64(),
291 )
292 .with_metadata(ToolRunMetadata {
293 detail: ToolMetadata::Custom {
294 name: "memory".to_string(),
295 data: serde_json::json!({
296 "action": action,
297 "name": name,
298 "scope": scope.as_str(),
299 "path": path.display().to_string(),
300 }),
301 },
302 ..ToolRunMetadata::default()
303 })
304}
305
306fn run_search(workdir: &std::path::Path, query: &str, start: std::time::Instant) -> ToolOutcome {
309 let hits = memory::search(workdir, query);
310 let secs = start.elapsed().as_secs_f64();
311 if hits.is_empty() {
312 return ToolOutcome::success(
313 format!("No memory matched '{query}'."),
314 format!("search '{query}': 0 hits"),
315 secs,
316 );
317 }
318 let mut body = format!("{} memory hit(s) for '{query}':\n", hits.len());
319 for hit in &hits {
320 body.push_str(&format!(
321 "- {} [{}] ({}) — {}\n",
322 hit.entry.name,
323 hit.entry.scope.as_str(),
324 hit.entry.path.display(),
325 hit.snippet,
326 ));
327 }
328 ToolOutcome::success(body, format!("search '{query}': {} hits", hits.len()), secs)
329}