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