Skip to main content

ai_memory/cli/
recall.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_recall` migration. See `cli::store` for the design pattern.
5//!
6//! W6 (v0.6.3) — embedder construction was unified into
7//! [`crate::daemon_runtime::build_embedder`]. Both `serve()` and this
8//! handler now call the same builder, killing the per-call-site
9//! duplication that the original W5b note flagged. The TestHelper that
10//! used to live here (`build_embedder_for_recall`) is gone.
11
12use 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/// Clap-derived arg shape for the `recall` subcommand. Definition moved
23/// from `main.rs` verbatim in W5b — fields and attrs unchanged.
24#[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    /// Feature tier for recall: keyword, semantic, smart, autonomous
39    #[arg(long, short = 'T')]
40    pub tier: Option<String>,
41    /// Task 1.5: querying agent's namespace position. Enables scope-based
42    /// visibility filtering (private/team/unit/org/collective).
43    #[arg(long)]
44    pub as_agent: Option<String>,
45    /// Task 1.11: context-budget-aware recall. Return the top-ranked
46    /// memories whose cumulative estimated tokens fit within N. Omit
47    /// for unlimited (limit-based only).
48    #[arg(long)]
49    pub budget_tokens: Option<usize>,
50    /// v0.6.0.0 contextual recall. Comma-separated list of recent
51    /// conversation tokens used to bias the query embedding at 70/30
52    /// (primary/context). Shifts the recall towards memories that
53    /// match both the explicit query and the conversation's nearby
54    /// topics.
55    #[arg(long, value_delimiter = ',')]
56    pub context_tokens: Option<Vec<String>>,
57    /// v0.7.0 (issue #518) — when set, splice defaults from
58    /// `[agents.defaults.recall_scope]` in `config.toml` for any
59    /// filter field not explicitly passed on the command line.
60    /// Resolution: explicit args > recall_scope defaults > compiled
61    /// defaults. Default `false` preserves v0.6.x recall semantics.
62    #[arg(long)]
63    pub session_default: bool,
64    /// v0.7.0 WT-1-E — when set, recall returns archived sources
65    /// (those replaced by their atoms after WT-1-B atomisation)
66    /// alongside the atoms. Default `false` surfaces atoms only,
67    /// which is the canonical post-atomisation recall unit.
68    #[arg(long)]
69    pub include_archived: bool,
70    /// v0.7.0 Form 4 (issue #757) — restrict results to memories
71    /// whose `citations` array is non-empty. Composes with the
72    /// other filters; default `false` (no provenance filter).
73    #[arg(long)]
74    pub has_citations: bool,
75    /// v0.7.0 Form 4 (issue #757) — restrict results to memories
76    /// whose `source_uri` starts with this prefix. Matches the
77    /// substring exactly (no glob/regex). Typical use:
78    /// `--source-uri-prefix doc:` to surface every atom or memory
79    /// pointing at a substrate doc; `--source-uri-prefix uri:https://`
80    /// to surface every memory citing an HTTP source.
81    #[arg(long)]
82    pub source_uri_prefix: Option<String>,
83    /// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
84    /// filter. Comma-separated. Examples:
85    ///   --kind concept
86    ///   --kind concept,entity,claim
87    ///   --kinds concept,entity,claim    (plural alias for MCP parity)
88    /// Recognised values: observation, reflection, persona, concept,
89    /// entity, claim, relation, event, conversation, decision.
90    /// OR-of-kinds within the flag; AND with the other filters.
91    /// Pass 'all' or omit for no filter.
92    ///
93    /// Cluster E audit API-3 (issue #767): the MCP tool param is
94    /// `kinds` (plural), so the CLI accepts both spellings via an
95    /// alias for cross-interface ergonomics.
96    #[arg(long = "kind", alias = "kinds", value_name = "KIND[,KIND...]")]
97    pub kind: Option<String>,
98    /// v0.7.0 #1098 — restrict to memories whose confidence tier
99    /// matches one of {high, medium, low}. Wired through to
100    /// [`crate::models::RecallRequest::confidence_tier`] via
101    /// `RecallRequest::from_cli_args`; the MCP / HTTP surfaces have
102    /// accepted this filter since RC, the CLI surface closes the
103    /// three-surface parity gap.
104    #[arg(long = "confidence-tier", value_name = "TIER", value_parser = ["high", "medium", "low"])]
105    pub confidence_tier: Option<String>,
106    /// v0.7.0 #1098 — when set, emit per-row provenance decoration
107    /// (Gap-7 #890): `citations`, `source_uri`, `source_span`,
108    /// `confidence_source`, `confidence_signals`. The flag flows
109    /// through the DTO so MCP / HTTP / CLI agree on the verbose
110    /// envelope shape; the JSON renderer downstream owns the actual
111    /// expansion (today's CLI emits the full `Memory` row already,
112    /// so the flag is preserved for cross-surface parity).
113    #[arg(long = "verbose-provenance")]
114    pub verbose_provenance: bool,
115    /// v0.7.0 #1098 — response format selector: `human` (default
116    /// pretty text), `json` (the same envelope `--json` produces),
117    /// or `toon` (TOON compact format, ~79% smaller than JSON; see
118    /// [`crate::toon`]). The MCP / HTTP surfaces accept the same
119    /// vocabulary via `RecallRequest::format`. Default `human`
120    /// preserves v0.6.x CLI semantics.
121    #[arg(long = "format", value_name = "FORMAT", value_parser = ["human", "json", "toon"], default_value = "human")]
122    pub format: String,
123    /// v0.7.0 #1257 — session-id parity flag (DTO C2 #967, +0.05
124    /// rerank boost under #518). Pre-#1257 this was hard-coded to
125    /// `None` in `RecallRequest::from_cli_args`, so a CLI caller
126    /// could not reach the in-session ring boost even though MCP
127    /// (`{"session_id": "…"}` param) and HTTP (`?session_id=…` or
128    /// JSON body) callers could. Optional; omit to preserve v0.6.x
129    /// recall semantics.
130    #[arg(long = "session-id", value_name = "SESSION_ID")]
131    pub session_id: Option<String>,
132}
133
134/// v0.7.0 Form 4 (issue #757) — post-filter a recall result set by
135/// the Form 4 fact-provenance criteria. Composes with the existing
136/// substrate-level WHERE clauses (those run inside SQL); these
137/// filters run in Rust because both criteria are read-only checks
138/// on already-deserialised Memory rows and the alternative would
139/// be a substrate-wide signature change on `recall` / `recall_hybrid`.
140#[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/// `recall` handler. Mirrors `cmd_recall` from the pre-W5b `main.rs`
167/// verbatim except every emit routes through `out.stdout` / `out.stderr`
168/// instead of `println!` / `eprintln!`. The embedder is built via the
169/// shared [`crate::daemon_runtime::build_embedder`] helper so the offline
170/// recall path and the HTTP daemon use identical construction logic.
171#[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    // #151: validate --as-agent namespace
180    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    // Resolve feature tier
187    let feature_tier = app_config.effective_tier(args.tier.as_deref());
188
189    // Initialize embedder if tier supports it. Use the shared builder so
190    // recall and the HTTP daemon agree on tier→embedder semantics
191    // (embed_url, model selection, error fallback). The shared builder
192    // is async; we drive it on a dedicated OS thread that owns a fresh
193    // current-thread runtime. Tier=Keyword short-circuits inside the
194    // builder before any tokio work happens, so the thread's only cost
195    // is the keyword path.
196    let embedder = {
197        // #1182: `build_embedder` internally `.await`s a `spawn_blocking`
198        // for the candle / HF-Hub model load. Driving it via
199        // `block_in_place(|| handle.block_on(..))` on the ambient
200        // multi-thread runtime (the case when `run()` is reached through
201        // `#[tokio::main]`) can deadlock under a scheduling race: the
202        // main thread parks inside `block_on` while every worker is idle
203        // and no thread is left to drive the blocking task to completion.
204        // A standalone `std::thread` is never a tokio runtime worker, so
205        // creating a fresh current-thread runtime and `block_on`-ing it
206        // there is always safe regardless of whether `run()` was invoked
207        // from inside `#[tokio::main]` (the CLI) or a sync `#[test]`. This
208        // unifies both prior branches into one deadlock-free path.
209        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    // Delegate to the embedder-injected helper so test code can reach
228    // every branch downstream without owning a real candle Embedder.
229    let embedder_ref: Option<&dyn Embed> = embedder.as_ref().map(|e| e as &dyn Embed);
230    // #1598 — model_description now returns an owned String (the
231    // remote variant reports its live model id + dim).
232    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
247/// #1579 B3 — should a ONE-SHOT CLI invocation pay the HNSW
248/// graph-construction cost for `embedded_rows` stored embeddings?
249/// `false` below [`hnsw::CLI_HNSW_BUILD_MIN_ENTRIES`] (the recall
250/// pipeline's linear-scan fallback is faster end-to-end there — see
251/// the const for the P1 numbers); negative/garbage counts never
252/// build.
253pub(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/// Test-injectable core of [`run`]. Production callers go through `run`
258/// which builds an [`Embedder`] via `daemon_runtime::build_embedder` and
259/// delegates here. Tests can pass a `MockEmbedder` directly without the
260/// candle / HuggingFace dependency chain.
261#[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    // v0.7.0 (issue #518) — when `--session-default` is passed AND a
275    // given filter axis is absent on the CLI, splice in the
276    // `[agents.defaults.recall_scope]` value from config.toml.
277    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    // v0.7.x Form 6 — parse the optional --kind filter. Treat the
308    // literal "all" as "no filter" to match the MCP `kinds: "all"`
309    // shorthand, and accept comma-separated tokens otherwise.
310    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    // Backfill embeddings for memories that don't have them.
328    //
329    // #1579 B6-CLI — routed through the same batched helper the MCP
330    // boot path uses (`run_embedding_backfill_with_batch_size`:
331    // `embed_batch` chunks + `set_embeddings_batch`) instead of the
332    // legacy per-row `emb.embed` loop. On the local candle backend a
333    // true batched forward is ~10-20× faster than row-at-a-time
334    // (PERF-5), and the batch size follows the canonical #1146
335    // `[embeddings].backfill_batch` resolver instead of being
336    // implicitly 1.
337    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    // Build HNSW vector index if embedder is available.
345    //
346    // #1579 B3 — but ONLY above the SSOT row threshold
347    // (`hnsw::CLI_HNSW_BUILD_MIN_ENTRIES`). A one-shot CLI recall
348    // pays the full graph-construction cost per invocation (P1
349    // audit: ~40 s at 10k vectors) while the recall pipeline's
350    // linear-scan fallback answers in ≤ 35 ms at that scale — so
351    // below the threshold we skip the build entirely (pass `None`;
352    // the semantic phase linear-scans the embedding column). The
353    // cheap COUNT probe avoids even decoding the blobs when the
354    // build is going to be skipped.
355    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    // Perform recall: hybrid if embedder available, keyword otherwise
378    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    // v0.7.0 Form 4 (issue #757) — fact-provenance post-filter.
472    let results = apply_form4_recall_filters(
473        results,
474        args.has_citations,
475        args.source_uri_prefix.as_deref(),
476    );
477
478    // v0.7.x Form 6 — apply the parsed kinds filter to the result set
479    // in-place. No-op when `kinds_filter == None`. Cheap (results are
480    // already capped at limit.min(50)), and avoids touching the recall
481    // SQL on the existing storage path.
482    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            // Phase P6 (R1) meta block — same shape as MCP / HTTP paths.
513            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            // v0.7.0 #1098 — three CLI parity flags wired in via
585            // `RecallRequest::from_cli_args`. Test fixtures default
586            // to None / false / "human" so existing tests keep their
587            // pre-#1098 semantics.
588            confidence_tier: None,
589            verbose_provenance: false,
590            format: "human".to_string(),
591            // v0.7.0 #1257 — CLI parity for session_id (DTO C2 #967).
592            // Test fixtures default to None so existing tests keep
593            // their pre-#1257 semantics (no in-session boost).
594            session_id: None,
595        }
596    }
597
598    #[test]
599    fn test_recall_keyword_tier_no_embedder() {
600        // Keyword tier => no embedder; the keyword branch must run
601        // happily and find the seeded title.
602        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        // No seeded rows => empty results => stderr emits "no memories
619        // found for: ..." and stdout stays empty (text mode).
620        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        // JSON mode — parse and verify only the ns-a row came back.
650        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        // tags filter takes a string; absence of tags on seeded rows
660        // means the filter excludes them. Just verify the call shape
661        // doesn't error when a tags filter is supplied.
662        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        // No row has the "nonexistent" tag => 0 results.
674        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        // A date range that excludes the just-now timestamp.
684        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        // --as-agent must validate as a namespace; passing a real
698        // namespace exercises the validation branch and succeeds.
699        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        // No assertion error; JSON shape comes through.
710        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        // budget_tokens flips through into recall(); JSON envelope
717        // includes the budget echo when set.
718        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        // Header line: tier/short-id, title, score, namespace.
767        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        // Invalid namespace: empty after trimming, or contains illegal chars.
779        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        // With tier=keyword, no embedder is built, so the fusion path
789        // is skipped entirely and the call falls through the keyword
790        // branch. This proves the fall-through path exists when an
791        // embedder is absent. The actual fusion path requires a real
792        // embedder and is exercised under feature = "test-with-models".
793        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        // Same shape as the no-embedder test, but routed through the
810        // build_embedder_for_recall path. Keyword tier => Ok(None) and
811        // no stderr emission about embedder failure.
812        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        // No embedder messages on stderr in the keyword branch.
824        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    /// Coverage lift (per-module floor): pins the text-mode
832    /// `conf=NN%` suffix arm. Rows with `confidence < 1.0` must render
833    /// their confidence percentage in the header line; the seeded
834    /// default (1.0) never exercises that arm, so this seeds a 0.5-
835    /// confidence row directly via `db::insert`.
836    #[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    /// Coverage lift (per-module floor): pins the
887    /// `Handle::try_current()` → `block_in_place` bridge arm. When
888    /// `run()` is invoked from inside an existing multi-threaded tokio
889    /// runtime (the `daemon_runtime::run` path), it must NOT build a
890    /// nested runtime — it drives `build_embedder` on the ambient
891    /// handle via `block_in_place`. Keyword tier keeps the embedder
892    /// build a no-op so the test stays model-free and offline.
893    #[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        // W6 — recall now delegates embedder construction to
912        // `daemon_runtime::build_embedder`. Smoke-test that the keyword
913        // tier short-circuit still yields `None` (no model load attempt,
914        // no panic).
915        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    // ----------------------------------------------------------------
921    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
922    // ----------------------------------------------------------------
923
924    /// Build an AppConfig with a recall_scope so `--session-default`
925    /// has something to splice in. Uses TOML parsing because
926    /// `AppConfig` does not directly expose builder methods for the
927    /// nested defaults block.
928    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        // Drives the session_default scope path (lines 90-110).
944        let mut env = TestEnv::fresh();
945        let db = env.db_path.clone();
946        // Seed a memory in the scoped namespace.
947        seed_memory(&db, "scope-ns", "needle title", "scoped");
948        // Seed a memory in another namespace which should be filtered out.
949        seed_memory(&db, "other-ns", "needle elsewhere", "other");
950        let mut args = default_args();
951        args.session_default = true;
952        // Leave namespace=None so the scope splice picks "scope-ns".
953        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        // Only memories in scope-ns survive.
960        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        // Explicit args > scope (line 95: args.namespace.clone().or_else).
968        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        // When args.limit != default (10), the scope.limit splice is
989        // skipped (line 117 condition).
990        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; // explicit override
998        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    // ------------------------------------------------------------------
1009    // L0.7-3 chunk-e2 — embedder-driven branches via run_with_embedder.
1010    // ------------------------------------------------------------------
1011
1012    /// Embedder that returns an error on `embed` — drives the
1013    /// "embedding query failed, falling back to keyword" branch.
1014    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    /// Embedder that errors only when the input is exactly "joined
1022    /// context tokens" — drives the fuse-failure branch (primary
1023    /// succeeds, context_tokens embed fails).
1024    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        // run_with_embedder + MockEmbedder drives the `embedder.is_some()`
1040        // branch in run_with_embedder including embedder-loaded banner,
1041        // backfill, vector index build, and the hybrid recall_hybrid call.
1042        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        // #1579 B6-CLI: the backfill banner now comes from the shared
1067        // batched helper (process stderr, not the captured CliOutput),
1068        // so assert the backfill EFFECT instead: the seeded row gained
1069        // an embedding.
1070        {
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    // -----------------------------------------------------------------
1083    // #1579 B3 — CLI HNSW build threshold
1084    // -----------------------------------------------------------------
1085
1086    #[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        // Below CLI_HNSW_BUILD_MIN_ENTRIES the vector_index is None and
1107        // the recall pipeline's linear-scan fallback serves the
1108        // semantic phase — results must still come back in hybrid mode.
1109        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        // FailingEmbedder errors on the primary `embed(query)`. The
1145        // recall handler emits the "embedding query failed" banner and
1146        // falls back to db::recall (lines 272-291 in original).
1147        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        // Primary embed OK, context_tokens embed fails → emit the
1179        // "context_tokens embed failed" banner and continue with
1180        // primary_emb alone.
1181        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        // Primary OK + context_tokens OK → triggers the fuse() path.
1215        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        // tier_config.embedding_model.is_some() && embedder=None → emit
1244        // the "embedder failed to load, falling back to keyword" banner.
1245        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, // tier_config.embedding_model = Some
1259                None,                  // simulate failed load
1260                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        // Drives the `confidence < 1.0` branch in the text output loop
1275        // (line 350) which formats " conf=XX%". Use a custom inserted
1276        // memory with confidence below 1.0.
1277        let mut env = TestEnv::fresh();
1278        let db = env.db_path.clone();
1279        // Insert a low-confidence memory directly.
1280        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            // text mode (json_out=false) — drives the text-rendering loop.
1318            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        // Empty result text path (lines 343-345).
1338        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        // session_default=false short-circuits the scope branch to None
1364        // (line 92), so the configured scope is invisible.
1365        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        // Both namespaces should be visible — no scope splice.
1378        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    // -----------------------------------------------------------------
1388    // v0.7-polish coverage recovery (issue #767) — Form 4 + Form 6
1389    // filter coverage. Drives apply_form4_recall_filters every-branch
1390    // and the run() integration of --source-uri-prefix / --has-citations
1391    // / --kind no-match paths.
1392    // -----------------------------------------------------------------
1393
1394    #[test]
1395    fn apply_form4_recall_filters_no_filter_passes_through() {
1396        // Both filters absent → original results returned verbatim.
1397        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        // c has source_uri = None → excluded by prefix filter.
1445        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        // The 0.2% gap closure for cli/recall.rs — drives the
1454        // "filter declared, nothing matches" path.
1455        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        // Has citations but wrong prefix.
1480        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        // End-to-end via run(): seed two memories without source_uri,
1500        // then ask for source_uri_prefix that never matches.
1501        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        // --kind=all parses to None → no filter applied.
1519        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        // The "all" sentinel passes through every memory (no kind filter).
1531        assert!(
1532            v["count"].as_u64().unwrap() >= 1,
1533            "expected at least one match under --kind=all"
1534        );
1535    }
1536}