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#[expect(
67 clippy::too_many_lines,
68 reason = "predates the lint; see .github/baselines/expect_budget.txt"
69)]
70#[async_trait]
71impl ToolExecutor for MemoryTool {
72 fn name(&self) -> &'static str {
73 "memory"
74 }
75
76 fn schema(&self) -> ToolDefinition {
77 ToolDefinition {
78 name: "memory".to_string(),
79 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. \
80 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. \
81 `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. \
82 Keep each fact atomic — one idea per memory — and never merge or re-summarize the whole corpus. \
83 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. \
84 Save durable knowledge, not transient task state, and never store secrets, tokens, or PII."
85 .to_string(),
86 input_schema: serde_json::json!({
87 "type": "object",
88 "properties": {
89 "action": {
90 "type": "string",
91 "enum": ["remember", "update", "forget", "search"],
92 "description": "What to do."
93 },
94 "query": {
95 "type": "string",
96 "description": "Keyword(s) to search for across memory names, descriptions, and bodies. Required for search."
97 },
98 "name": {
99 "type": "string",
100 "description": "Short title for the fact; also the filename. Required for remember."
101 },
102 "content": {
103 "type": "string",
104 "description": "The fact itself (Markdown). Required for remember and update."
105 },
106 "description": {
107 "type": "string",
108 "description": "One-line summary shown in the always-loaded index. Defaults to the first line of content."
109 },
110 "tags": {
111 "type": "array",
112 "items": { "type": "string" },
113 "description": "Optional keywords."
114 },
115 "shared": {
116 "type": "boolean",
117 "description": "Store in the committed project-shared scope (.mermaid/memory) instead of private."
118 },
119 "global": {
120 "type": "boolean",
121 "description": "Store in the global, cross-project scope."
122 },
123 "id": {
124 "type": "string",
125 "description": "Name/id of the fact to update or forget (as shown in the index)."
126 }
127 },
128 "required": ["action"]
129 }),
130 }
131 }
132
133 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
134 let start = std::time::Instant::now();
135 let action = str_arg(&args, "action").unwrap_or_default();
136
137 if action == "search" {
141 let Some(query) = str_arg(&args, "query") else {
142 return ToolOutcome::error(
143 "memory search requires 'query'",
144 start.elapsed().as_secs_f64(),
145 );
146 };
147 return run_search(&ctx.workdir, &query, start);
148 }
149
150 if let Some(blocked) = super::policy_gate::gate_external(
153 &ctx,
154 "memory",
155 mermaid_runtime::ToolCategory::Memory,
156 format!("memory {action}"),
157 &args,
158 )
159 .await
160 {
161 return blocked;
162 }
163
164 let secs = || start.elapsed().as_secs_f64();
165 match action.as_str() {
166 "remember" => {
167 let Some(name) = str_arg(&args, "name") else {
168 return ToolOutcome::error("memory remember requires 'name'", secs());
169 };
170 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
171 return ToolOutcome::error("memory remember requires 'content'", secs());
172 };
173 let scope = scope_from_args(&args);
174 let description = str_arg(&args, "description").unwrap_or_else(|| {
175 content
176 .lines()
177 .find(|l| !l.trim().is_empty())
178 .unwrap_or("")
179 .to_string()
180 });
181 let tags = tags_arg(&args);
182 match memory::write_memory(&ctx.workdir, scope, &name, &description, &tags, content)
183 {
184 Ok(path) => finish(start, "remember", &name, scope, &path),
185 Err(e) => ToolOutcome::error(format!("memory remember failed: {e}"), secs()),
186 }
187 },
188 "update" => {
189 let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
190 return ToolOutcome::error("memory update requires 'id'", secs());
191 };
192 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
193 return ToolOutcome::error("memory update requires 'content'", secs());
194 };
195 let Some(existing) = memory::find(&ctx.workdir, &id) else {
196 return ToolOutcome::error(
197 format!("memory update: no memory named '{id}'"),
198 secs(),
199 );
200 };
201 let description =
202 str_arg(&args, "description").unwrap_or_else(|| existing.description.clone());
203 let tags = tags_arg(&args);
204 match memory::write_memory(
205 &ctx.workdir,
206 existing.scope,
207 &existing.name,
208 &description,
209 &tags,
210 content,
211 ) {
212 Ok(path) => {
213 if path != existing.path {
217 let _ = std::fs::remove_file(&existing.path);
218 }
219 finish(start, "update", &existing.name, existing.scope, &path)
220 },
221 Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), secs()),
222 }
223 },
224 "forget" => {
225 let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
226 return ToolOutcome::error("memory forget requires 'id'", secs());
227 };
228 match memory::delete_memory(&ctx.workdir, &id) {
229 Ok(Some(path)) => ToolOutcome::success(
230 format!("Forgot memory '{id}' ({})", path.display()),
231 format!("forgot {id}"),
232 secs(),
233 )
234 .with_metadata(ToolRunMetadata {
235 detail: ToolMetadata::Custom {
236 name: "memory".to_string(),
237 data: serde_json::json!({
238 "action": "forget",
239 "id": id,
240 "path": path.display().to_string(),
241 }),
242 },
243 ..ToolRunMetadata::default()
244 }),
245 Ok(None) => {
246 ToolOutcome::error(format!("memory forget: no memory named '{id}'"), secs())
247 },
248 Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), secs()),
249 }
250 },
251 other => ToolOutcome::error(
252 format!(
253 "memory: unknown action '{other}' (expected remember, update, forget, or search)"
254 ),
255 secs(),
256 ),
257 }
258 }
259}
260
261fn finish(
263 start: std::time::Instant,
264 action: &str,
265 name: &str,
266 scope: MemoryScope,
267 path: &std::path::Path,
268) -> ToolOutcome {
269 let verb = if action == "remember" {
270 "Remembered"
271 } else {
272 "Updated"
273 };
274 ToolOutcome::success(
275 format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
276 format!("{action} {name} [{}]", scope.as_str()),
277 start.elapsed().as_secs_f64(),
278 )
279 .with_metadata(ToolRunMetadata {
280 detail: ToolMetadata::Custom {
281 name: "memory".to_string(),
282 data: serde_json::json!({
283 "action": action,
284 "name": name,
285 "scope": scope.as_str(),
286 "path": path.display().to_string(),
287 }),
288 },
289 ..ToolRunMetadata::default()
290 })
291}
292
293fn run_search(workdir: &std::path::Path, query: &str, start: std::time::Instant) -> ToolOutcome {
296 let hits = memory::search(workdir, query);
297 let secs = start.elapsed().as_secs_f64();
298 if hits.is_empty() {
299 return ToolOutcome::success(
300 format!("No memory matched '{query}'."),
301 format!("search '{query}': 0 hits"),
302 secs,
303 );
304 }
305 let mut body = format!("{} memory hit(s) for '{query}':\n", hits.len());
306 for hit in &hits {
307 body.push_str(&format!(
308 "- {} [{}] ({}) — {}\n",
309 hit.entry.name,
310 hit.entry.scope.as_str(),
311 hit.entry.path.display(),
312 hit.snippet,
313 ));
314 }
315 ToolOutcome::success(body, format!("search '{query}': {} hits", hits.len()), secs)
316}