1use 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 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 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 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
256fn 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
288fn 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}