1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
7
8#[derive(Debug)]
10pub struct MemoryTool;
11
12impl MemoryTool {
13 fn memory_dir() -> Option<PathBuf> {
15 dirs::home_dir().map(|h| h.join(".opendev").join("memory"))
16 }
17
18 const MAX_READ_SIZE: u64 = 256 * 1024;
20}
21
22#[async_trait::async_trait]
23impl BaseTool for MemoryTool {
24 fn name(&self) -> &str {
25 "memory"
26 }
27
28 fn description(&self) -> &str {
29 "Read, write, or search persistent memory files stored in ~/.opendev/memory/."
30 }
31
32 fn parameter_schema(&self) -> serde_json::Value {
33 serde_json::json!({
34 "type": "object",
35 "properties": {
36 "action": {
37 "type": "string",
38 "enum": ["read", "write", "search", "list"],
39 "description": "Action to perform"
40 },
41 "file": {
42 "type": "string",
43 "description": "Memory file name (e.g., 'patterns.md')"
44 },
45 "content": {
46 "type": "string",
47 "description": "Content to write (for write action)"
48 },
49 "query": {
50 "type": "string",
51 "description": "Search query (for search action)"
52 }
53 },
54 "required": ["action"]
55 })
56 }
57
58 async fn execute(
59 &self,
60 args: HashMap<String, serde_json::Value>,
61 _ctx: &ToolContext,
62 ) -> ToolResult {
63 let action = match args.get("action").and_then(|v| v.as_str()) {
64 Some(a) => a,
65 None => return ToolResult::fail("action is required"),
66 };
67
68 let memory_dir = match Self::memory_dir() {
69 Some(d) => d,
70 None => return ToolResult::fail("Cannot determine home directory"),
71 };
72
73 match action {
74 "read" => {
75 let file = match args.get("file").and_then(|v| v.as_str()) {
76 Some(f) => f,
77 None => return ToolResult::fail("file is required for read"),
78 };
79 memory_read(&memory_dir, file)
80 }
81 "write" => {
82 let file = match args.get("file").and_then(|v| v.as_str()) {
83 Some(f) => f,
84 None => return ToolResult::fail("file is required for write"),
85 };
86 let content = match args.get("content").and_then(|v| v.as_str()) {
87 Some(c) => c,
88 None => return ToolResult::fail("content is required for write"),
89 };
90 memory_write(&memory_dir, file, content)
91 }
92 "search" => {
93 let query = match args.get("query").and_then(|v| v.as_str()) {
94 Some(q) => q,
95 None => return ToolResult::fail("query is required for search"),
96 };
97 memory_search(&memory_dir, query)
98 }
99 "list" => memory_list(&memory_dir),
100 _ => ToolResult::fail(format!(
101 "Unknown action: {action}. Available: read, write, search, list"
102 )),
103 }
104 }
105
106 fn display_meta(&self) -> Option<ToolDisplayMeta> {
107 Some(ToolDisplayMeta {
108 verb: "Memory",
109 label: "memory",
110 category: "Other",
111 primary_arg_keys: &["action", "file", "query"],
112 })
113 }
114}
115
116fn memory_read(dir: &Path, file: &str) -> ToolResult {
117 if file.contains("..") || file.starts_with('/') {
119 return ToolResult::fail("Invalid file name (no path traversal allowed)");
120 }
121
122 let path = dir.join(file);
123 if !path.exists() {
124 return ToolResult::fail(format!("Memory file not found: {file}"));
125 }
126
127 match std::fs::metadata(&path) {
128 Ok(m) if m.len() > MemoryTool::MAX_READ_SIZE => {
129 return ToolResult::fail(format!(
130 "Memory file too large ({} bytes, max {})",
131 m.len(),
132 MemoryTool::MAX_READ_SIZE
133 ));
134 }
135 Err(e) => return ToolResult::fail(format!("Cannot read file: {e}")),
136 _ => {}
137 }
138
139 match std::fs::read_to_string(&path) {
140 Ok(content) => ToolResult::ok(content),
141 Err(e) => ToolResult::fail(format!("Failed to read {file}: {e}")),
142 }
143}
144
145fn memory_write(dir: &Path, file: &str, content: &str) -> ToolResult {
146 if file.contains("..") || file.starts_with('/') {
147 return ToolResult::fail("Invalid file name (no path traversal allowed)");
148 }
149
150 if let Err(e) = std::fs::create_dir_all(dir) {
151 return ToolResult::fail(format!("Failed to create memory directory: {e}"));
152 }
153
154 let path = dir.join(file);
155 match std::fs::write(&path, content) {
156 Ok(_) => ToolResult::ok(format!("Written {} bytes to {file}", content.len())),
157 Err(e) => ToolResult::fail(format!("Failed to write {file}: {e}")),
158 }
159}
160
161fn memory_search(dir: &Path, query: &str) -> ToolResult {
162 if !dir.exists() {
163 return ToolResult::ok("No memory files found (directory does not exist)".to_string());
164 }
165
166 let query_lower = query.to_lowercase();
167 let keywords: Vec<&str> = query_lower.split_whitespace().collect();
168 if keywords.is_empty() {
169 return ToolResult::fail("Search query cannot be empty");
170 }
171
172 let mut results = Vec::new();
173
174 let entries = match std::fs::read_dir(dir) {
175 Ok(e) => e,
176 Err(e) => return ToolResult::fail(format!("Failed to read memory directory: {e}")),
177 };
178
179 for entry in entries.flatten() {
180 let path = entry.path();
181 if !path.is_file() {
182 continue;
183 }
184
185 let content = match std::fs::read_to_string(&path) {
186 Ok(c) => c,
187 Err(_) => continue,
188 };
189
190 let content_lower = content.to_lowercase();
191 let score: usize = keywords
192 .iter()
193 .filter(|kw| content_lower.contains(*kw))
194 .count();
195
196 if score > 0 {
197 let filename = path
198 .file_name()
199 .map(|n| n.to_string_lossy().to_string())
200 .unwrap_or_default();
201
202 let mut matching_lines = Vec::new();
204 for (i, line) in content.lines().enumerate() {
205 let line_lower = line.to_lowercase();
206 if keywords.iter().any(|kw| line_lower.contains(*kw)) {
207 matching_lines.push(format!(" {}:{}: {}", filename, i + 1, line));
208 if matching_lines.len() >= 5 {
209 break;
210 }
211 }
212 }
213
214 results.push((score, filename, matching_lines));
215 }
216 }
217
218 if results.is_empty() {
219 return ToolResult::ok(format!("No matches found for '{query}'"));
220 }
221
222 results.sort_by(|a, b| b.0.cmp(&a.0));
224
225 let mut output = format!("Found matches in {} files:\n\n", results.len());
226 for (score, filename, lines) in &results {
227 output.push_str(&format!(
228 "{filename} (relevance: {score}/{}):\n",
229 keywords.len()
230 ));
231 for line in lines {
232 output.push_str(&format!("{line}\n"));
233 }
234 output.push('\n');
235 }
236
237 ToolResult::ok(output)
238}
239
240fn memory_list(dir: &Path) -> ToolResult {
241 if !dir.exists() {
242 return ToolResult::ok("No memory files (directory does not exist)".to_string());
243 }
244
245 let entries = match std::fs::read_dir(dir) {
246 Ok(e) => e,
247 Err(e) => return ToolResult::fail(format!("Failed to read memory directory: {e}")),
248 };
249
250 let mut files: Vec<(String, u64)> = Vec::new();
251 for entry in entries.flatten() {
252 let path = entry.path();
253 if path.is_file() {
254 let name = path
255 .file_name()
256 .map(|n| n.to_string_lossy().to_string())
257 .unwrap_or_default();
258 let size = path.metadata().map(|m| m.len()).unwrap_or(0);
259 files.push((name, size));
260 }
261 }
262
263 files.sort_by(|a, b| a.0.cmp(&b.0));
264
265 if files.is_empty() {
266 return ToolResult::ok("No memory files found".to_string());
267 }
268
269 let mut output = format!("Memory files ({}):\n", files.len());
270 for (name, size) in &files {
271 output.push_str(&format!(" {name} ({size} bytes)\n"));
272 }
273
274 ToolResult::ok(output)
275}
276
277#[cfg(test)]
278#[path = "memory_tests.rs"]
279mod tests;