Skip to main content

opendev_tools_impl/
memory.rs

1//! Memory tool — search and write memory files for cross-session persistence.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
7
8/// Tool for managing persistent memory files.
9#[derive(Debug)]
10pub struct MemoryTool;
11
12impl MemoryTool {
13    /// Default memory directory under the user's home.
14    fn memory_dir() -> Option<PathBuf> {
15        dirs::home_dir().map(|h| h.join(".opendev").join("memory"))
16    }
17
18    /// Maximum file size to read (256 KB).
19    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    // Prevent path traversal
118    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            // Collect matching lines
203            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    // Sort by score descending
223    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;