1use crate::cli::CliOutput;
13use crate::cli::helpers::{human_age, id_short};
14use crate::config::AppConfig;
15use crate::embeddings::Embed;
16use crate::models::field_names;
17use crate::{color, daemon_runtime, db, embeddings, hnsw, reranker, validate};
18use anyhow::Result;
19use clap::Args;
20use std::path::Path;
21
22#[derive(Args)]
25pub struct RecallArgs {
26 #[arg(allow_hyphen_values = true)]
27 pub context: String,
28 #[arg(long, short)]
29 pub namespace: Option<String>,
30 #[arg(long, default_value_t = 10)]
31 pub limit: usize,
32 #[arg(long)]
33 pub tags: Option<String>,
34 #[arg(long)]
35 pub since: Option<String>,
36 #[arg(long)]
37 pub until: Option<String>,
38 #[arg(long, short = 'T')]
40 pub tier: Option<String>,
41 #[arg(long)]
44 pub as_agent: Option<String>,
45 #[arg(long)]
49 pub budget_tokens: Option<usize>,
50 #[arg(long, value_delimiter = ',')]
56 pub context_tokens: Option<Vec<String>>,
57 #[arg(long)]
63 pub session_default: bool,
64 #[arg(long)]
69 pub include_archived: bool,
70 #[arg(long)]
74 pub has_citations: bool,
75 #[arg(long)]
82 pub source_uri_prefix: Option<String>,
83 #[arg(long = "kind", alias = "kinds", value_name = "KIND[,KIND...]")]
97 pub kind: Option<String>,
98 #[arg(long = "confidence-tier", value_name = "TIER", value_parser = ["high", "medium", "low"])]
105 pub confidence_tier: Option<String>,
106 #[arg(long = "verbose-provenance")]
114 pub verbose_provenance: bool,
115 #[arg(long = "format", value_name = "FORMAT", value_parser = ["human", "json", "toon"], default_value = "human")]
122 pub format: String,
123 #[arg(long = "session-id", value_name = "SESSION_ID")]
131 pub session_id: Option<String>,
132}
133
134#[must_use]
141pub fn apply_form4_recall_filters(
142 results: Vec<(crate::models::Memory, f64)>,
143 has_citations: bool,
144 source_uri_prefix: Option<&str>,
145) -> Vec<(crate::models::Memory, f64)> {
146 if !has_citations && source_uri_prefix.is_none() {
147 return results;
148 }
149 results
150 .into_iter()
151 .filter(|(m, _)| {
152 if has_citations && m.citations.is_empty() {
153 return false;
154 }
155 if let Some(prefix) = source_uri_prefix {
156 match m.source_uri.as_deref() {
157 Some(uri) if uri.starts_with(prefix) => {}
158 _ => return false,
159 }
160 }
161 true
162 })
163 .collect()
164}
165
166#[allow(clippy::too_many_lines)]
172pub fn run(
173 db_path: &Path,
174 args: &RecallArgs,
175 json_out: bool,
176 app_config: &AppConfig,
177 out: &mut CliOutput<'_>,
178) -> Result<()> {
179 if let Some(ref a) = args.as_agent {
181 validate::validate_namespace(a)?;
182 }
183 let mut conn = db::open(db_path)?;
184 let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
185
186 let feature_tier = app_config.effective_tier(args.tier.as_deref());
188
189 let embedder = {
197 let built = std::thread::scope(|scope| {
210 scope
211 .spawn(|| {
212 tokio::runtime::Builder::new_current_thread()
213 .enable_all()
214 .build()
215 .map(|rt| {
216 rt.block_on(daemon_runtime::build_embedder(feature_tier, app_config))
217 })
218 })
219 .join()
220 });
221 match built {
222 Ok(Ok(embedder)) => embedder,
223 Ok(Err(e)) => return Err(e.into()),
224 Err(_) => anyhow::bail!("embedder build thread panicked"),
225 }
226 };
227 let embedder_ref: Option<&dyn Embed> = embedder.as_ref().map(|e| e as &dyn Embed);
230 let embedder_model_description = embedder
233 .as_ref()
234 .map(crate::embeddings::Embedder::model_description);
235 run_with_embedder(
236 &mut conn,
237 args,
238 json_out,
239 app_config,
240 feature_tier,
241 embedder_ref,
242 embedder_model_description.as_deref(),
243 out,
244 )
245}
246
247pub(crate) fn should_build_cli_hnsw(embedded_rows: i64) -> bool {
254 usize::try_from(embedded_rows).is_ok_and(|n| n >= hnsw::CLI_HNSW_BUILD_MIN_ENTRIES)
255}
256
257#[allow(clippy::too_many_lines)]
262#[allow(clippy::too_many_arguments)]
263pub(crate) fn run_with_embedder(
264 conn: &mut rusqlite::Connection,
265 args: &RecallArgs,
266 json_out: bool,
267 app_config: &AppConfig,
268 feature_tier: crate::config::FeatureTier,
269 embedder: Option<&dyn Embed>,
270 embedder_model_description: Option<&str>,
271 out: &mut CliOutput<'_>,
272) -> Result<()> {
273 let tier_config = feature_tier.config();
274 let scope = if args.session_default {
278 app_config.effective_recall_scope()
279 } else {
280 None
281 };
282 let effective_namespace: Option<String> = args.namespace.clone().or_else(|| {
283 scope
284 .and_then(|s| s.namespaces.as_ref())
285 .and_then(|v| v.first())
286 .cloned()
287 });
288 let effective_since: Option<String> = args.since.clone().or_else(|| {
289 scope.and_then(|s| {
290 s.since.as_deref().and_then(|d| {
291 crate::config::parse_duration_string(d).map(|dur| {
292 let cutoff = chrono::Utc::now() - dur;
293 cutoff.to_rfc3339()
294 })
295 })
296 })
297 });
298 let effective_limit_usize = if args.limit == 10
299 && let Some(v) = scope.and_then(|s| s.limit)
300 {
301 usize::try_from(v).unwrap_or(usize::MAX)
302 } else {
303 args.limit
304 };
305 let _effective_recall_tier: Option<String> = scope.and_then(|s| s.tier.clone());
306
307 let kinds_filter: Option<Vec<crate::models::MemoryKind>> = args.kind.as_deref().and_then(|s| {
311 if s.trim().eq_ignore_ascii_case("all") {
312 None
313 } else {
314 crate::models::MemoryKind::parse_csv(s)
315 }
316 });
317
318 if let Some(desc) = embedder_model_description {
319 writeln!(out.stderr, "ai-memory: embedder loaded ({desc})")?;
320 } else if tier_config.embedding_model.is_some() {
321 writeln!(
322 out.stderr,
323 "ai-memory: embedder failed to load, falling back to keyword"
324 )?;
325 }
326
327 if let Some(emb) = embedder {
338 let batch_size = app_config.resolve_embeddings().backfill_batch as usize;
339 if let Err(e) = crate::mcp::run_embedding_backfill_with_batch_size(conn, emb, batch_size) {
340 writeln!(out.stderr, "ai-memory: backfill failed: {e}")?;
341 }
342 }
343
344 let vector_index = if embedder.is_some()
356 && db::count_embedded_memories(conn).is_ok_and(should_build_cli_hnsw)
357 {
358 match db::get_all_embeddings(conn) {
359 Ok(entries) if !entries.is_empty() => Some(hnsw::VectorIndex::build(entries)),
360 _ => Some(hnsw::VectorIndex::empty()),
361 }
362 } else {
363 None
364 };
365
366 let reranker = if tier_config.cross_encoder {
367 Some(reranker::BatchedReranker::new(
368 reranker::CrossEncoder::new_neural(),
369 ))
370 } else {
371 None
372 };
373
374 let resolved_ttl = app_config.effective_ttl();
375 let resolved_scoring = app_config.effective_scoring();
376
377 let (results, outcome, mode) = if let Some(emb) = embedder {
379 match emb.embed_query(&args.context) {
380 Ok(primary_emb) => {
381 let query_emb = match args.context_tokens.as_deref() {
382 Some(tokens) if !tokens.is_empty() => {
383 let joined = tokens.join(" ");
384 match emb.embed_query(&joined) {
385 Ok(ctx_emb) => embeddings::Embedder::fuse(
386 &primary_emb,
387 &ctx_emb,
388 crate::RECALL_PRIMARY_CTX_BLEND,
389 ),
390 Err(e) => {
391 writeln!(
392 out.stderr,
393 "ai-memory: context_tokens embed failed: {e}, using primary only"
394 )?;
395 primary_emb
396 }
397 }
398 }
399 _ => primary_emb,
400 };
401 let (results, outcome) = db::recall_hybrid(
402 conn,
403 &args.context,
404 &query_emb,
405 effective_namespace.as_deref(),
406 effective_limit_usize.min(50),
407 args.tags.as_deref(),
408 effective_since.as_deref(),
409 args.until.as_deref(),
410 vector_index.as_ref(),
411 resolved_ttl.short_extend_secs,
412 resolved_ttl.mid_extend_secs,
413 args.as_agent.as_deref(),
414 args.budget_tokens,
415 &resolved_scoring,
416 args.include_archived,
417 args.source_uri_prefix.as_deref(),
418 )?;
419 if let Some(ref ce) = reranker {
420 (
421 ce.rerank(&args.context, results),
422 outcome,
423 crate::models::RECALL_MODE_HYBRID_RERANK,
424 )
425 } else {
426 (results, outcome, "hybrid")
427 }
428 }
429 Err(e) => {
430 writeln!(
431 out.stderr,
432 "ai-memory: embedding query failed: {e}, falling back to keyword"
433 )?;
434 let (results, outcome) = db::recall(
435 conn,
436 &args.context,
437 effective_namespace.as_deref(),
438 effective_limit_usize,
439 args.tags.as_deref(),
440 effective_since.as_deref(),
441 args.until.as_deref(),
442 resolved_ttl.short_extend_secs,
443 resolved_ttl.mid_extend_secs,
444 args.as_agent.as_deref(),
445 args.budget_tokens,
446 args.include_archived,
447 args.source_uri_prefix.as_deref(),
448 )?;
449 (results, outcome, "keyword")
450 }
451 }
452 } else {
453 let (results, outcome) = db::recall(
454 conn,
455 &args.context,
456 effective_namespace.as_deref(),
457 effective_limit_usize,
458 args.tags.as_deref(),
459 effective_since.as_deref(),
460 args.until.as_deref(),
461 resolved_ttl.short_extend_secs,
462 resolved_ttl.mid_extend_secs,
463 args.as_agent.as_deref(),
464 args.budget_tokens,
465 args.include_archived,
466 args.source_uri_prefix.as_deref(),
467 )?;
468 (results, outcome, "keyword")
469 };
470
471 let results = apply_form4_recall_filters(
473 results,
474 args.has_citations,
475 args.source_uri_prefix.as_deref(),
476 );
477
478 let results: Vec<(crate::models::Memory, f64)> = match kinds_filter.as_deref() {
483 None => results,
484 Some(allowed) => results
485 .into_iter()
486 .filter(|(m, _)| allowed.contains(&m.memory_kind))
487 .collect(),
488 };
489
490 if json_out {
491 let scored: Vec<serde_json::Value> = results
492 .iter()
493 .map(|(m, s)| {
494 let mut v = serde_json::to_value(m).unwrap_or_default();
495 if let Some(obj) = v.as_object_mut() {
496 obj.insert(
497 "score".to_string(),
498 serde_json::json!((s * 1000.0).round() / 1000.0),
499 );
500 }
501 v
502 })
503 .collect();
504 let mut body = serde_json::json!({
505 "memories": scored,
506 "count": results.len(),
507 "mode": mode,
508 (field_names::TOKENS_USED): outcome.tokens_used,
509 });
510 if let Some(b) = args.budget_tokens {
511 body[field_names::BUDGET_TOKENS] = serde_json::json!(b);
512 body["meta"] = serde_json::json!({
514 "budget_tokens_used": outcome.tokens_used,
515 "budget_tokens_remaining": outcome.tokens_remaining.unwrap_or(0),
516 (field_names::MEMORIES_DROPPED): outcome.memories_dropped,
517 "budget_overflow": outcome.budget_overflow,
518 });
519 }
520 writeln!(out.stdout, "{}", serde_json::to_string(&body)?)?;
521 return Ok(());
522 }
523 if results.is_empty() {
524 writeln!(out.stderr, "no memories found for: {}", args.context)?;
525 return Ok(());
526 }
527 for (mem, score) in &results {
528 let age = human_age(&mem.updated_at);
529 let config = if mem.confidence < 1.0 {
530 format!(" conf={:.0}%", mem.confidence * 100.0)
531 } else {
532 String::new()
533 };
534 writeln!(
535 out.stdout,
536 "[{}] {} {} score={:.2} (ns={}, {}x, {}{})",
537 color::tier_color(
538 mem.tier.as_str(),
539 &format!("{}/{}", mem.tier, id_short(&mem.id))
540 ),
541 color::bold(&mem.title),
542 color::priority_bar(mem.priority),
543 score,
544 color::cyan(&mem.namespace),
545 mem.access_count,
546 color::dim(&age),
547 config
548 )?;
549 let preview: String = mem.content.chars().take(200).collect();
550 writeln!(out.stdout, " {}\n", color::dim(&preview))?;
551 }
552 writeln!(
553 out.stdout,
554 "{} memory(ies) recalled [{}]",
555 results.len(),
556 mode
557 )?;
558 Ok(())
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::cli::test_utils::{TestEnv, seed_memory};
565 use crate::config::FeatureTier;
566
567 fn default_args() -> RecallArgs {
568 RecallArgs {
569 context: "needle".to_string(),
570 namespace: None,
571 limit: 10,
572 tags: None,
573 since: None,
574 until: None,
575 tier: Some("keyword".to_string()),
576 as_agent: None,
577 budget_tokens: None,
578 context_tokens: None,
579 session_default: false,
580 include_archived: false,
581 has_citations: false,
582 source_uri_prefix: None,
583 kind: None,
584 confidence_tier: None,
589 verbose_provenance: false,
590 format: "human".to_string(),
591 session_id: None,
595 }
596 }
597
598 #[test]
599 fn test_recall_keyword_tier_no_embedder() {
600 let mut env = TestEnv::fresh();
603 let db = env.db_path.clone();
604 seed_memory(&db, "test", "needle title", "haystack content");
605 let args = default_args();
606 let cfg = AppConfig::default();
607 {
608 let mut out = env.output();
609 run(&db, &args, false, &cfg, &mut out).unwrap();
610 }
611 let stdout = env.stdout_str();
612 assert!(stdout.contains("needle title"), "got: {stdout}");
613 assert!(stdout.contains("[keyword]"), "got: {stdout}");
614 }
615
616 #[test]
617 fn test_recall_keyword_empty_results() {
618 let mut env = TestEnv::fresh();
621 let db = env.db_path.clone();
622 let args = default_args();
623 let cfg = AppConfig::default();
624 {
625 let mut out = env.output();
626 run(&db, &args, false, &cfg, &mut out).unwrap();
627 }
628 assert_eq!(env.stdout_str(), "");
629 assert!(
630 env.stderr_str().contains("no memories found for: needle"),
631 "got: {}",
632 env.stderr_str()
633 );
634 }
635
636 #[test]
637 fn test_recall_keyword_with_namespace_filter() {
638 let mut env = TestEnv::fresh();
639 let db = env.db_path.clone();
640 seed_memory(&db, "ns-a", "needle in a", "content a");
641 seed_memory(&db, "ns-b", "needle in b", "content b");
642 let mut args = default_args();
643 args.namespace = Some("ns-a".to_string());
644 let cfg = AppConfig::default();
645 {
646 let mut out = env.output();
647 run(&db, &args, true, &cfg, &mut out).unwrap();
648 }
649 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
651 let mems = v["memories"].as_array().unwrap();
652 for m in mems {
653 assert_eq!(m["namespace"].as_str().unwrap(), "ns-a");
654 }
655 }
656
657 #[test]
658 fn test_recall_keyword_with_tags_filter() {
659 let mut env = TestEnv::fresh();
663 let db = env.db_path.clone();
664 seed_memory(&db, "test", "needle title", "content");
665 let mut args = default_args();
666 args.tags = Some("nonexistent".to_string());
667 let cfg = AppConfig::default();
668 {
669 let mut out = env.output();
670 run(&db, &args, true, &cfg, &mut out).unwrap();
671 }
672 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
673 assert_eq!(v["count"].as_u64().unwrap(), 0);
675 }
676
677 #[test]
678 fn test_recall_keyword_with_since_until_window() {
679 let mut env = TestEnv::fresh();
680 let db = env.db_path.clone();
681 seed_memory(&db, "test", "needle title", "content");
682 let mut args = default_args();
683 args.since = Some("1970-01-01T00:00:00Z".to_string());
685 args.until = Some("1970-01-02T00:00:00Z".to_string());
686 let cfg = AppConfig::default();
687 {
688 let mut out = env.output();
689 run(&db, &args, true, &cfg, &mut out).unwrap();
690 }
691 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
692 assert_eq!(v["count"].as_u64().unwrap(), 0);
693 }
694
695 #[test]
696 fn test_recall_with_as_agent_scope_filter() {
697 let mut env = TestEnv::fresh();
700 let db = env.db_path.clone();
701 seed_memory(&db, "test", "needle title", "content");
702 let mut args = default_args();
703 args.as_agent = Some("test".to_string());
704 let cfg = AppConfig::default();
705 {
706 let mut out = env.output();
707 run(&db, &args, true, &cfg, &mut out).unwrap();
708 }
709 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
711 assert!(v["memories"].is_array());
712 }
713
714 #[test]
715 fn test_recall_with_budget_tokens_caps_results() {
716 let mut env = TestEnv::fresh();
719 let db = env.db_path.clone();
720 seed_memory(&db, "test", "needle one", "content one");
721 seed_memory(&db, "test", "needle two", "content two");
722 let mut args = default_args();
723 args.budget_tokens = Some(64);
724 let cfg = AppConfig::default();
725 {
726 let mut out = env.output();
727 run(&db, &args, true, &cfg, &mut out).unwrap();
728 }
729 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
730 assert_eq!(v["budget_tokens"].as_u64().unwrap(), 64);
731 }
732
733 #[test]
734 fn test_recall_json_output_includes_score_mode_tokens() {
735 let mut env = TestEnv::fresh();
736 let db = env.db_path.clone();
737 seed_memory(&db, "test", "needle title", "haystack content");
738 let args = default_args();
739 let cfg = AppConfig::default();
740 {
741 let mut out = env.output();
742 run(&db, &args, true, &cfg, &mut out).unwrap();
743 }
744 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
745 assert_eq!(v["mode"].as_str().unwrap(), "keyword");
746 assert!(v["tokens_used"].is_number());
747 let mems = v["memories"].as_array().unwrap();
748 assert!(!mems.is_empty(), "expected at least one match");
749 for m in mems {
750 assert!(m["score"].is_number());
751 }
752 }
753
754 #[test]
755 fn test_recall_text_output_formats_correctly() {
756 let mut env = TestEnv::fresh();
757 let db = env.db_path.clone();
758 seed_memory(&db, "test-ns", "needle title", "haystack content");
759 let args = default_args();
760 let cfg = AppConfig::default();
761 {
762 let mut out = env.output();
763 run(&db, &args, false, &cfg, &mut out).unwrap();
764 }
765 let stdout = env.stdout_str();
766 assert!(stdout.contains("needle title"));
768 assert!(stdout.contains("ns="));
769 assert!(stdout.contains("score="));
770 assert!(stdout.contains("memory(ies) recalled"));
771 }
772
773 #[test]
774 fn test_recall_invalid_as_agent_namespace_validation_error() {
775 let mut env = TestEnv::fresh();
776 let db = env.db_path.clone();
777 let mut args = default_args();
778 args.as_agent = Some(String::new());
780 let cfg = AppConfig::default();
781 let mut out = env.output();
782 let res = run(&db, &args, false, &cfg, &mut out);
783 assert!(res.is_err(), "expected validate_namespace to reject");
784 }
785
786 #[test]
787 fn test_recall_with_context_tokens_fusion() {
788 let mut env = TestEnv::fresh();
794 let db = env.db_path.clone();
795 seed_memory(&db, "test", "needle title", "content");
796 let mut args = default_args();
797 args.context_tokens = Some(vec!["recent".to_string(), "talk".to_string()]);
798 let cfg = AppConfig::default();
799 {
800 let mut out = env.output();
801 run(&db, &args, true, &cfg, &mut out).unwrap();
802 }
803 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
804 assert_eq!(v["mode"].as_str().unwrap(), "keyword");
805 }
806
807 #[test]
808 fn test_recall_embedder_failure_falls_back_to_keyword() {
809 let mut env = TestEnv::fresh();
813 let db = env.db_path.clone();
814 seed_memory(&db, "test", "needle title", "content");
815 let args = default_args();
816 let cfg = AppConfig::default();
817 {
818 let mut out = env.output();
819 run(&db, &args, true, &cfg, &mut out).unwrap();
820 }
821 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
822 assert_eq!(v["mode"].as_str().unwrap(), "keyword");
823 let stderr = env.stderr_str();
825 assert!(
826 !stderr.contains("embedder loaded"),
827 "no embedder should be loaded on keyword tier"
828 );
829 }
830
831 #[test]
837 fn test_recall_text_output_shows_confidence_below_full() {
838 let mut env = TestEnv::fresh();
839 let db = env.db_path.clone();
840 {
841 let conn = crate::db::open(&db).unwrap();
842 let now = chrono::Utc::now().to_rfc3339();
843 let mem = crate::models::Memory {
844 id: uuid::Uuid::new_v4().to_string(),
845 tier: crate::models::Tier::Mid,
846 namespace: "test".to_string(),
847 title: "needle low-confidence".to_string(),
848 content: "uncertain content".to_string(),
849 tags: vec![],
850 priority: 5,
851 confidence: 0.5,
852 source: "import".to_string(),
853 access_count: 0,
854 created_at: now.clone(),
855 updated_at: now,
856 last_accessed_at: None,
857 expires_at: None,
858 metadata: crate::models::default_metadata(),
859 reflection_depth: 0,
860 memory_kind: crate::models::MemoryKind::Observation,
861 entity_id: None,
862 persona_version: None,
863 citations: Vec::new(),
864 source_uri: None,
865 source_span: None,
866 confidence_source: crate::models::ConfidenceSource::CallerProvided,
867 confidence_signals: None,
868 confidence_decayed_at: None,
869 version: 1,
870 };
871 crate::db::insert(&conn, &mem).unwrap();
872 }
873 let args = default_args();
874 let cfg = AppConfig::default();
875 {
876 let mut out = env.output();
877 run(&db, &args, false, &cfg, &mut out).unwrap();
878 }
879 let stdout = env.stdout_str();
880 assert!(
881 stdout.contains("conf=50%"),
882 "confidence < 1.0 must render the conf= suffix; got: {stdout}"
883 );
884 }
885
886 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
894 async fn test_recall_inside_runtime_uses_block_in_place_bridge() {
895 let mut env = TestEnv::fresh();
896 let db = env.db_path.clone();
897 seed_memory(&db, "test", "needle title", "haystack content");
898 let args = default_args();
899 let cfg = AppConfig::default();
900 {
901 let mut out = env.output();
902 run(&db, &args, true, &cfg, &mut out).unwrap();
903 }
904 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
905 assert_eq!(v["mode"].as_str().unwrap(), "keyword");
906 assert!(v["count"].as_u64().unwrap() >= 1, "seeded row must match");
907 }
908
909 #[tokio::test]
910 async fn test_shared_build_embedder_keyword_returns_none() {
911 let cfg = AppConfig::default();
916 let res = daemon_runtime::build_embedder(FeatureTier::Keyword, &cfg).await;
917 assert!(res.is_none(), "keyword tier must not build an embedder");
918 }
919
920 fn app_config_with_recall_scope() -> AppConfig {
929 let toml = r#"
930tier = "keyword"
931
932[agents.defaults.recall_scope]
933namespaces = ["scope-ns"]
934since = "1d"
935tier = "long"
936limit = 25
937"#;
938 toml::from_str(toml).expect("parse test config")
939 }
940
941 #[test]
942 fn recall_session_default_splices_namespace_and_since_from_scope() {
943 let mut env = TestEnv::fresh();
945 let db = env.db_path.clone();
946 seed_memory(&db, "scope-ns", "needle title", "scoped");
948 seed_memory(&db, "other-ns", "needle elsewhere", "other");
950 let mut args = default_args();
951 args.session_default = true;
952 let cfg = app_config_with_recall_scope();
954 {
955 let mut out = env.output();
956 run(&db, &args, true, &cfg, &mut out).unwrap();
957 }
958 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
959 for m in v["memories"].as_array().unwrap() {
961 assert_eq!(m["namespace"].as_str().unwrap(), "scope-ns");
962 }
963 }
964
965 #[test]
966 fn recall_session_default_explicit_namespace_wins_over_scope() {
967 let mut env = TestEnv::fresh();
969 let db = env.db_path.clone();
970 seed_memory(&db, "scope-ns", "needle title", "content");
971 seed_memory(&db, "explicit-ns", "needle elsewhere", "content");
972 let mut args = default_args();
973 args.session_default = true;
974 args.namespace = Some("explicit-ns".to_string());
975 let cfg = app_config_with_recall_scope();
976 {
977 let mut out = env.output();
978 run(&db, &args, true, &cfg, &mut out).unwrap();
979 }
980 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
981 for m in v["memories"].as_array().unwrap() {
982 assert_eq!(m["namespace"].as_str().unwrap(), "explicit-ns");
983 }
984 }
985
986 #[test]
987 fn recall_session_default_with_explicit_limit_does_not_apply_scope_limit() {
988 let mut env = TestEnv::fresh();
991 let db = env.db_path.clone();
992 for i in 0..5 {
993 seed_memory(&db, "scope-ns", &format!("needle {i}"), "c");
994 }
995 let mut args = default_args();
996 args.session_default = true;
997 args.limit = 2; let cfg = app_config_with_recall_scope();
999 {
1000 let mut out = env.output();
1001 run(&db, &args, true, &cfg, &mut out).unwrap();
1002 }
1003 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1004 let mems = v["memories"].as_array().unwrap();
1005 assert!(mems.len() <= 2, "explicit limit=2 should cap results");
1006 }
1007
1008 struct FailingEmbedder;
1015 impl Embed for FailingEmbedder {
1016 fn embed(&self, _text: &str) -> Result<Vec<f32>> {
1017 anyhow::bail!("synthetic embed failure for test")
1018 }
1019 }
1020
1021 struct FailOnContextTokens {
1025 joined_marker: String,
1026 }
1027 impl Embed for FailOnContextTokens {
1028 fn embed(&self, text: &str) -> Result<Vec<f32>> {
1029 if text == self.joined_marker {
1030 anyhow::bail!("synthetic context-tokens failure")
1031 }
1032 let mock = crate::embeddings::test_support::MockEmbedder::new_local()?;
1033 mock.embed(text)
1034 }
1035 }
1036
1037 #[test]
1038 fn recall_with_embedder_takes_hybrid_path() {
1039 let mut env = TestEnv::fresh();
1043 let db = env.db_path.clone();
1044 seed_memory(&db, "test", "needle title", "content");
1045 let mut conn = db::open(&db).unwrap();
1046 let mock = crate::embeddings::test_support::MockEmbedder::new_local().unwrap();
1047 let args = default_args();
1048 let cfg = AppConfig::default();
1049 let feature_tier = FeatureTier::Keyword;
1050 {
1051 let mut out = env.output();
1052 run_with_embedder(
1053 &mut conn,
1054 &args,
1055 true,
1056 &cfg,
1057 feature_tier,
1058 Some(&mock as &dyn Embed),
1059 Some(mock.model_description()),
1060 &mut out,
1061 )
1062 .unwrap();
1063 }
1064 let stderr = env.stderr_str();
1065 assert!(stderr.contains("embedder loaded"), "got: {stderr}");
1066 {
1071 let conn2 = db::open(&db).unwrap();
1072 let ids = db::get_unembedded_ids(&conn2).unwrap();
1073 assert!(
1074 ids.is_empty(),
1075 "batched backfill must embed every unembedded row; left: {ids:?}"
1076 );
1077 }
1078 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1079 assert_eq!(v["mode"].as_str().unwrap(), "hybrid");
1080 }
1081
1082 #[test]
1087 fn b3_1579_should_build_cli_hnsw_threshold() {
1088 use crate::hnsw::CLI_HNSW_BUILD_MIN_ENTRIES;
1089 assert!(
1090 !should_build_cli_hnsw(0),
1091 "empty corpus never builds a graph"
1092 );
1093 assert!(
1094 !should_build_cli_hnsw(i64::try_from(CLI_HNSW_BUILD_MIN_ENTRIES - 1).unwrap()),
1095 "one under the threshold: linear scan wins"
1096 );
1097 assert!(
1098 should_build_cli_hnsw(i64::try_from(CLI_HNSW_BUILD_MIN_ENTRIES).unwrap()),
1099 "at the threshold: build"
1100 );
1101 assert!(!should_build_cli_hnsw(-1), "garbage counts never build");
1102 }
1103
1104 #[test]
1105 fn b3_1579_small_corpus_recall_skips_hnsw_and_still_answers_semantically() {
1106 let mut env = TestEnv::fresh();
1110 let db = env.db_path.clone();
1111 seed_memory(&db, "test", "needle title", "needle content body");
1112 let mut conn = db::open(&db).unwrap();
1113 let mock = crate::embeddings::test_support::MockEmbedder::new_local().unwrap();
1114 let args = default_args();
1115 let cfg = AppConfig::default();
1116 {
1117 let mut out = env.output();
1118 run_with_embedder(
1119 &mut conn,
1120 &args,
1121 true,
1122 &cfg,
1123 FeatureTier::Keyword,
1124 Some(&mock as &dyn Embed),
1125 Some(mock.model_description()),
1126 &mut out,
1127 )
1128 .unwrap();
1129 }
1130 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1131 assert_eq!(
1132 v["mode"].as_str().unwrap(),
1133 "hybrid",
1134 "semantic phase must still answer via the linear-scan fallback"
1135 );
1136 assert!(
1137 v["memories"].as_array().is_some_and(|r| !r.is_empty()),
1138 "seeded row must be recalled without an HNSW graph; got: {v}"
1139 );
1140 }
1141
1142 #[test]
1143 fn recall_with_embedder_failing_primary_falls_back_to_keyword() {
1144 let mut env = TestEnv::fresh();
1148 let db = env.db_path.clone();
1149 seed_memory(&db, "test", "needle title", "content");
1150 let mut conn = db::open(&db).unwrap();
1151 let args = default_args();
1152 let cfg = AppConfig::default();
1153 {
1154 let mut out = env.output();
1155 run_with_embedder(
1156 &mut conn,
1157 &args,
1158 true,
1159 &cfg,
1160 FeatureTier::Keyword,
1161 Some(&FailingEmbedder as &dyn Embed),
1162 Some("failing-mock"),
1163 &mut out,
1164 )
1165 .unwrap();
1166 }
1167 let stderr = env.stderr_str();
1168 assert!(
1169 stderr.contains("embedding query failed"),
1170 "expected fallback banner; got: {stderr}"
1171 );
1172 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1173 assert_eq!(v["mode"].as_str().unwrap(), "keyword");
1174 }
1175
1176 #[test]
1177 fn recall_with_embedder_context_tokens_fail_uses_primary_only() {
1178 let mut env = TestEnv::fresh();
1182 let db = env.db_path.clone();
1183 seed_memory(&db, "test", "needle title", "content");
1184 let mut conn = db::open(&db).unwrap();
1185 let mock = FailOnContextTokens {
1186 joined_marker: "alpha beta".to_string(),
1187 };
1188 let mut args = default_args();
1189 args.context_tokens = Some(vec!["alpha".into(), "beta".into()]);
1190 let cfg = AppConfig::default();
1191 {
1192 let mut out = env.output();
1193 run_with_embedder(
1194 &mut conn,
1195 &args,
1196 true,
1197 &cfg,
1198 FeatureTier::Keyword,
1199 Some(&mock as &dyn Embed),
1200 Some("primary-ok-context-fail"),
1201 &mut out,
1202 )
1203 .unwrap();
1204 }
1205 let stderr = env.stderr_str();
1206 assert!(
1207 stderr.contains("context_tokens embed failed"),
1208 "got: {stderr}"
1209 );
1210 }
1211
1212 #[test]
1213 fn recall_with_embedder_context_tokens_success_drives_fuse() {
1214 let mut env = TestEnv::fresh();
1216 let db = env.db_path.clone();
1217 seed_memory(&db, "test", "needle title", "content");
1218 let mut conn = db::open(&db).unwrap();
1219 let mock = crate::embeddings::test_support::MockEmbedder::new_local().unwrap();
1220 let mut args = default_args();
1221 args.context_tokens = Some(vec!["a".into(), "b".into()]);
1222 let cfg = AppConfig::default();
1223 {
1224 let mut out = env.output();
1225 run_with_embedder(
1226 &mut conn,
1227 &args,
1228 true,
1229 &cfg,
1230 FeatureTier::Keyword,
1231 Some(&mock as &dyn Embed),
1232 Some(mock.model_description()),
1233 &mut out,
1234 )
1235 .unwrap();
1236 }
1237 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1238 assert_eq!(v["mode"].as_str().unwrap(), "hybrid");
1239 }
1240
1241 #[test]
1242 fn recall_with_embedder_load_failed_emits_failed_banner() {
1243 let mut env = TestEnv::fresh();
1246 let db = env.db_path.clone();
1247 seed_memory(&db, "test", "needle title", "content");
1248 let mut conn = db::open(&db).unwrap();
1249 let args = default_args();
1250 let cfg = AppConfig::default();
1251 {
1252 let mut out = env.output();
1253 run_with_embedder(
1254 &mut conn,
1255 &args,
1256 true,
1257 &cfg,
1258 FeatureTier::Semantic, None, None,
1261 &mut out,
1262 )
1263 .unwrap();
1264 }
1265 let stderr = env.stderr_str();
1266 assert!(
1267 stderr.contains("embedder failed to load"),
1268 "expected failed-load banner; got: {stderr}"
1269 );
1270 }
1271
1272 #[test]
1273 fn recall_text_output_no_embedder_with_low_confidence_emits_conf_pct() {
1274 let mut env = TestEnv::fresh();
1278 let db = env.db_path.clone();
1279 let mut conn = db::open(&db).unwrap();
1281 let mut mem = crate::models::Memory {
1282 id: uuid::Uuid::new_v4().to_string(),
1283 tier: crate::models::Tier::Mid,
1284 namespace: "test".to_string(),
1285 title: "needle low".to_string(),
1286 content: "low confidence content".to_string(),
1287 tags: vec![],
1288 priority: 5,
1289 confidence: 0.42,
1290 source: "import".to_string(),
1291 access_count: 0,
1292 created_at: chrono::Utc::now().to_rfc3339(),
1293 updated_at: chrono::Utc::now().to_rfc3339(),
1294 last_accessed_at: None,
1295 expires_at: None,
1296 metadata: crate::models::default_metadata(),
1297 reflection_depth: 0,
1298 memory_kind: crate::models::MemoryKind::Observation,
1299 entity_id: None,
1300 persona_version: None,
1301 citations: Vec::new(),
1302 source_uri: None,
1303 source_span: None,
1304 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1305 confidence_signals: None,
1306 confidence_decayed_at: None,
1307 version: 1,
1308 };
1309 if let Some(obj) = mem.metadata.as_object_mut() {
1310 obj.insert("agent_id".to_string(), serde_json::json!("t"));
1311 }
1312 db::insert(&conn, &mem).unwrap();
1313 let args = default_args();
1314 let cfg = AppConfig::default();
1315 {
1316 let mut out = env.output();
1317 run_with_embedder(
1319 &mut conn,
1320 &args,
1321 false,
1322 &cfg,
1323 FeatureTier::Keyword,
1324 None,
1325 None,
1326 &mut out,
1327 )
1328 .unwrap();
1329 }
1330 let stdout = env.stdout_str();
1331 assert!(stdout.contains("conf=42%"), "got: {stdout}");
1332 assert!(stdout.contains("memory(ies) recalled"), "got: {stdout}");
1333 }
1334
1335 #[test]
1336 fn recall_text_output_no_results_emits_no_memories_message() {
1337 let mut env = TestEnv::fresh();
1339 let db = env.db_path.clone();
1340 let mut conn = db::open(&db).unwrap();
1341 let args = default_args();
1342 let cfg = AppConfig::default();
1343 {
1344 let mut out = env.output();
1345 run_with_embedder(
1346 &mut conn,
1347 &args,
1348 false,
1349 &cfg,
1350 FeatureTier::Keyword,
1351 None,
1352 None,
1353 &mut out,
1354 )
1355 .unwrap();
1356 }
1357 let stderr = env.stderr_str();
1358 assert!(stderr.contains("no memories found"), "got: {stderr}");
1359 }
1360
1361 #[test]
1362 fn recall_session_default_off_does_not_splice_scope() {
1363 let mut env = TestEnv::fresh();
1366 let db = env.db_path.clone();
1367 seed_memory(&db, "scope-ns", "needle title", "content");
1368 seed_memory(&db, "other-ns", "needle elsewhere", "content");
1369 let mut args = default_args();
1370 args.session_default = false;
1371 let cfg = app_config_with_recall_scope();
1372 {
1373 let mut out = env.output();
1374 run(&db, &args, true, &cfg, &mut out).unwrap();
1375 }
1376 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1377 let nses: std::collections::HashSet<String> = v["memories"]
1379 .as_array()
1380 .unwrap()
1381 .iter()
1382 .map(|m| m["namespace"].as_str().unwrap().to_string())
1383 .collect();
1384 assert!(nses.len() >= 2 || nses.contains("other-ns"));
1385 }
1386
1387 #[test]
1395 fn apply_form4_recall_filters_no_filter_passes_through() {
1396 let m = crate::models::Memory {
1398 id: "id".to_string(),
1399 ..Default::default()
1400 };
1401 let input = vec![(m.clone(), 0.5)];
1402 let out = apply_form4_recall_filters(input, false, None);
1403 assert_eq!(out.len(), 1);
1404 }
1405
1406 #[test]
1407 fn apply_form4_recall_filters_has_citations_drops_empty_citations() {
1408 let mut a = crate::models::Memory {
1409 id: "a".to_string(),
1410 ..Default::default()
1411 };
1412 a.citations = vec![crate::models::Citation {
1413 uri: "doc:x".to_string(),
1414 accessed_at: "2026-01-01T00:00:00Z".to_string(),
1415 hash: None,
1416 span: None,
1417 }];
1418 let b = crate::models::Memory {
1419 id: "b".to_string(),
1420 ..Default::default()
1421 };
1422 let input = vec![(a, 0.9), (b, 0.8)];
1423 let out = apply_form4_recall_filters(input, true, None);
1424 assert_eq!(out.len(), 1);
1425 assert_eq!(out[0].0.id, "a");
1426 }
1427
1428 #[test]
1429 fn apply_form4_recall_filters_source_uri_prefix_drops_non_matches() {
1430 let mut a = crate::models::Memory {
1431 id: "a".to_string(),
1432 ..Default::default()
1433 };
1434 a.source_uri = Some("uri:https://example.com/path".to_string());
1435 let mut b = crate::models::Memory {
1436 id: "b".to_string(),
1437 ..Default::default()
1438 };
1439 b.source_uri = Some("uri:https://other.org/elsewhere".to_string());
1440 let c = crate::models::Memory {
1441 id: "c".to_string(),
1442 ..Default::default()
1443 };
1444 let input = vec![(a, 1.0), (b, 0.9), (c, 0.8)];
1446 let out = apply_form4_recall_filters(input, false, Some("uri:https://example.com"));
1447 assert_eq!(out.len(), 1);
1448 assert_eq!(out[0].0.id, "a");
1449 }
1450
1451 #[test]
1452 fn apply_form4_recall_filters_source_uri_prefix_no_matches_returns_empty() {
1453 let mut a = crate::models::Memory {
1456 id: "a".to_string(),
1457 ..Default::default()
1458 };
1459 a.source_uri = Some("uri:https://example.com/path".to_string());
1460 let input = vec![(a, 1.0)];
1461 let out =
1462 apply_form4_recall_filters(input, false, Some("uri:https://nothing-matches.invalid"));
1463 assert!(out.is_empty(), "expected no matches for unrelated prefix");
1464 }
1465
1466 #[test]
1467 fn apply_form4_recall_filters_combined_has_citations_and_prefix() {
1468 let mut a = crate::models::Memory {
1469 id: "a".to_string(),
1470 ..Default::default()
1471 };
1472 a.citations = vec![crate::models::Citation {
1473 uri: "doc:x".to_string(),
1474 accessed_at: "2026-01-01T00:00:00Z".to_string(),
1475 hash: None,
1476 span: None,
1477 }];
1478 a.source_uri = Some("uri:https://example.com/x".to_string());
1479 let mut b = crate::models::Memory {
1481 id: "b".to_string(),
1482 ..Default::default()
1483 };
1484 b.citations = vec![crate::models::Citation {
1485 uri: "doc:y".to_string(),
1486 accessed_at: "2026-01-01T00:00:00Z".to_string(),
1487 hash: None,
1488 span: None,
1489 }];
1490 b.source_uri = Some("uri:https://other.org/y".to_string());
1491 let input = vec![(a, 0.9), (b, 0.8)];
1492 let out = apply_form4_recall_filters(input, true, Some("uri:https://example.com"));
1493 assert_eq!(out.len(), 1);
1494 assert_eq!(out[0].0.id, "a");
1495 }
1496
1497 #[test]
1498 fn recall_with_source_uri_prefix_no_match_returns_empty_envelope() {
1499 let mut env = TestEnv::fresh();
1502 let db = env.db_path.clone();
1503 seed_memory(&db, "test", "needle title", "haystack content");
1504 let mut args = default_args();
1505 args.source_uri_prefix = Some("uri:https://no-such-source.invalid".to_string());
1506 let cfg = AppConfig::default();
1507 {
1508 let mut out = env.output();
1509 run(&db, &args, true, &cfg, &mut out).unwrap();
1510 }
1511 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1512 assert_eq!(v["count"].as_u64().unwrap(), 0);
1513 assert!(v["memories"].as_array().unwrap().is_empty());
1514 }
1515
1516 #[test]
1517 fn recall_with_kind_filter_all_keyword_is_noop() {
1518 let mut env = TestEnv::fresh();
1520 let db = env.db_path.clone();
1521 seed_memory(&db, "test", "needle title", "haystack content");
1522 let mut args = default_args();
1523 args.kind = Some("ALL".to_string());
1524 let cfg = AppConfig::default();
1525 {
1526 let mut out = env.output();
1527 run(&db, &args, true, &cfg, &mut out).unwrap();
1528 }
1529 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1530 assert!(
1532 v["count"].as_u64().unwrap() >= 1,
1533 "expected at least one match under --kind=all"
1534 );
1535 }
1536}