Skip to main content

ai_memory/cli/
curator.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_curator` migration. The daemon-mode body delegates to
5//! `daemon_runtime::run_curator_daemon_with_primitives` (W3 work);
6//! this module owns only the outer wrapper and the report printer.
7
8// The SAL store-build helpers (`curator_store_url`, the `--store-url`
9// path) and their `anyhow::Result` import are only live under the
10// sal-gated curator path; relax dead-code / unused-import in a non-sal
11// build only (sal builds enforce both fully).
12#![cfg_attr(not(feature = "sal"), allow(dead_code, unused_imports))]
13
14use crate::cli::CliOutput;
15use crate::curator::reflection_pass;
16use crate::identity::keypair as identity_keypair;
17use crate::{autonomy, config, curator, db, llm};
18use anyhow::{Context, Result};
19use clap::Args;
20use std::path::Path;
21
22#[derive(Args)]
23#[allow(clippy::struct_excessive_bools)]
24pub struct CuratorArgs {
25    /// Run exactly one sweep and exit. Mutually exclusive with --daemon.
26    #[arg(long, conflicts_with = "daemon")]
27    pub once: bool,
28    /// Loop forever, sleeping --interval-secs between sweeps. SIGINT /
29    /// SIGTERM trigger a clean shutdown between cycles.
30    #[arg(long)]
31    pub daemon: bool,
32    /// Seconds between daemon sweeps. Clamped to [60, 86400].
33    #[arg(long, default_value_t = crate::SECS_PER_HOUR as u64)]
34    pub interval_secs: u64,
35    /// Hard cap on LLM-invoking operations per cycle.
36    #[arg(long, default_value_t = 100)]
37    pub max_ops: usize,
38    /// Emit the report without persisting any metadata changes.
39    #[arg(long)]
40    pub dry_run: bool,
41    /// Only curate memories in these namespaces. Repeat flag for multiple.
42    #[arg(long = "include-namespace")]
43    pub include_namespaces: Vec<String>,
44    /// Exclude these namespaces from curation. Repeat flag for multiple.
45    #[arg(long = "exclude-namespace")]
46    pub exclude_namespaces: Vec<String>,
47    /// Print the report as JSON rather than a human-readable summary.
48    #[arg(long)]
49    pub json: bool,
50    /// Reverse rollback-log entries instead of running a sweep. Accepts
51    /// a specific rollback-memory id, or `--last N` for the most recent.
52    /// Mutually exclusive with `--once` and `--daemon`.
53    #[arg(long, conflicts_with_all = ["once", "daemon"])]
54    pub rollback: Option<String>,
55    /// With `--rollback`, reverse the N most recent rollback-log entries
56    /// instead of a single id.
57    #[arg(long)]
58    pub rollback_last: Option<usize>,
59    /// v0.7.0 L2-1 — Run the reflection-pass curator mode. Clusters
60    /// co-recalled Observations and synthesises typed Reflection
61    /// memories with `reflects_on` provenance. Mutually exclusive with
62    /// the sweep / rollback modes. Requires either `--namespace` or
63    /// `--all-namespaces`.
64    #[arg(long, conflicts_with_all = ["once", "daemon", "rollback", "rollback_last"])]
65    pub reflect: bool,
66    /// Scope the reflection pass to a single namespace. Pairs with
67    /// `--reflect`; ignored otherwise.
68    #[arg(long)]
69    pub namespace: Option<String>,
70    /// Curator-side reflection-depth ceiling. The substrate's per-
71    /// namespace `max_reflection_depth` policy is still enforced on
72    /// top — this flag refuses to *propose* reflections that would
73    /// exceed the operator-supplied cap so the curator never burns an
74    /// LLM round-trip on a doomed write.
75    #[arg(long)]
76    pub max_depth: Option<u32>,
77    /// Run the reflection pass over every observable namespace rather
78    /// than a single one. Per-namespace `reflection_pass.enabled`
79    /// flags still gate participation. Pairs with `--reflect`.
80    #[arg(long)]
81    pub all_namespaces: bool,
82    /// v0.7.0 #1548 — full SAL store URL. When set, the curator binds
83    /// its [`crate::store::MemoryStore`] handle to the URL-resolved
84    /// adapter instead of the SQLite path derived from `--db`, so the
85    /// reflection / consolidation passes run against a **Postgres**
86    /// (or SQLite) federated store. Mirrors `serve --store-url`.
87    ///
88    /// Accepted shapes:
89    ///
90    /// - `sqlite:///absolute/path/to/file.db` — SQLite adapter (same
91    ///   semantics as `--db`).
92    /// - `postgres://user:pass@host:port/dbname` — Postgres adapter.
93    /// - `postgresql://...` — alias for the Postgres scheme.
94    ///
95    /// `--db` and `--store-url` are mutually exclusive: passing both is
96    /// rejected at startup with a clear error (mirrors `serve`).
97    ///
98    /// Postgres-backed curators require `--features sal,sal-postgres`
99    /// at build time; otherwise the URL is rejected at startup.
100    #[cfg(feature = "sal")]
101    #[arg(long, value_name = "URL")]
102    pub store_url: Option<String>,
103}
104
105/// #1143: honor `AI_MEMORY_LLM_BACKEND` env so the `ai-memory curator`
106/// CLI (sweep / reflect / daemon modes) reaches xAI / OpenAI /
107/// Anthropic / Gemini / etc. The legacy arm preserves v0.6.x behavior
108/// (tier-default Ollama at the default URL). Returns `None` for the
109/// keyword tier (no curator LLM configured) and on env / construction
110/// failure — the curator falls through to keyword-only behavior so
111/// the daemon never hard-fails on an unreachable provider.
112fn build_curator_llm(tier: config::FeatureTier) -> Option<llm::OllamaClient> {
113    // v0.7.x (#1146) — route through the canonical resolver. Two
114    // short-circuits preserve pre-#1146 semantics:
115    //   1. Tiers with no `llm_model` preset (Keyword, Semantic) AND
116    //      no operator intent (env / config / legacy field absent —
117    //      resolver `source == CompiledDefault`) return None without
118    //      attempting client construction. Avoids paying a blocking
119    //      reqwest call to a (likely-absent) Ollama under tokio test
120    //      contexts and matches pre-#1146 v0.6.x behaviour.
121    //   2. With operator intent, the resolver folds CLI / env /
122    //      config / legacy / compiled through the uniform precedence
123    //      ladder.
124    let app_config = config::AppConfig::load();
125    let resolved = app_config.resolve_llm(None, None, None);
126    if matches!(resolved.source, config::ConfigSource::CompiledDefault)
127        && tier.config().llm_model.is_none()
128    {
129        return None;
130    }
131    llm::OllamaClient::build_from_resolved(&resolved)
132        .ok()
133        .flatten()
134}
135
136fn print_curator_report(r: &curator::CuratorReport, out: &mut CliOutput<'_>) -> Result<()> {
137    writeln!(out.stdout, "curator cycle report")?;
138    writeln!(out.stdout, "  started_at:        {}", r.started_at)?;
139    writeln!(out.stdout, "  completed_at:      {}", r.completed_at)?;
140    writeln!(out.stdout, "  duration_ms:       {}", r.cycle_duration_ms)?;
141    writeln!(out.stdout, "  memories_scanned:  {}", r.memories_scanned)?;
142    writeln!(out.stdout, "  memories_eligible: {}", r.memories_eligible)?;
143    writeln!(
144        out.stdout,
145        "  operations:        {}",
146        r.operations_attempted
147    )?;
148    writeln!(out.stdout, "  auto_tagged:       {}", r.auto_tagged)?;
149    writeln!(
150        out.stdout,
151        "  contradictions:    {}",
152        r.contradictions_found
153    )?;
154    writeln!(
155        out.stdout,
156        "  skipped (cap):     {}",
157        r.operations_skipped_cap
158    )?;
159    writeln!(out.stdout, "  errors:            {}", r.errors.len())?;
160    writeln!(out.stdout, "  dry_run:           {}", r.dry_run)?;
161    for e in &r.errors {
162        writeln!(out.stdout, "    - {e}")?;
163    }
164    Ok(())
165}
166
167/// `curator` handler. Daemon-mode delegates to `daemon_runtime`.
168pub async fn run(
169    db_path: &Path,
170    args: &CuratorArgs,
171    app_config: &config::AppConfig,
172    out: &mut CliOutput<'_>,
173) -> Result<()> {
174    if args.rollback.is_some() || args.rollback_last.is_some() {
175        return run_rollback(db_path, args, out);
176    }
177
178    if args.reflect {
179        return run_reflect(db_path, args, app_config, out).await;
180    }
181
182    if !args.once && !args.daemon {
183        anyhow::bail!(
184            "curator requires --once, --daemon, --reflect, --rollback <id>, or --rollback-last N"
185        );
186    }
187
188    // v0.7.0 #1548 — when `--store-url` selects a Postgres adapter, the
189    // `--once` / `--daemon` upkeep sweep runs against the federated
190    // store through the SAL `MemoryStore` trait (reflection-pass
191    // upkeep). The SQLite path keeps the full pre-#1548 conn-bound
192    // daemon (auto_tag + contradiction + autonomy + persona) for exact
193    // behaviour parity, since that subsystem is not yet trait-ported.
194    #[cfg(feature = "sal")]
195    if curator_store_url(args).is_some() {
196        return run_store_backed_sweep(db_path, args, app_config, out).await;
197    }
198
199    let cfg = curator::CuratorConfig {
200        interval_secs: args.interval_secs,
201        max_ops_per_cycle: args.max_ops,
202        dry_run: args.dry_run,
203        include_namespaces: args.include_namespaces.clone(),
204        exclude_namespaces: args.exclude_namespaces.clone(),
205        compaction: curator::CompactionConfig::default(),
206    };
207
208    let feature_tier = app_config.effective_tier(None);
209    let llm = build_curator_llm(feature_tier);
210
211    if args.once {
212        let conn = db::open(db_path)?;
213        let report = curator::run_once(&conn, llm.as_ref(), &cfg, None)?;
214        if args.json {
215            writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
216        } else {
217            print_curator_report(&report, out)?;
218        }
219        return Ok(());
220    }
221
222    // Daemon mode — delegate to daemon_runtime.
223    let shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
224    let shutdown_for_signal = shutdown.clone();
225    tokio::spawn(async move {
226        let _ = tokio::signal::ctrl_c().await;
227        shutdown_for_signal.notify_one();
228    });
229
230    // #1440 — hand the daemon the SAME resolver-built client the
231    // `--once` path uses (built at the top of `run` via
232    // `build_curator_llm`). The pre-#1440 code re-derived a model
233    // string from the tier default (`gemma4:e4b`) and injected it as a
234    // CLI-arm model override, clobbering the operator's configured
235    // `[llm].model` and 400-ing every call on non-Ollama backends.
236    crate::daemon_runtime::run_curator_daemon_with_primitives(
237        db_path.to_path_buf(),
238        args.interval_secs,
239        args.max_ops,
240        args.dry_run,
241        args.include_namespaces.clone(),
242        args.exclude_namespaces.clone(),
243        llm.map(std::sync::Arc::new),
244        shutdown,
245    )
246    .await
247}
248
249/// v0.7.0 #1548 — resolve the operator-supplied `--store-url` flag in
250/// a feature-flag-aware way (no env binding — the
251/// `AI_MEMORY_STORE_URL` env fallback was deliberately dropped in
252/// `1e8ad69b`). Returns `None`
253/// on builds without the `sal` feature (where the field does not exist)
254/// so the curator falls through to the legacy SQLite path.
255#[must_use]
256fn curator_store_url(args: &CuratorArgs) -> Option<&str> {
257    #[cfg(feature = "sal")]
258    {
259        args.store_url.as_deref()
260    }
261    #[cfg(not(feature = "sal"))]
262    {
263        let _ = args;
264        None
265    }
266}
267
268/// v0.7.0 #1548 — `--once` / `--daemon` upkeep against a SAL store
269/// (Postgres or SQLite by `--store-url` scheme). Runs the reflection
270/// pass over every operator-enabled namespace through the
271/// [`crate::store::MemoryStore`] trait, so a federated Postgres-backed
272/// curator performs the same recursive-refinement upkeep the SQLite
273/// daemon does via `run_reflection_pass`.
274///
275/// `--once` runs a single sweep and prints the report; `--daemon` loops
276/// every `--interval-secs` until SIGINT / SIGTERM, logging each cycle.
277///
278/// The reflection pass is LLM-backed; when no LLM client is configured
279/// the sweep returns a populated report carrying the configured-but-
280/// unreachable error (matching the `--reflect` no-LLM contract) rather
281/// than hard-failing the daemon.
282#[cfg(feature = "sal")]
283async fn run_store_backed_sweep(
284    db_path: &Path,
285    args: &CuratorArgs,
286    app_config: &config::AppConfig,
287    out: &mut CliOutput<'_>,
288) -> Result<()> {
289    let store =
290        crate::daemon_runtime::build_curator_store(curator_store_url(args), db_path, app_config)
291            .await?;
292
293    let keypair = load_curator_keypair_best_effort();
294    let feature_tier = app_config.effective_tier(None);
295    let llm = build_curator_llm(feature_tier);
296
297    if args.once {
298        let report = store_backed_reflection_sweep(
299            store.as_ref(),
300            llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
301            keypair.as_ref(),
302            args,
303        )
304        .await;
305        if args.json {
306            writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
307        } else {
308            print_reflection_report(&report, out)?;
309        }
310        return Ok(());
311    }
312
313    // Daemon mode — loop the SAL reflection sweep until shutdown.
314    // SIGINT / SIGTERM flip the shared shutdown flag; the loop checks it
315    // before each cycle and the `select!` wakes the interval sleep early.
316    let shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
317    let shutdown_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
318    let shutdown_for_signal = shutdown.clone();
319    let flag_for_signal = shutdown_flag.clone();
320    tokio::spawn(async move {
321        let _ = tokio::signal::ctrl_c().await;
322        flag_for_signal.store(true, std::sync::atomic::Ordering::Relaxed);
323        shutdown_for_signal.notify_one();
324    });
325
326    // Clamp the interval to the same [60, 86400] band the SQLite
327    // `curator::run_daemon` loop enforces, so a stray small / huge value
328    // can't busy-spin or stall the federated upkeep sweep.
329    let interval_secs = args.interval_secs.clamp(60, crate::SECS_PER_DAY as u64);
330    tracing::info!(
331        "curator SAL daemon started (store-url backend, interval={interval_secs}s, \
332         max_ops={}, dry_run={})",
333        args.max_ops,
334        args.dry_run,
335    );
336
337    while !shutdown_flag.load(std::sync::atomic::Ordering::Relaxed) {
338        let report = store_backed_reflection_sweep(
339            store.as_ref(),
340            llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
341            keypair.as_ref(),
342            args,
343        )
344        .await;
345        tracing::info!(
346            "curator SAL cycle: namespaces={} observations={} clusters_eligible={} \
347             reflections_persisted={} depth_refusals={} errors={} (dry_run={})",
348            report.namespaces_visited,
349            report.observations_scanned,
350            report.clusters_eligible,
351            report.reflections_persisted,
352            report.depth_refusals,
353            report.errors.len(),
354            report.dry_run,
355        );
356
357        // Sleep the interval, waking early on shutdown.
358        tokio::select! {
359            () = tokio::time::sleep(std::time::Duration::from_secs(interval_secs)) => {}
360            () = shutdown.notified() => break,
361        }
362    }
363
364    tracing::info!("curator SAL daemon shutdown");
365    Ok(())
366}
367
368/// Run a single reflection-pass sweep over a SAL store. Shared by the
369/// `--once` and `--daemon` arms of [`run_store_backed_sweep`]. Returns
370/// a populated [`reflection_pass::ReflectionPassReport`] regardless of
371/// outcome — an unreachable LLM is surfaced as a report error, not a
372/// propagated failure, so the daemon loop never aborts on a transient
373/// provider outage.
374///
375/// All non-reserved namespaces are swept (the `--daemon` upkeep
376/// contract); per-namespace `reflection_pass.enabled` config-file
377/// gating is a v0.7.1 follow-up, identical to the `--reflect
378/// --all-namespaces` posture.
379#[cfg(feature = "sal")]
380async fn store_backed_reflection_sweep(
381    store: &dyn crate::store::MemoryStore,
382    llm: Option<&dyn crate::autonomy::AutonomyLlm>,
383    keypair: Option<&identity_keypair::AgentKeypair>,
384    args: &CuratorArgs,
385) -> reflection_pass::ReflectionPassReport {
386    // Upkeep mode sweeps every non-reserved namespace (matching the
387    // `--all-namespaces` reflection contract).
388    run_reflection_pass_with_optional_llm(
389        store,
390        llm,
391        keypair,
392        None,
393        args.max_depth,
394        args.dry_run,
395        |_ns: &str| true,
396    )
397    .await
398}
399
400/// v0.7.0 #1548 — run the reflection pass over a SAL store with an
401/// OPTIONAL LLM, folding any pass error into the returned report rather
402/// than propagating it (so a daemon sweep never aborts on a transient
403/// provider outage); when `llm` is `None` the report carries the
404/// no-LLM-configured error. Shared by [`run_reflect`] +
405/// [`store_backed_reflection_sweep`]; taking `&dyn AutonomyLlm` lets the
406/// unit tests drive it with a deterministic stub instead of a live
407/// Ollama (which `build_curator_llm` cannot construct in CI).
408#[cfg(feature = "sal")]
409async fn run_reflection_pass_with_optional_llm(
410    store: &dyn crate::store::MemoryStore,
411    llm: Option<&dyn crate::autonomy::AutonomyLlm>,
412    keypair: Option<&identity_keypair::AgentKeypair>,
413    namespace: Option<&str>,
414    max_depth: Option<u32>,
415    dry_run: bool,
416    enabled_check: impl Fn(&str) -> bool,
417) -> reflection_pass::ReflectionPassReport {
418    let stamp = || chrono::Utc::now().to_rfc3339();
419    let Some(llm_client) = llm else {
420        let mut empty = reflection_pass::ReflectionPassReport {
421            started_at: stamp(),
422            completed_at: stamp(),
423            dry_run,
424            ..Default::default()
425        };
426        empty.errors.push(
427            "no LLM client configured — set a feature tier that provides an llm_model".into(),
428        );
429        return empty;
430    };
431    match reflection_pass::run_reflection_pass(
432        store,
433        llm_client,
434        keypair,
435        namespace,
436        max_depth,
437        dry_run,
438        enabled_check,
439    )
440    .await
441    {
442        Ok(report) => report,
443        Err(e) => {
444            let mut report = reflection_pass::ReflectionPassReport {
445                started_at: stamp(),
446                completed_at: stamp(),
447                dry_run,
448                ..Default::default()
449            };
450            report.errors.push(format!("reflection pass failed: {e}"));
451            report
452        }
453    }
454}
455
456/// v0.7.0 L2-1 — reflection-pass entry point. Wires the operator's
457/// CLI flags to [`reflection_pass::run_reflection_pass`] and prints
458/// the structured report.
459///
460/// Per #666 acceptance:
461///
462/// * `--namespace foo` runs the pass on one namespace; `--all-
463///   namespaces` enumerates every observable namespace.
464/// * Per-namespace `reflection_pass.enabled` config gates which
465///   namespaces actually run (defaults to `false`). The CLI does NOT
466///   load the per-namespace config from `ai-memory.toml` yet — that's
467///   a v0.7.1 follow-up; for now, the operator-supplied
468///   `--namespace` is treated as "operator opted in for this run"
469///   so a single-namespace invocation always proceeds. The
470///   `--all-namespaces` path applies the strict `enabled` gate (no
471///   external config loaded → no namespaces enabled → zero rows
472///   written), which is the safe default until the config-file
473///   wiring lands.
474/// * `--dry-run` reports proposed clusters without writing anything.
475/// * `--max-depth` is the curator-side guard rail on top of the
476///   substrate's per-namespace policy cap.
477///
478/// v0.7.0 #1548 — the reflection pass operates over the SAL
479/// [`crate::store::MemoryStore`] trait, which is `sal`-gated. The
480/// `not(sal)` variant below returns a clear capability error so a
481/// binary built without `--features sal` fails loudly rather than
482/// silently dropping `--reflect`.
483#[cfg(feature = "sal")]
484async fn run_reflect(
485    db_path: &Path,
486    args: &CuratorArgs,
487    app_config: &config::AppConfig,
488    out: &mut CliOutput<'_>,
489) -> Result<()> {
490    if args.namespace.is_none() && !args.all_namespaces {
491        anyhow::bail!("--reflect requires either --namespace <ns> or --all-namespaces");
492    }
493    if args.namespace.is_some() && args.all_namespaces {
494        anyhow::bail!("--reflect: --namespace and --all-namespaces are mutually exclusive");
495    }
496
497    // v0.7.0 #1548 — resolve the SAL store handle from `--store-url`
498    // (Postgres or SQLite) when supplied, else a SQLite store at the
499    // `--db` path. The reflection pass operates over the
500    // `MemoryStore` trait so `--reflect` works against a federated
501    // Postgres store identically to the local SQLite path.
502    let store = build_reflect_store(db_path, args, app_config).await?;
503
504    // Resolve the curator's signing keypair. We rely on the
505    // process-wide identity (the same one `serve` uses) so every
506    // `reflects_on` edge attributes to the daemon's Ed25519 identity.
507    // When no keypair is configured (operator opted out via
508    // `[identity].disabled = true` or runs a one-off `--reflect`
509    // against a fresh data dir) the pass falls back to `"ai:curator"`
510    // — same fall-back the autonomy `consolidate` path uses.
511    let keypair = load_curator_keypair_best_effort();
512
513    let feature_tier = app_config.effective_tier(None);
514    let llm = build_curator_llm(feature_tier);
515
516    // Single-namespace invocations bypass the per-namespace `enabled`
517    // gate (operator explicitly asked). `--all-namespaces` defers to
518    // the gate predicate, which conservatively returns `false` for
519    // every namespace until the per-namespace config-file wiring
520    // lands (v0.7.1). Operators who want to fan out today can script
521    // a loop of `--namespace <each>` invocations.
522    let scope_single = args.namespace.is_some();
523    let enabled_check = |_ns: &str| -> bool { scope_single };
524
525    let report = run_reflection_pass_with_optional_llm(
526        store.as_ref(),
527        llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
528        keypair.as_ref(),
529        args.namespace.as_deref(),
530        args.max_depth,
531        args.dry_run,
532        enabled_check,
533    )
534    .await;
535
536    if args.json {
537        writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
538    } else {
539        print_reflection_report(&report, out)?;
540    }
541    Ok(())
542}
543
544/// `not(sal)` companion of [`run_reflect`]. The reflection pass requires
545/// the SAL `MemoryStore` trait, which is `sal`-gated; a binary built
546/// without `--features sal` cannot run `--reflect`, so surface a clear
547/// capability error rather than silently dropping the mode.
548#[cfg(not(feature = "sal"))]
549#[allow(clippy::unused_async)]
550async fn run_reflect(
551    _db_path: &Path,
552    _args: &CuratorArgs,
553    _app_config: &config::AppConfig,
554    _out: &mut CliOutput<'_>,
555) -> Result<()> {
556    anyhow::bail!(
557        "curator --reflect requires a binary built with --features sal \
558         (the reflection pass operates over the SAL MemoryStore trait)"
559    )
560}
561
562/// v0.7.0 #1548 — resolve the SAL store handle for the `--reflect` mode.
563/// Mirrors [`run_store_backed_sweep`]'s builder: binds to the
564/// `--store-url` adapter (Postgres or SQLite) when supplied, else a
565/// SQLite store at the `--db` path.
566#[cfg(feature = "sal")]
567async fn build_reflect_store(
568    db_path: &Path,
569    args: &CuratorArgs,
570    app_config: &config::AppConfig,
571) -> Result<std::sync::Arc<dyn crate::store::MemoryStore>> {
572    crate::daemon_runtime::build_curator_store(curator_store_url(args), db_path, app_config).await
573}
574
575/// Load the curator's per-process signing keypair. Best-effort — if the
576/// keypair file is missing or unreadable we return `None` and the pass
577/// stamps `ai:curator` as `agent_id`. Errors are deliberately not
578/// surfaced; an operator who wants a strict-mode "fail if keypair
579/// missing" can run `ai-memory identity list` first.
580#[cfg_attr(not(feature = "sal"), allow(dead_code))]
581fn load_curator_keypair_best_effort() -> Option<identity_keypair::AgentKeypair> {
582    let dir = identity_keypair::default_key_dir().ok()?;
583    // We don't know which agent_id the operator wants the curator to
584    // run as. Pick the lexicographically-first key under the key dir;
585    // operators who run multiple curators on the same host should
586    // either give each a dedicated key dir via `AI_MEMORY_KEY_DIR` or
587    // set the daemon `AI_MEMORY_AGENT_ID` env var.
588    let listed = identity_keypair::list(&dir).ok()?;
589    let first = listed.into_iter().next()?;
590    identity_keypair::load(&first.agent_id, &dir).ok()
591}
592
593#[cfg_attr(not(feature = "sal"), allow(dead_code))]
594fn print_reflection_report(
595    r: &reflection_pass::ReflectionPassReport,
596    out: &mut CliOutput<'_>,
597) -> Result<()> {
598    writeln!(out.stdout, "reflection pass report")?;
599    writeln!(out.stdout, "  started_at:            {}", r.started_at)?;
600    writeln!(out.stdout, "  completed_at:          {}", r.completed_at)?;
601    writeln!(
602        out.stdout,
603        "  namespaces_visited:    {}",
604        r.namespaces_visited
605    )?;
606    writeln!(
607        out.stdout,
608        "  observations_scanned:  {}",
609        r.observations_scanned
610    )?;
611    writeln!(out.stdout, "  clusters_formed:       {}", r.clusters_formed)?;
612    writeln!(
613        out.stdout,
614        "  clusters_eligible:     {}",
615        r.clusters_eligible
616    )?;
617    writeln!(
618        out.stdout,
619        "  reflections_persisted: {}",
620        r.reflections_persisted
621    )?;
622    writeln!(out.stdout, "  depth_refusals:        {}", r.depth_refusals)?;
623    writeln!(out.stdout, "  errors:                {}", r.errors.len())?;
624    writeln!(out.stdout, "  dry_run:               {}", r.dry_run)?;
625    for e in &r.errors {
626        writeln!(out.stdout, "    - {e}")?;
627    }
628    for prop in &r.dry_run_proposals {
629        writeln!(
630            out.stdout,
631            "  proposal: ns='{}' title='{}' sources={}",
632            prop.namespace,
633            prop.proposed_title,
634            prop.source_ids.len()
635        )?;
636    }
637    Ok(())
638}
639
640fn run_rollback(db_path: &Path, args: &CuratorArgs, out: &mut CliOutput<'_>) -> Result<()> {
641    let conn = db::open(db_path)?;
642
643    if let Some(id) = &args.rollback {
644        let Some(mem) = db::get(&conn, id)? else {
645            anyhow::bail!("rollback entry {id} not found");
646        };
647        let entry: autonomy::RollbackEntry = serde_json::from_str(&mem.content)
648            .context("rollback entry content is not a valid RollbackEntry JSON")?;
649        let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
650        let mut tags = mem.tags.clone();
651        if !tags.iter().any(|t| t == "_reversed") {
652            tags.push("_reversed".to_string());
653            db::update(
654                &conn,
655                &mem.id,
656                None,
657                None,
658                None,
659                None,
660                Some(&tags),
661                None,
662                None,
663                None,
664                None,
665            )?;
666        }
667        writeln!(
668            out.stdout,
669            "rollback {id}: {}",
670            if applied { "applied" } else { "no-op" }
671        )?;
672        return Ok(());
673    }
674
675    if let Some(n) = args.rollback_last {
676        let log = db::list(
677            &conn,
678            Some("_curator/rollback"),
679            None,
680            n.max(1),
681            0,
682            None,
683            None,
684            None,
685            None,
686            None,
687        )?;
688        let mut reversed = 0usize;
689        for mem in &log {
690            if mem.tags.iter().any(|t| t == "_reversed") {
691                continue;
692            }
693            let Ok(entry) = serde_json::from_str::<autonomy::RollbackEntry>(&mem.content) else {
694                continue;
695            };
696            let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
697            if applied {
698                reversed += 1;
699                let mut tags = mem.tags.clone();
700                tags.push("_reversed".to_string());
701                db::update(
702                    &conn,
703                    &mem.id,
704                    None,
705                    None,
706                    None,
707                    None,
708                    Some(&tags),
709                    None,
710                    None,
711                    None,
712                    None,
713                )?;
714            }
715        }
716        writeln!(out.stdout, "reversed {reversed} rollback entries")?;
717        return Ok(());
718    }
719
720    // QUAL-2 (med/low review batch) — typed error instead of `unreachable!()`.
721    // The caller-side guard at `cmd_curator` (line ~147) already short-circuits
722    // when neither `--rollback` nor `--rollback-last` is set; this branch is
723    // reachable only if that guard ever regresses. Returning an `anyhow::Error`
724    // preserves the audit message but keeps the failure recoverable (typed
725    // CLI exit code) instead of crashing the process with a panic.
726    anyhow::bail!("run_rollback entered without --rollback or --rollback-last");
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crate::cli::test_utils::TestEnv;
733
734    fn default_args() -> CuratorArgs {
735        CuratorArgs {
736            once: false,
737            daemon: false,
738            interval_secs: crate::SECS_PER_HOUR as u64,
739            max_ops: 100,
740            dry_run: false,
741            include_namespaces: Vec::new(),
742            exclude_namespaces: Vec::new(),
743            json: false,
744            rollback: None,
745            rollback_last: None,
746            reflect: false,
747            namespace: None,
748            max_depth: None,
749            all_namespaces: false,
750            #[cfg(feature = "sal")]
751            store_url: None,
752        }
753    }
754
755    #[tokio::test]
756    async fn test_curator_requires_mode() {
757        let mut env = TestEnv::fresh();
758        let db = env.db_path.clone();
759        let cfg = config::AppConfig::default();
760        let args = default_args();
761        let mut out = env.output();
762        let res = run(&db, &args, &cfg, &mut out).await;
763        assert!(res.is_err());
764        assert!(
765            res.unwrap_err()
766                .to_string()
767                .contains("--once, --daemon, --reflect")
768        );
769    }
770
771    #[tokio::test]
772    async fn test_curator_once_runs_single_sweep_text() {
773        let mut env = TestEnv::fresh();
774        let db = env.db_path.clone();
775        let cfg = config::AppConfig::default();
776        let mut args = default_args();
777        args.once = true;
778        args.dry_run = true;
779        {
780            let mut out = env.output();
781            run(&db, &args, &cfg, &mut out).await.unwrap();
782        }
783        assert!(env.stdout_str().contains("curator cycle report"));
784    }
785
786    #[tokio::test]
787    async fn test_curator_once_json_format() {
788        let mut env = TestEnv::fresh();
789        let db = env.db_path.clone();
790        let cfg = config::AppConfig::default();
791        let mut args = default_args();
792        args.once = true;
793        args.json = true;
794        args.dry_run = true;
795        {
796            let mut out = env.output();
797            run(&db, &args, &cfg, &mut out).await.unwrap();
798        }
799        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
800        assert!(v["dry_run"].as_bool().unwrap());
801    }
802
803    #[tokio::test]
804    async fn test_curator_dry_run_skips_writes() {
805        let mut env = TestEnv::fresh();
806        let db = env.db_path.clone();
807        let cfg = config::AppConfig::default();
808        let mut args = default_args();
809        args.once = true;
810        args.dry_run = true;
811        {
812            let mut out = env.output();
813            run(&db, &args, &cfg, &mut out).await.unwrap();
814        }
815        // Report mentions dry_run flag.
816        let s = env.stdout_str();
817        assert!(s.contains("dry_run:") || s.contains("\"dry_run\""));
818    }
819
820    #[tokio::test]
821    async fn test_curator_include_namespaces_filter() {
822        let mut env = TestEnv::fresh();
823        let db = env.db_path.clone();
824        let cfg = config::AppConfig::default();
825        let mut args = default_args();
826        args.once = true;
827        args.dry_run = true;
828        args.include_namespaces = vec!["only-this-ns".to_string()];
829        {
830            let mut out = env.output();
831            run(&db, &args, &cfg, &mut out).await.unwrap();
832        }
833        // No memories — operations attempted should be 0.
834        assert!(env.stdout_str().contains("operations:"));
835    }
836
837    #[tokio::test]
838    async fn test_curator_exclude_namespaces_filter() {
839        let mut env = TestEnv::fresh();
840        let db = env.db_path.clone();
841        let cfg = config::AppConfig::default();
842        let mut args = default_args();
843        args.once = true;
844        args.dry_run = true;
845        args.exclude_namespaces = vec!["skip-me".to_string()];
846        {
847            let mut out = env.output();
848            run(&db, &args, &cfg, &mut out).await.unwrap();
849        }
850        assert!(env.stdout_str().contains("curator cycle report"));
851    }
852
853    #[tokio::test]
854    async fn test_curator_max_ops_cap_respected() {
855        let mut env = TestEnv::fresh();
856        let db = env.db_path.clone();
857        let cfg = config::AppConfig::default();
858        let mut args = default_args();
859        args.once = true;
860        args.dry_run = true;
861        args.max_ops = 0; // immediately at cap
862        {
863            let mut out = env.output();
864            run(&db, &args, &cfg, &mut out).await.unwrap();
865        }
866        assert!(env.stdout_str().contains("operations:"));
867    }
868
869    #[tokio::test]
870    async fn test_curator_rollback_id_not_found() {
871        let mut env = TestEnv::fresh();
872        let db = env.db_path.clone();
873        let cfg = config::AppConfig::default();
874        let mut args = default_args();
875        args.rollback = Some("00000000-0000-0000-0000-000000000000".to_string());
876        let mut out = env.output();
877        let res = run(&db, &args, &cfg, &mut out).await;
878        assert!(res.is_err());
879        assert!(res.unwrap_err().to_string().contains("rollback entry"));
880    }
881
882    #[tokio::test]
883    async fn test_curator_rollback_last_zero_entries() {
884        let mut env = TestEnv::fresh();
885        let db = env.db_path.clone();
886        let cfg = config::AppConfig::default();
887        let mut args = default_args();
888        args.rollback_last = Some(5);
889        {
890            let mut out = env.output();
891            run(&db, &args, &cfg, &mut out).await.unwrap();
892        }
893        // No rollback log entries; should report 0.
894        assert!(env.stdout_str().contains("reversed 0"));
895    }
896
897    // PR-9i — buffer coverage uplift. Targets run_rollback() — rollback
898    // path with valid PriorityAdjust entry, rollback_last with both
899    // applied & malformed JSON entries (skip branch), already-reversed
900    // skip branch.
901
902    fn build_priority_rollback_entry_json(memory_id: &str, before: i32, after: i32) -> String {
903        // Serialize as the externally-tagged enum form `autonomy::RollbackEntry`
904        // uses (the Rust default).
905        serde_json::to_string(&autonomy::RollbackEntry::PriorityAdjust {
906            memory_id: memory_id.to_string(),
907            before,
908            after,
909        })
910        .unwrap()
911    }
912
913    fn seed_rollback_entry(db_path: &std::path::Path, content: &str) -> String {
914        // Insert a memory in the _curator/rollback namespace whose content
915        // is a serialized RollbackEntry. Returns the inserted id.
916        let conn = db::open(db_path).expect("db::open");
917        let now = chrono::Utc::now().to_rfc3339();
918        let mut metadata = crate::models::default_metadata();
919        if let Some(obj) = metadata.as_object_mut() {
920            obj.insert(
921                "agent_id".to_string(),
922                serde_json::Value::String("test-agent".to_string()),
923            );
924        }
925        let mem = crate::models::Memory {
926            id: uuid::Uuid::new_v4().to_string(),
927            tier: crate::models::Tier::Mid,
928            namespace: "_curator/rollback".to_string(),
929            title: format!("rollback-{}", uuid::Uuid::new_v4()),
930            content: content.to_string(),
931            tags: vec![],
932            priority: 5,
933            confidence: 1.0,
934            source: "test".to_string(),
935            access_count: 0,
936            created_at: now.clone(),
937            updated_at: now,
938            last_accessed_at: None,
939            expires_at: None,
940            metadata,
941            reflection_depth: 0,
942            memory_kind: crate::models::MemoryKind::Observation,
943            entity_id: None,
944            persona_version: None,
945            citations: Vec::new(),
946            source_uri: None,
947            source_span: None,
948            confidence_source: crate::models::ConfidenceSource::CallerProvided,
949            confidence_signals: None,
950            confidence_decayed_at: None,
951            version: 1,
952        };
953        db::insert(&conn, &mem).expect("db::insert")
954    }
955
956    #[tokio::test]
957    async fn pr9i_curator_rollback_priority_adjust_applies() {
958        // Seed a real memory whose priority we'll roll back from 7→3.
959        let mut env = TestEnv::fresh();
960        let db = env.db_path.clone();
961        let cfg = config::AppConfig::default();
962
963        // 1. Seed a target memory at priority=7.
964        let target = {
965            let conn = db::open(&db).unwrap();
966            let now = chrono::Utc::now().to_rfc3339();
967            let mut metadata = crate::models::default_metadata();
968            if let Some(obj) = metadata.as_object_mut() {
969                obj.insert(
970                    "agent_id".to_string(),
971                    serde_json::Value::String("test-agent".to_string()),
972                );
973            }
974            let mem = crate::models::Memory {
975                id: uuid::Uuid::new_v4().to_string(),
976                tier: crate::models::Tier::Mid,
977                namespace: "ns".to_string(),
978                title: "target".to_string(),
979                content: "c".to_string(),
980                tags: vec![],
981                priority: 7,
982                confidence: 1.0,
983                source: "test".to_string(),
984                access_count: 0,
985                created_at: now.clone(),
986                updated_at: now,
987                last_accessed_at: None,
988                expires_at: None,
989                metadata,
990                reflection_depth: 0,
991                memory_kind: crate::models::MemoryKind::Observation,
992                entity_id: None,
993                persona_version: None,
994                citations: Vec::new(),
995                source_uri: None,
996                source_span: None,
997                confidence_source: crate::models::ConfidenceSource::CallerProvided,
998                confidence_signals: None,
999                confidence_decayed_at: None,
1000                version: 1,
1001            };
1002            db::insert(&conn, &mem).unwrap()
1003        };
1004
1005        // 2. Seed a rollback entry that says "revert priority to 3".
1006        let entry_json = build_priority_rollback_entry_json(&target, 3, 7);
1007        let entry_id = seed_rollback_entry(&db, &entry_json);
1008
1009        // 3. Run rollback by id.
1010        let mut args = default_args();
1011        args.rollback = Some(entry_id.clone());
1012        {
1013            let mut out = env.output();
1014            run(&db, &args, &cfg, &mut out).await.unwrap();
1015        }
1016        // Stdout reports rollback applied.
1017        let s = env.stdout_str();
1018        assert!(s.contains(&format!("rollback {entry_id}")));
1019        assert!(s.contains("applied"));
1020
1021        // The target's priority must now be 3.
1022        let conn = db::open(&db).unwrap();
1023        let target_mem = db::get(&conn, &target).unwrap().unwrap();
1024        assert_eq!(target_mem.priority, 3);
1025
1026        // The rollback entry must be tagged _reversed.
1027        let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1028        assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1029    }
1030
1031    #[tokio::test]
1032    async fn pr9i_curator_rollback_last_processes_multiple() {
1033        let mut env = TestEnv::fresh();
1034        let db = env.db_path.clone();
1035        let cfg = config::AppConfig::default();
1036
1037        // Seed two targets.
1038        let t1;
1039        let t2;
1040        {
1041            let conn = db::open(&db).unwrap();
1042            let now = chrono::Utc::now().to_rfc3339();
1043            let mut metadata = crate::models::default_metadata();
1044            if let Some(obj) = metadata.as_object_mut() {
1045                obj.insert(
1046                    "agent_id".to_string(),
1047                    serde_json::Value::String("test-agent".to_string()),
1048                );
1049            }
1050            let m1 = crate::models::Memory {
1051                id: uuid::Uuid::new_v4().to_string(),
1052                tier: crate::models::Tier::Mid,
1053                namespace: "ns".to_string(),
1054                title: "t1".to_string(),
1055                content: "c1".to_string(),
1056                tags: vec![],
1057                priority: 8,
1058                confidence: 1.0,
1059                source: "test".to_string(),
1060                access_count: 0,
1061                created_at: now.clone(),
1062                updated_at: now.clone(),
1063                last_accessed_at: None,
1064                expires_at: None,
1065                metadata: metadata.clone(),
1066                reflection_depth: 0,
1067                memory_kind: crate::models::MemoryKind::Observation,
1068                entity_id: None,
1069                persona_version: None,
1070                citations: Vec::new(),
1071                source_uri: None,
1072                source_span: None,
1073                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1074                confidence_signals: None,
1075                confidence_decayed_at: None,
1076                version: 1,
1077            };
1078            let m2 = crate::models::Memory {
1079                id: uuid::Uuid::new_v4().to_string(),
1080                tier: crate::models::Tier::Mid,
1081                namespace: "ns".to_string(),
1082                title: "t2".to_string(),
1083                content: "c2".to_string(),
1084                tags: vec![],
1085                priority: 9,
1086                confidence: 1.0,
1087                source: "test".to_string(),
1088                access_count: 0,
1089                created_at: now.clone(),
1090                updated_at: now,
1091                last_accessed_at: None,
1092                expires_at: None,
1093                metadata,
1094                reflection_depth: 0,
1095                memory_kind: crate::models::MemoryKind::Observation,
1096                entity_id: None,
1097                persona_version: None,
1098                citations: Vec::new(),
1099                source_uri: None,
1100                source_span: None,
1101                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1102                confidence_signals: None,
1103                confidence_decayed_at: None,
1104                version: 1,
1105            };
1106            t1 = db::insert(&conn, &m1).unwrap();
1107            t2 = db::insert(&conn, &m2).unwrap();
1108        }
1109
1110        // Seed two rollback entries plus one malformed JSON entry.
1111        seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t1, 4, 8));
1112        seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t2, 5, 9));
1113        seed_rollback_entry(&db, "{not valid json: at all"); // malformed → skip branch
1114
1115        // Run rollback_last 5 (caps at actual count).
1116        let mut args = default_args();
1117        args.rollback_last = Some(5);
1118        {
1119            let mut out = env.output();
1120            run(&db, &args, &cfg, &mut out).await.unwrap();
1121        }
1122        // Reverses 2 entries (the malformed one is skipped).
1123        let s = env.stdout_str();
1124        assert!(s.contains("reversed 2"));
1125
1126        // Both targets reverted.
1127        let conn = db::open(&db).unwrap();
1128        assert_eq!(db::get(&conn, &t1).unwrap().unwrap().priority, 4);
1129        assert_eq!(db::get(&conn, &t2).unwrap().unwrap().priority, 5);
1130    }
1131
1132    #[tokio::test]
1133    async fn pr9i_curator_rollback_last_skips_already_reversed() {
1134        // Seed a rollback entry pre-tagged as _reversed; rollback_last must
1135        // skip it (lines 203-205).
1136        let mut env = TestEnv::fresh();
1137        let db = env.db_path.clone();
1138        let cfg = config::AppConfig::default();
1139
1140        // Seed a target.
1141        let target;
1142        {
1143            let conn = db::open(&db).unwrap();
1144            let now = chrono::Utc::now().to_rfc3339();
1145            let mut metadata = crate::models::default_metadata();
1146            if let Some(obj) = metadata.as_object_mut() {
1147                obj.insert(
1148                    "agent_id".to_string(),
1149                    serde_json::Value::String("test-agent".to_string()),
1150                );
1151            }
1152            let mem = crate::models::Memory {
1153                id: uuid::Uuid::new_v4().to_string(),
1154                tier: crate::models::Tier::Mid,
1155                namespace: "ns".to_string(),
1156                title: "x".to_string(),
1157                content: "c".to_string(),
1158                tags: vec![],
1159                priority: 7,
1160                confidence: 1.0,
1161                source: "test".to_string(),
1162                access_count: 0,
1163                created_at: now.clone(),
1164                updated_at: now,
1165                last_accessed_at: None,
1166                expires_at: None,
1167                metadata,
1168                reflection_depth: 0,
1169                memory_kind: crate::models::MemoryKind::Observation,
1170                entity_id: None,
1171                persona_version: None,
1172                citations: Vec::new(),
1173                source_uri: None,
1174                source_span: None,
1175                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1176                confidence_signals: None,
1177                confidence_decayed_at: None,
1178                version: 1,
1179            };
1180            target = db::insert(&conn, &mem).unwrap();
1181        }
1182
1183        // Insert a rollback entry already tagged _reversed.
1184        let entry_json = build_priority_rollback_entry_json(&target, 2, 7);
1185        let entry_id;
1186        {
1187            let conn = db::open(&db).unwrap();
1188            let now = chrono::Utc::now().to_rfc3339();
1189            let mut metadata = crate::models::default_metadata();
1190            if let Some(obj) = metadata.as_object_mut() {
1191                obj.insert(
1192                    "agent_id".to_string(),
1193                    serde_json::Value::String("test-agent".to_string()),
1194                );
1195            }
1196            let mem = crate::models::Memory {
1197                id: uuid::Uuid::new_v4().to_string(),
1198                tier: crate::models::Tier::Mid,
1199                namespace: "_curator/rollback".to_string(),
1200                title: "preexisting-reversed".to_string(),
1201                content: entry_json,
1202                tags: vec!["_reversed".to_string()],
1203                priority: 5,
1204                confidence: 1.0,
1205                source: "test".to_string(),
1206                access_count: 0,
1207                created_at: now.clone(),
1208                updated_at: now,
1209                last_accessed_at: None,
1210                expires_at: None,
1211                metadata,
1212                reflection_depth: 0,
1213                memory_kind: crate::models::MemoryKind::Observation,
1214                entity_id: None,
1215                persona_version: None,
1216                citations: Vec::new(),
1217                source_uri: None,
1218                source_span: None,
1219                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1220                confidence_signals: None,
1221                confidence_decayed_at: None,
1222                version: 1,
1223            };
1224            entry_id = db::insert(&conn, &mem).unwrap();
1225        }
1226
1227        let mut args = default_args();
1228        args.rollback_last = Some(5);
1229        {
1230            let mut out = env.output();
1231            run(&db, &args, &cfg, &mut out).await.unwrap();
1232        }
1233        // Already-reversed entry is skipped → reversed 0.
1234        let s = env.stdout_str();
1235        assert!(s.contains("reversed 0"));
1236
1237        // Target's priority is unchanged from 7.
1238        let conn = db::open(&db).unwrap();
1239        assert_eq!(db::get(&conn, &target).unwrap().unwrap().priority, 7);
1240        // Sanity: entry_id memory still tagged _reversed.
1241        let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1242        assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1243    }
1244
1245    #[tokio::test]
1246    async fn pr9i_curator_rollback_id_with_malformed_content() {
1247        // Seed a memory in _curator/rollback whose content is NOT a valid
1248        // RollbackEntry — the explicit-id rollback path bails (lines 160-161).
1249        let mut env = TestEnv::fresh();
1250        let db = env.db_path.clone();
1251        let cfg = config::AppConfig::default();
1252        let entry_id = seed_rollback_entry(&db, "{invalid json");
1253
1254        let mut args = default_args();
1255        args.rollback = Some(entry_id);
1256        let mut out = env.output();
1257        let res = run(&db, &args, &cfg, &mut out).await;
1258        assert!(res.is_err());
1259        let err = res.unwrap_err().to_string();
1260        assert!(
1261            err.contains("rollback") || err.contains("RollbackEntry"),
1262            "expected parse-error message, got: {err}"
1263        );
1264    }
1265
1266    // ---------- E1 coverage uplift -----------------------------------
1267    // Targets: build_curator_llm body (smart/autonomous tier branch),
1268    // print_curator_report error-list iteration, --once with errors
1269    // present.
1270
1271    #[test]
1272    fn build_curator_llm_with_keyword_tier_returns_none() {
1273        // Keyword tier has no llm_model — the function returns None
1274        // BEFORE entering the body. Sanity check.
1275        //
1276        // TEST-5 — pin `AI_MEMORY_NO_CONFIG=1` so `AppConfig::load()`
1277        // returns `Default::default()` instead of reading the
1278        // developer's `~/.config/ai-memory/config.toml` (which would
1279        // resolve a non-default `[llm]` stanza and cause this
1280        // assertion to fail).
1281        crate::cli::test_utils::ensure_no_config_env();
1282        let result = build_curator_llm(config::FeatureTier::Keyword);
1283        assert!(result.is_none());
1284    }
1285
1286    #[test]
1287    fn build_curator_llm_with_smart_tier_runs_body() {
1288        // Smart tier has llm_model = Some(_), so the body executes the
1289        // `let model = ...` + `OllamaClient::new(&model).ok()` lines.
1290        // In hermetic tests Ollama is unreachable, so the result is
1291        // None — but the body lines are now covered.
1292        //
1293        // TEST-5 — pin `AI_MEMORY_NO_CONFIG=1` so the resolver always
1294        // returns the Ollama compiled default rather than reading the
1295        // host's user-config-resolved backend.
1296        crate::cli::test_utils::ensure_no_config_env();
1297        let _ = build_curator_llm(config::FeatureTier::Smart);
1298        // No assertion on the value; the test exercises lines 55-56.
1299    }
1300
1301    // Unix-only — the test self-fires `libc::kill(getpid, SIGINT)` to
1302    // exercise the ctrl_c shutdown path. The libc crate's `getpid` /
1303    // `kill` / `SIGINT` symbols are not available on Windows, where
1304    // signal handling uses a different surface entirely. The daemon
1305    // shutdown path itself is cross-platform (tokio::signal::ctrl_c
1306    // works on Windows); only the self-fire test mechanism is
1307    // POSIX-bound.
1308    #[cfg(unix)]
1309    #[tokio::test(flavor = "multi_thread")]
1310    async fn curator_daemon_mode_short_loop_returns_on_shutdown() {
1311        // Drives lines 128-150 — daemon mode entry. We fire SIGINT to
1312        // ourselves after a short delay so the ctrl_c spawn notifies
1313        // shutdown, the AtomicBool flag flips, and `run_daemon`'s loop
1314        // exits at its next check. The blocking task joins and the
1315        // outer `await` returns.
1316        //
1317        // We do NOT install our own signal handler — tokio's signal
1318        // registry consumes the single SIGINT before any default
1319        // handler trips. This test runs under multi_thread so the
1320        // ctrl_c watcher can fire on a separate worker.
1321        use std::path::PathBuf;
1322        let env = TestEnv::fresh();
1323        let db: PathBuf = env.db_path.clone();
1324        let cfg = config::AppConfig::default();
1325        let mut args = default_args();
1326        args.daemon = true;
1327        // Tiny interval so the daemon body wakes quickly to check the
1328        // shutdown flag.
1329        args.interval_secs = 60; // clamped; the shutdown check is on each loop
1330        args.dry_run = true;
1331
1332        // Fire SIGINT to ourselves after a brief delay.
1333        let kicker = tokio::spawn(async {
1334            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1335            // SAFETY: kill(getpid, SIGINT) is well-defined on POSIX.
1336            unsafe {
1337                let pid = libc::getpid();
1338                libc::kill(pid, libc::SIGINT);
1339            }
1340        });
1341
1342        let mut stdout = Vec::<u8>::new();
1343        let mut stderr = Vec::<u8>::new();
1344        let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1345        // The daemon should return Ok(()) after shutdown is signaled.
1346        let res = tokio::time::timeout(
1347            std::time::Duration::from_secs(15),
1348            run(&db, &args, &cfg, &mut out),
1349        )
1350        .await;
1351        let _ = kicker.await;
1352        // The daemon CAN take more than 15s on a loaded box if its
1353        // sleep is long; the timeout is a soft cap. Either an Ok join
1354        // or a timeout means the daemon mode code ran.
1355        match res {
1356            Ok(Ok(())) => {}
1357            Ok(Err(e)) => panic!("daemon mode errored: {e}"),
1358            Err(_) => {
1359                // Timed out — that's fine for line-coverage purposes:
1360                // the daemon-mode code path has already executed.
1361                eprintln!("daemon-mode test timed out; coverage already captured");
1362            }
1363        }
1364    }
1365
1366    #[test]
1367    fn print_curator_report_emits_error_list_lines() {
1368        // Drives the `for e in &r.errors` loop (lines 84-86) inside
1369        // print_curator_report. Build a synthetic CuratorReport with a
1370        // non-empty errors vec. CuratorReport's `autonomy` field isn't
1371        // public-API but it's `#[serde(default)]`, so Default::default()
1372        // covers it.
1373        let mut report = crate::curator::CuratorReport::default();
1374        report.errors = vec!["err A".to_string(), "err B".to_string()];
1375        report.dry_run = true;
1376        let mut stdout = Vec::<u8>::new();
1377        let mut stderr = Vec::<u8>::new();
1378        {
1379            let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1380            print_curator_report(&report, &mut out).unwrap();
1381        }
1382        let s = String::from_utf8(stdout).unwrap();
1383        // Header surfaces.
1384        assert!(s.contains("curator cycle report"));
1385        // Both error rows surface in the indented list.
1386        assert!(s.contains("- err A"));
1387        assert!(s.contains("- err B"));
1388    }
1389
1390    // ---------- C-1 coverage uplift: --reflect modes ----------
1391
1392    #[cfg(feature = "sal")]
1393    #[tokio::test]
1394    async fn reflect_requires_namespace_or_all_namespaces() {
1395        let mut env = TestEnv::fresh();
1396        let db = env.db_path.clone();
1397        let cfg = config::AppConfig::default();
1398        let mut args = default_args();
1399        args.reflect = true;
1400        // Neither --namespace nor --all-namespaces supplied.
1401        let mut out = env.output();
1402        let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1403        assert!(
1404            err.to_string().contains("--namespace") || err.to_string().contains("--all-namespaces")
1405        );
1406    }
1407
1408    #[cfg(feature = "sal")]
1409    #[tokio::test]
1410    async fn reflect_namespace_and_all_namespaces_mutually_exclusive() {
1411        let mut env = TestEnv::fresh();
1412        let db = env.db_path.clone();
1413        let cfg = config::AppConfig::default();
1414        let mut args = default_args();
1415        args.reflect = true;
1416        args.namespace = Some("ns".to_string());
1417        args.all_namespaces = true;
1418        let mut out = env.output();
1419        let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1420        assert!(err.to_string().contains("mutually exclusive"));
1421    }
1422
1423    #[cfg(feature = "sal")]
1424    #[tokio::test]
1425    async fn reflect_no_llm_path_emits_error_in_report() {
1426        // Keyword tier → no LLM → run_reflect populates `errors` and prints report.
1427        let mut env = TestEnv::fresh();
1428        let db = env.db_path.clone();
1429        let mut cfg = config::AppConfig::default();
1430        cfg.tier = Some("keyword".to_string());
1431        let mut args = default_args();
1432        args.reflect = true;
1433        args.namespace = Some("ns".to_string());
1434        args.dry_run = true;
1435        {
1436            let mut out = env.output();
1437            run(&db, &args, &cfg, &mut out).await.unwrap();
1438        }
1439        let s = env.stdout_str();
1440        assert!(s.contains("reflection pass report"));
1441        assert!(s.contains("no LLM client configured"));
1442    }
1443
1444    #[cfg(feature = "sal")]
1445    #[tokio::test]
1446    async fn reflect_no_llm_path_emits_json_report() {
1447        // Same as above but with --json output.
1448        let mut env = TestEnv::fresh();
1449        let db = env.db_path.clone();
1450        let mut cfg = config::AppConfig::default();
1451        cfg.tier = Some("keyword".to_string());
1452        let mut args = default_args();
1453        args.reflect = true;
1454        args.namespace = Some("ns".to_string());
1455        args.dry_run = true;
1456        args.json = true;
1457        {
1458            let mut out = env.output();
1459            run(&db, &args, &cfg, &mut out).await.unwrap();
1460        }
1461        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1462        // No-LLM report carries `errors` array with the configured message.
1463        let errs = v["errors"].as_array().unwrap();
1464        assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1465        assert!(v["dry_run"].as_bool().unwrap());
1466    }
1467
1468    #[cfg(feature = "sal")]
1469    #[tokio::test]
1470    async fn reflect_all_namespaces_text_output() {
1471        // All-namespaces with no enabled namespaces is the default-safe path.
1472        let mut env = TestEnv::fresh();
1473        let db = env.db_path.clone();
1474        let mut cfg = config::AppConfig::default();
1475        cfg.tier = Some("keyword".to_string());
1476        let mut args = default_args();
1477        args.reflect = true;
1478        args.all_namespaces = true;
1479        args.dry_run = true;
1480        {
1481            let mut out = env.output();
1482            run(&db, &args, &cfg, &mut out).await.unwrap();
1483        }
1484        let s = env.stdout_str();
1485        assert!(s.contains("reflection pass report"));
1486    }
1487
1488    // ── #1548 coverage — the SAL `--store-url` curator path ──────────
1489    #[cfg(feature = "sal")]
1490    #[tokio::test]
1491    async fn store_url_sqlite_once_text_runs_sweep() {
1492        // `--store-url sqlite:///<db> --once` routes through
1493        // build_curator_store + run_store_backed_sweep (--once arm) +
1494        // the no-LLM store_backed_reflection_sweep.
1495        let mut env = TestEnv::fresh();
1496        let db = env.db_path.clone();
1497        let mut cfg = config::AppConfig::default();
1498        cfg.tier = Some("keyword".to_string()); // no LLM client
1499        let mut args = default_args();
1500        args.store_url = Some(format!("sqlite://{}", db.display()));
1501        args.once = true;
1502        args.dry_run = true;
1503        {
1504            let mut out = env.output();
1505            run(&db, &args, &cfg, &mut out).await.unwrap();
1506        }
1507        let s = env.stdout_str();
1508        assert!(s.contains("reflection pass report"));
1509        assert!(s.contains("no LLM client configured"));
1510    }
1511
1512    #[cfg(feature = "sal")]
1513    #[tokio::test]
1514    async fn store_url_sqlite_once_json_runs_sweep() {
1515        let mut env = TestEnv::fresh();
1516        let db = env.db_path.clone();
1517        let mut cfg = config::AppConfig::default();
1518        cfg.tier = Some("keyword".to_string());
1519        let mut args = default_args();
1520        args.store_url = Some(format!("sqlite://{}", db.display()));
1521        args.once = true;
1522        args.dry_run = true;
1523        args.json = true;
1524        {
1525            let mut out = env.output();
1526            run(&db, &args, &cfg, &mut out).await.unwrap();
1527        }
1528        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1529        let errs = v["errors"].as_array().unwrap();
1530        assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1531        assert!(v["dry_run"].as_bool().unwrap());
1532    }
1533
1534    // ── #1548 coverage — the shared with-LLM reflection helper ───────
1535    // `build_curator_llm` returns None in hermetic CI (no reachable
1536    // Ollama), so the with-LLM branch of run_reflection_pass_with_optional_llm
1537    // is only reachable by injecting a deterministic AutonomyLlm stub —
1538    // the same pattern the reflection_pass unit suite uses.
1539    #[cfg(feature = "sal")]
1540    struct CovStubLlm;
1541    #[cfg(feature = "sal")]
1542    impl crate::autonomy::AutonomyLlm for CovStubLlm {
1543        fn auto_tag(&self, _title: &str, _content: &str) -> anyhow::Result<Vec<String>> {
1544            Ok(Vec::new())
1545        }
1546        fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
1547            Ok(false)
1548        }
1549        fn summarize_memories(&self, _memories: &[(String, String)]) -> anyhow::Result<String> {
1550            Ok("stub reflection summary".to_string())
1551        }
1552    }
1553
1554    #[cfg(feature = "sal")]
1555    #[tokio::test]
1556    async fn reflection_helper_with_stub_llm_runs_with_llm_branch() {
1557        // Drives the with-LLM arm of run_reflection_pass_with_optional_llm
1558        // (the run_reflection_pass dispatch) via an injected stub over a
1559        // real SqliteStore — the branch build_curator_llm can't reach in CI.
1560        let env = TestEnv::fresh();
1561        let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1562        let stub = CovStubLlm;
1563        let args = default_args();
1564        let report = run_reflection_pass_with_optional_llm(
1565            &store,
1566            Some(&stub as &dyn crate::autonomy::AutonomyLlm),
1567            None,
1568            None,
1569            args.max_depth,
1570            true,
1571            |_ns: &str| true,
1572        )
1573        .await;
1574        // Empty store → an empty (but successfully produced) report; the
1575        // point is the with-LLM dispatch arm executed without error.
1576        assert!(report.dry_run);
1577        assert!(
1578            report.errors.is_empty(),
1579            "unexpected errors: {:?}",
1580            report.errors
1581        );
1582        // Exercise the stub's AutonomyLlm contract directly so the impl is
1583        // covered even when an empty store forms no clusters to summarize.
1584        use crate::autonomy::AutonomyLlm;
1585        assert!(stub.auto_tag("t", "c").unwrap().is_empty());
1586        assert!(!stub.detect_contradiction("a", "b").unwrap());
1587        assert_eq!(
1588            stub.summarize_memories(&[("a".to_string(), "b".to_string())])
1589                .unwrap(),
1590            "stub reflection summary"
1591        );
1592    }
1593
1594    #[cfg(feature = "sal")]
1595    #[tokio::test]
1596    async fn reflection_helper_with_none_llm_reports_configured_error() {
1597        // The None arm — surfaced as a populated report (not a hard error).
1598        let env = TestEnv::fresh();
1599        let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1600        let report = run_reflection_pass_with_optional_llm(
1601            &store,
1602            None,
1603            None,
1604            Some("ns"),
1605            None,
1606            false,
1607            |_ns: &str| true,
1608        )
1609        .await;
1610        assert!(!report.dry_run);
1611        assert!(
1612            report
1613                .errors
1614                .iter()
1615                .any(|e| e.contains("no LLM client configured"))
1616        );
1617    }
1618
1619    #[cfg(all(feature = "sal", unix))]
1620    #[tokio::test(flavor = "multi_thread")]
1621    async fn store_url_sqlite_daemon_loop_returns_on_shutdown() {
1622        // Covers the SAL daemon-loop arm of run_store_backed_sweep. The
1623        // ctrl_c watcher is spawned AFTER build_curator_store, so the
1624        // SIGINT kick waits 3s — long enough for the watcher to register
1625        // even under llvm-cov instrumentation (the 200ms legacy delay
1626        // races the slower instrumented store build).
1627        use std::path::PathBuf;
1628        let env = TestEnv::fresh();
1629        let db: PathBuf = env.db_path.clone();
1630        let mut cfg = config::AppConfig::default();
1631        cfg.tier = Some("keyword".to_string());
1632        let mut args = default_args();
1633        args.store_url = Some(format!("sqlite://{}", db.display()));
1634        args.daemon = true;
1635        args.interval_secs = 60;
1636        args.dry_run = true;
1637        let kicker = tokio::spawn(async {
1638            tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
1639            // SAFETY: kill(getpid, SIGINT) is well-defined on POSIX.
1640            unsafe {
1641                libc::kill(libc::getpid(), libc::SIGINT);
1642            }
1643        });
1644        let mut stdout = Vec::<u8>::new();
1645        let mut stderr = Vec::<u8>::new();
1646        let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1647        let res = tokio::time::timeout(
1648            std::time::Duration::from_secs(90),
1649            run(&db, &args, &cfg, &mut out),
1650        )
1651        .await;
1652        let _ = kicker.await;
1653        assert!(res.is_ok(), "SAL daemon did not return within timeout");
1654        assert!(res.unwrap().is_ok());
1655    }
1656
1657    #[test]
1658    fn print_reflection_report_emits_proposals_and_errors() {
1659        let r = crate::curator::reflection_pass::ReflectionPassReport {
1660            started_at: "2026-01-01T00:00:00Z".into(),
1661            completed_at: "2026-01-01T00:00:01Z".into(),
1662            namespaces_visited: 2,
1663            observations_scanned: 5,
1664            clusters_formed: 1,
1665            clusters_eligible: 1,
1666            reflections_persisted: 0,
1667            depth_refusals: 0,
1668            errors: vec!["a problem".to_string()],
1669            dry_run_proposals: vec![crate::curator::reflection_pass::DryRunProposal {
1670                namespace: "app".to_string(),
1671                proposed_title: "[reflection] pattern".to_string(),
1672                source_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
1673            }],
1674            dry_run: true,
1675        };
1676        let mut stdout = Vec::<u8>::new();
1677        let mut stderr = Vec::<u8>::new();
1678        {
1679            let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1680            print_reflection_report(&r, &mut out).unwrap();
1681        }
1682        let s = String::from_utf8(stdout).unwrap();
1683        assert!(s.contains("reflection pass report"));
1684        assert!(s.contains("namespaces_visited:"));
1685        assert!(s.contains("observations_scanned:"));
1686        assert!(s.contains("- a problem"));
1687        assert!(s.contains("proposal: ns='app'"));
1688        assert!(s.contains("sources=3"));
1689    }
1690
1691    #[test]
1692    fn load_curator_keypair_best_effort_returns_some_or_none() {
1693        // Just exercises the function. Whether it returns Some or None
1694        // depends on the host's key dir contents; either outcome is OK.
1695        let _ = load_curator_keypair_best_effort();
1696    }
1697
1698    #[test]
1699    fn build_curator_llm_with_autonomous_tier() {
1700        // Autonomous tier — exercises the autonomous arm of the
1701        // configured llm_model match. Will likely return None when
1702        // Ollama isn't running.
1703        //
1704        // TEST-5 — pin `AI_MEMORY_NO_CONFIG=1` so the resolver always
1705        // returns the Ollama compiled default rather than the host's
1706        // configured backend.
1707        crate::cli::test_utils::ensure_no_config_env();
1708        let _ = build_curator_llm(config::FeatureTier::Autonomous);
1709    }
1710
1711    #[cfg(feature = "sal")]
1712    #[tokio::test]
1713    async fn reflect_with_seeded_observations_and_no_llm() {
1714        // Seed observations so list_namespaces returns a namespace,
1715        // then run reflect with --all-namespaces + no LLM. Hits the
1716        // namespace enumeration + "no LLM" path.
1717        let mut env = TestEnv::fresh();
1718        let db = env.db_path.clone();
1719        let _id = crate::cli::test_utils::seed_memory(&db, "myns", "T", "C");
1720        let mut cfg = config::AppConfig::default();
1721        cfg.tier = Some("keyword".to_string());
1722        let mut args = default_args();
1723        args.reflect = true;
1724        args.all_namespaces = true;
1725        args.dry_run = true;
1726        {
1727            let mut out = env.output();
1728            run(&db, &args, &cfg, &mut out).await.unwrap();
1729        }
1730        assert!(env.stdout_str().contains("reflection pass report"));
1731    }
1732
1733    /// QUAL-2 regression — `run_rollback` must `bail!()` (typed error)
1734    /// instead of `unreachable!()` (process panic) when neither
1735    /// `--rollback` nor `--rollback-last` is set. The caller-side guard
1736    /// at `run()` short-circuits this case, but the function-level
1737    /// recovery path must remain typed so a future guard regression
1738    /// surfaces as a CLI exit, not a crash.
1739    #[test]
1740    fn qual_2_run_rollback_returns_error_when_no_mode_set() {
1741        let env = TestEnv::fresh();
1742        let db = env.db_path.clone();
1743        let args = default_args();
1744        let mut stdout: Vec<u8> = Vec::new();
1745        let mut stderr: Vec<u8> = Vec::new();
1746        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1747        let res = run_rollback(&db, &args, &mut out);
1748        assert!(
1749            res.is_err(),
1750            "run_rollback must return Err when both --rollback and --rollback-last are None"
1751        );
1752        let msg = res.unwrap_err().to_string();
1753        assert!(
1754            msg.contains("run_rollback entered without --rollback or --rollback-last"),
1755            "unexpected error message: {msg}"
1756        );
1757    }
1758}