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 Block::Image {
179 media_type, source, ..
180 } => (
181 "image",
182 Block::image_placeholder(media_type, source.as_deref()),
183 ),
184 }
185}
186
187fn role_name(role: &Role) -> &'static str {
188 match role {
189 Role::User => "user",
190 Role::Assistant => "assistant",
191 }
192}
193
194fn search(messages: &[Message], query: &str, max_matches: usize) -> (String, usize, usize) {
197 let needle = query.to_lowercase();
198 let mut rendered = Vec::new();
199 let mut shown = 0usize;
200 let mut beyond = 0usize;
201
202 for (idx, message) in messages.iter().enumerate() {
203 for block in &message.content {
204 let (kind, text) = block_text(block);
205 let windows = matching_windows(&text, &needle);
206 if windows.is_empty() {
207 continue;
208 }
209 if shown >= max_matches {
210 beyond += 1;
211 continue;
212 }
213 shown += 1;
214 let lines: Vec<&str> = text.lines().collect();
215 let mut body = String::new();
216 for (start, end) in &windows {
217 if !body.is_empty() {
218 body.push_str(" ⋮\n");
219 }
220 for line in &lines[*start..*end] {
221 body.push_str(" ");
222 body.push_str(line);
223 body.push('\n');
224 }
225 }
226 rendered.push(format!(
227 "[message {idx} · {} · {kind}]\n{body}",
228 role_name(&message.role)
229 ));
230 }
231 }
232 (rendered.join("\n"), shown, beyond)
233}
234
235fn matching_windows(text: &str, lowercase_needle: &str) -> Vec<(usize, usize)> {
239 let lines: Vec<&str> = text.lines().collect();
240 let mut windows: Vec<(usize, usize)> = Vec::new();
241 for (i, line) in lines.iter().enumerate() {
242 if !line.to_lowercase().contains(lowercase_needle) {
243 continue;
244 }
245 let start = i.saturating_sub(CONTEXT_LINES);
246 let end = (i + CONTEXT_LINES + 1).min(lines.len());
247 match windows.last_mut() {
248 Some((_, prev_end)) if start <= *prev_end => *prev_end = end,
249 _ => windows.push((start, end)),
250 }
251 }
252 windows
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::message::Message;
259 use crate::session::{Record, SessionMeta};
260 use crate::tool::ToolCtx;
261
262 fn write_transcript(records: &[Record]) -> PathBuf {
263 let path =
264 std::env::temp_dir().join(format!("mecha-recall-{}.jsonl", uuid::Uuid::new_v4()));
265 let body: String = records
266 .iter()
267 .map(|r| serde_json::to_string(r).unwrap() + "\n")
268 .collect();
269 std::fs::write(&path, body).unwrap();
270 path
271 }
272
273 fn meta() -> Record {
274 Record::Meta(SessionMeta {
275 id: "recall-test".into(),
276 created_at: chrono::Utc::now(),
277 provider: "scripted".into(),
278 model: "none".into(),
279 workspace: std::env::temp_dir(),
280 title: None,
281 })
282 }
283
284 fn ctx() -> ToolCtx {
285 ToolCtx::default().with_workspace(std::env::temp_dir())
286 }
287
288 async fn run(tool: &Recall, input: Value) -> ToolOutput {
289 tool.call(input, &ctx()).await.unwrap()
290 }
291
292 #[tokio::test]
296 async fn finds_content_a_rewrite_dropped() {
297 let dropped = Message::assistant(vec![Block::text("the magic number is 74656")]);
298 let path = write_transcript(&[
299 meta(),
300 Record::Message(Message::user("compute the magic number")),
301 Record::Message(dropped),
302 Record::Rewrite {
303 messages: vec![Message::user("[summary: a number was computed]")],
304 },
305 ]);
306 let tool = Recall::new(path);
307
308 let out = run(&tool, json!({"query": "74656"})).await;
309 assert!(!out.is_error);
310 assert!(
311 out.content.contains("74656"),
312 "dropped content not found: {}",
313 out.content
314 );
315 assert!(
316 out.content.contains("assistant"),
317 "match not attributed: {}",
318 out.content
319 );
320
321 let out = run(&tool, json!({"query": "summary:"})).await;
323 assert!(out.content.contains("[summary:"));
324 }
325
326 #[tokio::test]
330 async fn a_rewritten_duplicate_matches_once() {
331 let kept = Message::user("the anchor phrase");
332 let path = write_transcript(&[
333 meta(),
334 Record::Message(kept.clone()),
335 Record::Rewrite {
336 messages: vec![kept],
337 },
338 ]);
339 let out = run(&Recall::new(path), json!({"query": "anchor phrase"})).await;
340 assert!(
341 out.content.starts_with("1 matching block(s)"),
342 "{}",
343 out.content
344 );
345 }
346
347 #[tokio::test]
348 async fn matching_is_case_insensitive_and_labelled_by_block_kind() {
349 let path = write_transcript(&[
350 meta(),
351 Record::Message(Message::tool_results(vec![Block::ToolResult {
352 tool_use_id: "t1".into(),
353 content: "Quarterly Total: $12,345".into(),
354 is_error: false,
355 }])),
356 ]);
357 let out = run(&Recall::new(path), json!({"query": "quarterly total"})).await;
358 assert!(!out.is_error);
359 assert!(out.content.contains("tool_result"), "{}", out.content);
360 assert!(out.content.contains("$12,345"));
361 }
362
363 #[tokio::test]
364 async fn zero_matches_reports_the_corpus_size_not_an_error() {
365 let path = write_transcript(&[meta(), Record::Message(Message::user("hello"))]);
366 let out = run(&Recall::new(path), json!({"query": "absent"})).await;
367 assert!(!out.is_error);
368 assert!(out.content.contains("no matches"));
369 assert!(out.content.contains("1 recorded messages"));
370 }
371
372 #[tokio::test]
373 async fn a_missing_transcript_is_an_expected_failure() {
374 let tool = Recall::new(std::env::temp_dir().join("mecha-recall-nonexistent.jsonl"));
375 let out = run(&tool, json!({"query": "anything"})).await;
376 assert!(out.is_error);
377 assert!(out.content.contains("not be recording"));
378 }
379
380 #[tokio::test]
381 async fn an_empty_query_is_refused() {
382 let path = write_transcript(&[meta()]);
383 let out = run(&Recall::new(path), json!({"query": " "})).await;
384 assert!(out.is_error);
385 }
386
387 #[tokio::test]
388 async fn the_match_cap_reports_what_it_hid() {
389 let records: Vec<Record> = std::iter::once(meta())
390 .chain((0..5).map(|i| Record::Message(Message::user(format!("needle row {i}")))))
391 .collect();
392 let path = write_transcript(&records);
393 let out = run(
394 &Recall::new(path),
395 json!({"query": "needle", "max_matches": 2}),
396 )
397 .await;
398 assert!(
399 out.content.contains("2 matching block(s)"),
400 "{}",
401 out.content
402 );
403 assert!(
404 out.content.contains("3 more matching block(s)"),
405 "{}",
406 out.content
407 );
408 }
409}