Skip to main content

skilllite_agent/extensions/
memory.rs

1//! Memory tools for the agent: search, write, list.
2//!
3//! Wraps the executor-layer `executor::memory` module to provide
4//! agent-facing memory tools (memory_search, memory_write, memory_list).
5//! With `memory_vector` feature: semantic search via sqlite-vec.
6//! Ported from Python `extensions/memory.py`.
7
8use 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
17// ─── Tool definitions ───────────────────────────────────────────────────────
18
19/// Get memory tool definitions for the LLM.
20pub 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
88/// Memory tools paired with capability requirements and handlers.
89pub 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
102// ─── Tool execution ─────────────────────────────────────────────────────────
103
104/// Execute a memory tool.
105/// Memory is stored in ~/.skilllite/chat/memory, not the workspace.
106/// When enable_vector and embed_ctx are set, uses semantic search and indexes embeddings.
107pub 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    // Load sqlite-vec extension BEFORE opening any connection. sqlite3_auto_extension
116    // only affects new connections; connections opened before this call won't have vec0.
117    #[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/// Search memory. Uses vector search when enable_vector and embed_ctx are set.
166#[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/// Write content to memory and index for BM25 + vector (when enabled).
239#[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    // Security: ensure path stays within memory directory
264    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    // Create parent directories
270    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    // Write or append
276    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    // Index for BM25 (always)
287    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    // Index for vector when enabled
296    #[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
342/// List all memory files.
343fn 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
363/// Recursively collect .md files in memory directory (skip .sqlite files).
364fn 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
380// ─── Memory context for chat sessions ───────────────────────────────────────
381
382/// Build memory context by searching for relevant memories (BM25).
383/// Returns a context string to inject into the system prompt, or None if empty.
384/// Vector search for build_memory_context can be added later (requires async).
385pub 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
422// ─── Evolution knowledge index ───────────────────────────────────────────────
423
424/// Index `memory/evolution/knowledge.md` into the memory FTS so it can be found by
425/// memory_search and build_memory_context. Call this after memory evolution writes the file.
426pub 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// ─── EVO-1: Structured experience writing ───────────────────────────────────
450
451/// Write a structured experience entry to memory (e.g. tool effectiveness, task pattern).
452/// Creates or appends to a topic-specific file under `memory/evolution/`.
453/// Used by the evolution engine to persist aggregated insights.
454#[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    // Re-index for BM25 search
474    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
488// ─── Path helpers ───────────────────────────────────────────────────────────
489
490/// Normalize a path by resolving `.` and `..` components without filesystem access.
491fn 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}