1use 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
16pub 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 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 let sessions: Vec<&PathBuf> = if let Some(ref sid) = session_id {
96 all_sessions
97 .iter()
98 .filter(|dir| {
99 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 let mut match_indices: Vec<usize> = Vec::new();
130 for (i, entry) in entries.iter().enumerate() {
131 if entry.role.to_lowercase().contains(&query_lower) {
133 match_indices.push(i);
134 continue;
135 }
136 if entry.content.to_lowercase().contains(&query_lower) {
138 match_indices.push(i);
139 continue;
140 }
141 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 match_indices.sort();
162 match_indices.dedup();
163
164 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 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 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()); }
211
212 if results.is_empty() {
213 return Ok(format!("no matches found for '{}'", query));
214 }
215
216 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
238pub 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 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 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 let sid1 = setup_session_with_messages(
349 ws,
350 "first-session-unique-goal",
351 &[("user", "Hello from session 1")],
352 );
353
354 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 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 let summary = episodic_recall_summary(ws, 5);
399 assert_eq!(summary, "");
400
401 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 let tmp = crate::test_util::IsolatedWorkspace::new();
421 let ws = tmp.path();
422
423 let long_goal = "A".repeat(100); setup_session_with_messages(ws, &long_goal, &[("user", "hello")]);
425
426 let summary = episodic_recall_summary(ws, 5);
427 assert!(
429 summary.contains("..."),
430 "goal > 80 chars must be truncated with '...': {summary}"
431 );
432 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 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 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 let tmp = crate::test_util::IsolatedWorkspace::new();
478 let ws = tmp.path();
479
480 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 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 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 let tmp = crate::test_util::IsolatedWorkspace::new();
524 let ws = tmp.path();
525 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 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 assert!(
559 !output.contains(&long_content),
560 "long content must be truncated in output"
561 );
562 assert!(
564 output.contains("UNIQUE_PREFIX"),
565 "truncated content must still contain prefix: {output}"
566 );
567 }
568}