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#[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        // `search` is a pure read (a targeted alternative to scanning the
132        // always-loaded index), so it runs before the mutation gate below and
133        // stays available in every safety mode, including read-only.
134        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        // Ungated except read-only: this returns `Some(block)` only when the
145        // policy denies (read-only mode); it never touches the approval broker.
146        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
172/// The `remember` action: write a new memory file from name + content.
173fn 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
200/// The `update` action: rewrite an existing memory in place (by id or name),
201/// keeping its scope and, unless given, its description.
202fn 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 the slug differs from the original file's stem
232            // (e.g. a hand-named file), drop the stale file so we
233            // don't leave a duplicate.
234            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
243/// The `forget` action: delete a memory by id or name.
244fn 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
278/// Shared success path for remember/update.
279fn 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
310/// Handle the read-only `search` action: substring-match memory and return the
311/// hits (name, scope, path, and a one-line snippet) as a compact text block.
312fn 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}