1use 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
21pub 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 let mut files: Vec<PathBuf> = Vec::new();
29
30 let memory_md = workspace.join("MEMORY.md");
32 if memory_md.exists() {
33 files.push(memory_md);
34 }
35
36 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 if line_lower.contains(&query_lower) {
63 score += 10.0;
64 }
65
66 for word in &query_words {
68 if line_lower.contains(word) {
69 score += 2.0;
70 }
71 }
72
73 if score > 0.0 {
74 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 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
101pub 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 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); 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
130use crate::tools::{Tool, ToolResult, ToolSpec};
133use async_trait::async_trait;
134use serde_json::json;
135
136pub 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
201pub 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}