Skip to main content

apollo/memory/
search.rs

1//! Memory search — full-text search over MEMORY.md + memory/*.md files
2//! Provides memory_search and memory_get as agent tools.
3
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use crate::memory::MemoryBackend;
8
9#[derive(Debug, Clone)]
10pub struct SearchResult {
11    pub path: String,
12    pub line_number: usize,
13    pub snippet: String,
14    pub score: f32,
15}
16
17fn truncate_text(input: &str, max_chars: usize) -> String {
18    input.chars().take(max_chars).collect()
19}
20
21/// Search MEMORY.md + memory/*.md for a query string
22pub fn memory_search(workspace: &Path, query: &str, max_results: usize) -> Vec<SearchResult> {
23    let query_lower = query.to_lowercase();
24    let query_words: Vec<&str> = query_lower.split_whitespace().collect();
25    let mut results = Vec::new();
26
27    // Files to search
28    let mut files: Vec<PathBuf> = Vec::new();
29
30    // MEMORY.md
31    let memory_md = workspace.join("MEMORY.md");
32    if memory_md.exists() {
33        files.push(memory_md);
34    }
35
36    // memory/*.md
37    let memory_dir = workspace.join("memory");
38    if memory_dir.is_dir() {
39        if let Ok(entries) = std::fs::read_dir(&memory_dir) {
40            for entry in entries.flatten() {
41                let path = entry.path();
42                if path.extension().is_some_and(|e| e == "md") {
43                    files.push(path);
44                }
45            }
46        }
47    }
48
49    for file in &files {
50        if let Ok(content) = std::fs::read_to_string(file) {
51            let rel_path = file
52                .strip_prefix(workspace)
53                .unwrap_or(file)
54                .to_string_lossy()
55                .to_string();
56
57            for (i, line) in content.lines().enumerate() {
58                let line_lower = line.to_lowercase();
59                let mut score = 0.0f32;
60
61                // Exact substring match
62                if line_lower.contains(&query_lower) {
63                    score += 10.0;
64                }
65
66                // Word-level matches
67                for word in &query_words {
68                    if line_lower.contains(word) {
69                        score += 2.0;
70                    }
71                }
72
73                if score > 0.0 {
74                    // Get context (surrounding lines)
75                    let lines: Vec<&str> = content.lines().collect();
76                    let start = i.saturating_sub(1);
77                    let end = (i + 2).min(lines.len());
78                    let snippet = lines[start..end].join("\n");
79
80                    results.push(SearchResult {
81                        path: rel_path.clone(),
82                        line_number: i + 1,
83                        snippet,
84                        score,
85                    });
86                }
87            }
88        }
89    }
90
91    // Sort by score descending
92    results.sort_by(|a, b| {
93        b.score
94            .partial_cmp(&a.score)
95            .unwrap_or(std::cmp::Ordering::Equal)
96    });
97    results.truncate(max_results);
98    results
99}
100
101/// Get specific lines from a memory file
102pub fn memory_get(
103    workspace: &Path,
104    file_path: &str,
105    from_line: usize,
106    num_lines: usize,
107) -> Option<String> {
108    let full_path = workspace.join(file_path);
109
110    // Security: ensure path stays within workspace
111    let canonical = full_path.canonicalize().ok()?;
112    let workspace_canonical = workspace.canonicalize().ok()?;
113    if !canonical.starts_with(&workspace_canonical) {
114        return None;
115    }
116
117    let content = std::fs::read_to_string(&full_path).ok()?;
118    let lines: Vec<&str> = content.lines().collect();
119
120    let start = from_line.saturating_sub(1); // 1-indexed to 0-indexed
121    let end = (start + num_lines).min(lines.len());
122
123    if start >= lines.len() {
124        return None;
125    }
126
127    Some(lines[start..end].join("\n"))
128}
129
130// -- Tool wrappers for the agent loop --
131
132use crate::tools::{Tool, ToolResult, ToolSpec};
133use async_trait::async_trait;
134use serde_json::json;
135
136/// Tool: memory_search — search memory files for a query.
137pub struct MemorySearchTool {
138    workspace: PathBuf,
139}
140
141impl MemorySearchTool {
142    pub fn new(workspace: PathBuf) -> Self {
143        Self { workspace }
144    }
145}
146
147#[async_trait]
148impl Tool for MemorySearchTool {
149    fn name(&self) -> &str {
150        "memory_search"
151    }
152
153    fn spec(&self) -> ToolSpec {
154        ToolSpec {
155            name: "memory_search".to_string(),
156            description:
157                "Search workspace memory files (MEMORY.md, memory/*.md) for relevant information."
158                    .to_string(),
159            parameters: json!({
160                "type": "object",
161                "properties": {
162                    "query": { "type": "string", "description": "Search query" },
163                    "limit": { "type": "integer", "description": "Maximum results (default 10)" }
164                },
165                "required": ["query"]
166            }),
167        }
168    }
169
170    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
171        let args: serde_json::Value = serde_json::from_str(arguments)?;
172        let query = args["query"].as_str().unwrap_or("");
173        let limit = args["limit"].as_u64().unwrap_or(10) as usize;
174
175        if query.is_empty() {
176            return Ok(ToolResult::error("Query is required"));
177        }
178
179        let results = memory_search(&self.workspace, query, limit);
180        if results.is_empty() {
181            return Ok(ToolResult::success("No results found."));
182        }
183
184        let output: Vec<String> = results
185            .iter()
186            .map(|r| {
187                format!(
188                    "{}:{} (score {:.1}) — {}",
189                    r.path,
190                    r.line_number,
191                    r.score,
192                    r.snippet.replace('\n', " | ")
193                )
194            })
195            .collect();
196
197        Ok(ToolResult::success(output.join("\n")))
198    }
199}
200
201/// Tool: memory_get — read lines from a memory file.
202pub struct MemoryGetTool {
203    workspace: PathBuf,
204}
205
206pub struct SessionSearchTool {
207    memory: Arc<dyn MemoryBackend>,
208}
209
210impl SessionSearchTool {
211    pub fn new(memory: Arc<dyn MemoryBackend>) -> Self {
212        Self { memory }
213    }
214}
215
216#[async_trait]
217impl Tool for SessionSearchTool {
218    fn name(&self) -> &str {
219        "session_search"
220    }
221
222    fn spec(&self) -> ToolSpec {
223        ToolSpec {
224            name: "session_search".to_string(),
225            description: "Search persisted conversation history across sessions or within a specific chat_id.".to_string(),
226            parameters: json!({
227                "type": "object",
228                "properties": {
229                    "query": { "type": "string", "description": "Search query" },
230                    "limit": { "type": "integer", "description": "Maximum results (default 10)" },
231                    "chat_id": { "type": "string", "description": "Optional chat/session identifier to restrict the search" }
232                },
233                "required": ["query"]
234            }),
235        }
236    }
237
238    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
239        let args: serde_json::Value = serde_json::from_str(arguments)?;
240        let query = args["query"].as_str().unwrap_or("");
241        let limit = args["limit"].as_u64().unwrap_or(10) as usize;
242        let chat_id = args["chat_id"].as_str();
243
244        if query.is_empty() {
245            return Ok(ToolResult::error("Query is required"));
246        }
247
248        let results = self
249            .memory
250            .search_conversations(query, limit, chat_id)
251            .await?;
252        if results.is_empty() {
253            return Ok(ToolResult::success("No matching conversation history."));
254        }
255
256        let output = results
257            .iter()
258            .map(|hit| {
259                format!(
260                    "{} [{}] {} — {}",
261                    hit.chat_id,
262                    hit.role,
263                    hit.created_at.to_rfc3339(),
264                    truncate_text(&hit.content.replace('\n', " | "), 160)
265                )
266            })
267            .collect::<Vec<_>>()
268            .join("\n");
269        Ok(ToolResult::success(output))
270    }
271}
272
273impl MemoryGetTool {
274    pub fn new(workspace: PathBuf) -> Self {
275        Self { workspace }
276    }
277}
278
279#[async_trait]
280impl Tool for MemoryGetTool {
281    fn name(&self) -> &str {
282        "memory_get"
283    }
284
285    fn spec(&self) -> ToolSpec {
286        ToolSpec {
287            name: "memory_get".to_string(),
288            description: "Read lines from a memory file in the workspace.".to_string(),
289            parameters: json!({
290                "type": "object",
291                "properties": {
292                    "path": { "type": "string", "description": "Relative path (e.g. MEMORY.md, memory/notes.md)" },
293                    "from_line": { "type": "integer", "description": "Starting line (1-based, default 1)" },
294                    "num_lines": { "type": "integer", "description": "Lines to read (default 50)" }
295                },
296                "required": ["path"]
297            }),
298        }
299    }
300
301    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
302        let args: serde_json::Value = serde_json::from_str(arguments)?;
303        let path = args["path"].as_str().unwrap_or("");
304        let from_line = args["from_line"].as_u64().unwrap_or(1) as usize;
305        let num_lines = args["num_lines"].as_u64().unwrap_or(50) as usize;
306
307        if path.is_empty() {
308            return Ok(ToolResult::error("Path is required"));
309        }
310
311        match memory_get(&self.workspace, path, from_line, num_lines) {
312            Some(content) => Ok(ToolResult::success(content)),
313            None => Ok(ToolResult::error("File not found or path not allowed")),
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use std::path::PathBuf;
322
323    #[test]
324    fn test_memory_search_nonexistent() {
325        let results = memory_search(&PathBuf::from("/nonexistent"), "test", 5);
326        assert!(results.is_empty());
327    }
328}