1use 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 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 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 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
253fn 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
285fn 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}