use super::display::{print_tool_call, print_tool_output};
use crate::store::SearchHit;
const RECALL_DEFAULT_LIMIT: usize = 5;
const RECALL_MAX_LIMIT: usize = 10;
pub trait RecallSource: Send + Sync {
fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>>;
fn this_conversation_recent(&self, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
let _ = limit;
Ok(Vec::new())
}
}
pub struct StoreRecallSource<'a> {
store: &'a crate::store::ConversationStore,
current_conversation_id: &'a str,
}
const EXCLUSION_FETCH_HEADROOM: usize = 20;
impl<'a> StoreRecallSource<'a> {
pub fn new(
store: &'a crate::store::ConversationStore,
current_conversation_id: &'a str,
) -> Self {
Self {
store,
current_conversation_id,
}
}
}
impl RecallSource for StoreRecallSource<'_> {
fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
let hits = self
.store
.search(query, limit.saturating_add(EXCLUSION_FETCH_HEADROOM))?;
Ok(hits
.into_iter()
.filter(|h| h.conversation_id != self.current_conversation_id)
.take(limit)
.collect())
}
fn this_conversation_recent(&self, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
if limit == 0 {
return Ok(Vec::new());
}
let record = self.store.load(self.current_conversation_id)?;
let start = record.turns.len().saturating_sub(limit);
Ok(record
.turns
.iter()
.enumerate()
.skip(start)
.map(|(i, turn)| SearchHit {
conversation_id: record.id.clone(),
title: record.title.clone(),
seq: (i + 1) as i64,
snippet: recent_turn_snippet(&turn.user, &turn.assistant),
rank: 0.0,
})
.collect())
}
}
const RESUME_TURN_FIELD_CAP: usize = 600;
fn recent_turn_snippet(user: &str, assistant: &str) -> String {
format!(
"you: {}\n me: {}",
flatten_and_cap(user, RESUME_TURN_FIELD_CAP),
flatten_and_cap(assistant, RESUME_TURN_FIELD_CAP),
)
}
fn flatten_and_cap(s: &str, cap: usize) -> String {
let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() > cap {
let head: String = flat.chars().take(cap).collect();
format!("{head}[…]")
} else {
flat
}
}
const RECALL_DESCRIPTION: &str =
"Search PAST conversations in this workspace. The current conversation is \
never searched — what was said here is already in your context. Use this \
when the user references earlier work ('like we did before', 'that bug \
we fixed', 'where did we leave off') or resumes a topic you don't see in \
context. Do NOT use it for information already in this conversation. \
Query with plain keywords ('tokio panic retry'), not boolean operators \
or quotes. Each result is a conversation id, title, and snippet with the \
match marked «like this»; the user can reopen one with \
/conversation restore <id>.";
pub fn recall_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "recall",
"description": RECALL_DESCRIPTION,
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Plain keywords describing what to find, e.g. \
'fts5 sanitizer tests' — no boolean or quote syntax"
},
"limit": {
"type": "integer",
"description": "Max matches to return, 1-10 (default 5)"
}
},
"required": ["query"]
}
}
})
}
pub(crate) fn execute_recall(
args: &serde_json::Value,
source: &dyn RecallSource,
color: bool,
tool_output_lines: usize,
) -> String {
let query = args["query"].as_str().unwrap_or("").trim();
let limit = args["limit"]
.as_u64()
.map(|l| {
usize::try_from(l)
.unwrap_or(RECALL_MAX_LIMIT)
.clamp(1, RECALL_MAX_LIMIT)
})
.unwrap_or(RECALL_DEFAULT_LIMIT);
print_tool_call("recall", query, color);
if query.is_empty() {
return "error: recall requires `query` — plain keywords describing what to find"
.to_string();
}
if crate::store::sanitize_fts5_query(query).is_err() {
let out = format!(
"no searchable terms in {query:?} — every term was search syntax or \
punctuation; try plain keywords (e.g. 'tokio panic retry')"
);
print_tool_output(&out, tool_output_lines, color);
return out;
}
let hits = match source.search(query, limit) {
Ok(hits) => hits,
Err(e) => return format!("error: {e}"),
};
if hits.is_empty() {
let out = format!(
"no matches in past conversations for {query:?} — try different keywords. \
To recover THIS conversation's own earlier work, call resume_context."
);
print_tool_output(&out, tool_output_lines, color);
return out;
}
let mut out = format!(
"{} match(es) in past conversations (best first):",
hits.len()
);
for hit in &hits {
let title = hit.title.trim();
let title = if title.is_empty() {
"(untitled)"
} else {
title
};
out.push_str(&format!(
"\n{} {} · seq {}\n {}",
short_id(&hit.conversation_id),
title,
hit.seq,
readable_snippet(&hit.snippet),
));
}
print_tool_output(&out, tool_output_lines, color);
out
}
fn short_id(id: &str) -> &str {
id.get(..12).unwrap_or(id)
}
fn readable_snippet(snippet: &str) -> String {
snippet
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace(">>>", "«")
.replace("<<<", "»")
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
pub(crate) struct MockSource {
pub calls: Mutex<Vec<(String, usize)>>,
pub hits: Vec<SearchHit>,
pub fail_with: Option<String>,
}
impl RecallSource for MockSource {
fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
self.calls.lock().unwrap().push((query.to_string(), limit));
match &self.fail_with {
Some(e) => Err(anyhow::anyhow!("{e}")),
None => Ok(self.hits.iter().take(limit).cloned().collect()),
}
}
}
pub(crate) fn hit(id: &str, title: &str, seq: i64, snippet: &str) -> SearchHit {
SearchHit {
conversation_id: id.to_string(),
title: title.to_string(),
seq,
snippet: snippet.to_string(),
rank: -1.0,
}
}
#[test]
fn schema_says_past_conversations_and_excludes_current() {
let def = recall_tool_definition();
let desc = def["function"]["description"].as_str().unwrap();
assert!(desc.contains("Search PAST conversations in this workspace"));
assert!(
desc.contains("The current conversation is never searched"),
"got: {desc}"
);
assert!(desc.contains("already in your context"), "got: {desc}");
}
#[test]
fn schema_coaches_when_to_use_and_when_not() {
let def = recall_tool_definition();
let desc = def["function"]["description"].as_str().unwrap();
assert!(desc.contains("'like we did before'"), "got: {desc}");
assert!(desc.contains("'where did we leave off'"), "got: {desc}");
assert!(
desc.contains("Do NOT use it for information already in this conversation"),
"got: {desc}"
);
}
#[test]
fn schema_coaches_plain_keywords_over_fts_syntax() {
let def = recall_tool_definition();
let desc = def["function"]["description"].as_str().unwrap();
assert!(desc.contains("plain keywords"), "got: {desc}");
assert!(
desc.contains("not boolean operators or quotes"),
"got: {desc}"
);
}
#[test]
fn schema_shape_query_required_limit_optional() {
let def = recall_tool_definition();
assert_eq!(def["function"]["name"], "recall");
let required: Vec<&str> = def["function"]["parameters"]["required"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(required, vec!["query"]);
let props = &def["function"]["parameters"]["properties"];
assert!(props["query"].is_object());
assert_eq!(props["limit"]["type"], "integer");
}
#[test]
fn happy_path_formats_short_id_title_seq_and_markers() {
let source = MockSource {
hits: vec![
hit(
"1748563200123-aaaa-bbbb",
"fixing the sanitizer",
4,
"…the >>>sanitizer<<< drops dangling\noperators…",
),
hit("1748563200456-cccc-dddd", " ", 2, ">>>sanitizer<<< port"),
],
..Default::default()
};
let out = execute_recall(
&serde_json::json!({"query": "sanitizer"}),
&source,
false,
20,
);
assert!(
out.starts_with("2 match(es) in past conversations (best first):"),
"got: {out}"
);
assert!(
out.contains("174856320012 fixing the sanitizer · seq 4"),
"got: {out}"
);
assert!(
out.contains("«sanitizer» drops dangling operators"),
"got: {out}"
);
assert!(!out.contains(">>>"), "raw FTS5 markers leaked: {out}");
assert!(out.contains("(untitled) · seq 2"), "got: {out}");
assert_eq!(
*source.calls.lock().unwrap(),
vec![("sanitizer".to_string(), 5)]
);
}
#[test]
fn limit_is_defaulted_and_clamped() {
let source = MockSource::default();
execute_recall(
&serde_json::json!({"query": "x", "limit": 25}),
&source,
false,
20,
);
execute_recall(
&serde_json::json!({"query": "x", "limit": 0}),
&source,
false,
20,
);
execute_recall(&serde_json::json!({"query": "x"}), &source, false, 20);
let limits: Vec<usize> = source.calls.lock().unwrap().iter().map(|c| c.1).collect();
assert_eq!(limits, vec![10, 1, 5]);
}
#[test]
fn sanitizer_rejected_query_is_coaching_not_error() {
let source = MockSource::default();
let out = execute_recall(&serde_json::json!({"query": "AND (*)"}), &source, false, 20);
assert!(out.contains("no searchable terms"), "got: {out}");
assert!(out.contains("plain keywords"), "got: {out}");
assert!(!out.starts_with("error:"), "must not abort-shape: {out}");
assert!(
source.calls.lock().unwrap().is_empty(),
"rejected query must never reach the backend"
);
}
#[test]
fn zero_hits_says_no_matches_never_empty() {
let source = MockSource::default();
let out = execute_recall(
&serde_json::json!({"query": "quetzalcoatl"}),
&source,
false,
20,
);
assert!(out.contains("no matches"), "got: {out}");
assert!(!out.is_empty());
assert!(
out.starts_with("no matches in past conversations"),
"got: {out}"
);
assert!(out.contains("resume_context"), "got: {out}");
}
#[test]
fn this_conversation_recent_default_impl_is_empty() {
let source = MockSource {
hits: vec![hit("conv-a", "t", 1, "snip")],
..Default::default()
};
assert!(
source.this_conversation_recent(5).unwrap().is_empty(),
"default impl ignores `hits` and returns empty"
);
}
#[test]
fn recent_turn_snippet_flattens_and_caps() {
let s = recent_turn_snippet("fix\nthe bug", "done\nshipping it");
assert_eq!(s, "you: fix the bug\n me: done shipping it");
let long = recent_turn_snippet(&"x".repeat(RESUME_TURN_FIELD_CAP + 50), "ok");
assert!(long.contains("[…]"), "got: {long}");
assert!(long.contains("me: ok"), "got: {long}");
}
#[test]
fn missing_query_is_a_clear_error() {
let source = MockSource::default();
let out = execute_recall(&serde_json::json!({}), &source, false, 20);
assert!(out.contains("requires `query`"), "got: {out}");
assert!(source.calls.lock().unwrap().is_empty());
}
#[test]
fn backend_failure_surfaces_as_tool_error_text() {
let source = MockSource {
fail_with: Some("database is on fire".to_string()),
..Default::default()
};
let out = execute_recall(
&serde_json::json!({"query": "anything"}),
&source,
false,
20,
);
assert_eq!(out, "error: database is on fire");
}
}