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 is_deferred(&self) -> bool {
32        true
33    }
34
35    fn spec(&self) -> ToolSpec {
36        ToolSpec {
37            name: "episodic_recall".into(),
38            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(),
39            parameters: json!({
40                "type": "object",
41                "properties": {
42                    "query": {
43                        "type": "string",
44                        "description": "Search query (case-insensitive substring match against message content, role, and tool call names/arguments)"
45                    },
46                    "session_id": {
47                        "type": "string",
48                        "description": "Optional session ID to restrict the search to a single session. If omitted, searches all sessions."
49                    },
50                    "limit": {
51                        "type": "integer",
52                        "description": "Maximum number of matching entries to return (default 5)",
53                        "default": 5
54                    },
55                    "context_lines": {
56                        "type": "integer",
57                        "description": "Number of surrounding messages to include before and after each match (default 2)",
58                        "default": 2
59                    }
60                },
61                "required": ["query"]
62            }),
63        }
64    }
65
66    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
67        crate::tools::ToolSideEffect::ReadOnly
68    }
69
70    async fn execute(&self, arguments: Value) -> Result<String> {
71        let query = arguments["query"]
72            .as_str()
73            .ok_or_else(|| Error::BadToolArgs {
74                name: "episodic_recall".into(),
75                message: "missing required parameter: query".to_string(),
76            })?;
77
78        let session_id = arguments["session_id"].as_str().map(|s| s.to_string());
79        let limit = arguments["limit"].as_i64().unwrap_or(5) as usize;
80        let context_lines = arguments["context_lines"].as_i64().unwrap_or(2) as usize;
81
82        // List all session directories
83        let all_sessions = match SessionReader::list_sessions(&self.workspace) {
84            Ok(sessions) => sessions,
85            Err(e) => {
86                return Ok(format!("failed to list sessions: {e}"));
87            }
88        };
89
90        if all_sessions.is_empty() {
91            return Ok("no past sessions found".to_string());
92        }
93
94        // Filter by session_id if provided
95        let sessions: Vec<&PathBuf> = if let Some(ref sid) = session_id {
96            all_sessions
97                .iter()
98                .filter(|dir| {
99                    // Match the session directory name exactly (or as a suffix)
100                    let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
101                    dir_name == sid.as_str() || dir_name.ends_with(sid.as_str())
102                })
103                .collect()
104        } else {
105            all_sessions.iter().collect()
106        };
107
108        if sessions.is_empty() {
109            return Ok(format!(
110                "no sessions found matching '{}'",
111                session_id.unwrap_or_default()
112            ));
113        }
114
115        let query_lower = query.to_lowercase();
116        let mut results: Vec<String> = Vec::new();
117
118        for session_dir in sessions {
119            let entries = match SessionReader::load_transcript(session_dir) {
120                Ok(e) => e,
121                Err(_) => continue,
122            };
123
124            if entries.is_empty() {
125                continue;
126            }
127
128            // Find matching indices
129            let mut match_indices: Vec<usize> = Vec::new();
130            for (i, entry) in entries.iter().enumerate() {
131                // Search in role
132                if entry.role.to_lowercase().contains(&query_lower) {
133                    match_indices.push(i);
134                    continue;
135                }
136                // Search in content
137                if entry.content.to_lowercase().contains(&query_lower) {
138                    match_indices.push(i);
139                    continue;
140                }
141                // Search in tool_calls
142                for tc in &entry.tool_calls {
143                    if tc.name.to_lowercase().contains(&query_lower)
144                        || tc
145                            .arguments
146                            .to_string()
147                            .to_lowercase()
148                            .contains(&query_lower)
149                    {
150                        match_indices.push(i);
151                        break;
152                    }
153                }
154            }
155
156            if match_indices.is_empty() {
157                continue;
158            }
159
160            // Deduplicate and sort match indices
161            match_indices.sort();
162            match_indices.dedup();
163
164            // Build session header
165            let session_name = session_dir
166                .file_name()
167                .and_then(|n| n.to_str())
168                .unwrap_or("unknown");
169            results.push(format!("--- Session: {} ---", session_name));
170
171            // Collect context windows around each match
172            let mut rendered_indices: std::collections::BTreeSet<usize> =
173                std::collections::BTreeSet::new();
174            for &idx in &match_indices {
175                let start = idx.saturating_sub(context_lines);
176                let end = (idx + context_lines + 1).min(entries.len());
177                for i in start..end {
178                    rendered_indices.insert(i);
179                }
180            }
181
182            // Render the context window
183            for &i in &rendered_indices {
184                let entry = &entries[i];
185                let marker = if match_indices.contains(&i) {
186                    " >>> "
187                } else {
188                    "     "
189                };
190                let role_padded = format!("{:>9}", entry.role);
191                let content_preview = if entry.content.len() > 200 {
192                    format!("{}...", crate::truncate_str(&entry.content, 197))
193                } else {
194                    entry.content.clone()
195                };
196                let tool_info = if !entry.tool_calls.is_empty() {
197                    let names: Vec<&str> =
198                        entry.tool_calls.iter().map(|tc| tc.name.as_str()).collect();
199                    format!(" [tool_calls: {}]", names.join(", "))
200                } else {
201                    String::new()
202                };
203                results.push(format!(
204                    "{}[{}] {}{} {}",
205                    marker, entry.id, role_padded, tool_info, content_preview
206                ));
207            }
208
209            results.push(String::new()); // blank line between sessions
210        }
211
212        if results.is_empty() {
213            return Ok(format!("no matches found for '{}'", query));
214        }
215
216        // Truncate to limit (limit applies to number of match entries, not context)
217        // We'll limit the output by counting actual match entries rendered
218        let mut output_lines: Vec<String> = Vec::new();
219        let mut match_count = 0;
220        for line in results {
221            if line.starts_with(" >>> ") {
222                match_count += 1;
223                if match_count > limit {
224                    output_lines.push(format!(
225                        "... (truncated, showing {}/{} matches)",
226                        limit, match_count
227                    ));
228                    break;
229                }
230            }
231            output_lines.push(line);
232        }
233
234        Ok(output_lines.join("\n"))
235    }
236}
237
238/// Build a summary of recent sessions for injection into the system prompt.
239/// Returns the top N most recent session goals as a formatted block.
240pub fn episodic_recall_summary(workspace: &std::path::Path, limit: usize) -> String {
241    let sessions = match SessionReader::list_sessions(workspace) {
242        Ok(s) => s,
243        Err(_) => return String::new(),
244    };
245
246    if sessions.is_empty() {
247        return String::new();
248    }
249
250    let mut lines: Vec<String> = Vec::new();
251    lines.push(format!(
252        "# Recent Sessions (last {}; use `episodic_recall` to search)",
253        limit
254    ));
255
256    // Sessions are sorted by name (timestamp-prefixed), so most recent are last
257    let recent: Vec<&PathBuf> = sessions.iter().rev().take(limit).collect();
258    for session_dir in recent {
259        let meta = match SessionReader::load_meta(session_dir) {
260            Ok(m) => m,
261            Err(_) => continue,
262        };
263        let session_name = session_dir
264            .file_name()
265            .and_then(|n| n.to_str())
266            .unwrap_or("unknown");
267        let goal_preview = if meta.goal.len() > 80 {
268            format!("{}...", crate::truncate_str(&meta.goal, 77))
269        } else {
270            meta.goal.clone()
271        };
272        lines.push(format!(
273            "- {} ({} msgs, {}): {}",
274            session_name, meta.message_count, meta.status, goal_preview
275        ));
276    }
277
278    lines.join("\n")
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::message::Message;
285    use crate::session::SessionWriter;
286    use crate::SessionStatus;
287    use std::time::Duration;
288
289    /// Helper: create a temporary workspace and write some session data.
290    fn setup_session_with_messages(
291        ws: &std::path::Path,
292        goal: &str,
293        messages: &[(&str, &str)],
294    ) -> String {
295        let mut writer = SessionWriter::create(ws, goal, "test-model", "test-provider").unwrap();
296        let session_dir = writer.session_dir().to_path_buf();
297        for (role, content) in messages {
298            let msg = match *role {
299                "user" => Message::user(content.to_string()),
300                "assistant" => Message::assistant(content.to_string()),
301                "system" => Message::system(content.to_string()),
302                _ => Message::user(content.to_string()),
303            };
304            writer.append(&msg, None, None).unwrap();
305        }
306        writer.finish(SessionStatus::Completed).unwrap();
307        session_dir
308            .file_name()
309            .unwrap()
310            .to_str()
311            .unwrap()
312            .to_string()
313    }
314
315    #[test]
316    fn test_a_episodic_recall_finds_messages() {
317        let tmp = crate::test_util::IsolatedWorkspace::new();
318        let ws = tmp.path();
319
320        setup_session_with_messages(
321            ws,
322            "test goal",
323            &[
324                ("system", "You are a helpful assistant."),
325                ("user", "Hello, how are you?"),
326                ("assistant", "I'm doing great, thanks!"),
327                ("user", "What is Rust?"),
328                ("assistant", "Rust is a systems programming language."),
329            ],
330        );
331
332        let tool = EpisodicRecall::new(ws);
333        let result = tokio::runtime::Runtime::new()
334            .unwrap()
335            .block_on(tool.execute(json!({"query": "Rust"})));
336        assert!(result.is_ok());
337        let output = result.unwrap();
338        assert!(output.contains("Rust"), "output: {output}");
339        assert!(output.contains("systems programming"), "output: {output}");
340    }
341
342    #[test]
343    fn test_b_episodic_recall_with_session_filter() {
344        let tmp = crate::test_util::IsolatedWorkspace::new();
345        let ws = tmp.path();
346
347        // Create two sessions with different goals so we can distinguish them
348        let sid1 = setup_session_with_messages(
349            ws,
350            "first-session-unique-goal",
351            &[("user", "Hello from session 1")],
352        );
353
354        // Sleep 1 second to ensure different timestamp in session_id
355        std::thread::sleep(Duration::from_secs(1));
356        let sid2 = setup_session_with_messages(
357            ws,
358            "second-session-unique-goal",
359            &[("user", "Hello from session 2")],
360        );
361
362        // Verify we got different session IDs
363        assert_ne!(sid1, sid2, "session IDs should differ");
364
365        let tool = EpisodicRecall::new(ws);
366        let result = tokio::runtime::Runtime::new()
367            .unwrap()
368            .block_on(tool.execute(json!({
369                "query": "Hello",
370                "session_id": &sid2
371            })));
372        assert!(result.is_ok());
373        let output = result.unwrap();
374        assert!(output.contains("session 2"), "output: {output}");
375        assert!(!output.contains("session 1"), "output: {output}");
376    }
377
378    #[test]
379    fn test_c_episodic_recall_empty_when_no_sessions() {
380        let tmp = crate::test_util::IsolatedWorkspace::new();
381        let ws = tmp.path();
382
383        let tool = EpisodicRecall::new(ws);
384        let result = tokio::runtime::Runtime::new()
385            .unwrap()
386            .block_on(tool.execute(json!({"query": "anything"})));
387        assert!(result.is_ok());
388        let output = result.unwrap();
389        assert_eq!(output, "no past sessions found");
390    }
391
392    #[test]
393    fn test_d_episodic_recall_summary() {
394        let tmp = crate::test_util::IsolatedWorkspace::new();
395        let ws = tmp.path();
396
397        // No sessions yet
398        let summary = episodic_recall_summary(ws, 5);
399        assert_eq!(summary, "");
400
401        // Create a session
402        setup_session_with_messages(
403            ws,
404            "fix the bug in parser",
405            &[("user", "Please fix the parser bug")],
406        );
407
408        let summary = episodic_recall_summary(ws, 5);
409        assert!(!summary.is_empty(), "summary should not be empty");
410        assert!(
411            summary.contains("fix the bug in parser"),
412            "summary: {summary}"
413        );
414        assert!(summary.contains("1 msgs"), "summary: {summary}");
415    }
416
417    #[test]
418    fn episodic_recall_summary_truncates_long_goals() {
419        // kills `replace > with >=` or `delete if meta.goal.len() > 80` truncation check
420        let tmp = crate::test_util::IsolatedWorkspace::new();
421        let ws = tmp.path();
422
423        let long_goal = "A".repeat(100); // 100 chars, well above the 80-char threshold
424        setup_session_with_messages(ws, &long_goal, &[("user", "hello")]);
425
426        let summary = episodic_recall_summary(ws, 5);
427        // Long goal must be truncated: summary should end with "..."
428        assert!(
429            summary.contains("..."),
430            "goal > 80 chars must be truncated with '...': {summary}"
431        );
432        // The truncated version must NOT contain the full 100-char string
433        assert!(
434            !summary.contains(&long_goal),
435            "full long goal must not appear verbatim: {summary}"
436        );
437    }
438
439    #[test]
440    fn is_deferred_is_true() {
441        // kills `replace EpisodicRecall::is_deferred -> bool with false`
442        let tmp = crate::test_util::IsolatedWorkspace::new();
443        let tool = EpisodicRecall::new(tmp.path());
444        assert!(
445            tool.is_deferred(),
446            "episodic_recall must be a deferred tool"
447        );
448    }
449
450    #[test]
451    fn no_match_in_existing_session_returns_no_matches_message() {
452        // kills `if results.is_empty()` guard-removal mutations
453        let tmp = crate::test_util::IsolatedWorkspace::new();
454        let ws = tmp.path();
455
456        setup_session_with_messages(
457            ws,
458            "some goal",
459            &[("user", "Hello world"), ("assistant", "Hi there")],
460        );
461
462        let tool = EpisodicRecall::new(ws);
463        let output = tokio::runtime::Runtime::new()
464            .unwrap()
465            .block_on(tool.execute(json!({"query": "xyzzy_unique_never_matches"})))
466            .unwrap();
467
468        assert!(
469            output.contains("no matches found"),
470            "expected 'no matches found' but got: {output}"
471        );
472    }
473
474    #[test]
475    fn limit_truncates_output_when_exceeded() {
476        // kills `if match_count > limit` guard removal and off-by-one mutations
477        let tmp = crate::test_util::IsolatedWorkspace::new();
478        let ws = tmp.path();
479
480        // Create a session with many "Rust" references (more than limit=2)
481        setup_session_with_messages(
482            ws,
483            "rust session",
484            &[
485                ("user", "Rust 1"),
486                ("assistant", "Rust 2"),
487                ("user", "Rust 3"),
488                ("assistant", "Rust 4"),
489                ("user", "Rust 5"),
490            ],
491        );
492
493        let tool = EpisodicRecall::new(ws);
494        // limit=2: only 2 matches should be shown, rest truncated
495        let output = tokio::runtime::Runtime::new()
496            .unwrap()
497            .block_on(tool.execute(json!({"query": "Rust", "limit": 2})))
498            .unwrap();
499
500        assert!(
501            output.contains("truncated"),
502            "output with limit=2 for 5 matches should say 'truncated': {output}"
503        );
504    }
505
506    #[test]
507    fn missing_query_returns_error() {
508        // kills `ok_or_else(|| Error::BadToolArgs)` guard removal mutation for missing query
509        let tmp = crate::test_util::IsolatedWorkspace::new();
510        let tool = EpisodicRecall::new(tmp.path());
511        let res = tokio::runtime::Runtime::new()
512            .unwrap()
513            .block_on(tool.execute(json!({})));
514        assert!(
515            matches!(res, Err(crate::error::Error::BadToolArgs { .. })),
516            "missing 'query' must return BadToolArgs"
517        );
518    }
519
520    #[test]
521    fn session_id_filter_returns_not_found_when_no_match() {
522        // kills `if sessions.is_empty() { return Ok("no sessions found matching ...") }` removal
523        let tmp = crate::test_util::IsolatedWorkspace::new();
524        let ws = tmp.path();
525        // Create a real session
526        setup_session_with_messages(ws, "goal", &[("user", "hello world")]);
527
528        let tool = EpisodicRecall::new(ws);
529        let output = tokio::runtime::Runtime::new()
530            .unwrap()
531            .block_on(tool.execute(json!({
532                "query": "hello",
533                "session_id": "nonexistent-session-xyz"
534            })))
535            .unwrap();
536        assert!(
537            output.contains("no sessions found matching"),
538            "filter to non-existent session must say 'no sessions found matching'; got: {output}"
539        );
540    }
541
542    #[test]
543    fn long_content_is_truncated_to_200_chars() {
544        // kills `if entry.content.len() > 200` guard removal mutations
545        let tmp = crate::test_util::IsolatedWorkspace::new();
546        let ws = tmp.path();
547
548        let long_content = format!("UNIQUE_PREFIX_{}", "X".repeat(250));
549        setup_session_with_messages(ws, "long content goal", &[("user", &long_content)]);
550
551        let tool = EpisodicRecall::new(ws);
552        let output = tokio::runtime::Runtime::new()
553            .unwrap()
554            .block_on(tool.execute(json!({"query": "UNIQUE_PREFIX"})))
555            .unwrap();
556
557        // The full 250-char string must NOT appear verbatim
558        assert!(
559            !output.contains(&long_content),
560            "long content must be truncated in output"
561        );
562        // But the prefix must appear
563        assert!(
564            output.contains("UNIQUE_PREFIX"),
565            "truncated content must still contain prefix: {output}"
566        );
567    }
568}