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