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 only
6//! *changes* memory, with three actions:
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//!
11//! The directory chosen by `scope` is authoritative (private by default,
12//! `shared`/`global` opt-in). Writes are ungated in every safety mode except
13//! read-only (see `ToolCategory::Memory` in the policy engine) — there is no
14//! approval modal — so the gate here only blocks read-only.
15
16use async_trait::async_trait;
17
18use crate::app::memory::{self, MemoryScope};
19use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
20
21use super::super::ctx::ExecContext;
22use super::ToolExecutor;
23
24pub struct MemoryTool;
25
26fn scope_from_args(args: &serde_json::Value) -> MemoryScope {
27    if args
28        .get("global")
29        .and_then(|v| v.as_bool())
30        .unwrap_or(false)
31    {
32        MemoryScope::Global
33    } else if args
34        .get("shared")
35        .and_then(|v| v.as_bool())
36        .unwrap_or(false)
37    {
38        MemoryScope::ProjectShared
39    } else {
40        MemoryScope::ProjectPrivate
41    }
42}
43
44fn str_arg(args: &serde_json::Value, key: &str) -> Option<String> {
45    args.get(key)
46        .and_then(|v| v.as_str())
47        .map(str::trim)
48        .filter(|s| !s.is_empty())
49        .map(str::to_string)
50}
51
52fn tags_arg(args: &serde_json::Value) -> Vec<String> {
53    args.get("tags")
54        .and_then(|v| v.as_array())
55        .map(|a| {
56            a.iter()
57                .filter_map(|t| t.as_str())
58                .map(str::to_string)
59                .collect()
60        })
61        .unwrap_or_default()
62}
63
64#[async_trait]
65impl ToolExecutor for MemoryTool {
66    fn name(&self) -> &'static str {
67        "memory"
68    }
69
70    fn schema(&self) -> ToolDefinition {
71        ToolDefinition {
72            name: "memory".to_string(),
73            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. \
74                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. \
75                `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. \
76                Keep each fact atomic — one idea per memory — and never merge or re-summarize the whole corpus. \
77                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. \
78                Save durable knowledge, not transient task state, and 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                    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            crate::runtime::ToolCategory::Memory,
150            format!("memory {action}"),
151            &args,
152        )
153        .await
154        {
155            return blocked;
156        }
157
158        let secs = || start.elapsed().as_secs_f64();
159        match action.as_str() {
160            "remember" => {
161                let Some(name) = str_arg(&args, "name") else {
162                    return ToolOutcome::error("memory remember requires 'name'", secs());
163                };
164                let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
165                    return ToolOutcome::error("memory remember requires 'content'", secs());
166                };
167                let scope = scope_from_args(&args);
168                let description = str_arg(&args, "description").unwrap_or_else(|| {
169                    content
170                        .lines()
171                        .find(|l| !l.trim().is_empty())
172                        .unwrap_or("")
173                        .to_string()
174                });
175                let tags = tags_arg(&args);
176                match memory::write_memory(&ctx.workdir, scope, &name, &description, &tags, content)
177                {
178                    Ok(path) => finish(start, "remember", &name, scope, &path),
179                    Err(e) => ToolOutcome::error(format!("memory remember failed: {e}"), secs()),
180                }
181            },
182            "update" => {
183                let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
184                    return ToolOutcome::error("memory update requires 'id'", secs());
185                };
186                let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
187                    return ToolOutcome::error("memory update requires 'content'", secs());
188                };
189                let Some(existing) = memory::find(&ctx.workdir, &id) else {
190                    return ToolOutcome::error(
191                        format!("memory update: no memory named '{id}'"),
192                        secs(),
193                    );
194                };
195                let description =
196                    str_arg(&args, "description").unwrap_or_else(|| existing.description.clone());
197                let tags = tags_arg(&args);
198                match memory::write_memory(
199                    &ctx.workdir,
200                    existing.scope,
201                    &existing.name,
202                    &description,
203                    &tags,
204                    content,
205                ) {
206                    Ok(path) => {
207                        // If the slug differs from the original file's stem
208                        // (e.g. a hand-named file), drop the stale file so we
209                        // don't leave a duplicate.
210                        if path != existing.path {
211                            let _ = std::fs::remove_file(&existing.path);
212                        }
213                        finish(start, "update", &existing.name, existing.scope, &path)
214                    },
215                    Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), secs()),
216                }
217            },
218            "forget" => {
219                let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
220                    return ToolOutcome::error("memory forget requires 'id'", secs());
221                };
222                match memory::delete_memory(&ctx.workdir, &id) {
223                    Ok(Some(path)) => ToolOutcome::success(
224                        format!("Forgot memory '{id}' ({})", path.display()),
225                        format!("forgot {id}"),
226                        secs(),
227                    )
228                    .with_metadata(ToolRunMetadata {
229                        detail: ToolMetadata::Custom {
230                            name: "memory".to_string(),
231                            data: serde_json::json!({
232                                "action": "forget",
233                                "id": id,
234                                "path": path.display().to_string(),
235                            }),
236                        },
237                        ..ToolRunMetadata::default()
238                    }),
239                    Ok(None) => {
240                        ToolOutcome::error(format!("memory forget: no memory named '{id}'"), secs())
241                    },
242                    Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), secs()),
243                }
244            },
245            other => ToolOutcome::error(
246                format!("memory: unknown action '{other}' (expected remember, update, or forget)"),
247                secs(),
248            ),
249        }
250    }
251}
252
253/// Shared success path for remember/update.
254fn finish(
255    start: std::time::Instant,
256    action: &str,
257    name: &str,
258    scope: MemoryScope,
259    path: &std::path::Path,
260) -> ToolOutcome {
261    let verb = if action == "remember" {
262        "Remembered"
263    } else {
264        "Updated"
265    };
266    ToolOutcome::success(
267        format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
268        format!("{action} {name} [{}]", scope.as_str()),
269        start.elapsed().as_secs_f64(),
270    )
271    .with_metadata(ToolRunMetadata {
272        detail: ToolMetadata::Custom {
273            name: "memory".to_string(),
274            data: serde_json::json!({
275                "action": action,
276                "name": name,
277                "scope": scope.as_str(),
278                "path": path.display().to_string(),
279            }),
280        },
281        ..ToolRunMetadata::default()
282    })
283}
284
285/// Handle the read-only `search` action: substring-match memory and return the
286/// hits (name, scope, path, and a one-line snippet) as a compact text block.
287fn run_search(workdir: &std::path::Path, query: &str, start: std::time::Instant) -> ToolOutcome {
288    let hits = memory::search(workdir, query);
289    let secs = start.elapsed().as_secs_f64();
290    if hits.is_empty() {
291        return ToolOutcome::success(
292            format!("No memory matched '{query}'."),
293            format!("search '{query}': 0 hits"),
294            secs,
295        );
296    }
297    let mut body = format!("{} memory hit(s) for '{query}':\n", hits.len());
298    for hit in &hits {
299        body.push_str(&format!(
300            "- {} [{}] ({}) — {}\n",
301            hit.entry.name,
302            hit.entry.scope.as_str(),
303            hit.entry.path.display(),
304            hit.snippet,
305        ));
306    }
307    ToolOutcome::success(body, format!("search '{query}': {} hits", hits.len()), secs)
308}