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 it. \
74                `action=remember` saves a new fact; `action=update` replaces one fact's body (pass its `id`); `action=forget` deletes a fact (pass its `id`). \
75                Keep each fact atomic — one idea per memory — and never merge or re-summarize the whole corpus. \
76                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. \
77                Save durable knowledge, not transient task state, and never store secrets, tokens, or PII."
78                .to_string(),
79            input_schema: serde_json::json!({
80                "type": "object",
81                "properties": {
82                    "action": {
83                        "type": "string",
84                        "enum": ["remember", "update", "forget"],
85                        "description": "What to do."
86                    },
87                    "name": {
88                        "type": "string",
89                        "description": "Short title for the fact; also the filename. Required for remember."
90                    },
91                    "content": {
92                        "type": "string",
93                        "description": "The fact itself (Markdown). Required for remember and update."
94                    },
95                    "description": {
96                        "type": "string",
97                        "description": "One-line summary shown in the always-loaded index. Defaults to the first line of content."
98                    },
99                    "tags": {
100                        "type": "array",
101                        "items": { "type": "string" },
102                        "description": "Optional keywords."
103                    },
104                    "shared": {
105                        "type": "boolean",
106                        "description": "Store in the committed project-shared scope (.mermaid/memory) instead of private."
107                    },
108                    "global": {
109                        "type": "boolean",
110                        "description": "Store in the global, cross-project scope."
111                    },
112                    "id": {
113                        "type": "string",
114                        "description": "Name/id of the fact to update or forget (as shown in the index)."
115                    }
116                },
117                "required": ["action"]
118            }),
119        }
120    }
121
122    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
123        let start = std::time::Instant::now();
124        let action = str_arg(&args, "action").unwrap_or_default();
125
126        // Ungated except read-only: this returns `Some(block)` only when the
127        // policy denies (read-only mode); it never touches the approval broker.
128        if let Some(blocked) = super::policy_gate::gate_external(
129            &ctx,
130            "memory",
131            crate::runtime::ToolCategory::Memory,
132            format!("memory {action}"),
133            &args,
134        )
135        .await
136        {
137            return blocked;
138        }
139
140        let secs = || start.elapsed().as_secs_f64();
141        match action.as_str() {
142            "remember" => {
143                let Some(name) = str_arg(&args, "name") else {
144                    return ToolOutcome::error("memory remember requires 'name'", secs());
145                };
146                let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
147                    return ToolOutcome::error("memory remember requires 'content'", secs());
148                };
149                let scope = scope_from_args(&args);
150                let description = str_arg(&args, "description").unwrap_or_else(|| {
151                    content
152                        .lines()
153                        .find(|l| !l.trim().is_empty())
154                        .unwrap_or("")
155                        .to_string()
156                });
157                let tags = tags_arg(&args);
158                match memory::write_memory(&ctx.workdir, scope, &name, &description, &tags, content)
159                {
160                    Ok(path) => finish(start, "remember", &name, scope, &path),
161                    Err(e) => ToolOutcome::error(format!("memory remember failed: {e}"), secs()),
162                }
163            },
164            "update" => {
165                let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
166                    return ToolOutcome::error("memory update requires 'id'", secs());
167                };
168                let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
169                    return ToolOutcome::error("memory update requires 'content'", secs());
170                };
171                let Some(existing) = memory::find(&ctx.workdir, &id) else {
172                    return ToolOutcome::error(
173                        format!("memory update: no memory named '{id}'"),
174                        secs(),
175                    );
176                };
177                let description =
178                    str_arg(&args, "description").unwrap_or_else(|| existing.description.clone());
179                let tags = tags_arg(&args);
180                match memory::write_memory(
181                    &ctx.workdir,
182                    existing.scope,
183                    &existing.name,
184                    &description,
185                    &tags,
186                    content,
187                ) {
188                    Ok(path) => {
189                        // If the slug differs from the original file's stem
190                        // (e.g. a hand-named file), drop the stale file so we
191                        // don't leave a duplicate.
192                        if path != existing.path {
193                            let _ = std::fs::remove_file(&existing.path);
194                        }
195                        finish(start, "update", &existing.name, existing.scope, &path)
196                    },
197                    Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), secs()),
198                }
199            },
200            "forget" => {
201                let Some(id) = str_arg(&args, "id").or_else(|| str_arg(&args, "name")) else {
202                    return ToolOutcome::error("memory forget requires 'id'", secs());
203                };
204                match memory::delete_memory(&ctx.workdir, &id) {
205                    Ok(Some(path)) => ToolOutcome::success(
206                        format!("Forgot memory '{id}' ({})", path.display()),
207                        format!("forgot {id}"),
208                        secs(),
209                    )
210                    .with_metadata(ToolRunMetadata {
211                        detail: ToolMetadata::Custom {
212                            name: "memory".to_string(),
213                            data: serde_json::json!({
214                                "action": "forget",
215                                "id": id,
216                                "path": path.display().to_string(),
217                            }),
218                        },
219                        ..ToolRunMetadata::default()
220                    }),
221                    Ok(None) => {
222                        ToolOutcome::error(format!("memory forget: no memory named '{id}'"), secs())
223                    },
224                    Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), secs()),
225                }
226            },
227            other => ToolOutcome::error(
228                format!("memory: unknown action '{other}' (expected remember, update, or forget)"),
229                secs(),
230            ),
231        }
232    }
233}
234
235/// Shared success path for remember/update.
236fn finish(
237    start: std::time::Instant,
238    action: &str,
239    name: &str,
240    scope: MemoryScope,
241    path: &std::path::Path,
242) -> ToolOutcome {
243    let verb = if action == "remember" {
244        "Remembered"
245    } else {
246        "Updated"
247    };
248    ToolOutcome::success(
249        format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
250        format!("{action} {name} [{}]", scope.as_str()),
251        start.elapsed().as_secs_f64(),
252    )
253    .with_metadata(ToolRunMetadata {
254        detail: ToolMetadata::Custom {
255            name: "memory".to_string(),
256            data: serde_json::json!({
257                "action": action,
258                "name": name,
259                "scope": scope.as_str(),
260                "path": path.display().to_string(),
261            }),
262        },
263        ..ToolRunMetadata::default()
264    })
265}