use super::{Capabilities, Tool, ToolCtx, ToolOutput};
use crate::message::{Block, Message, Role};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::PathBuf;
const DEFAULT_MAX_MATCHES: usize = 20;
const CONTEXT_LINES: usize = 2;
pub struct Recall {
transcript: PathBuf,
}
impl Recall {
pub fn new(transcript: PathBuf) -> Self {
Recall { transcript }
}
}
#[async_trait]
impl Tool for Recall {
fn name(&self) -> &str {
"recall"
}
fn description(&self) -> &str {
"Search this conversation's full recorded history — including turns that were \
summarized away by compaction — for a case-insensitive literal string. Use it when \
an earlier detail (a value a tool returned, an instruction's exact wording) is no \
longer in context: searching the record is cheaper and more faithful than re-running \
the tool or reconstructing from memory. Returns matching lines with surrounding \
context, oldest first."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Case-insensitive literal text to search for. Not a regex."
},
"max_matches": {
"type": "integer",
"description": "Maximum matching blocks to return (default 20)."
}
},
"required": ["query"]
})
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
let query = match input.get("query").and_then(Value::as_str) {
Some(q) if !q.trim().is_empty() => q.to_string(),
_ => {
return Ok(ToolOutput::err(
"missing or empty required argument `query`",
))
}
};
let max_matches = input
.get("max_matches")
.and_then(Value::as_u64)
.map(|n| n.max(1) as usize)
.unwrap_or(DEFAULT_MAX_MATCHES);
let text = match tokio::fs::read_to_string(&self.transcript).await {
Ok(t) => t,
Err(e) => {
return Ok(ToolOutput::err(format!(
"cannot read the session transcript ({e}); this conversation may not \
be recording, in which case there is no history beyond what is in \
context"
)))
}
};
let messages = crate::session::Session::messages_ever(&text);
let (rendered, matched, capped) = search(&messages, &query, max_matches);
if matched == 0 {
return Ok(ToolOutput::ok(format!(
"no matches for {query:?} in {} recorded messages. The record covers \
completed runs of this session; the current run's turns are still in \
context rather than in the record.",
messages.len()
)));
}
let mut out = format!(
"{matched} matching block(s) for {query:?} across {} recorded messages, \
oldest first:\n\n{rendered}",
messages.len()
);
if capped > 0 {
out.push_str(&format!(
"\n[{capped} more matching block(s) not shown — narrow the query, or \
raise max_matches]"
));
}
Ok(ToolOutput::ok(out))
}
}
fn block_text(block: &Block) -> (&'static str, String) {
match block {
Block::Text { text } => ("text", text.clone()),
Block::Thinking { text, .. } => ("thinking", text.clone()),
Block::ToolUse { name, input, .. } => ("tool_use", format!("{name} {input}")),
Block::ToolResult { content, .. } => ("tool_result", content.clone()),
Block::Image {
media_type, source, ..
} => (
"image",
Block::image_placeholder(media_type, source.as_deref()),
),
}
}
fn role_name(role: &Role) -> &'static str {
match role {
Role::User => "user",
Role::Assistant => "assistant",
}
}
fn search(messages: &[Message], query: &str, max_matches: usize) -> (String, usize, usize) {
let needle = query.to_lowercase();
let mut rendered = Vec::new();
let mut shown = 0usize;
let mut beyond = 0usize;
for (idx, message) in messages.iter().enumerate() {
for block in &message.content {
let (kind, text) = block_text(block);
let windows = matching_windows(&text, &needle);
if windows.is_empty() {
continue;
}
if shown >= max_matches {
beyond += 1;
continue;
}
shown += 1;
let lines: Vec<&str> = text.lines().collect();
let mut body = String::new();
for (start, end) in &windows {
if !body.is_empty() {
body.push_str(" ⋮\n");
}
for line in &lines[*start..*end] {
body.push_str(" ");
body.push_str(line);
body.push('\n');
}
}
rendered.push(format!(
"[message {idx} · {} · {kind}]\n{body}",
role_name(&message.role)
));
}
}
(rendered.join("\n"), shown, beyond)
}
fn matching_windows(text: &str, lowercase_needle: &str) -> Vec<(usize, usize)> {
let lines: Vec<&str> = text.lines().collect();
let mut windows: Vec<(usize, usize)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
if !line.to_lowercase().contains(lowercase_needle) {
continue;
}
let start = i.saturating_sub(CONTEXT_LINES);
let end = (i + CONTEXT_LINES + 1).min(lines.len());
match windows.last_mut() {
Some((_, prev_end)) if start <= *prev_end => *prev_end = end,
_ => windows.push((start, end)),
}
}
windows
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::Message;
use crate::session::{Record, SessionMeta};
use crate::tool::ToolCtx;
fn write_transcript(records: &[Record]) -> PathBuf {
let path =
std::env::temp_dir().join(format!("mecha-recall-{}.jsonl", uuid::Uuid::new_v4()));
let body: String = records
.iter()
.map(|r| serde_json::to_string(r).unwrap() + "\n")
.collect();
std::fs::write(&path, body).unwrap();
path
}
fn meta() -> Record {
Record::Meta(SessionMeta {
id: "recall-test".into(),
created_at: chrono::Utc::now(),
provider: "scripted".into(),
model: "none".into(),
workspace: std::env::temp_dir(),
title: None,
})
}
fn ctx() -> ToolCtx {
ToolCtx::default().with_workspace(std::env::temp_dir())
}
async fn run(tool: &Recall, input: Value) -> ToolOutput {
tool.call(input, &ctx()).await.unwrap()
}
#[tokio::test]
async fn finds_content_a_rewrite_dropped() {
let dropped = Message::assistant(vec![Block::text("the magic number is 74656")]);
let path = write_transcript(&[
meta(),
Record::Message(Message::user("compute the magic number")),
Record::Message(dropped),
Record::Rewrite {
messages: vec![Message::user("[summary: a number was computed]")],
},
]);
let tool = Recall::new(path);
let out = run(&tool, json!({"query": "74656"})).await;
assert!(!out.is_error);
assert!(
out.content.contains("74656"),
"dropped content not found: {}",
out.content
);
assert!(
out.content.contains("assistant"),
"match not attributed: {}",
out.content
);
let out = run(&tool, json!({"query": "summary:"})).await;
assert!(out.content.contains("[summary:"));
}
#[tokio::test]
async fn a_rewritten_duplicate_matches_once() {
let kept = Message::user("the anchor phrase");
let path = write_transcript(&[
meta(),
Record::Message(kept.clone()),
Record::Rewrite {
messages: vec![kept],
},
]);
let out = run(&Recall::new(path), json!({"query": "anchor phrase"})).await;
assert!(
out.content.starts_with("1 matching block(s)"),
"{}",
out.content
);
}
#[tokio::test]
async fn matching_is_case_insensitive_and_labelled_by_block_kind() {
let path = write_transcript(&[
meta(),
Record::Message(Message::tool_results(vec![Block::ToolResult {
tool_use_id: "t1".into(),
content: "Quarterly Total: $12,345".into(),
is_error: false,
}])),
]);
let out = run(&Recall::new(path), json!({"query": "quarterly total"})).await;
assert!(!out.is_error);
assert!(out.content.contains("tool_result"), "{}", out.content);
assert!(out.content.contains("$12,345"));
}
#[tokio::test]
async fn zero_matches_reports_the_corpus_size_not_an_error() {
let path = write_transcript(&[meta(), Record::Message(Message::user("hello"))]);
let out = run(&Recall::new(path), json!({"query": "absent"})).await;
assert!(!out.is_error);
assert!(out.content.contains("no matches"));
assert!(out.content.contains("1 recorded messages"));
}
#[tokio::test]
async fn a_missing_transcript_is_an_expected_failure() {
let tool = Recall::new(std::env::temp_dir().join("mecha-recall-nonexistent.jsonl"));
let out = run(&tool, json!({"query": "anything"})).await;
assert!(out.is_error);
assert!(out.content.contains("not be recording"));
}
#[tokio::test]
async fn an_empty_query_is_refused() {
let path = write_transcript(&[meta()]);
let out = run(&Recall::new(path), json!({"query": " "})).await;
assert!(out.is_error);
}
#[tokio::test]
async fn the_match_cap_reports_what_it_hid() {
let records: Vec<Record> = std::iter::once(meta())
.chain((0..5).map(|i| Record::Message(Message::user(format!("needle row {i}")))))
.collect();
let path = write_transcript(&records);
let out = run(
&Recall::new(path),
json!({"query": "needle", "max_matches": 2}),
)
.await;
assert!(
out.content.contains("2 matching block(s)"),
"{}",
out.content
);
assert!(
out.content.contains("3 more matching block(s)"),
"{}",
out.content
);
}
}