Skip to main content

recursive/tools/
episodic_recall.rs

1//! Episodic recall tool — search past session transcripts.
2//!
3//! Reads JSONL session transcripts from `<workspace>/.recursive/sessions/`
4//! and allows the agent to search through them by content, role, or tool
5//! call details. Returns matching entries with surrounding context.
6
7use async_trait::async_trait;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10
11use crate::error::{Error, Result};
12use crate::llm::ToolSpec;
13use crate::session::SessionReader;
14use crate::tools::Tool;
15
16/// Episodic recall tool: search past session transcripts.
17pub struct EpisodicRecall {
18    workspace: PathBuf,
19}
20
21impl EpisodicRecall {
22    pub fn new(workspace: impl Into<PathBuf>) -> Self {
23        Self {
24            workspace: workspace.into(),
25        }
26    }
27}
28
29#[async_trait]
30impl Tool for EpisodicRecall {
31    fn spec(&self) -> ToolSpec {
32        ToolSpec {
33            name: "episodic_recall".into(),
34            description: "Search past session transcripts for messages matching a query. Reads JSONL session files from the workspace's session directory. Returns matching entries with surrounding context.".into(),
35            parameters: json!({
36                "type": "object",
37                "properties": {
38                    "query": {
39                        "type": "string",
40                        "description": "Search query (case-insensitive substring match against message content, role, and tool call names/arguments)"
41                    },
42                    "session_id": {
43                        "type": "string",
44                        "description": "Optional session ID to restrict the search to a single session. If omitted, searches all sessions."
45                    },
46                    "limit": {
47                        "type": "integer",
48                        "description": "Maximum number of matching entries to return (default 5)",
49                        "default": 5
50                    },
51                    "context_lines": {
52                        "type": "integer",
53                        "description": "Number of surrounding messages to include before and after each match (default 2)",
54                        "default": 2
55                    }
56                },
57                "required": ["query"]
58            }),
59        }
60    }
61
62    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
63        crate::tools::ToolSideEffect::ReadOnly
64    }
65
66    async fn execute(&self, arguments: Value) -> Result<String> {
67        let query = arguments["query"]
68            .as_str()
69            .ok_or_else(|| Error::BadToolArgs {
70                name: "episodic_recall".into(),
71                message: "missing required parameter: query".to_string(),
72            })?;
73
74        let session_id = arguments["session_id"].as_str().map(|s| s.to_string());
75        let limit = arguments["limit"].as_i64().unwrap_or(5) as usize;
76        let context_lines = arguments["context_lines"].as_i64().unwrap_or(2) as usize;
77
78        // List all session directories
79        let all_sessions = match SessionReader::list_sessions(&self.workspace) {
80            Ok(sessions) => sessions,
81            Err(e) => {
82                return Ok(format!("failed to list sessions: {e}"));
83            }
84        };
85
86        if all_sessions.is_empty() {
87            return Ok("no past sessions found".to_string());
88        }
89
90        // Filter by session_id if provided
91        let sessions: Vec<&PathBuf> = if let Some(ref sid) = session_id {
92            all_sessions
93                .iter()
94                .filter(|dir| {
95                    // Match the session directory name exactly (or as a suffix)
96                    let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
97                    dir_name == sid.as_str() || dir_name.ends_with(sid.as_str())
98                })
99                .collect()
100        } else {
101            all_sessions.iter().collect()
102        };
103
104        if sessions.is_empty() {
105            return Ok(format!(
106                "no sessions found matching '{}'",
107                session_id.unwrap_or_default()
108            ));
109        }
110
111        let query_lower = query.to_lowercase();
112        let mut results: Vec<String> = Vec::new();
113
114        for session_dir in sessions {
115            let entries = match SessionReader::load_transcript(session_dir) {
116                Ok(e) => e,
117                Err(_) => continue,
118            };
119
120            if entries.is_empty() {
121                continue;
122            }
123
124            // Find matching indices
125            let mut match_indices: Vec<usize> = Vec::new();
126            for (i, entry) in entries.iter().enumerate() {
127                // Search in role
128                if entry.role.to_lowercase().contains(&query_lower) {
129                    match_indices.push(i);
130                    continue;
131                }
132                // Search in content
133                if entry.content.to_lowercase().contains(&query_lower) {
134                    match_indices.push(i);
135                    continue;
136                }
137                // Search in tool_calls
138                for tc in &entry.tool_calls {
139                    if tc.name.to_lowercase().contains(&query_lower)
140                        || tc
141                            .arguments
142                            .to_string()
143                            .to_lowercase()
144                            .contains(&query_lower)
145                    {
146                        match_indices.push(i);
147                        break;
148                    }
149                }
150            }
151
152            if match_indices.is_empty() {
153                continue;
154            }
155
156            // Deduplicate and sort match indices
157            match_indices.sort();
158            match_indices.dedup();
159
160            // Build session header
161            let session_name = session_dir
162                .file_name()
163                .and_then(|n| n.to_str())
164                .unwrap_or("unknown");
165            results.push(format!("--- Session: {} ---", session_name));
166
167            // Collect context windows around each match
168            let mut rendered_indices: std::collections::BTreeSet<usize> =
169                std::collections::BTreeSet::new();
170            for &idx in &match_indices {
171                let start = idx.saturating_sub(context_lines);
172                let end = (idx + context_lines + 1).min(entries.len());
173                for i in start..end {
174                    rendered_indices.insert(i);
175                }
176            }
177
178            // Render the context window
179            for &i in &rendered_indices {
180                let entry = &entries[i];
181                let marker = if match_indices.contains(&i) {
182                    " >>> "
183                } else {
184                    "     "
185                };
186                let role_padded = format!("{:>9}", entry.role);
187                let content_preview = if entry.content.len() > 200 {
188                    format!("{}...", crate::truncate_str(&entry.content, 197))
189                } else {
190                    entry.content.clone()
191                };
192                let tool_info = if !entry.tool_calls.is_empty() {
193                    let names: Vec<&str> =
194                        entry.tool_calls.iter().map(|tc| tc.name.as_str()).collect();
195                    format!(" [tool_calls: {}]", names.join(", "))
196                } else {
197                    String::new()
198                };
199                results.push(format!(
200                    "{}[{}] {}{} {}",
201                    marker, entry.id, role_padded, tool_info, content_preview
202                ));
203            }
204
205            results.push(String::new()); // blank line between sessions
206        }
207
208        if results.is_empty() {
209            return Ok(format!("no matches found for '{}'", query));
210        }
211
212        // Truncate to limit (limit applies to number of match entries, not context)
213        // We'll limit the output by counting actual match entries rendered
214        let mut output_lines: Vec<String> = Vec::new();
215        let mut match_count = 0;
216        for line in results {
217            if line.starts_with(" >>> ") {
218                match_count += 1;
219                if match_count > limit {
220                    output_lines.push(format!(
221                        "... (truncated, showing {}/{} matches)",
222                        limit, match_count
223                    ));
224                    break;
225                }
226            }
227            output_lines.push(line);
228        }
229
230        Ok(output_lines.join("\n"))
231    }
232}
233
234/// Build a summary of recent sessions for injection into the system prompt.
235/// Returns the top N most recent session goals as a formatted block.
236pub fn episodic_recall_summary(workspace: &std::path::Path, limit: usize) -> String {
237    let sessions = match SessionReader::list_sessions(workspace) {
238        Ok(s) => s,
239        Err(_) => return String::new(),
240    };
241
242    if sessions.is_empty() {
243        return String::new();
244    }
245
246    let mut lines: Vec<String> = Vec::new();
247    lines.push(format!(
248        "# Recent Sessions (last {}; use `episodic_recall` to search)",
249        limit
250    ));
251
252    // Sessions are sorted by name (timestamp-prefixed), so most recent are last
253    let recent: Vec<&PathBuf> = sessions.iter().rev().take(limit).collect();
254    for session_dir in recent {
255        let meta = match SessionReader::load_meta(session_dir) {
256            Ok(m) => m,
257            Err(_) => continue,
258        };
259        let session_name = session_dir
260            .file_name()
261            .and_then(|n| n.to_str())
262            .unwrap_or("unknown");
263        let goal_preview = if meta.goal.len() > 80 {
264            format!("{}...", crate::truncate_str(&meta.goal, 77))
265        } else {
266            meta.goal.clone()
267        };
268        lines.push(format!(
269            "- {} ({} msgs, {}): {}",
270            session_name, meta.message_count, meta.status, goal_preview
271        ));
272    }
273
274    lines.join("\n")
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::message::Message;
281    use crate::session::SessionWriter;
282    use std::time::Duration;
283
284    /// Helper: create a temporary workspace and write some session data.
285    fn setup_session_with_messages(
286        ws: &std::path::Path,
287        goal: &str,
288        messages: &[(&str, &str)],
289    ) -> String {
290        let mut writer = SessionWriter::create(ws, goal, "test-model", "test-provider").unwrap();
291        let session_dir = writer.session_dir().to_path_buf();
292        for (role, content) in messages {
293            let msg = match *role {
294                "user" => Message::user(content.to_string()),
295                "assistant" => Message::assistant(content.to_string()),
296                "system" => Message::system(content.to_string()),
297                _ => Message::user(content.to_string()),
298            };
299            writer.append(&msg, None, None).unwrap();
300        }
301        writer.finish("completed").unwrap();
302        session_dir
303            .file_name()
304            .unwrap()
305            .to_str()
306            .unwrap()
307            .to_string()
308    }
309
310    #[test]
311    fn test_a_episodic_recall_finds_messages() {
312        let tmp = crate::test_util::IsolatedWorkspace::new();
313        let ws = tmp.path();
314
315        setup_session_with_messages(
316            ws,
317            "test goal",
318            &[
319                ("system", "You are a helpful assistant."),
320                ("user", "Hello, how are you?"),
321                ("assistant", "I'm doing great, thanks!"),
322                ("user", "What is Rust?"),
323                ("assistant", "Rust is a systems programming language."),
324            ],
325        );
326
327        let tool = EpisodicRecall::new(ws);
328        let result = tokio::runtime::Runtime::new()
329            .unwrap()
330            .block_on(tool.execute(json!({"query": "Rust"})));
331        assert!(result.is_ok());
332        let output = result.unwrap();
333        assert!(output.contains("Rust"), "output: {output}");
334        assert!(output.contains("systems programming"), "output: {output}");
335    }
336
337    #[test]
338    fn test_b_episodic_recall_with_session_filter() {
339        let tmp = crate::test_util::IsolatedWorkspace::new();
340        let ws = tmp.path();
341
342        // Create two sessions with different goals so we can distinguish them
343        let sid1 = setup_session_with_messages(
344            ws,
345            "first-session-unique-goal",
346            &[("user", "Hello from session 1")],
347        );
348
349        // Sleep 1 second to ensure different timestamp in session_id
350        std::thread::sleep(Duration::from_secs(1));
351        let sid2 = setup_session_with_messages(
352            ws,
353            "second-session-unique-goal",
354            &[("user", "Hello from session 2")],
355        );
356
357        // Verify we got different session IDs
358        assert_ne!(sid1, sid2, "session IDs should differ");
359
360        let tool = EpisodicRecall::new(ws);
361        let result = tokio::runtime::Runtime::new()
362            .unwrap()
363            .block_on(tool.execute(json!({
364                "query": "Hello",
365                "session_id": &sid2
366            })));
367        assert!(result.is_ok());
368        let output = result.unwrap();
369        assert!(output.contains("session 2"), "output: {output}");
370        assert!(!output.contains("session 1"), "output: {output}");
371    }
372
373    #[test]
374    fn test_c_episodic_recall_empty_when_no_sessions() {
375        let tmp = crate::test_util::IsolatedWorkspace::new();
376        let ws = tmp.path();
377
378        let tool = EpisodicRecall::new(ws);
379        let result = tokio::runtime::Runtime::new()
380            .unwrap()
381            .block_on(tool.execute(json!({"query": "anything"})));
382        assert!(result.is_ok());
383        let output = result.unwrap();
384        assert_eq!(output, "no past sessions found");
385    }
386
387    #[test]
388    fn test_d_episodic_recall_summary() {
389        let tmp = crate::test_util::IsolatedWorkspace::new();
390        let ws = tmp.path();
391
392        // No sessions yet
393        let summary = episodic_recall_summary(ws, 5);
394        assert_eq!(summary, "");
395
396        // Create a session
397        setup_session_with_messages(
398            ws,
399            "fix the bug in parser",
400            &[("user", "Please fix the parser bug")],
401        );
402
403        let summary = episodic_recall_summary(ws, 5);
404        assert!(!summary.is_empty(), "summary should not be empty");
405        assert!(
406            summary.contains("fix the bug in parser"),
407            "summary: {summary}"
408        );
409        assert!(summary.contains("1 msgs"), "summary: {summary}");
410    }
411}