1use anyhow::{Context, Result};
9use rusqlite::Connection;
10use serde_json::json;
11use std::path::Path;
12
13use crate::types::{FunctionDef, ToolDefinition, ToolResult};
14
15use super::registry::{MemoryVectorContext, RegisteredTool, ToolCapability, ToolHandler};
16
17pub fn get_memory_tool_definitions() -> Vec<ToolDefinition> {
21 let search_desc = "Search the agent's memory. Use keywords or natural language. \
22 Returns relevant memory chunks ranked by relevance.";
23 vec![
24 ToolDefinition {
25 tool_type: "function".to_string(),
26 function: FunctionDef {
27 name: "memory_search".to_string(),
28 description: search_desc.to_string(),
29 parameters: json!({
30 "type": "object",
31 "properties": {
32 "query": {
33 "type": "string",
34 "description": "Search query (keywords or natural language)"
35 },
36 "limit": {
37 "type": "integer",
38 "description": "Maximum number of results (default: 10)"
39 }
40 },
41 "required": ["query"]
42 }),
43 },
44 },
45 ToolDefinition {
46 tool_type: "function".to_string(),
47 function: FunctionDef {
48 name: "memory_write".to_string(),
49 description: "Store information in the agent's memory for future \
50 retrieval. Use this to save user preferences, conversation \
51 summaries, or any information that should persist across sessions."
52 .to_string(),
53 parameters: json!({
54 "type": "object",
55 "properties": {
56 "rel_path": {
57 "type": "string",
58 "description": "Relative path within memory directory (e.g. 'preferences/theme.md')"
59 },
60 "content": {
61 "type": "string",
62 "description": "Content to store (markdown format recommended)"
63 },
64 "append": {
65 "type": "boolean",
66 "description": "If true, append to existing file instead of overwriting. Default: false."
67 }
68 },
69 "required": ["rel_path", "content"]
70 }),
71 },
72 },
73 ToolDefinition {
74 tool_type: "function".to_string(),
75 function: FunctionDef {
76 name: "memory_list".to_string(),
77 description: "List all memory files stored by the agent.".to_string(),
78 parameters: json!({
79 "type": "object",
80 "properties": {},
81 "required": []
82 }),
83 },
84 },
85 ]
86}
87
88pub fn get_memory_tools() -> Vec<RegisteredTool> {
90 get_memory_tool_definitions()
91 .into_iter()
92 .map(|definition| {
93 let capabilities = match definition.function.name.as_str() {
94 "memory_write" => vec![ToolCapability::MemoryWrite],
95 _ => Vec::new(),
96 };
97 RegisteredTool::new(definition, capabilities, ToolHandler::Memory)
98 })
99 .collect()
100}
101
102pub async fn execute_memory_tool(
108 tool_name: &str,
109 arguments: &str,
110 _workspace: &Path,
111 agent_id: &str,
112 enable_vector: bool,
113 embed_ctx: Option<&MemoryVectorContext<'_>>,
114) -> ToolResult {
115 #[cfg(feature = "memory_vector")]
118 if enable_vector {
119 skilllite_executor::memory::ensure_vec_extension_loaded();
120 }
121
122 let args: serde_json::Value = match serde_json::from_str(arguments) {
123 Ok(v) => v,
124 Err(e) => {
125 return ToolResult {
126 tool_call_id: String::new(),
127 tool_name: tool_name.to_string(),
128 content: format!("Invalid arguments JSON: {}", e),
129 is_error: true,
130 counts_as_failure: true,
131 };
132 }
133 };
134
135 let mem_root = skilllite_executor::chat_root();
136 let result = match tool_name {
137 "memory_search" => {
138 execute_memory_search(&args, &mem_root, agent_id, enable_vector, embed_ctx).await
139 }
140 "memory_write" => {
141 execute_memory_write(&args, &mem_root, agent_id, enable_vector, embed_ctx).await
142 }
143 "memory_list" => execute_memory_list(&mem_root),
144 _ => Err(anyhow::anyhow!("Unknown memory tool: {}", tool_name)),
145 };
146
147 match result {
148 Ok(content) => ToolResult {
149 tool_call_id: String::new(),
150 tool_name: tool_name.to_string(),
151 content,
152 is_error: false,
153 counts_as_failure: false,
154 },
155 Err(e) => ToolResult {
156 tool_call_id: String::new(),
157 tool_name: tool_name.to_string(),
158 content: format!("Error: {}", e),
159 is_error: true,
160 counts_as_failure: true,
161 },
162 }
163}
164
165#[allow(unused_variables)]
167async fn execute_memory_search(
168 args: &serde_json::Value,
169 chat_root: &Path,
170 agent_id: &str,
171 enable_vector: bool,
172 embed_ctx: Option<&MemoryVectorContext<'_>>,
173) -> Result<String> {
174 let query = args
175 .get("query")
176 .and_then(|v| v.as_str())
177 .context("'query' is required")?;
178 let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10);
179
180 let idx_path = skilllite_executor::memory::index_path(chat_root, agent_id);
181 if !idx_path.exists() {
182 return Ok("No memory index found. Memory is empty.".to_string());
183 }
184
185 let conn = Connection::open(&idx_path).context("Failed to open memory index")?;
186 skilllite_executor::memory::ensure_index(&conn)?;
187
188 #[cfg(feature = "memory_vector")]
189 let use_vec =
190 enable_vector && embed_ctx.is_some() && skilllite_executor::memory::has_vec_index(&conn);
191
192 #[cfg(not(feature = "memory_vector"))]
193 let use_vec = false;
194
195 let hits = if use_vec {
196 #[cfg(feature = "memory_vector")]
197 {
198 let ctx = embed_ctx.context("embed_ctx disappeared despite is_some() check")?;
199 let embeddings = ctx
200 .client
201 .embed(
202 &ctx.embed_config.model,
203 &[query],
204 Some(&ctx.embed_config.api_base),
205 Some(&ctx.embed_config.api_key),
206 )
207 .await
208 .context("Embedding API failed")?;
209 let query_emb = embeddings.first().context("No embedding returned")?;
210 skilllite_executor::memory::ensure_vec0_table(&conn, ctx.embed_config.dimension)?;
211 skilllite_executor::memory::search_vec(&conn, query_emb, limit)?
212 }
213 #[cfg(not(feature = "memory_vector"))]
214 {
215 unreachable!()
216 }
217 } else {
218 skilllite_executor::memory::search_bm25(&conn, query, limit)?
219 };
220
221 if hits.is_empty() {
222 return Ok(format!("No results found for query: '{}'", query));
223 }
224
225 let mut result = format!("Found {} results for '{}':\n\n", hits.len(), query);
226 for (i, hit) in hits.iter().enumerate() {
227 result.push_str(&format!(
228 "--- Result {} (file: {}, score: {:.2}) ---\n{}\n\n",
229 i + 1,
230 hit.path,
231 hit.score,
232 hit.content
233 ));
234 }
235 Ok(result)
236}
237
238#[allow(unused_variables)]
240async fn execute_memory_write(
241 args: &serde_json::Value,
242 chat_root: &Path,
243 agent_id: &str,
244 enable_vector: bool,
245 embed_ctx: Option<&MemoryVectorContext<'_>>,
246) -> Result<String> {
247 let rel_path = args
248 .get("rel_path")
249 .and_then(|v| v.as_str())
250 .context("'rel_path' is required")?;
251 let content = args
252 .get("content")
253 .and_then(|v| v.as_str())
254 .context("'content' is required")?;
255 let append = args
256 .get("append")
257 .and_then(|v| v.as_bool())
258 .unwrap_or(false);
259
260 let memory_dir = chat_root.join("memory");
261 let file_path = memory_dir.join(rel_path);
262
263 let normalized = normalize_memory_path(&file_path);
265 if !normalized.starts_with(&memory_dir) {
266 anyhow::bail!("Path escapes memory directory: {}", rel_path);
267 }
268
269 if let Some(parent) = file_path.parent() {
271 skilllite_fs::create_dir_all(parent)
272 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
273 }
274
275 let final_content = if append && file_path.exists() {
277 let existing = skilllite_fs::read_file(&file_path).unwrap_or_default();
278 format!("{}\n\n{}", existing, content)
279 } else {
280 content.to_string()
281 };
282
283 skilllite_fs::write_file(&file_path, &final_content)
284 .with_context(|| format!("Failed to write memory file: {}", file_path.display()))?;
285
286 let idx_path = skilllite_executor::memory::index_path(chat_root, agent_id);
288 if let Some(parent) = idx_path.parent() {
289 skilllite_fs::create_dir_all(parent)?;
290 }
291 let conn = Connection::open(&idx_path).context("Failed to open memory index")?;
292 skilllite_executor::memory::ensure_index(&conn)?;
293 skilllite_executor::memory::index_file(&conn, rel_path, &final_content)?;
294
295 #[cfg(feature = "memory_vector")]
297 if enable_vector {
298 if let Some(ctx) = embed_ctx {
299 let chunks = skilllite_executor::memory::chunk_content_for_embed(&final_content);
300 if !chunks.is_empty() {
301 let texts: Vec<&str> = chunks.iter().map(|s| s.as_str()).collect();
302 match ctx
303 .client
304 .embed(
305 &ctx.embed_config.model,
306 &texts,
307 Some(&ctx.embed_config.api_base),
308 Some(&ctx.embed_config.api_key),
309 )
310 .await
311 {
312 Ok(embeddings) if embeddings.len() == chunks.len() => {
313 skilllite_executor::memory::ensure_vec0_table(
314 &conn,
315 ctx.embed_config.dimension,
316 )?;
317 skilllite_executor::memory::index_file_vec(
318 &conn,
319 rel_path,
320 &chunks,
321 &embeddings,
322 )?;
323 }
324 Ok(_) => {
325 tracing::warn!("Embedding count mismatch, skipping vector index");
326 }
327 Err(e) => {
328 tracing::warn!("Embedding failed, BM25 index only: {}", e);
329 }
330 }
331 }
332 }
333 }
334
335 Ok(format!(
336 "Successfully wrote {} chars to memory://{}",
337 final_content.len(),
338 rel_path
339 ))
340}
341
342fn execute_memory_list(chat_root: &Path) -> Result<String> {
344 let memory_dir = chat_root.join("memory");
345 if !memory_dir.exists() {
346 return Ok("Memory directory is empty (no files stored yet).".to_string());
347 }
348
349 let mut files = Vec::new();
350 collect_memory_files(&memory_dir, &memory_dir, &mut files)?;
351
352 if files.is_empty() {
353 return Ok("Memory directory exists but contains no .md files.".to_string());
354 }
355
356 let mut result = format!("Memory files ({}):\n", files.len());
357 for f in &files {
358 result.push_str(&format!(" - {}\n", f));
359 }
360 Ok(result)
361}
362
363fn collect_memory_files(base: &Path, current: &Path, files: &mut Vec<String>) -> Result<()> {
365 if !current.is_dir() {
366 return Ok(());
367 }
368 for (path, is_dir) in skilllite_fs::read_dir(current)? {
369 if is_dir {
370 collect_memory_files(base, &path, files)?;
371 } else if path.extension().is_some_and(|ext| ext == "md") {
372 if let Ok(rel) = path.strip_prefix(base) {
373 files.push(rel.to_string_lossy().to_string());
374 }
375 }
376 }
377 Ok(())
378}
379
380pub fn build_memory_context(
386 _workspace: &Path,
387 agent_id: &str,
388 user_message: &str,
389) -> Option<String> {
390 let chat_root = skilllite_executor::chat_root();
391 let idx_path = skilllite_executor::memory::index_path(&chat_root, agent_id);
392 if !idx_path.exists() {
393 return None;
394 }
395
396 let conn = match Connection::open(&idx_path) {
397 Ok(c) => c,
398 Err(_) => return None,
399 };
400 if skilllite_executor::memory::ensure_index(&conn).is_err() {
401 return None;
402 }
403
404 let hits = match skilllite_executor::memory::search_bm25(&conn, user_message, 5) {
405 Ok(h) => h,
406 Err(_) => return None,
407 };
408
409 if hits.is_empty() {
410 return None;
411 }
412
413 let mut context = String::from("\n\n## Relevant Memory Context\n\n");
414 for hit in &hits {
415 let truncated: String = hit.content.chars().take(500).collect();
416 context.push_str(&format!("**[{}]**: {}\n\n", hit.path, truncated));
417 }
418
419 Some(context)
420}
421
422pub fn index_evolution_knowledge(chat_root: &Path, agent_id: &str) -> Result<()> {
427 let path = chat_root
428 .join("memory")
429 .join("evolution")
430 .join("knowledge.md");
431 if !path.exists() {
432 return Ok(());
433 }
434 let content = skilllite_fs::read_file(&path).unwrap_or_default();
435 if content.is_empty() {
436 return Ok(());
437 }
438 let idx_path = skilllite_executor::memory::index_path(chat_root, agent_id);
439 if let Some(parent) = idx_path.parent() {
440 skilllite_fs::create_dir_all(parent)?;
441 }
442 let conn = Connection::open(&idx_path).context("Failed to open memory index")?;
443 skilllite_executor::memory::ensure_index(&conn)?;
444 skilllite_executor::memory::index_file(&conn, "evolution/knowledge.md", &content)?;
445 tracing::debug!("Indexed evolution/knowledge.md into memory");
446 Ok(())
447}
448
449#[allow(dead_code)]
455pub fn write_structured_experience(
456 chat_root: &Path,
457 agent_id: &str,
458 topic: &str,
459 content: &str,
460) -> Result<()> {
461 let memory_dir = chat_root.join("memory").join("evolution");
462 skilllite_fs::create_dir_all(&memory_dir)?;
463
464 let file_path = memory_dir.join(format!("{}.md", topic));
465 let final_content = if file_path.exists() {
466 let existing = skilllite_fs::read_file(&file_path).unwrap_or_default();
467 format!("{}\n\n{}", existing, content)
468 } else {
469 content.to_string()
470 };
471 skilllite_fs::write_file(&file_path, &final_content)?;
472
473 let idx_path = skilllite_executor::memory::index_path(chat_root, agent_id);
475 if let Some(parent) = idx_path.parent() {
476 skilllite_fs::create_dir_all(parent)?;
477 }
478 if let Ok(conn) = Connection::open(&idx_path) {
479 let _ = skilllite_executor::memory::ensure_index(&conn).and_then(|_| {
480 let rel = format!("evolution/{}.md", topic);
481 skilllite_executor::memory::index_file(&conn, &rel, &final_content)
482 });
483 }
484
485 Ok(())
486}
487
488fn normalize_memory_path(path: &Path) -> std::path::PathBuf {
492 let mut components = Vec::new();
493 for component in path.components() {
494 match component {
495 std::path::Component::ParentDir => {
496 components.pop();
497 }
498 std::path::Component::CurDir => {}
499 other => components.push(other),
500 }
501 }
502 components.iter().collect()
503}