Skip to main content

ai_memory/cli/
boot.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory boot` — universal session-boot context primitive (issue #487).
5//!
6//! Every AI-agent integration recipe (Claude Code SessionStart hook, Cursor
7//! `.cursorrules` boot directive, Cline / Continue / Windsurf system-message,
8//! Codex CLI / Claude Agent SDK / OpenAI Apps SDK programmatic prepend,
9//! OpenClaw built-in, local models via LM Studio / Ollama / vLLM) calls this
10//! same subcommand and consumes its stdout as the agent's first-turn context.
11//!
12//! Boot deliberately does **not** load the embedder. It returns the
13//! most-recently-accessed memories in the inferred namespace, falls back to
14//! the most-recently-accessed memories globally if the namespace is empty,
15//! and clamps output to a token budget so a misconfigured agent can't bloat
16//! its first turn.
17//!
18//! Failure modes are graceful by default:
19//! - DB unavailable + `--quiet`: exit 0, empty stdout (a hook that fails
20//!   here would otherwise wedge the agent's session).
21//! - DB unavailable + no `--quiet`: write the error to stderr, emit only
22//!   the header on stdout, still exit 0.
23//! - No memories found: emit the header (or nothing with `--no-header`),
24//!   exit 0.
25//!
26//! ## Diagnostic manifest (PR-4 of #487)
27//!
28//! Boot's header is a transparent, multi-field manifest — never a black box.
29//! Every field reflects a fact about *this* invocation so agents and humans
30//! always know exactly what was loaded and what's configured. Fields:
31//!
32//! - `version`     — binary version (`CARGO_PKG_VERSION` at compile time)
33//! - `db`          — resolved DB path + schema version + total live memories
34//! - `tier`        — active feature tier and *configured* (not loaded)
35//!                   embedder / reranker / llm models
36//! - `latency`     — wall-clock from `run()` entry to header emit
37//! - `namespace`   — resolved namespace + how many memories matched
38
39use crate::cli::CliOutput;
40use crate::cli::helpers::{auto_namespace, human_age, id_short};
41use crate::config::AppConfig;
42use crate::{db, models, toon};
43use anyhow::Result;
44use clap::Args;
45use models::Tier;
46use std::path::Path;
47use std::time::Instant;
48
49/// Lower bound of the DB-schema range this binary supports. Below this
50/// we emit a `warn-schema-unsupported` manifest header so the user
51/// knows their `ai-memory` binary is too new for an old DB. Set to the
52/// v0.6.3 baseline (16) — older schemas won't have the columns the
53/// recall pipeline expects. v0.6.3.1 (PR-9h / issue #487 PR #497 req #72).
54pub const MIN_SUPPORTED_SCHEMA: u32 = 16;
55
56/// Upper bound of the DB-schema range this binary supports. Mirrors
57/// `db::CURRENT_SCHEMA_VERSION` (20 in v0.6.4 — bumped by v0.6.4-009
58/// `audit_log` table). When a DB's `schema_version` exceeds this, the
59/// binary is too old for a newer DB and we surface a warning.
60/// v0.6.3.1 (PR-9h / issue #487 PR #497 req #72).
61pub const MAX_SUPPORTED_SCHEMA: u32 = 20;
62
63/// Pure boundary check: `true` when `v` lies within
64/// `[MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA]`. Extracted so the
65/// boundary semantics (inclusive both ends) can be unit-tested without
66/// needing a synthetic DB whose `schema_version` lies outside the
67/// migration ladder's reach. v0.6.3.1 (PR-9h / issue #487 PR #497 req
68/// #72).
69#[must_use]
70pub fn schema_in_supported_range(v: u32) -> bool {
71    v >= MIN_SUPPORTED_SCHEMA && v <= MAX_SUPPORTED_SCHEMA
72}
73
74/// Default budget — large enough for ~10 toon-compact rows, small enough that
75/// a misconfigured hook can't wedge the first turn with megabytes of context.
76const DEFAULT_BUDGET_TOKENS: usize = 4096;
77
78/// Approximate tokens-per-character for cl100k_base / English text. Used for
79/// the cheap budget clamp. Real tokenization happens elsewhere (recall_hybrid);
80/// boot's budget is advisory and only needs to be in the right order of
81/// magnitude to bound output cost.
82const TOKENS_PER_CHAR: f32 = 0.25;
83
84/// Sentinel used in manifest fields that couldn't be resolved on this
85/// invocation — most often because the DB itself is unreachable, so
86/// schema/total/etc. simply don't have an answer.
87const UNAVAILABLE: &str = "<unavailable>";
88
89/// Output formats supported by `ai-memory boot`.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum BootFormat {
92    /// Human-readable bulleted list (the default — works in any agent's
93    /// system message and is easiest to scan).
94    Text,
95    /// JSON object: `{namespace, count, memories: [...]}`. For programmatic
96    /// integrations (Claude Agent SDK, Apps SDK, Codex CLI prepend).
97    Json,
98    /// TOON-compact (the canonical token-efficient memory format).
99    /// Mirrors the wire shape `memory_recall` returns over MCP.
100    Toon,
101}
102
103impl BootFormat {
104    fn parse(s: &str) -> Result<Self> {
105        match s {
106            "text" => Ok(Self::Text),
107            "json" => Ok(Self::Json),
108            "toon" | "toon-compact" | "toon_compact" => Ok(Self::Toon),
109            other => Err(anyhow::anyhow!(
110                "unknown --format value: {other} (expected: text | json | toon)"
111            )),
112        }
113    }
114}
115
116/// Args for `ai-memory boot`. Every field has a defaulted value so the
117/// subcommand is safe to invoke with no arguments — that is the contract
118/// every integration recipe relies on.
119#[derive(Args, Debug)]
120pub struct BootArgs {
121    /// Override the inferred namespace. Default: derived from the current
122    /// working directory via the same `auto_namespace` helper used by
123    /// `ai-memory store` (git remote name → cwd basename → "global").
124    #[arg(long)]
125    pub namespace: Option<String>,
126    /// Maximum number of memories to return. Clamped to `[1, 50]`.
127    #[arg(long, default_value_t = 10)]
128    pub limit: usize,
129    /// Approximate token budget for the rendered output. Cumulative
130    /// character count divided by 4 ≈ tokens; boot stops adding rows
131    /// when the next row would exceed the budget. Set to 0 to disable.
132    #[arg(long, default_value_t = DEFAULT_BUDGET_TOKENS)]
133    pub budget_tokens: usize,
134    /// Output format: `text` (default), `json`, or `toon`.
135    #[arg(long, default_value = "text")]
136    pub format: String,
137    /// Suppress the `# ai-memory boot context (...)` header line.
138    /// Useful when the integration recipe wraps boot output inside
139    /// its own framing.
140    #[arg(long, default_value_t = false)]
141    pub no_header: bool,
142    /// Exit 0 with empty stdout if the DB is unavailable or no memories
143    /// are found. Without this flag, errors land on stderr and stdout
144    /// gets the header only. Hooks should pass `--quiet` so a failed
145    /// boot never wedges the agent's first turn.
146    #[arg(long, default_value_t = false)]
147    pub quiet: bool,
148    /// Override `auto_namespace`'s working-directory inference. Useful
149    /// when the hook fires before the agent has chdir'd into the
150    /// project root.
151    #[arg(long, value_name = "PATH")]
152    pub cwd: Option<std::path::PathBuf>,
153}
154
155/// Resolve the boot namespace. Explicit `--namespace` wins; otherwise
156/// `auto_namespace` runs against the optional `--cwd` (or the current
157/// process's CWD if unset).
158fn resolve_namespace(args: &BootArgs) -> String {
159    if let Some(ref ns) = args.namespace {
160        return ns.clone();
161    }
162    if let Some(ref cwd) = args.cwd {
163        let _ = std::env::set_current_dir(cwd);
164    }
165    auto_namespace()
166}
167
168/// Pull the boot set from the DB. Two-stage:
169///   1. List most-recently-accessed memories in the resolved namespace.
170///   2. If empty, fall back to the most-recently-accessed memories at
171///      tier=Long globally (cross-project context for greenfield checkouts).
172fn fetch_boot_memories(
173    conn: &rusqlite::Connection,
174    namespace: &str,
175    limit: usize,
176) -> Result<(Vec<models::Memory>, String)> {
177    // Stage 1: namespace-scoped list.
178    let primary = db::list(
179        conn,
180        Some(namespace),
181        None,
182        limit,
183        0,
184        None,
185        None,
186        None,
187        None,
188        None,
189    )?;
190    if !primary.is_empty() {
191        return Ok((primary, namespace.to_string()));
192    }
193    // Stage 2: global tier=Long fallback. The "" sentinel signals
194    // "no namespace match found; surfacing global context" to the
195    // formatter so it can flag the divergence in the header.
196    let fallback = db::list(
197        conn,
198        None,
199        Some(&Tier::Long),
200        limit,
201        0,
202        None,
203        None,
204        None,
205        None,
206        None,
207    )?;
208    Ok((fallback, String::new()))
209}
210
211/// Cumulative character → approximate-tokens budget clamp. Returns the
212/// prefix of `mems` that fits in the budget. Always keeps the first
213/// memory (R1 always-return-at-least-one parity with `memory_recall`).
214fn clamp_to_budget(mems: Vec<models::Memory>, budget_tokens: usize) -> Vec<models::Memory> {
215    if budget_tokens == 0 || mems.is_empty() {
216        return mems;
217    }
218    let mut chars_so_far: usize = 0;
219    let mut out = Vec::with_capacity(mems.len());
220    for (idx, mem) in mems.into_iter().enumerate() {
221        // Conservative per-row width: title + namespace + tier label +
222        // age + ~20 chars of decorations. Real toon/text rows are ~150
223        // chars; we round up to bound risk.
224        let row_chars = mem.title.len() + mem.namespace.len() + 80;
225        let projected_tokens =
226            ((chars_so_far + row_chars) as f32 * TOKENS_PER_CHAR).ceil() as usize;
227        if idx > 0 && projected_tokens > budget_tokens {
228            break;
229        }
230        chars_so_far += row_chars;
231        out.push(mem);
232    }
233    out
234}
235
236/// Boot status — encodes the diagnostic the agent (and the human running
237/// it) sees on every invocation. End users asked for an always-visible
238/// signal so a missing memory context is a known state, not a guess.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240enum BootStatus {
241    /// Found memories in the requested namespace. Normal happy path.
242    OkLoaded,
243    /// Requested namespace had no memories; falling back to global Long tier.
244    InfoFallback,
245    /// Both the requested namespace and the global fallback were empty.
246    /// First-run condition for greenfield checkouts. Not an error.
247    InfoEmpty,
248    /// Requested DB path does not exist or could not be opened. With
249    /// `--quiet` we still exit 0, but the header surfaces the warning so
250    /// the agent can say "I would have loaded context but couldn't" rather
251    /// than silently appearing memory-less.
252    WarnDbUnavailable,
253    /// DB opened cleanly but its `schema_version` falls outside the
254    /// `[MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA]` range this binary
255    /// implements. We still proceed (boot exits 0 — never wedge the
256    /// agent's first turn) but the manifest tells the user to run
257    /// `ai-memory doctor` and consider upgrading. v0.6.3.1 (PR-9h /
258    /// issue #487 PR #497 req #72).
259    WarnSchemaUnsupported { db_schema: u32 },
260}
261
262impl BootStatus {
263    fn label(self) -> &'static str {
264        match self {
265            Self::OkLoaded => "ok",
266            Self::InfoFallback | Self::InfoEmpty => "info",
267            Self::WarnDbUnavailable | Self::WarnSchemaUnsupported { .. } => "warn",
268        }
269    }
270}
271
272/// Read the schema version from the DB's `schema_version` table.
273/// Returns the formatted display string (`vN` or `<unavailable>`) and,
274/// when the table read succeeded, the parsed integer for in-range checks
275/// against `[MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA]`. The manifest
276/// is best-effort: a query error degrades to the sentinel rather than
277/// failing boot.
278fn read_schema_version(conn: &rusqlite::Connection) -> (String, Option<u32>) {
279    match conn.query_row(
280        "SELECT COALESCE(MAX(version), 0) FROM schema_version",
281        [],
282        |r| r.get::<_, i64>(0),
283    ) {
284        Ok(v) => {
285            let display = format!("v{v}");
286            // Negative or absurdly-large values fall back to the
287            // sentinel — schema_version is monotonically increasing
288            // u32-ish in practice.
289            let numeric = u32::try_from(v).ok();
290            (display, numeric)
291        }
292        Err(_) => (UNAVAILABLE.to_string(), None),
293    }
294}
295
296/// Cheap COUNT of live (non-expired) memories. Same expiry semantics as
297/// the recall pipeline: NULL `expires_at` is permanent, otherwise must be
298/// in the future. Errors degrade to the sentinel rather than fail boot.
299fn count_live_memories(conn: &rusqlite::Connection) -> String {
300    let now = chrono::Utc::now().to_rfc3339();
301    conn.query_row(
302        "SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1",
303        rusqlite::params![now],
304        |r| r.get::<_, i64>(0),
305    )
306    .map_or_else(|_| UNAVAILABLE.to_string(), |v| v.to_string())
307}
308
309/// Diagnostic manifest assembled before each header emit. Every field is
310/// a string so the same struct can carry both real values and the
311/// `<unavailable>` sentinel without branching downstream.
312///
313/// Field semantics:
314/// - `version`         — `env!("CARGO_PKG_VERSION")` at compile time
315/// - `db_path`         — resolved path the boot ran against
316/// - `schema_version`  — `vN` from the DB's `schema_version` table
317/// - `total_memories`  — count of live (non-expired) rows
318/// - `tier`            — active feature tier name
319/// - `embedder`        — *configured* embedder (boot does NOT load it)
320/// - `reranker`        — *configured* cross-encoder (or "none")
321/// - `llm`             — *configured* LLM model id (or "none")
322/// - `latency_ms`      — wall-clock from `run()` entry to emit
323/// - `namespace`       — the namespace the body actually came from
324/// - `count`           — number of memories included in the body
325struct BootManifest {
326    version: String,
327    db_path: String,
328    schema_version: String,
329    total_memories: String,
330    tier: String,
331    embedder: String,
332    reranker: String,
333    llm: String,
334    latency_ms: u128,
335    namespace: String,
336    count: usize,
337    // The status note, used by JSON `note` and (for InfoEmpty / Warn) the
338    // multi-line text/toon header expansion.
339    note: String,
340    status: BootStatus,
341    /// PR-9h (#487 PR #497 req #72) — `true` when the DB's
342    /// `schema_version` lies within `[MIN_SUPPORTED_SCHEMA,
343    /// MAX_SUPPORTED_SCHEMA]`. Surfaced as a top-level JSON field
344    /// (`schema_supported`) so SIEMs / fleet dashboards can alert on
345    /// schema drift without parsing the free-text status note. `false`
346    /// when the DB couldn't be opened or the schema falls outside the
347    /// supported range.
348    schema_supported: bool,
349}
350
351impl BootManifest {
352    fn build(
353        status: BootStatus,
354        namespace: &str,
355        count: usize,
356        db_path: &Path,
357        app_config: &AppConfig,
358        schema_version: String,
359        total_memories: String,
360        latency_ms: u128,
361        schema_supported: bool,
362    ) -> Self {
363        // Resolve the *configured* tier. Boot doesn't materialize the
364        // embedder / LLM / reranker handles, so these reflect what would
365        // load — not what actually loaded.
366        let feature_tier = app_config.effective_tier(None);
367        let tier_config = feature_tier.config();
368        let embedder = tier_config
369            .embedding_model
370            .map_or_else(|| "none".to_string(), |m| m.hf_model_id().to_string());
371        let llm = tier_config
372            .llm_model
373            .map_or_else(|| "none".to_string(), |m| m.ollama_model_id().to_string());
374        let reranker = if tier_config.cross_encoder {
375            "ms-marco-MiniLM-L-6-v2".to_string()
376        } else {
377            "none".to_string()
378        };
379
380        let note = match status {
381            BootStatus::OkLoaded => format!(
382                "loaded {count} memor{plural} from ns={namespace}",
383                plural = if count == 1 { "y" } else { "ies" }
384            ),
385            BootStatus::InfoFallback => format!(
386                "namespace empty; loaded {count} memor{plural} from global Long tier fallback",
387                plural = if count == 1 { "y" } else { "ies" }
388            ),
389            BootStatus::InfoEmpty => format!(
390                "namespace '{namespace}' is empty and no global Long-tier fallback found — \
391                 nothing to load (this is normal on a fresh install)"
392            ),
393            BootStatus::WarnDbUnavailable => format!(
394                "db unavailable at {} — proceeding without memory context. \
395                 Run `ai-memory doctor` to diagnose. \
396                 See https://github.com/alphaonedev/ai-memory-mcp/blob/main/docs/integrations/README.md",
397                db_path.display()
398            ),
399            BootStatus::WarnSchemaUnsupported { db_schema } => format!(
400                "db schema v{db_schema} unsupported by binary {bin_ver} \
401                 (supports v{min}..v{max}); proceeding with degraded context. \
402                 Run `ai-memory doctor` and consider upgrading.",
403                bin_ver = env!("CARGO_PKG_VERSION"),
404                min = MIN_SUPPORTED_SCHEMA,
405                max = MAX_SUPPORTED_SCHEMA,
406            ),
407        };
408
409        Self {
410            version: env!("CARGO_PKG_VERSION").to_string(),
411            db_path: db_path.display().to_string(),
412            schema_version,
413            total_memories,
414            tier: feature_tier.as_str().to_string(),
415            embedder,
416            reranker,
417            llm,
418            latency_ms,
419            namespace: namespace.to_string(),
420            count,
421            note,
422            status,
423            schema_supported,
424        }
425    }
426}
427
428/// `ai-memory boot` entry point.
429#[allow(clippy::too_many_lines)]
430pub fn run(
431    db_path: &Path,
432    args: &BootArgs,
433    app_config: &AppConfig,
434    out: &mut CliOutput<'_>,
435) -> Result<()> {
436    let start = Instant::now();
437
438    // PR-9h (#487 PR #497 req #73) — privacy kill-switch. When the
439    // operator sets `[boot] enabled = false` (or
440    // `AI_MEMORY_BOOT_ENABLED=0`), boot exits 0 with empty stdout AND
441    // empty stderr. The hook injects nothing — true silence for
442    // privacy-sensitive hosts. This MUST run before any other side
443    // effect (file open, env probe, etc.) so the contract is "boot
444    // produces zero output."
445    let boot_cfg = app_config.effective_boot();
446    if !boot_cfg.effective_enabled() {
447        return Ok(());
448    }
449    let redact_titles = boot_cfg.effective_redact_titles();
450
451    let format = BootFormat::parse(&args.format)?;
452    let limit = args.limit.clamp(1, 50);
453    let namespace = resolve_namespace(args);
454
455    // Open the DB. On failure, honor `--quiet` (exit 0 with empty stdout
456    // when `--no-header` is also set; otherwise emit a warning header so
457    // the agent always sees that boot ran, even on failure).
458    let conn = match db::open(db_path) {
459        Ok(c) => c,
460        Err(e) => {
461            if !args.quiet {
462                writeln!(
463                    out.stderr,
464                    "ai-memory boot: db unavailable at {}: {e}",
465                    db_path.display()
466                )?;
467            }
468            if !args.no_header {
469                let manifest = BootManifest::build(
470                    BootStatus::WarnDbUnavailable,
471                    &namespace,
472                    0,
473                    db_path,
474                    app_config,
475                    UNAVAILABLE.to_string(),
476                    UNAVAILABLE.to_string(),
477                    start.elapsed().as_millis(),
478                    false, // schema_supported: DB unavailable → unknown → false
479                );
480                emit_status_header(out, &manifest, format)?;
481            }
482            return Ok(());
483        }
484    };
485
486    // Cheap diagnostic lookups. Both degrade to the sentinel rather than
487    // fail the boot — the manifest is best-effort.
488    let (schema_version, schema_int) = read_schema_version(&conn);
489    let total_memories = count_live_memories(&conn);
490
491    // PR-9h (#487 PR #497 req #72) — version-drift detection. If the
492    // DB's schema lies outside `[MIN, MAX]`, surface a warn-schema
493    // header. Boot still exits 0 (consistent with WarnDbUnavailable —
494    // never wedge the agent's first turn). When schema_int is None
495    // (parse failure / unreadable table) we treat schema as unsupported
496    // for SIEM purposes but otherwise carry on with the existing
497    // status flow.
498    let schema_supported = schema_int.is_some_and(schema_in_supported_range);
499    if let Some(v) = schema_int
500        && !schema_in_supported_range(v)
501    {
502        if !args.no_header {
503            let manifest = BootManifest::build(
504                BootStatus::WarnSchemaUnsupported { db_schema: v },
505                &namespace,
506                0,
507                db_path,
508                app_config,
509                schema_version,
510                total_memories,
511                start.elapsed().as_millis(),
512                false,
513            );
514            emit_status_header(out, &manifest, format)?;
515        }
516        return Ok(());
517    }
518
519    let (mems, used_namespace) = fetch_boot_memories(&conn, &namespace, limit)?;
520    let mems = clamp_to_budget(mems, args.budget_tokens);
521    let fell_back = !mems.is_empty() && used_namespace.is_empty();
522
523    if mems.is_empty() {
524        if !args.no_header {
525            let manifest = BootManifest::build(
526                BootStatus::InfoEmpty,
527                &namespace,
528                0,
529                db_path,
530                app_config,
531                schema_version,
532                total_memories,
533                start.elapsed().as_millis(),
534                schema_supported,
535            );
536            emit_status_header(out, &manifest, format)?;
537        }
538        return Ok(());
539    }
540
541    let displayed_ns = if fell_back { "global" } else { &namespace };
542    let status = if fell_back {
543        BootStatus::InfoFallback
544    } else {
545        BootStatus::OkLoaded
546    };
547
548    match format {
549        BootFormat::Json => {
550            // JSON output is one object: header fields + memories together.
551            // `--no-header` is meaningless for JSON (the JSON IS the
552            // boundary) but we honor it as "skip the diagnostic JSON" by
553            // emitting only the memories array, for advanced wrappers.
554            if args.no_header {
555                writeln!(
556                    out.stdout,
557                    "{}",
558                    serde_json::to_string(&serde_json::json!({
559                        "memories": render_memories_for_emit(&mems, redact_titles)
560                    }))?
561                )?;
562            } else {
563                let manifest = BootManifest::build(
564                    status,
565                    displayed_ns,
566                    mems.len(),
567                    db_path,
568                    app_config,
569                    schema_version,
570                    total_memories,
571                    start.elapsed().as_millis(),
572                    schema_supported,
573                );
574                emit_json_with_status(out, &manifest, &mems, fell_back, redact_titles)?;
575            }
576        }
577        BootFormat::Text => {
578            if !args.no_header {
579                let manifest = BootManifest::build(
580                    status,
581                    displayed_ns,
582                    mems.len(),
583                    db_path,
584                    app_config,
585                    schema_version,
586                    total_memories,
587                    start.elapsed().as_millis(),
588                    schema_supported,
589                );
590                emit_status_header(out, &manifest, format)?;
591            }
592            emit_text(out, &mems, redact_titles)?;
593        }
594        BootFormat::Toon => {
595            if !args.no_header {
596                let manifest = BootManifest::build(
597                    status,
598                    displayed_ns,
599                    mems.len(),
600                    db_path,
601                    app_config,
602                    schema_version,
603                    total_memories,
604                    start.elapsed().as_millis(),
605                    schema_supported,
606                );
607                emit_status_header(out, &manifest, format)?;
608            }
609            emit_toon(out, &mems, redact_titles)?;
610        }
611    }
612
613    Ok(())
614}
615
616/// Sentinel substituted for `memory.title` when `[boot] redact_titles =
617/// true`. Identical to the `redact_content` placeholder pattern used by
618/// the audit subsystem (PR-5). v0.6.3.1 (PR-9h / issue #487 PR #497 req
619/// #73).
620const REDACTED_TITLE: &str = "<redacted>";
621
622/// Apply title redaction to a slice of memories, returning a freshly
623/// owned `Vec` with each `title` replaced by [`REDACTED_TITLE`] when
624/// the operator opted in via `[boot] redact_titles = true`. The
625/// no-redact path returns a clone — the cost is one Vec allocation per
626/// boot, which is dwarfed by the SQL list call.
627fn render_memories_for_emit(mems: &[models::Memory], redact_titles: bool) -> Vec<models::Memory> {
628    if !redact_titles {
629        return mems.to_vec();
630    }
631    mems.iter()
632        .map(|m| {
633            let mut redacted = m.clone();
634            redacted.title = REDACTED_TITLE.to_string();
635            redacted
636        })
637        .collect()
638}
639
640/// Always-visible diagnostic header. Agents see this in their session log
641/// even when the body is empty, so the absence of memory context is a
642/// surfaced signal rather than a silent failure.
643///
644/// **Format (text/toon)** — multi-line manifest, every field labelled:
645/// ```text
646/// # ai-memory boot: ok
647/// #   version:    0.6.3+patch.1
648/// #   db:         /home/u/.claude/ai-memory.db (schema=v19, 161 memories)
649/// #   tier:       autonomous (embedder=..., reranker=..., llm=...)
650/// #   latency:    12ms
651/// #   namespace:  ns-x (loaded 3 memories)
652/// ```
653///
654/// **Format (json)** — single JSON object with every manifest field as a
655/// top-level key (`version`, `db_path`, `schema_version`, `total_memories`,
656/// `tier`, `embedder`, `reranker`, `llm`, `latency_ms`, `namespace`,
657/// `count`, `status`, `note`).
658fn emit_status_header(
659    out: &mut CliOutput<'_>,
660    manifest: &BootManifest,
661    format: BootFormat,
662) -> Result<()> {
663    match format {
664        BootFormat::Json => {
665            writeln!(
666                out.stdout,
667                "{}",
668                serde_json::json!({
669                    "status": manifest.status.label(),
670                    "version": manifest.version,
671                    "db_path": manifest.db_path,
672                    "schema_version": manifest.schema_version,
673                    "schema_supported": manifest.schema_supported,
674                    "total_memories": manifest.total_memories,
675                    "tier": manifest.tier,
676                    "embedder": manifest.embedder,
677                    "reranker": manifest.reranker,
678                    "llm": manifest.llm,
679                    "latency_ms": manifest.latency_ms,
680                    "namespace": manifest.namespace,
681                    "count": manifest.count,
682                    "note": manifest.note,
683                })
684            )?;
685        }
686        _ => {
687            // Multi-line transparent manifest. Each field is on its own
688            // line so a grep / log scrape can pick them out individually,
689            // and the human reader sees a top-down summary.
690            writeln!(out.stdout, "# ai-memory boot: {}", manifest.status.label())?;
691            writeln!(out.stdout, "#   version:    {}", manifest.version)?;
692            writeln!(
693                out.stdout,
694                "#   db:         {} (schema={}, {} memories)",
695                manifest.db_path, manifest.schema_version, manifest.total_memories
696            )?;
697            writeln!(
698                out.stdout,
699                "#   tier:       {} (embedder={}, reranker={}, llm={})",
700                manifest.tier, manifest.embedder, manifest.reranker, manifest.llm
701            )?;
702            writeln!(out.stdout, "#   latency:    {}ms", manifest.latency_ms)?;
703            // Namespace line carries the same status-specific note the
704            // single-line PR-1 header used to carry — so a reader can see
705            // *why* a particular count showed up.
706            match manifest.status {
707                BootStatus::OkLoaded => {
708                    writeln!(
709                        out.stdout,
710                        "#   namespace:  {} (loaded {} memor{})",
711                        manifest.namespace,
712                        manifest.count,
713                        if manifest.count == 1 { "y" } else { "ies" }
714                    )?;
715                }
716                BootStatus::InfoFallback => {
717                    writeln!(
718                        out.stdout,
719                        "#   namespace:  {} (fallback: loaded {} memor{} from global Long tier)",
720                        manifest.namespace,
721                        manifest.count,
722                        if manifest.count == 1 { "y" } else { "ies" }
723                    )?;
724                }
725                BootStatus::InfoEmpty => {
726                    writeln!(
727                        out.stdout,
728                        "#   namespace:  {} (empty — nothing to load; this is normal on a fresh install)",
729                        manifest.namespace
730                    )?;
731                }
732                BootStatus::WarnDbUnavailable => {
733                    writeln!(
734                        out.stdout,
735                        "#   namespace:  {} (db unavailable — see `ai-memory doctor`)",
736                        manifest.namespace
737                    )?;
738                }
739                BootStatus::WarnSchemaUnsupported { db_schema } => {
740                    // PR-9h (#487 PR #497 req #72) — full warn-schema
741                    // message: `db schema vN unsupported by binary
742                    // X.Y.Z (supports v{MIN}..v{MAX}); proceeding with
743                    // degraded context. Run \`ai-memory doctor\` and
744                    // consider upgrading.`
745                    writeln!(
746                        out.stdout,
747                        "#   namespace:  {} (db schema v{} unsupported by binary {} \
748                         (supports v{}..v{}); proceeding with degraded context. \
749                         Run `ai-memory doctor` and consider upgrading.)",
750                        manifest.namespace,
751                        db_schema,
752                        manifest.version,
753                        MIN_SUPPORTED_SCHEMA,
754                        MAX_SUPPORTED_SCHEMA,
755                    )?;
756                }
757            }
758        }
759    }
760    Ok(())
761}
762
763fn emit_text(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
764    for mem in mems {
765        let age = human_age(&mem.updated_at);
766        // PR-9h (#487 PR #497 req #73) — when `[boot] redact_titles =
767        // true`, replace the title field with the redaction sentinel.
768        // Every other row field (tier, id_short, namespace, priority,
769        // age) still surfaces so the operator retains the audit-trail
770        // signal of "boot ran with N memories" without exposing
771        // memory subjects.
772        let title: &str = if redact_titles {
773            REDACTED_TITLE
774        } else {
775            &mem.title
776        };
777        writeln!(
778            out.stdout,
779            "- [{}/{}] {} (ns={}, p={}, {})",
780            mem.tier,
781            id_short(&mem.id),
782            title,
783            mem.namespace,
784            mem.priority,
785            age
786        )?;
787    }
788    Ok(())
789}
790
791fn emit_json_with_status(
792    out: &mut CliOutput<'_>,
793    manifest: &BootManifest,
794    mems: &[models::Memory],
795    fell_back: bool,
796    redact_titles: bool,
797) -> Result<()> {
798    // Same shape as `emit_status_header` JSON path, plus `memories` and
799    // `fell_back_to_global`. Agents that ingest JSON get every manifest
800    // field as a top-level key so they can reason about the runtime
801    // without parsing a free-text header.
802    let rendered = render_memories_for_emit(mems, redact_titles);
803    let body = serde_json::json!({
804        "status": manifest.status.label(),
805        "version": manifest.version,
806        "db_path": manifest.db_path,
807        "schema_version": manifest.schema_version,
808        "schema_supported": manifest.schema_supported,
809        "total_memories": manifest.total_memories,
810        "tier": manifest.tier,
811        "embedder": manifest.embedder,
812        "reranker": manifest.reranker,
813        "llm": manifest.llm,
814        "latency_ms": manifest.latency_ms,
815        "namespace": manifest.namespace,
816        "count": manifest.count,
817        "note": manifest.note,
818        "fell_back_to_global": fell_back,
819        "memories": rendered,
820    });
821    writeln!(out.stdout, "{}", serde_json::to_string(&body)?)?;
822    Ok(())
823}
824
825fn emit_toon(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
826    // Reuse the canonical TOON serializer used by `memory_recall` so boot
827    // output is byte-identical to a recall response on the wire format.
828    // `memories_to_toon` takes the `{memories: [...], count: N}` shape.
829    let rendered = render_memories_for_emit(mems, redact_titles);
830    let body = serde_json::json!({
831        "memories": rendered,
832        "count": rendered.len(),
833    });
834    let toon_str = toon::memories_to_toon(&body, true);
835    writeln!(out.stdout, "{toon_str}")?;
836    Ok(())
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use crate::cli::test_utils::{TestEnv, seed_memory};
843
844    fn default_args() -> BootArgs {
845        BootArgs {
846            namespace: None,
847            limit: 10,
848            budget_tokens: DEFAULT_BUDGET_TOKENS,
849            format: "text".to_string(),
850            no_header: false,
851            quiet: false,
852            cwd: None,
853        }
854    }
855
856    fn default_config() -> AppConfig {
857        AppConfig::default()
858    }
859
860    /// Process-wide guard for the boot-test suite. `BootConfig::
861    /// effective_enabled` reads `AI_MEMORY_BOOT_ENABLED` on every
862    /// invocation — to avoid parallel tests observing the env-var
863    /// override fired by [`boot_disabled_via_env_var_overrides_config`],
864    /// every test that calls [`run`] takes this guard. Cheap (one
865    /// `Mutex` lock) and bullet-proof.
866    fn test_lock() -> std::sync::MutexGuard<'static, ()> {
867        use std::sync::{Mutex, OnceLock};
868        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
869        LOCK.get_or_init(|| Mutex::new(()))
870            .lock()
871            .unwrap_or_else(std::sync::PoisonError::into_inner)
872    }
873
874    #[test]
875    fn boot_format_parse_accepts_aliases() {
876        assert_eq!(BootFormat::parse("text").unwrap(), BootFormat::Text);
877        assert_eq!(BootFormat::parse("json").unwrap(), BootFormat::Json);
878        assert_eq!(BootFormat::parse("toon").unwrap(), BootFormat::Toon);
879        assert_eq!(BootFormat::parse("toon-compact").unwrap(), BootFormat::Toon);
880        assert_eq!(BootFormat::parse("toon_compact").unwrap(), BootFormat::Toon);
881        assert!(BootFormat::parse("yaml").is_err());
882    }
883
884    #[test]
885    fn boot_emits_ok_header_with_loaded_memories() {
886        let _g = test_lock();
887        let mut env = TestEnv::fresh();
888        seed_memory(&env.db_path, "ns-x", "first", "content one");
889        seed_memory(&env.db_path, "ns-x", "second", "content two");
890        seed_memory(&env.db_path, "ns-y", "elsewhere", "content three");
891        let db_path = env.db_path.clone();
892        let cfg = default_config();
893        let mut args = default_args();
894        args.namespace = Some("ns-x".to_string());
895        let mut out = env.output();
896        run(&db_path, &args, &cfg, &mut out).unwrap();
897        let stdout = std::str::from_utf8(&env.stdout).unwrap();
898        // Status line + every manifest field appears.
899        assert!(
900            stdout.contains("# ai-memory boot: ok"),
901            "expected ok status header, got: {stdout}"
902        );
903        assert!(
904            stdout.contains("#   version:"),
905            "manifest missing version line: {stdout}"
906        );
907        assert!(
908            stdout.contains("#   db:"),
909            "manifest missing db line: {stdout}"
910        );
911        assert!(
912            stdout.contains("#   tier:"),
913            "manifest missing tier line: {stdout}"
914        );
915        assert!(
916            stdout.contains("#   latency:"),
917            "manifest missing latency line: {stdout}"
918        );
919        assert!(
920            stdout.contains("#   namespace:") && stdout.contains("ns-x"),
921            "namespace line should contain ns-x: {stdout}"
922        );
923        assert!(stdout.contains("loaded 2 memories"));
924        assert!(stdout.contains("first"));
925        assert!(stdout.contains("second"));
926        assert!(!stdout.contains("elsewhere"));
927    }
928
929    #[test]
930    fn boot_header_includes_version() {
931        let _g = test_lock();
932        let mut env = TestEnv::fresh();
933        seed_memory(&env.db_path, "ns-v", "row", "x");
934        let db_path = env.db_path.clone();
935        let cfg = default_config();
936        let mut args = default_args();
937        args.namespace = Some("ns-v".to_string());
938        let mut out = env.output();
939        run(&db_path, &args, &cfg, &mut out).unwrap();
940        let stdout = std::str::from_utf8(&env.stdout).unwrap();
941        // CARGO_PKG_VERSION is "0.6.3+patch.1" or similar; assert the
942        // crate constant surfaces verbatim, not a hardcoded string.
943        let version = env!("CARGO_PKG_VERSION");
944        assert!(
945            stdout.contains(version),
946            "expected version `{version}` in header: {stdout}"
947        );
948    }
949
950    #[test]
951    fn boot_header_includes_db_path() {
952        let _g = test_lock();
953        let mut env = TestEnv::fresh();
954        seed_memory(&env.db_path, "ns-d", "row", "x");
955        let db_path = env.db_path.clone();
956        let cfg = default_config();
957        let mut args = default_args();
958        args.namespace = Some("ns-d".to_string());
959        let mut out = env.output();
960        run(&db_path, &args, &cfg, &mut out).unwrap();
961        let stdout = std::str::from_utf8(&env.stdout).unwrap();
962        let db_str = db_path.display().to_string();
963        assert!(
964            stdout.contains(&db_str),
965            "expected db path `{db_str}` in header: {stdout}"
966        );
967    }
968
969    #[test]
970    fn boot_header_includes_schema_version() {
971        let _g = test_lock();
972        let mut env = TestEnv::fresh();
973        seed_memory(&env.db_path, "ns-s", "row", "x");
974        let db_path = env.db_path.clone();
975        let cfg = default_config();
976        let mut args = default_args();
977        args.namespace = Some("ns-s".to_string());
978        let mut out = env.output();
979        run(&db_path, &args, &cfg, &mut out).unwrap();
980        let stdout = std::str::from_utf8(&env.stdout).unwrap();
981        assert!(
982            stdout.contains("schema=v"),
983            "expected `schema=vN` in header: {stdout}"
984        );
985    }
986
987    #[test]
988    fn boot_header_includes_latency_ms() {
989        let _g = test_lock();
990        let mut env = TestEnv::fresh();
991        seed_memory(&env.db_path, "ns-lat", "row", "x");
992        let db_path = env.db_path.clone();
993        let cfg = default_config();
994        let mut args = default_args();
995        args.namespace = Some("ns-lat".to_string());
996        let mut out = env.output();
997        run(&db_path, &args, &cfg, &mut out).unwrap();
998        let stdout = std::str::from_utf8(&env.stdout).unwrap();
999        // Latency line must exist; the value must end with `ms` and be
1000        // numeric (we don't pin a value because wall-clock varies).
1001        let latency_line = stdout
1002            .lines()
1003            .find(|l| l.contains("latency:"))
1004            .expect("latency line must exist in manifest");
1005        let suffix = latency_line.split("latency:").nth(1).unwrap().trim();
1006        assert!(
1007            suffix.ends_with("ms"),
1008            "latency value should end with `ms`: {suffix}"
1009        );
1010        let num_str = suffix.trim_end_matches("ms");
1011        assert!(
1012            num_str.parse::<u128>().is_ok(),
1013            "latency must parse as integer ms: {num_str}"
1014        );
1015    }
1016
1017    #[test]
1018    fn boot_json_includes_all_manifest_fields() {
1019        let _g = test_lock();
1020        let mut env = TestEnv::fresh();
1021        seed_memory(&env.db_path, "ns-jm", "row", "x");
1022        let db_path = env.db_path.clone();
1023        let cfg = default_config();
1024        let mut args = default_args();
1025        args.namespace = Some("ns-jm".to_string());
1026        args.format = "json".to_string();
1027        let mut out = env.output();
1028        run(&db_path, &args, &cfg, &mut out).unwrap();
1029        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1030        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1031        // Status fields (PR-1 contract preserved).
1032        assert_eq!(parsed["status"], "ok");
1033        assert_eq!(parsed["namespace"], "ns-jm");
1034        assert_eq!(parsed["count"], 1);
1035        assert_eq!(parsed["fell_back_to_global"], false);
1036        assert!(parsed["memories"].is_array());
1037        // PR-4 manifest fields.
1038        for key in [
1039            "version",
1040            "db_path",
1041            "schema_version",
1042            "total_memories",
1043            "tier",
1044            "embedder",
1045            "reranker",
1046            "llm",
1047            "latency_ms",
1048            "note",
1049        ] {
1050            assert!(
1051                parsed.get(key).is_some(),
1052                "json output missing manifest field `{key}`: {stdout}"
1053            );
1054        }
1055        assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
1056        assert!(parsed["latency_ms"].is_number());
1057        assert!(
1058            parsed["schema_version"]
1059                .as_str()
1060                .unwrap_or("")
1061                .starts_with('v'),
1062            "schema_version should be `vN` form"
1063        );
1064    }
1065
1066    #[test]
1067    fn boot_respects_limit() {
1068        let _g = test_lock();
1069        let mut env = TestEnv::fresh();
1070        for i in 0..5 {
1071            seed_memory(&env.db_path, "ns-l", &format!("m{i}"), "x");
1072        }
1073        let db_path = env.db_path.clone();
1074        let cfg = default_config();
1075        let mut args = default_args();
1076        args.namespace = Some("ns-l".to_string());
1077        args.limit = 2;
1078        let mut out = env.output();
1079        run(&db_path, &args, &cfg, &mut out).unwrap();
1080        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1081        assert!(stdout.contains("loaded 2 memories"));
1082        let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1083        assert_eq!(row_count, 2, "expected 2 rows, got {row_count}: {stdout}");
1084    }
1085
1086    #[test]
1087    fn boot_no_header_with_flag_suppresses_status() {
1088        let _g = test_lock();
1089        let mut env = TestEnv::fresh();
1090        seed_memory(&env.db_path, "ns-h", "row-one", "x");
1091        let db_path = env.db_path.clone();
1092        let cfg = default_config();
1093        let mut args = default_args();
1094        args.namespace = Some("ns-h".to_string());
1095        args.no_header = true;
1096        let mut out = env.output();
1097        run(&db_path, &args, &cfg, &mut out).unwrap();
1098        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1099        assert!(!stdout.contains("# ai-memory boot"));
1100        assert!(stdout.contains("row-one"));
1101    }
1102
1103    #[test]
1104    fn boot_json_format_emits_status_and_memories() {
1105        let _g = test_lock();
1106        let mut env = TestEnv::fresh();
1107        seed_memory(&env.db_path, "ns-j", "row", "x");
1108        let db_path = env.db_path.clone();
1109        let cfg = default_config();
1110        let mut args = default_args();
1111        args.namespace = Some("ns-j".to_string());
1112        args.format = "json".to_string();
1113        let mut out = env.output();
1114        run(&db_path, &args, &cfg, &mut out).unwrap();
1115        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1116        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1117        assert_eq!(parsed["status"], "ok");
1118        assert_eq!(parsed["namespace"], "ns-j");
1119        assert_eq!(parsed["count"], 1);
1120        assert_eq!(parsed["fell_back_to_global"], false);
1121        assert!(parsed["memories"].is_array());
1122    }
1123
1124    #[test]
1125    fn boot_quiet_with_unreachable_db_emits_warn_header_no_stderr() {
1126        // The user-facing diagnostic header MUST appear so the agent (and
1127        // a human looking at the agent log) sees that boot ran but
1128        // couldn't load context. --quiet suppresses *only* stderr.
1129        // PR-4: the warn variant still emits the manifest fields, with
1130        // `<unavailable>` in slots that need a live DB to fill.
1131        let _g = test_lock();
1132        let mut env = TestEnv::fresh();
1133        let bad_path = env
1134            .db_path
1135            .parent()
1136            .unwrap()
1137            .join("subdir/that/does/not/exist/db.sqlite");
1138        let cfg = default_config();
1139        let mut args = default_args();
1140        args.quiet = true;
1141        let mut out = env.output();
1142        run(&bad_path, &args, &cfg, &mut out).unwrap();
1143        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1144        assert!(
1145            stdout.contains("# ai-memory boot: warn"),
1146            "warn header should always appear under --quiet: {stdout}"
1147        );
1148        assert!(
1149            stdout.contains("db unavailable"),
1150            "header should explain the warning cause: {stdout}"
1151        );
1152        // What the warn variant CAN surface even without the DB.
1153        assert!(
1154            stdout.contains("#   version:"),
1155            "warn manifest should still carry version: {stdout}"
1156        );
1157        assert!(
1158            stdout.contains(env!("CARGO_PKG_VERSION")),
1159            "warn manifest version should be CARGO_PKG_VERSION: {stdout}"
1160        );
1161        assert!(
1162            stdout.contains("#   tier:"),
1163            "warn manifest should still carry tier: {stdout}"
1164        );
1165        assert!(
1166            stdout.contains("#   latency:"),
1167            "warn manifest should still carry latency: {stdout}"
1168        );
1169        // Slots that need a live DB degrade to the sentinel.
1170        assert!(
1171            stdout.contains(UNAVAILABLE),
1172            "warn manifest should mark unreachable fields as <unavailable>: {stdout}"
1173        );
1174        assert!(
1175            env.stderr.is_empty(),
1176            "stderr should be silent under --quiet"
1177        );
1178    }
1179
1180    #[test]
1181    fn boot_db_unavailable_without_quiet_writes_to_stderr() {
1182        let _g = test_lock();
1183        let mut env = TestEnv::fresh();
1184        let bad_path = env
1185            .db_path
1186            .parent()
1187            .unwrap()
1188            .join("subdir/that/does/not/exist/db.sqlite");
1189        let cfg = default_config();
1190        let args = default_args();
1191        // quiet = false (default) — error goes to stderr too.
1192        let mut out = env.output();
1193        run(&bad_path, &args, &cfg, &mut out).unwrap();
1194        let stderr = std::str::from_utf8(&env.stderr).unwrap();
1195        assert!(
1196            stderr.contains("ai-memory boot: db unavailable"),
1197            "stderr should carry the diagnostic without --quiet: {stderr}"
1198        );
1199    }
1200
1201    #[test]
1202    fn boot_quiet_with_no_header_silent_for_legacy_wrappers() {
1203        // Wrappers that frame their own context can opt out of both the
1204        // diagnostic header AND any error output by combining flags.
1205        let _g = test_lock();
1206        let mut env = TestEnv::fresh();
1207        let bad_path = env
1208            .db_path
1209            .parent()
1210            .unwrap()
1211            .join("subdir/that/does/not/exist/db.sqlite");
1212        let cfg = default_config();
1213        let mut args = default_args();
1214        args.quiet = true;
1215        args.no_header = true;
1216        let mut out = env.output();
1217        run(&bad_path, &args, &cfg, &mut out).unwrap();
1218        assert!(env.stdout.is_empty());
1219        assert!(env.stderr.is_empty());
1220    }
1221
1222    #[test]
1223    fn boot_falls_back_to_long_tier_when_namespace_empty() {
1224        let _g = test_lock();
1225        let mut env = TestEnv::fresh();
1226        let id = seed_memory(&env.db_path, "other", "long-tier-row", "x");
1227        let conn = db::open(&env.db_path).unwrap();
1228        conn.execute(
1229            "UPDATE memories SET tier='long' WHERE id=?1",
1230            rusqlite::params![id],
1231        )
1232        .unwrap();
1233        drop(conn);
1234        let db_path = env.db_path.clone();
1235        let cfg = default_config();
1236        let mut args = default_args();
1237        args.namespace = Some("nonexistent-ns".to_string());
1238        let mut out = env.output();
1239        run(&db_path, &args, &cfg, &mut out).unwrap();
1240        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1241        assert!(
1242            stdout.contains("# ai-memory boot: info") && stdout.contains("fallback"),
1243            "expected info/fallback status: {stdout}"
1244        );
1245        assert!(stdout.contains("long-tier-row"));
1246    }
1247
1248    #[test]
1249    fn boot_empty_namespace_emits_info_empty_status() {
1250        let _g = test_lock();
1251        let mut env = TestEnv::fresh();
1252        let db_path = env.db_path.clone();
1253        let cfg = default_config();
1254        let mut args = default_args();
1255        args.namespace = Some("nothing-here".to_string());
1256        let mut out = env.output();
1257        run(&db_path, &args, &cfg, &mut out).unwrap();
1258        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1259        assert!(
1260            stdout.contains("# ai-memory boot: info")
1261                && stdout.contains("nothing-here")
1262                && stdout.contains("empty"),
1263            "info/empty header expected: {stdout}"
1264        );
1265    }
1266
1267    #[test]
1268    fn boot_budget_tokens_clamps_output() {
1269        let _g = test_lock();
1270        let mut env = TestEnv::fresh();
1271        for i in 0..20 {
1272            seed_memory(
1273                &env.db_path,
1274                "ns-budget",
1275                &format!("memory number {i} with a moderate-length title"),
1276                "x",
1277            );
1278        }
1279        let db_path = env.db_path.clone();
1280        let cfg = default_config();
1281        let mut args = default_args();
1282        args.namespace = Some("ns-budget".to_string());
1283        args.limit = 50;
1284        args.budget_tokens = 100;
1285        let mut out = env.output();
1286        run(&db_path, &args, &cfg, &mut out).unwrap();
1287        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1288        let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1289        assert!(
1290            row_count >= 1 && row_count < 20,
1291            "budget_tokens=100 should clamp to fewer than 20 rows; got {row_count}\noutput:\n{stdout}"
1292        );
1293    }
1294
1295    #[test]
1296    fn boot_json_warn_status_when_db_unavailable() {
1297        let _g = test_lock();
1298        let mut env = TestEnv::fresh();
1299        let bad_path = env
1300            .db_path
1301            .parent()
1302            .unwrap()
1303            .join("subdir/that/does/not/exist/db.sqlite");
1304        let cfg = default_config();
1305        let mut args = default_args();
1306        args.format = "json".to_string();
1307        args.quiet = true;
1308        let mut out = env.output();
1309        run(&bad_path, &args, &cfg, &mut out).unwrap();
1310        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1311        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1312        assert_eq!(parsed["status"], "warn");
1313        assert_eq!(parsed["count"], 0);
1314        assert!(parsed["note"].as_str().unwrap().contains("db unavailable"));
1315        // PR-4: warn JSON variant carries the manifest with sentinels in
1316        // slots that need a live DB.
1317        assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
1318        assert_eq!(parsed["schema_version"], UNAVAILABLE);
1319        assert_eq!(parsed["total_memories"], UNAVAILABLE);
1320        // PR-9h (req #72): schema_supported is `false` when DB unavailable.
1321        assert_eq!(parsed["schema_supported"], false);
1322    }
1323
1324    // -----------------------------------------------------------------
1325    // PR-9h Part 1 — version-drift detection (#487 PR #497 req #72)
1326    // -----------------------------------------------------------------
1327
1328    /// Force-set the on-disk schema_version row to a synthetic value
1329    /// AFTER a previous `db::open` (via `seed_memory`) has run the
1330    /// migration ladder up to `CURRENT_SCHEMA_VERSION` (== MAX). Used
1331    /// to drive the ABOVE-MAX path. Uses a raw `rusqlite::Connection`
1332    /// so we don't trigger another round of migrations that would
1333    /// re-bump the version. The BELOW-MIN path is unreachable by this
1334    /// technique (the migration ladder ratchets any sub-MAX version
1335    /// back to MAX); see [`schema_below_min_is_unsupported`] for the
1336    /// pure-function unit test that covers the lower-bound semantics.
1337    fn override_schema_version(db_path: &std::path::Path, v: i64) {
1338        let conn = rusqlite::Connection::open(db_path).expect("rusqlite::open");
1339        conn.execute("DELETE FROM schema_version", []).unwrap();
1340        conn.execute(
1341            "INSERT INTO schema_version (version) VALUES (?1)",
1342            rusqlite::params![v],
1343        )
1344        .unwrap();
1345    }
1346
1347    #[test]
1348    fn boot_warns_on_schema_above_max() {
1349        let _g = test_lock();
1350        let mut env = TestEnv::fresh();
1351        seed_memory(&env.db_path, "ns-drift", "row", "x");
1352        // Bump schema beyond MAX_SUPPORTED_SCHEMA. Cast through i64 to
1353        // sidestep the migration ladder which would force MAX on
1354        // re-open.
1355        override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
1356        let db_path = env.db_path.clone();
1357        let cfg = default_config();
1358        let mut args = default_args();
1359        args.namespace = Some("ns-drift".to_string());
1360        let mut out = env.output();
1361        run(&db_path, &args, &cfg, &mut out).unwrap();
1362        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1363        assert!(
1364            stdout.contains("# ai-memory boot: warn"),
1365            "expected warn header for schema drift: {stdout}"
1366        );
1367        assert!(
1368            stdout.contains("unsupported by binary"),
1369            "expected schema-drift message text: {stdout}"
1370        );
1371        assert!(
1372            stdout.contains(&format!(
1373                "v{}..v{}",
1374                MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA
1375            )),
1376            "expected supported range in message: {stdout}"
1377        );
1378    }
1379
1380    #[test]
1381    fn boot_warns_on_schema_below_min() {
1382        // The maintainer's #72 directive lists this as a required test.
1383        // BELOW-MIN drift is unreachable via a synthetic DB because the
1384        // migration ladder in `db::open` ratchets any sub-MAX version
1385        // back to `CURRENT_SCHEMA_VERSION`. We therefore exercise the
1386        // lower-bound semantics through the pure boundary helper.
1387        // See [`schema_below_min_is_unsupported`] for the exhaustive
1388        // boundary table; this test is the directive-named smoke
1389        // check.
1390        assert!(
1391            !schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1),
1392            "schemas below MIN must be reported as unsupported"
1393        );
1394    }
1395
1396    #[test]
1397    fn schema_below_min_is_unsupported() {
1398        // Exhaustive boundary table for the inclusive [MIN, MAX]
1399        // window. Pure-function — no DB, no env, no test_lock needed.
1400        assert!(!schema_in_supported_range(0));
1401        assert!(!schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1));
1402        assert!(schema_in_supported_range(MIN_SUPPORTED_SCHEMA));
1403        assert!(schema_in_supported_range(MAX_SUPPORTED_SCHEMA));
1404        assert!(!schema_in_supported_range(MAX_SUPPORTED_SCHEMA + 1));
1405        assert!(!schema_in_supported_range(u32::MAX));
1406    }
1407
1408    #[test]
1409    fn boot_ok_for_schema_at_min() {
1410        let _g = test_lock();
1411        let mut env = TestEnv::fresh();
1412        seed_memory(&env.db_path, "ns-min", "row", "x");
1413        override_schema_version(&env.db_path, i64::from(MIN_SUPPORTED_SCHEMA));
1414        let db_path = env.db_path.clone();
1415        let cfg = default_config();
1416        let mut args = default_args();
1417        args.namespace = Some("ns-min".to_string());
1418        let mut out = env.output();
1419        run(&db_path, &args, &cfg, &mut out).unwrap();
1420        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1421        assert!(
1422            stdout.contains("# ai-memory boot: ok"),
1423            "MIN boundary should be supported (not warn): {stdout}"
1424        );
1425    }
1426
1427    #[test]
1428    fn boot_ok_for_schema_at_max() {
1429        let _g = test_lock();
1430        let mut env = TestEnv::fresh();
1431        seed_memory(&env.db_path, "ns-max", "row", "x");
1432        override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA));
1433        let db_path = env.db_path.clone();
1434        let cfg = default_config();
1435        let mut args = default_args();
1436        args.namespace = Some("ns-max".to_string());
1437        let mut out = env.output();
1438        run(&db_path, &args, &cfg, &mut out).unwrap();
1439        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1440        assert!(
1441            stdout.contains("# ai-memory boot: ok"),
1442            "MAX boundary should be supported (not warn): {stdout}"
1443        );
1444    }
1445
1446    #[test]
1447    fn boot_json_includes_schema_supported_flag() {
1448        // Happy path — schema in range → schema_supported = true.
1449        let _g = test_lock();
1450        let mut env = TestEnv::fresh();
1451        seed_memory(&env.db_path, "ns-ssj", "row", "x");
1452        let db_path = env.db_path.clone();
1453        let cfg = default_config();
1454        let mut args = default_args();
1455        args.namespace = Some("ns-ssj".to_string());
1456        args.format = "json".to_string();
1457        let mut out = env.output();
1458        run(&db_path, &args, &cfg, &mut out).unwrap();
1459        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1460        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1461        assert_eq!(
1462            parsed["schema_supported"], true,
1463            "happy path → schema_supported=true: {stdout}"
1464        );
1465
1466        // Drift path — schema beyond MAX → schema_supported = false.
1467        let mut env2 = TestEnv::fresh();
1468        seed_memory(&env2.db_path, "ns-ssj2", "row", "x");
1469        override_schema_version(&env2.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
1470        let db_path2 = env2.db_path.clone();
1471        let mut args2 = default_args();
1472        args2.namespace = Some("ns-ssj2".to_string());
1473        args2.format = "json".to_string();
1474        let mut out2 = env2.output();
1475        run(&db_path2, &args2, &cfg, &mut out2).unwrap();
1476        let stdout2 = std::str::from_utf8(&env2.stdout).unwrap();
1477        let parsed2: serde_json::Value = serde_json::from_str(stdout2.trim()).unwrap();
1478        assert_eq!(
1479            parsed2["schema_supported"], false,
1480            "drift path → schema_supported=false: {stdout2}"
1481        );
1482        assert_eq!(parsed2["status"], "warn");
1483    }
1484
1485    // -----------------------------------------------------------------
1486    // PR-9h Part 2 — `[boot]` privacy controls (#487 PR #497 req #73)
1487    // -----------------------------------------------------------------
1488
1489    fn config_with_boot(enabled: Option<bool>, redact_titles: Option<bool>) -> AppConfig {
1490        let mut cfg = AppConfig::default();
1491        cfg.boot = Some(crate::config::BootConfig {
1492            enabled,
1493            redact_titles,
1494        });
1495        cfg
1496    }
1497
1498    #[test]
1499    fn boot_disabled_emits_nothing_at_all() {
1500        // `[boot] enabled = false` → empty stdout AND empty stderr,
1501        // exit 0. This is the privacy-sensitive escape hatch.
1502        let _g = test_lock();
1503        // SAFETY: process-wide env mutation; serialized by `_g`.
1504        unsafe {
1505            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1506        }
1507        let mut env = TestEnv::fresh();
1508        seed_memory(&env.db_path, "ns-silent", "private-title", "secret");
1509        let db_path = env.db_path.clone();
1510        let cfg = config_with_boot(Some(false), None);
1511        let args = default_args();
1512        let mut out = env.output();
1513        run(&db_path, &args, &cfg, &mut out).unwrap();
1514        assert!(
1515            env.stdout.is_empty(),
1516            "stdout must be empty when boot is disabled: {:?}",
1517            std::str::from_utf8(&env.stdout)
1518        );
1519        assert!(
1520            env.stderr.is_empty(),
1521            "stderr must be empty when boot is disabled: {:?}",
1522            std::str::from_utf8(&env.stderr)
1523        );
1524    }
1525
1526    #[test]
1527    fn boot_disabled_via_env_var_overrides_config() {
1528        // Config says enabled=true (default), but env var forces disabled.
1529        let _g = test_lock();
1530        // SAFETY: process-wide env mutation; serialized by `_g`.
1531        unsafe {
1532            std::env::set_var("AI_MEMORY_BOOT_ENABLED", "0");
1533        }
1534        let mut env = TestEnv::fresh();
1535        seed_memory(&env.db_path, "ns-envoff", "row", "x");
1536        let db_path = env.db_path.clone();
1537        let cfg = config_with_boot(Some(true), None);
1538        let args = default_args();
1539        let mut out = env.output();
1540        let result = run(&db_path, &args, &cfg, &mut out);
1541        // Always restore the env var before assertions so a panic
1542        // doesn't poison subsequent tests.
1543        // SAFETY: process-wide env mutation; serialized by `_guard`.
1544        unsafe {
1545            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1546        }
1547        result.unwrap();
1548        assert!(
1549            env.stdout.is_empty(),
1550            "env-var off must override config: stdout={:?}",
1551            std::str::from_utf8(&env.stdout)
1552        );
1553        assert!(env.stderr.is_empty());
1554    }
1555
1556    #[test]
1557    fn boot_redact_titles_replaces_titles_in_body() {
1558        let _g = test_lock();
1559        // SAFETY: process-wide env mutation; serialized by `_g`.
1560        unsafe {
1561            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1562        }
1563        let mut env = TestEnv::fresh();
1564        seed_memory(&env.db_path, "ns-redact", "secret-subject-alpha", "x");
1565        seed_memory(&env.db_path, "ns-redact", "secret-subject-beta", "y");
1566        let db_path = env.db_path.clone();
1567        let cfg = config_with_boot(Some(true), Some(true));
1568        let mut args = default_args();
1569        args.namespace = Some("ns-redact".to_string());
1570        let mut out = env.output();
1571        run(&db_path, &args, &cfg, &mut out).unwrap();
1572        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1573        // Manifest still appears (audit-trail signal preserved).
1574        assert!(
1575            stdout.contains("# ai-memory boot: ok"),
1576            "manifest header should still appear when only redacting titles: {stdout}"
1577        );
1578        // Body rows replace title with `<redacted>`.
1579        let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1580        assert_eq!(row_count, 2, "expected 2 body rows: {stdout}");
1581        assert!(
1582            stdout.contains(REDACTED_TITLE),
1583            "expected redacted sentinel in body: {stdout}"
1584        );
1585        // The original distinctive titles MUST NOT leak.
1586        assert!(
1587            !stdout.contains("secret-subject-alpha"),
1588            "title leaked despite redact_titles=true: {stdout}"
1589        );
1590        assert!(
1591            !stdout.contains("secret-subject-beta"),
1592            "title leaked despite redact_titles=true: {stdout}"
1593        );
1594    }
1595
1596    #[test]
1597    fn boot_redact_titles_keeps_other_fields() {
1598        // Redacting titles MUST NOT redact namespace, tier, id_short,
1599        // priority, or age — those are non-PII operational signal.
1600        let _g = test_lock();
1601        // SAFETY: process-wide env mutation; serialized by `_g`.
1602        unsafe {
1603            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1604        }
1605        let mut env = TestEnv::fresh();
1606        seed_memory(&env.db_path, "ns-redact-keep", "private-title", "x");
1607        let db_path = env.db_path.clone();
1608        let cfg = config_with_boot(Some(true), Some(true));
1609        let mut args = default_args();
1610        args.namespace = Some("ns-redact-keep".to_string());
1611        let mut out = env.output();
1612        run(&db_path, &args, &cfg, &mut out).unwrap();
1613        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1614        // namespace surfaces.
1615        assert!(
1616            stdout.contains("ns-redact-keep"),
1617            "namespace must still surface under redact_titles: {stdout}"
1618        );
1619        // Find the body row line.
1620        let row_line = stdout
1621            .lines()
1622            .find(|l| l.starts_with("- ["))
1623            .expect("body row must exist");
1624        // Tier prefix `[mid/...]` (test_utils seeds Tier::Mid).
1625        assert!(
1626            row_line.starts_with("- [mid/"),
1627            "tier + id_short prefix must remain: {row_line}"
1628        );
1629        // Priority + age suffix appears.
1630        assert!(row_line.contains("p=5"), "priority must remain: {row_line}");
1631        // The redaction sentinel sits in the title slot.
1632        assert!(
1633            row_line.contains(REDACTED_TITLE),
1634            "title slot must carry the redaction sentinel: {row_line}"
1635        );
1636        // The original title MUST NOT leak anywhere.
1637        assert!(
1638            !stdout.contains("private-title"),
1639            "raw title must not leak: {stdout}"
1640        );
1641    }
1642
1643    #[test]
1644    fn boot_default_config_unchanged_behavior() {
1645        // Sanity: no [boot] section in config → behaves identically to
1646        // the PR-1 / PR-4 baseline (manifest + body, titles surfaced
1647        // verbatim).
1648        let _g = test_lock();
1649        // SAFETY: process-wide env mutation; serialized by `_g`.
1650        unsafe {
1651            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1652        }
1653        let mut env = TestEnv::fresh();
1654        seed_memory(&env.db_path, "ns-default", "visible-title", "x");
1655        let db_path = env.db_path.clone();
1656        let cfg = AppConfig::default(); // boot = None
1657        let mut args = default_args();
1658        args.namespace = Some("ns-default".to_string());
1659        let mut out = env.output();
1660        run(&db_path, &args, &cfg, &mut out).unwrap();
1661        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1662        assert!(
1663            stdout.contains("# ai-memory boot: ok"),
1664            "default config → manifest header: {stdout}"
1665        );
1666        assert!(
1667            stdout.contains("visible-title"),
1668            "default config → title surfaces verbatim: {stdout}"
1669        );
1670        assert!(
1671            !stdout.contains(REDACTED_TITLE),
1672            "default config must NOT redact: {stdout}"
1673        );
1674    }
1675}