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, MemoryScope};
20use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21
22use super::super::ctx::ExecContext;
23use super::ToolExecutor;
24
25pub struct MemoryTool;
26
27fn scope_from_args(args: &serde_json::Value) -> MemoryScope {
28    if args
29        .get("global")
30        .and_then(|v| v.as_bool())
31        .unwrap_or(false)
32    {
33        MemoryScope::Global
34    } else if args
35        .get("shared")
36        .and_then(|v| v.as_bool())
37        .unwrap_or(false)
38    {
39        MemoryScope::ProjectShared
40    } else {
41        MemoryScope::ProjectPrivate
42    }
43}
44
45fn str_arg(args: &serde_json::Value, key: &str) -> Option<String> {
46    args.get(key)
47        .and_then(|v| v.as_str())
48        .map(str::trim)
49        .filter(|s| !s.is_empty())
50        .map(str::to_string)
51}
52
53fn tags_arg(args: &serde_json::Value) -> Vec<String> {
54    args.get("tags")
55        .and_then(|v| v.as_array())
56        .map(|a| {
57            a.iter()
58                .filter_map(|t| t.as_str())
59                .map(str::to_string)
60                .collect()
61        })
62        .unwrap_or_default()
63}
64
65#[async_trait]
66impl ToolExecutor for MemoryTool {
67    fn name(&self) -> &'static str {
68        "memory"
69    }
70
71    fn schema(&self) -> ToolDefinition {
72        ToolDefinition {
73            name: "memory".to_string(),
74            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. \
75                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. \
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                Keep each fact atomic — one idea per memory — and never merge or re-summarize the whole corpus. \
78                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. \
79                Save durable knowledge, not transient task state, and never store secrets, tokens, or PII."
80                .to_string(),
81            input_schema: serde_json::json!({
82                "type": "object",
83                "properties": {
84                    "action": {
85                        "type": "string",
86                        "enum": ["remember", "update", "forget", "search"],
87                        "description": "What to do."
88                    },
89                    "query": {
90                        "type": "string",
91                        "description": "Keyword(s) to search for across memory names, descriptions, and bodies. Required for search."
92                    },
93                    "name": {
94                        "type": "string",
95                        "description": "Short title for the fact; also the filename. Required for remember."
96                    },
97                    "content": {
98                        "type": "string",
99                        "description": "The fact itself (Markdown). Required for remember and update."
100                    },
101                    "description": {
102                        "type": "string",
103                        "description": "One-line summary shown in the always-loaded index. Defaults to the first line of content."
104                    },
105                    "tags": {
106                        "type": "array",
107                        "items": { "type": "string" },
108                        "description": "Optional keywords."
109                    },
110                    "shared": {
111                        "type": "boolean",
112                        "description": "Store in the committed project-shared scope (.mermaid/memory) instead of private."
113                    },
114                    "global": {
115                        "type": "boolean",
116                        "description": "Store in the global, cross-project scope."
117                    },
118                    "id": {
119                        "type": "string",
120                        "description": "Name/id of the fact to update or forget (as shown in the index)."
121                    }
122                },
123                "required": ["action"]
124            }),
125        }
126    }
127
128    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
129        let start = std::time::Instant::now();
130        let action = str_arg(&args, "action").unwrap_or_default();
131
132        // `search` is a pure read (a targeted alternative to scanning the
133        // always-loaded index), so it runs before the mutation gate below and
134        // stays available in every safety mode, including read-only.
135        if action == "search" {
136            let Some(query) = str_arg(&args, "query") else {
137                return ToolOutcome::error(
138                    "memory search requires 'query'",
139                    start.elapsed().as_secs_f64(),
140                );
141            };
142            return run_search(&ctx.workdir, &query, start);
143        }
144
145        // Ungated except read-only: this returns `Some(block)` only when the
146        // policy denies (read-only mode); it never touches the approval broker.
147        if let Some(blocked) = super::policy_gate::gate_external(
148            &ctx,
149            "memory",
150            crate::runtime::ToolCategory::Memory,
151            format!("memory {action}"),
152            &args,
153        )
154        .await
155        {
156            return blocked;
157        }
158
159        let secs = || start.elapsed().as_secs_f64();
160        match action.as_str() {
161            "remember" => {
162                let Some(name) = str_arg(&args, "name") else {
163                    return ToolOutcome::error("memory remember requires 'name'", secs());
164                };
165                let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
166                    return ToolOutcome::error("memory remember requires 'content'", secs());
167                };
168                let scope = scope_from_args(&args);
169                let description = str_arg(&args, "description").unwrap_or_else(|| {
170                    content
171                        .lines()
172                        .find(|l| !l.trim().is_empty())
173                        .unwrap_or("")
174                        .to_string()
175                });
176                let tags = tags_arg(&args);
177                match memory::write_memory(&ctx.workdir, scope, &name, &description, &tags, content)
178                {
179                    Ok(path) => finish(start, "remember", &name, scope, &path),
180                    Err(e) => ToolOutcome::error(format!("memory remember failed: {e}"), secs()),
181                }
182            },
183            "update" => {
184                let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
185                    return ToolOutcome::error("memory update requires 'id'", secs());
186                };
187                let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
188                    return ToolOutcome::error("memory update requires 'content'", secs());
189                };
190                let Some(existing) = memory::find(&ctx.workdir, &id) else {
191                    return ToolOutcome::error(
192                        format!("memory update: no memory named '{id}'"),
193                        secs(),
194                    );
195                };
196                let description =
197                    str_arg(&args, "description").unwrap_or_else(|| existing.description.clone());
198                let tags = tags_arg(&args);
199                match memory::write_memory(
200                    &ctx.workdir,
201                    existing.scope,
202                    &existing.name,
203                    &description,
204                    &tags,
205                    content,
206                ) {
207                    Ok(path) => {
208                        // If the slug differs from the original file's stem
209                        // (e.g. a hand-named file), drop the stale file so we
210                        // don't leave a duplicate.
211                        if path != existing.path {
212                            let _ = std::fs::remove_file(&existing.path);
213                        }
214                        finish(start, "update", &existing.name, existing.scope, &path)
215                    },
216                    Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), secs()),
217                }
218            },
219            "forget" => {
220                let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
221                    return ToolOutcome::error("memory forget requires 'id'", secs());
222                };
223                match memory::delete_memory(&ctx.workdir, &id) {
224                    Ok(Some(path)) => ToolOutcome::success(
225                        format!("Forgot memory '{id}' ({})", path.display()),
226                        format!("forgot {id}"),
227                        secs(),
228                    )
229                    .with_metadata(ToolRunMetadata {
230                        detail: ToolMetadata::Custom {
231                            name: "memory".to_string(),
232                            data: serde_json::json!({
233                                "action": "forget",
234                                "id": id,
235                                "path": path.display().to_string(),
236                            }),
237                        },
238                        ..ToolRunMetadata::default()
239                    }),
240                    Ok(None) => {
241                        ToolOutcome::error(format!("memory forget: no memory named '{id}'"), secs())
242                    },
243                    Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), secs()),
244                }
245            },
246            other => ToolOutcome::error(
247                format!(
248                    "memory: unknown action '{other}' (expected remember, update, forget, or search)"
249                ),
250                secs(),
251            ),
252        }
253    }
254}
255
256/// Shared success path for remember/update.
257fn finish(
258    start: std::time::Instant,
259    action: &str,
260    name: &str,
261    scope: MemoryScope,
262    path: &std::path::Path,
263) -> ToolOutcome {
264    let verb = if action == "remember" {
265        "Remembered"
266    } else {
267        "Updated"
268    };
269    ToolOutcome::success(
270        format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
271        format!("{action} {name} [{}]", scope.as_str()),
272        start.elapsed().as_secs_f64(),
273    )
274    .with_metadata(ToolRunMetadata {
275        detail: ToolMetadata::Custom {
276            name: "memory".to_string(),
277            data: serde_json::json!({
278                "action": action,
279                "name": name,
280                "scope": scope.as_str(),
281                "path": path.display().to_string(),
282            }),
283        },
284        ..ToolRunMetadata::default()
285    })
286}
287
288/// Handle the read-only `search` action: substring-match memory and return the
289/// hits (name, scope, path, and a one-line snippet) as a compact text block.
290fn run_search(workdir: &std::path::Path, query: &str, start: std::time::Instant) -> ToolOutcome {
291    let hits = memory::search(workdir, query);
292    let secs = start.elapsed().as_secs_f64();
293    if hits.is_empty() {
294        return ToolOutcome::success(
295            format!("No memory matched '{query}'."),
296            format!("search '{query}': 0 hits"),
297            secs,
298        );
299    }
300    let mut body = format!("{} memory hit(s) for '{query}':\n", hits.len());
301    for hit in &hits {
302        body.push_str(&format!(
303            "- {} [{}] ({}) — {}\n",
304            hit.entry.name,
305            hit.entry.scope.as_str(),
306            hit.entry.path.display(),
307            hit.snippet,
308        ));
309    }
310    ToolOutcome::success(body, format!("search '{query}': {} hits", hits.len()), secs)
311}