1use super::{Capabilities, Tool, ToolCtx, ToolOutput};
42use crate::message::{Block, Message, Role};
43use anyhow::Result;
44use async_trait::async_trait;
45use serde_json::{json, Value};
46use std::path::PathBuf;
47
48const DEFAULT_MAX_MATCHES: usize = 20;
53
54const CONTEXT_LINES: usize = 2;
56
57pub struct Recall {
58 transcript: PathBuf,
59}
60
61impl Recall {
62 pub fn new(transcript: PathBuf) -> Self {
63 Recall { transcript }
64 }
65}
66
67#[async_trait]
68impl Tool for Recall {
69 fn name(&self) -> &str {
70 "recall"
71 }
72
73 fn description(&self) -> &str {
74 "Search this conversation's full recorded history — including turns that were \
75 summarized away by compaction — for a case-insensitive literal string. Use it when \
76 an earlier detail (a value a tool returned, an instruction's exact wording) is no \
77 longer in context: searching the record is cheaper and more faithful than re-running \
78 the tool or reconstructing from memory. Returns matching lines with surrounding \
79 context, oldest first."
80 }
81
82 fn input_schema(&self) -> Value {
83 json!({
84 "type": "object",
85 "properties": {
86 "query": {
87 "type": "string",
88 "description": "Case-insensitive literal text to search for. Not a regex."
89 },
90 "max_matches": {
91 "type": "integer",
92 "description": "Maximum matching blocks to return (default 20)."
93 }
94 },
95 "required": ["query"]
96 })
97 }
98
99 fn read_only(&self) -> bool {
100 true
101 }
102
103 fn capabilities(&self) -> Capabilities {
104 Capabilities::default()
107 }
108
109 async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
110 let query = match input.get("query").and_then(Value::as_str) {
111 Some(q) if !q.trim().is_empty() => q.to_string(),
112 _ => {
113 return Ok(ToolOutput::err(
114 "missing or empty required argument `query`",
115 ))
116 }
117 };
118 let max_matches = input
119 .get("max_matches")
120 .and_then(Value::as_u64)
121 .map(|n| n.max(1) as usize)
122 .unwrap_or(DEFAULT_MAX_MATCHES);
123
124 let text = match tokio::fs::read_to_string(&self.transcript).await {
125 Ok(t) => t,
126 Err(e) => {
127 return Ok(ToolOutput::err(format!(
128 "cannot read the session transcript ({e}); this conversation may not \
129 be recording, in which case there is no history beyond what is in \
130 context"
131 )))
132 }
133 };
134
135 let messages = crate::session::Session::messages_ever(&text);
136 let (rendered, matched, capped) = search(&messages, &query, max_matches);
137
138 if matched == 0 {
139 return Ok(ToolOutput::ok(format!(
140 "no matches for {query:?} in {} recorded messages. The record covers \
141 completed runs of this session; the current run's turns are still in \
142 context rather than in the record.",
143 messages.len()
144 )));
145 }
146
147 let mut out = format!(
148 "{matched} matching block(s) for {query:?} across {} recorded messages, \
149 oldest first:\n\n{rendered}",
150 messages.len()
151 );
152 if capped > 0 {
153 out.push_str(&format!(
154 "\n[{capped} more matching block(s) not shown — narrow the query, or \
155 raise max_matches]"
156 ));
157 }
158 Ok(ToolOutput::ok(out))
159 }
160}
161
162fn block_text(block: &Block) -> (&'static str, String) {
167 match block {
168 Block::Text { text } => ("text", text.clone()),
169 Block::Thinking { text, .. } => ("thinking", text.clone()),
170 Block::ToolUse { name, input, .. } => ("tool_use", format!("{name} {input}")),
171 Block::ToolResult { content, .. } => ("tool_result", content.clone()),
172 }
173}
174
175fn role_name(role: &Role) -> &'static str {
176 match role {
177 Role::User => "user",
178 Role::Assistant => "assistant",
179 }
180}
181
182fn search(messages: &[Message], query: &str, max_matches: usize) -> (String, usize, usize) {
185 let needle = query.to_lowercase();
186 let mut rendered = Vec::new();
187 let mut shown = 0usize;
188 let mut beyond = 0usize;
189
190 for (idx, message) in messages.iter().enumerate() {
191 for block in &message.content {
192 let (kind, text) = block_text(block);
193 let windows = matching_windows(&text, &needle);
194 if windows.is_empty() {
195 continue;
196 }
197 if shown >= max_matches {
198 beyond += 1;
199 continue;
200 }
201 shown += 1;
202 let lines: Vec<&str> = text.lines().collect();
203 let mut body = String::new();
204 for (start, end) in &windows {
205 if !body.is_empty() {
206 body.push_str(" ⋮\n");
207 }
208 for line in &lines[*start..*end] {
209 body.push_str(" ");
210 body.push_str(line);
211 body.push('\n');
212 }
213 }
214 rendered.push(format!(
215 "[message {idx} · {} · {kind}]\n{body}",
216 role_name(&message.role)
217 ));
218 }
219 }
220 (rendered.join("\n"), shown, beyond)
221}
222
223fn matching_windows(text: &str, lowercase_needle: &str) -> Vec<(usize, usize)> {
227 let lines: Vec<&str> = text.lines().collect();
228 let mut windows: Vec<(usize, usize)> = Vec::new();
229 for (i, line) in lines.iter().enumerate() {
230 if !line.to_lowercase().contains(lowercase_needle) {
231 continue;
232 }
233 let start = i.saturating_sub(CONTEXT_LINES);
234 let end = (i + CONTEXT_LINES + 1).min(lines.len());
235 match windows.last_mut() {
236 Some((_, prev_end)) if start <= *prev_end => *prev_end = end,
237 _ => windows.push((start, end)),
238 }
239 }
240 windows
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::message::Message;
247 use crate::session::{Record, SessionMeta};
248 use crate::tool::ToolCtx;
249
250 fn write_transcript(records: &[Record]) -> PathBuf {
251 let path =
252 std::env::temp_dir().join(format!("mecha-recall-{}.jsonl", uuid::Uuid::new_v4()));
253 let body: String = records
254 .iter()
255 .map(|r| serde_json::to_string(r).unwrap() + "\n")
256 .collect();
257 std::fs::write(&path, body).unwrap();
258 path
259 }
260
261 fn meta() -> Record {
262 Record::Meta(SessionMeta {
263 id: "recall-test".into(),
264 created_at: chrono::Utc::now(),
265 provider: "scripted".into(),
266 model: "none".into(),
267 workspace: std::env::temp_dir(),
268 title: None,
269 })
270 }
271
272 fn ctx() -> ToolCtx {
273 ToolCtx::default().with_workspace(std::env::temp_dir())
274 }
275
276 async fn run(tool: &Recall, input: Value) -> ToolOutput {
277 tool.call(input, &ctx()).await.unwrap()
278 }
279
280 #[tokio::test]
284 async fn finds_content_a_rewrite_dropped() {
285 let dropped = Message::assistant(vec![Block::text("the magic number is 74656")]);
286 let path = write_transcript(&[
287 meta(),
288 Record::Message(Message::user("compute the magic number")),
289 Record::Message(dropped),
290 Record::Rewrite {
291 messages: vec![Message::user("[summary: a number was computed]")],
292 },
293 ]);
294 let tool = Recall::new(path);
295
296 let out = run(&tool, json!({"query": "74656"})).await;
297 assert!(!out.is_error);
298 assert!(
299 out.content.contains("74656"),
300 "dropped content not found: {}",
301 out.content
302 );
303 assert!(
304 out.content.contains("assistant"),
305 "match not attributed: {}",
306 out.content
307 );
308
309 let out = run(&tool, json!({"query": "summary:"})).await;
311 assert!(out.content.contains("[summary:"));
312 }
313
314 #[tokio::test]
318 async fn a_rewritten_duplicate_matches_once() {
319 let kept = Message::user("the anchor phrase");
320 let path = write_transcript(&[
321 meta(),
322 Record::Message(kept.clone()),
323 Record::Rewrite {
324 messages: vec![kept],
325 },
326 ]);
327 let out = run(&Recall::new(path), json!({"query": "anchor phrase"})).await;
328 assert!(
329 out.content.starts_with("1 matching block(s)"),
330 "{}",
331 out.content
332 );
333 }
334
335 #[tokio::test]
336 async fn matching_is_case_insensitive_and_labelled_by_block_kind() {
337 let path = write_transcript(&[
338 meta(),
339 Record::Message(Message::tool_results(vec![Block::ToolResult {
340 tool_use_id: "t1".into(),
341 content: "Quarterly Total: $12,345".into(),
342 is_error: false,
343 }])),
344 ]);
345 let out = run(&Recall::new(path), json!({"query": "quarterly total"})).await;
346 assert!(!out.is_error);
347 assert!(out.content.contains("tool_result"), "{}", out.content);
348 assert!(out.content.contains("$12,345"));
349 }
350
351 #[tokio::test]
352 async fn zero_matches_reports_the_corpus_size_not_an_error() {
353 let path = write_transcript(&[meta(), Record::Message(Message::user("hello"))]);
354 let out = run(&Recall::new(path), json!({"query": "absent"})).await;
355 assert!(!out.is_error);
356 assert!(out.content.contains("no matches"));
357 assert!(out.content.contains("1 recorded messages"));
358 }
359
360 #[tokio::test]
361 async fn a_missing_transcript_is_an_expected_failure() {
362 let tool = Recall::new(std::env::temp_dir().join("mecha-recall-nonexistent.jsonl"));
363 let out = run(&tool, json!({"query": "anything"})).await;
364 assert!(out.is_error);
365 assert!(out.content.contains("not be recording"));
366 }
367
368 #[tokio::test]
369 async fn an_empty_query_is_refused() {
370 let path = write_transcript(&[meta()]);
371 let out = run(&Recall::new(path), json!({"query": " "})).await;
372 assert!(out.is_error);
373 }
374
375 #[tokio::test]
376 async fn the_match_cap_reports_what_it_hid() {
377 let records: Vec<Record> = std::iter::once(meta())
378 .chain((0..5).map(|i| Record::Message(Message::user(format!("needle row {i}")))))
379 .collect();
380 let path = write_transcript(&records);
381 let out = run(
382 &Recall::new(path),
383 json!({"query": "needle", "max_matches": 2}),
384 )
385 .await;
386 assert!(
387 out.content.contains("2 matching block(s)"),
388 "{}",
389 out.content
390 );
391 assert!(
392 out.content.contains("3 more matching block(s)"),
393 "{}",
394 out.content
395 );
396 }
397}