Skip to main content

mermaid_cli/providers/tool/
memory.rs

1//! The `memory` tool — the agent's autonomous write path into durable
2//! semantic memory.
3//!
4//! Reading needs no tool: the memory index is always in context and the full
5//! facts are fetched with `read_file` on the listed paths. This tool changes
6//! memory (plus one lookup verb):
7//!   - `remember` — create a new atomic fact.
8//!   - `update`   — replace one fact's body (clean replace, never a merge).
9//!   - `forget`   — delete a fact.
10//!   - `search`   — find facts by keyword across names/descriptions/bodies.
11//!
12//! The directory chosen by `scope` is authoritative (private by default,
13//! `shared`/`global` opt-in). Writes are ungated in every safety mode except
14//! read-only (see `ToolCategory::Memory` in the policy engine) — there is no
15//! approval modal — so the gate here only blocks read-only.
16
17use 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        // `search` is a pure read (a targeted alternative to scanning the
138        // always-loaded index), so it runs before the mutation gate below and
139        // stays available in every safety mode, including read-only.
140        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        // Ungated except read-only: this returns `Some(block)` only when the
151        // policy denies (read-only mode); it never touches the approval broker.
152        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 the slug differs from the original file's stem
214                        // (e.g. a hand-named file), drop the stale file so we
215                        // don't leave a duplicate.
216                        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
261/// Shared success path for remember/update.
262fn 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
293/// Handle the read-only `search` action: substring-match memory and return the
294/// hits (name, scope, path, and a one-line snippet) as a compact text block.
295fn 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}