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 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 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 let sessions: Vec<&PathBuf> = if let Some(ref sid) = session_id {
92 all_sessions
93 .iter()
94 .filter(|dir| {
95 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 let mut match_indices: Vec<usize> = Vec::new();
126 for (i, entry) in entries.iter().enumerate() {
127 if entry.role.to_lowercase().contains(&query_lower) {
129 match_indices.push(i);
130 continue;
131 }
132 if entry.content.to_lowercase().contains(&query_lower) {
134 match_indices.push(i);
135 continue;
136 }
137 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 match_indices.sort();
158 match_indices.dedup();
159
160 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 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 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()); }
207
208 if results.is_empty() {
209 return Ok(format!("no matches found for '{}'", query));
210 }
211
212 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
234pub 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 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 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 let sid1 = setup_session_with_messages(
344 ws,
345 "first-session-unique-goal",
346 &[("user", "Hello from session 1")],
347 );
348
349 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 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 let summary = episodic_recall_summary(ws, 5);
394 assert_eq!(summary, "");
395
396 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}