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 Some(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 Some(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'", Some(secs()));
183 };
184 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
185 return ToolOutcome::error("memory remember requires 'content'", Some(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}"), Some(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'", Some(secs()));
212 };
213 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
214 return ToolOutcome::error("memory update requires 'content'", Some(secs()));
215 };
216 let Some(existing) = memory::find(workdir, &id) else {
217 return ToolOutcome::error(
218 format!("memory update: no memory named '{id}'"),
219 Some(secs()),
220 );
221 };
222 let description = str_arg(args, "description").unwrap_or_else(|| existing.description.clone());
223 let tags = tags_arg(args);
224 match memory::write_memory(
225 workdir,
226 existing.scope,
227 &existing.name,
228 &description,
229 &tags,
230 content,
231 ) {
232 Ok(path) => {
233 if path != existing.path {
237 let _ = std::fs::remove_file(&existing.path);
238 }
239 finish(start, "update", &existing.name, existing.scope, &path)
240 },
241 Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), Some(secs())),
242 }
243}
244
245fn run_forget(
247 workdir: &std::path::Path,
248 args: &serde_json::Value,
249 start: std::time::Instant,
250) -> ToolOutcome {
251 let secs = || start.elapsed().as_secs_f64();
252 let Some(id) = str_arg(args, "id").or_else(|| str_arg(args, "name")) else {
253 return ToolOutcome::error("memory forget requires 'id'", Some(secs()));
254 };
255 match memory::delete_memory(workdir, &id) {
256 Ok(Some(path)) => ToolOutcome::success(
257 format!("Forgot memory '{id}' ({})", path.display()),
258 format!("forgot {id}"),
259 secs(),
260 )
261 .with_metadata(ToolRunMetadata {
262 detail: ToolMetadata::Custom {
263 name: "memory".to_string(),
264 data: serde_json::json!({
265 "action": "forget",
266 "id": id,
267 "path": path.display().to_string(),
268 }),
269 },
270 ..ToolRunMetadata::default()
271 }),
272 Ok(None) => ToolOutcome::error(
273 format!("memory forget: no memory named '{id}'"),
274 Some(secs()),
275 ),
276 Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), Some(secs())),
277 }
278}
279
280fn finish(
282 start: std::time::Instant,
283 action: &str,
284 name: &str,
285 scope: MemoryScope,
286 path: &std::path::Path,
287) -> ToolOutcome {
288 let verb = if action == "remember" {
289 "Remembered"
290 } else {
291 "Updated"
292 };
293 ToolOutcome::success(
294 format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
295 format!("{action} {name} [{}]", scope.as_str()),
296 start.elapsed().as_secs_f64(),
297 )
298 .with_metadata(ToolRunMetadata {
299 detail: ToolMetadata::Custom {
300 name: "memory".to_string(),
301 data: serde_json::json!({
302 "action": action,
303 "name": name,
304 "scope": scope.as_str(),
305 "path": path.display().to_string(),
306 }),
307 },
308 ..ToolRunMetadata::default()
309 })
310}
311
312fn run_search(workdir: &std::path::Path, query: &str, start: std::time::Instant) -> ToolOutcome {
315 let hits = memory::search(workdir, query);
316 let secs = start.elapsed().as_secs_f64();
317 if hits.is_empty() {
318 return ToolOutcome::success(
319 format!("No memory matched '{query}'."),
320 format!("search '{query}': 0 hits"),
321 secs,
322 );
323 }
324 let mut body = format!("{} memory hit(s) for '{query}':\n", hits.len());
325 for hit in &hits {
326 body.push_str(&format!(
327 "- {} [{}] ({}) — {}\n",
328 hit.entry.name,
329 hit.entry.scope.as_str(),
330 hit.entry.path.display(),
331 hit.snippet,
332 ));
333 }
334 ToolOutcome::success(body, format!("search '{query}': {} hits", hits.len()), secs)
335}