1use super::display::{print_tool_call, print_tool_output};
20use crate::store::SearchHit;
21
22const RECALL_DEFAULT_LIMIT: usize = 5;
24const RECALL_MAX_LIMIT: usize = 10;
27
28pub trait RecallSource: Send + Sync {
38 fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>>;
41
42 fn this_conversation_recent(&self, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
56 let _ = limit;
57 Ok(Vec::new())
58 }
59}
60
61pub struct StoreRecallSource<'a> {
65 store: &'a crate::store::ConversationStore,
66 current_conversation_id: &'a str,
67}
68
69const EXCLUSION_FETCH_HEADROOM: usize = 20;
74
75impl<'a> StoreRecallSource<'a> {
76 pub fn new(
79 store: &'a crate::store::ConversationStore,
80 current_conversation_id: &'a str,
81 ) -> Self {
82 Self {
83 store,
84 current_conversation_id,
85 }
86 }
87}
88
89impl RecallSource for StoreRecallSource<'_> {
90 fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
91 let hits = self
95 .store
96 .search(query, limit.saturating_add(EXCLUSION_FETCH_HEADROOM))?;
97 Ok(hits
98 .into_iter()
99 .filter(|h| h.conversation_id != self.current_conversation_id)
100 .take(limit)
101 .collect())
102 }
103
104 fn this_conversation_recent(&self, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
105 if limit == 0 {
111 return Ok(Vec::new());
112 }
113 let record = self.store.load(self.current_conversation_id)?;
114 let start = record.turns.len().saturating_sub(limit);
115 Ok(record
116 .turns
117 .iter()
118 .enumerate()
119 .skip(start)
120 .map(|(i, turn)| SearchHit {
121 conversation_id: record.id.clone(),
122 title: record.title.clone(),
123 seq: (i + 1) as i64,
127 snippet: recent_turn_snippet(&turn.user, &turn.assistant),
128 rank: 0.0,
129 })
130 .collect())
131 }
132}
133
134const RESUME_TURN_FIELD_CAP: usize = 600;
137
138fn recent_turn_snippet(user: &str, assistant: &str) -> String {
142 format!(
143 "you: {}\n me: {}",
144 flatten_and_cap(user, RESUME_TURN_FIELD_CAP),
145 flatten_and_cap(assistant, RESUME_TURN_FIELD_CAP),
146 )
147}
148
149fn flatten_and_cap(s: &str, cap: usize) -> String {
152 let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
153 if flat.chars().count() > cap {
154 let head: String = flat.chars().take(cap).collect();
155 format!("{head}[…]")
156 } else {
157 flat
158 }
159}
160
161const RECALL_DESCRIPTION: &str =
171 "Search PAST conversations in this workspace. The current conversation is \
172 never searched — what was said here is already in your context. Use this \
173 when the user references earlier work ('like we did before', 'that bug \
174 we fixed', 'where did we leave off') or resumes a topic you don't see in \
175 context. Do NOT use it for information already in this conversation. \
176 Query with plain keywords ('tokio panic retry'), not boolean operators \
177 or quotes. Each result is a conversation id, title, and snippet with the \
178 match marked «like this»; the user can reopen one with \
179 /conversation restore <id>.";
180
181pub fn recall_tool_definition() -> serde_json::Value {
185 serde_json::json!({
186 "type": "function",
187 "function": {
188 "name": "recall",
189 "description": RECALL_DESCRIPTION,
190 "parameters": {
191 "type": "object",
192 "properties": {
193 "query": {
194 "type": "string",
195 "description": "Plain keywords describing what to find, e.g. \
196 'fts5 sanitizer tests' — no boolean or quote syntax"
197 },
198 "limit": {
199 "type": "integer",
200 "description": "Max matches to return, 1-10 (default 5)"
201 }
202 },
203 "required": ["query"]
204 }
205 }
206 })
207}
208
209pub(crate) fn execute_recall(
223 args: &serde_json::Value,
224 source: &dyn RecallSource,
225 color: bool,
226 tool_output_lines: usize,
227) -> String {
228 let query = args["query"].as_str().unwrap_or("").trim();
229 let limit = args["limit"]
231 .as_u64()
232 .map(|l| {
233 usize::try_from(l)
234 .unwrap_or(RECALL_MAX_LIMIT)
235 .clamp(1, RECALL_MAX_LIMIT)
236 })
237 .unwrap_or(RECALL_DEFAULT_LIMIT);
238
239 print_tool_call("recall", query, color);
240
241 if query.is_empty() {
242 return "error: recall requires `query` — plain keywords describing what to find"
243 .to_string();
244 }
245 if crate::store::sanitize_fts5_query(query).is_err() {
248 let out = format!(
249 "no searchable terms in {query:?} — every term was search syntax or \
250 punctuation; try plain keywords (e.g. 'tokio panic retry')"
251 );
252 print_tool_output(&out, tool_output_lines, color);
253 return out;
254 }
255
256 let hits = match source.search(query, limit) {
257 Ok(hits) => hits,
258 Err(e) => return format!("error: {e}"),
259 };
260 if hits.is_empty() {
261 let out = format!(
267 "no matches in past conversations for {query:?} — try different keywords. \
268 To recover THIS conversation's own earlier work, call resume_context."
269 );
270 print_tool_output(&out, tool_output_lines, color);
271 return out;
272 }
273
274 let mut out = format!(
275 "{} match(es) in past conversations (best first):",
276 hits.len()
277 );
278 for hit in &hits {
279 let title = hit.title.trim();
280 let title = if title.is_empty() {
281 "(untitled)"
282 } else {
283 title
284 };
285 out.push_str(&format!(
286 "\n{} {} · seq {}\n {}",
287 short_id(&hit.conversation_id),
288 title,
289 hit.seq,
290 readable_snippet(&hit.snippet),
291 ));
292 }
293 print_tool_output(&out, tool_output_lines, color);
294 out
295}
296
297fn short_id(id: &str) -> &str {
301 id.get(..12).unwrap_or(id)
302}
303
304fn readable_snippet(snippet: &str) -> String {
309 snippet
310 .split_whitespace()
311 .collect::<Vec<_>>()
312 .join(" ")
313 .replace(">>>", "«")
314 .replace("<<<", "»")
315}
316
317#[cfg(test)]
322pub(crate) mod tests {
323 use super::*;
324 use std::sync::Mutex;
325
326 #[derive(Default)]
330 pub(crate) struct MockSource {
331 pub calls: Mutex<Vec<(String, usize)>>,
332 pub hits: Vec<SearchHit>,
333 pub fail_with: Option<String>,
334 }
335
336 impl RecallSource for MockSource {
337 fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
338 self.calls.lock().unwrap().push((query.to_string(), limit));
339 match &self.fail_with {
340 Some(e) => Err(anyhow::anyhow!("{e}")),
341 None => Ok(self.hits.iter().take(limit).cloned().collect()),
342 }
343 }
344 }
345
346 pub(crate) fn hit(id: &str, title: &str, seq: i64, snippet: &str) -> SearchHit {
347 SearchHit {
348 conversation_id: id.to_string(),
349 title: title.to_string(),
350 seq,
351 snippet: snippet.to_string(),
352 rank: -1.0,
353 }
354 }
355
356 #[test]
359 fn schema_says_past_conversations_and_excludes_current() {
360 let def = recall_tool_definition();
361 let desc = def["function"]["description"].as_str().unwrap();
362 assert!(desc.contains("Search PAST conversations in this workspace"));
363 assert!(
364 desc.contains("The current conversation is never searched"),
365 "got: {desc}"
366 );
367 assert!(desc.contains("already in your context"), "got: {desc}");
368 }
369
370 #[test]
371 fn schema_coaches_when_to_use_and_when_not() {
372 let def = recall_tool_definition();
373 let desc = def["function"]["description"].as_str().unwrap();
374 assert!(desc.contains("'like we did before'"), "got: {desc}");
375 assert!(desc.contains("'where did we leave off'"), "got: {desc}");
376 assert!(
377 desc.contains("Do NOT use it for information already in this conversation"),
378 "got: {desc}"
379 );
380 }
381
382 #[test]
383 fn schema_coaches_plain_keywords_over_fts_syntax() {
384 let def = recall_tool_definition();
385 let desc = def["function"]["description"].as_str().unwrap();
386 assert!(desc.contains("plain keywords"), "got: {desc}");
387 assert!(
388 desc.contains("not boolean operators or quotes"),
389 "got: {desc}"
390 );
391 }
392
393 #[test]
394 fn schema_shape_query_required_limit_optional() {
395 let def = recall_tool_definition();
396 assert_eq!(def["function"]["name"], "recall");
397 let required: Vec<&str> = def["function"]["parameters"]["required"]
398 .as_array()
399 .unwrap()
400 .iter()
401 .filter_map(|v| v.as_str())
402 .collect();
403 assert_eq!(required, vec!["query"]);
404 let props = &def["function"]["parameters"]["properties"];
405 assert!(props["query"].is_object());
406 assert_eq!(props["limit"]["type"], "integer");
407 }
408
409 #[test]
412 fn happy_path_formats_short_id_title_seq_and_markers() {
413 let source = MockSource {
414 hits: vec![
415 hit(
416 "1748563200123-aaaa-bbbb",
417 "fixing the sanitizer",
418 4,
419 "…the >>>sanitizer<<< drops dangling\noperators…",
420 ),
421 hit("1748563200456-cccc-dddd", " ", 2, ">>>sanitizer<<< port"),
422 ],
423 ..Default::default()
424 };
425 let out = execute_recall(
426 &serde_json::json!({"query": "sanitizer"}),
427 &source,
428 false,
429 20,
430 );
431 assert!(
432 out.starts_with("2 match(es) in past conversations (best first):"),
433 "got: {out}"
434 );
435 assert!(
437 out.contains("174856320012 fixing the sanitizer · seq 4"),
438 "got: {out}"
439 );
440 assert!(
442 out.contains("«sanitizer» drops dangling operators"),
443 "got: {out}"
444 );
445 assert!(!out.contains(">>>"), "raw FTS5 markers leaked: {out}");
446 assert!(out.contains("(untitled) · seq 2"), "got: {out}");
448 assert_eq!(
450 *source.calls.lock().unwrap(),
451 vec![("sanitizer".to_string(), 5)]
452 );
453 }
454
455 #[test]
456 fn limit_is_defaulted_and_clamped() {
457 let source = MockSource::default();
458 execute_recall(
460 &serde_json::json!({"query": "x", "limit": 25}),
461 &source,
462 false,
463 20,
464 );
465 execute_recall(
466 &serde_json::json!({"query": "x", "limit": 0}),
467 &source,
468 false,
469 20,
470 );
471 execute_recall(&serde_json::json!({"query": "x"}), &source, false, 20);
472 let limits: Vec<usize> = source.calls.lock().unwrap().iter().map(|c| c.1).collect();
473 assert_eq!(limits, vec![10, 1, 5]);
474 }
475
476 #[test]
477 fn sanitizer_rejected_query_is_coaching_not_error() {
478 let source = MockSource::default();
479 let out = execute_recall(&serde_json::json!({"query": "AND (*)"}), &source, false, 20);
480 assert!(out.contains("no searchable terms"), "got: {out}");
481 assert!(out.contains("plain keywords"), "got: {out}");
482 assert!(!out.starts_with("error:"), "must not abort-shape: {out}");
483 assert!(
484 source.calls.lock().unwrap().is_empty(),
485 "rejected query must never reach the backend"
486 );
487 }
488
489 #[test]
490 fn zero_hits_says_no_matches_never_empty() {
491 let source = MockSource::default();
492 let out = execute_recall(
493 &serde_json::json!({"query": "quetzalcoatl"}),
494 &source,
495 false,
496 20,
497 );
498 assert!(out.contains("no matches"), "got: {out}");
499 assert!(!out.is_empty());
500 assert!(
502 out.starts_with("no matches in past conversations"),
503 "got: {out}"
504 );
505 assert!(out.contains("resume_context"), "got: {out}");
507 }
508
509 #[test]
510 fn this_conversation_recent_default_impl_is_empty() {
511 let source = MockSource {
514 hits: vec![hit("conv-a", "t", 1, "snip")],
515 ..Default::default()
516 };
517 assert!(
518 source.this_conversation_recent(5).unwrap().is_empty(),
519 "default impl ignores `hits` and returns empty"
520 );
521 }
522
523 #[test]
524 fn recent_turn_snippet_flattens_and_caps() {
525 let s = recent_turn_snippet("fix\nthe bug", "done\nshipping it");
527 assert_eq!(s, "you: fix the bug\n me: done shipping it");
528 let long = recent_turn_snippet(&"x".repeat(RESUME_TURN_FIELD_CAP + 50), "ok");
530 assert!(long.contains("[…]"), "got: {long}");
531 assert!(long.contains("me: ok"), "got: {long}");
532 }
533
534 #[test]
535 fn missing_query_is_a_clear_error() {
536 let source = MockSource::default();
537 let out = execute_recall(&serde_json::json!({}), &source, false, 20);
538 assert!(out.contains("requires `query`"), "got: {out}");
539 assert!(source.calls.lock().unwrap().is_empty());
540 }
541
542 #[test]
543 fn backend_failure_surfaces_as_tool_error_text() {
544 let source = MockSource {
545 fail_with: Some("database is on fire".to_string()),
546 ..Default::default()
547 };
548 let out = execute_recall(
549 &serde_json::json!({"query": "anything"}),
550 &source,
551 false,
552 20,
553 );
554 assert_eq!(out, "error: database is on fire");
555 }
556}