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). #1671 — `--all-namespaces` now
518    // consults the per-namespace `[curator.reflection_namespaces]`
519    // config: a namespace participates only when it carries
520    // `enabled = true`. Absent / disabled namespaces are skipped, so the
521    // fan-out is opt-in (and `--all-namespaces` is no longer an inert
522    // no-op once the operator enables namespaces in config).
523    let scope_single = args.namespace.is_some();
524    let enabled_check =
525        |ns: &str| -> bool { scope_single || app_config.reflection_namespace_enabled(ns) };
526
527    let report = run_reflection_pass_with_optional_llm(
528        store.as_ref(),
529        llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
530        keypair.as_ref(),
531        args.namespace.as_deref(),
532        args.max_depth,
533        args.dry_run,
534        enabled_check,
535    )
536    .await;
537
538    if args.json {
539        writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
540    } else {
541        print_reflection_report(&report, out)?;
542    }
543    Ok(())
544}
545
546/// `not(sal)` companion of [`run_reflect`]. The reflection pass requires
547/// the SAL `MemoryStore` trait, which is `sal`-gated; a binary built
548/// without `--features sal` cannot run `--reflect`, so surface a clear
549/// capability error rather than silently dropping the mode.
550#[cfg(not(feature = "sal"))]
551#[allow(clippy::unused_async)]
552async fn run_reflect(
553    _db_path: &Path,
554    _args: &CuratorArgs,
555    _app_config: &config::AppConfig,
556    _out: &mut CliOutput<'_>,
557) -> Result<()> {
558    anyhow::bail!(
559        "curator --reflect requires a binary built with --features sal \
560         (the reflection pass operates over the SAL MemoryStore trait)"
561    )
562}
563
564/// v0.7.0 #1548 — resolve the SAL store handle for the `--reflect` mode.
565/// Mirrors [`run_store_backed_sweep`]'s builder: binds to the
566/// `--store-url` adapter (Postgres or SQLite) when supplied, else a
567/// SQLite store at the `--db` path.
568#[cfg(feature = "sal")]
569async fn build_reflect_store(
570    db_path: &Path,
571    args: &CuratorArgs,
572    app_config: &config::AppConfig,
573) -> Result<std::sync::Arc<dyn crate::store::MemoryStore>> {
574    crate::daemon_runtime::build_curator_store(curator_store_url(args), db_path, app_config).await
575}
576
577/// Load the curator's per-process signing keypair. Best-effort — if the
578/// keypair file is missing or unreadable we return `None` and the pass
579/// stamps `ai:curator` as `agent_id`. Errors are deliberately not
580/// surfaced; an operator who wants a strict-mode "fail if keypair
581/// missing" can run `ai-memory identity list` first.
582#[cfg_attr(not(feature = "sal"), allow(dead_code))]
583fn load_curator_keypair_best_effort() -> Option<identity_keypair::AgentKeypair> {
584    let dir = identity_keypair::default_key_dir().ok()?;
585    // We don't know which agent_id the operator wants the curator to
586    // run as. Pick the lexicographically-first key under the key dir;
587    // operators who run multiple curators on the same host should
588    // either give each a dedicated key dir via `AI_MEMORY_KEY_DIR` or
589    // set the daemon `AI_MEMORY_AGENT_ID` env var.
590    let listed = identity_keypair::list(&dir).ok()?;
591    let first = listed.into_iter().next()?;
592    identity_keypair::load(&first.agent_id, &dir).ok()
593}
594
595#[cfg_attr(not(feature = "sal"), allow(dead_code))]
596fn print_reflection_report(
597    r: &reflection_pass::ReflectionPassReport,
598    out: &mut CliOutput<'_>,
599) -> Result<()> {
600    writeln!(out.stdout, "reflection pass report")?;
601    writeln!(out.stdout, "  started_at:            {}", r.started_at)?;
602    writeln!(out.stdout, "  completed_at:          {}", r.completed_at)?;
603    writeln!(
604        out.stdout,
605        "  namespaces_visited:    {}",
606        r.namespaces_visited
607    )?;
608    writeln!(
609        out.stdout,
610        "  observations_scanned:  {}",
611        r.observations_scanned
612    )?;
613    writeln!(out.stdout, "  clusters_formed:       {}", r.clusters_formed)?;
614    writeln!(
615        out.stdout,
616        "  clusters_eligible:     {}",
617        r.clusters_eligible
618    )?;
619    writeln!(
620        out.stdout,
621        "  reflections_persisted: {}",
622        r.reflections_persisted
623    )?;
624    writeln!(out.stdout, "  depth_refusals:        {}", r.depth_refusals)?;
625    writeln!(out.stdout, "  errors:                {}", r.errors.len())?;
626    writeln!(out.stdout, "  dry_run:               {}", r.dry_run)?;
627    for e in &r.errors {
628        writeln!(out.stdout, "    - {e}")?;
629    }
630    for prop in &r.dry_run_proposals {
631        writeln!(
632            out.stdout,
633            "  proposal: ns='{}' title='{}' sources={}",
634            prop.namespace,
635            prop.proposed_title,
636            prop.source_ids.len()
637        )?;
638    }
639    Ok(())
640}
641
642fn run_rollback(db_path: &Path, args: &CuratorArgs, out: &mut CliOutput<'_>) -> Result<()> {
643    let conn = db::open(db_path)?;
644
645    if let Some(id) = &args.rollback {
646        let Some(mem) = db::get(&conn, id)? else {
647            anyhow::bail!("rollback entry {id} not found");
648        };
649        let entry: autonomy::RollbackEntry = serde_json::from_str(&mem.content)
650            .context("rollback entry content is not a valid RollbackEntry JSON")?;
651        let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
652        let mut tags = mem.tags.clone();
653        if !tags.iter().any(|t| t == "_reversed") {
654            tags.push("_reversed".to_string());
655            db::update(
656                &conn,
657                &mem.id,
658                None,
659                None,
660                None,
661                None,
662                Some(&tags),
663                None,
664                None,
665                None,
666                None,
667            )?;
668        }
669        writeln!(
670            out.stdout,
671            "rollback {id}: {}",
672            if applied { "applied" } else { "no-op" }
673        )?;
674        return Ok(());
675    }
676
677    if let Some(n) = args.rollback_last {
678        let log = db::list(
679            &conn,
680            Some("_curator/rollback"),
681            None,
682            n.max(1),
683            0,
684            None,
685            None,
686            None,
687            None,
688            None,
689        )?;
690        let mut reversed = 0usize;
691        for mem in &log {
692            if mem.tags.iter().any(|t| t == "_reversed") {
693                continue;
694            }
695            let Ok(entry) = serde_json::from_str::<autonomy::RollbackEntry>(&mem.content) else {
696                continue;
697            };
698            let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
699            if applied {
700                reversed += 1;
701                let mut tags = mem.tags.clone();
702                tags.push("_reversed".to_string());
703                db::update(
704                    &conn,
705                    &mem.id,
706                    None,
707                    None,
708                    None,
709                    None,
710                    Some(&tags),
711                    None,
712                    None,
713                    None,
714                    None,
715                )?;
716            }
717        }
718        writeln!(out.stdout, "reversed {reversed} rollback entries")?;
719        return Ok(());
720    }
721
722    // QUAL-2 (med/low review batch) — typed error instead of `unreachable!()`.
723    // The caller-side guard at `cmd_curator` (line ~147) already short-circuits
724    // when neither `--rollback` nor `--rollback-last` is set; this branch is
725    // reachable only if that guard ever regresses. Returning an `anyhow::Error`
726    // preserves the audit message but keeps the failure recoverable (typed
727    // CLI exit code) instead of crashing the process with a panic.
728    anyhow::bail!("run_rollback entered without --rollback or --rollback-last");
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use crate::cli::test_utils::TestEnv;
735
736    fn default_args() -> CuratorArgs {
737        CuratorArgs {
738            once: false,
739            daemon: false,
740            interval_secs: crate::SECS_PER_HOUR as u64,
741            max_ops: 100,
742            dry_run: false,
743            include_namespaces: Vec::new(),
744            exclude_namespaces: Vec::new(),
745            json: false,
746            rollback: None,
747            rollback_last: None,
748            reflect: false,
749            namespace: None,
750            max_depth: None,
751            all_namespaces: false,
752            #[cfg(feature = "sal")]
753            store_url: None,
754        }
755    }
756
757    #[tokio::test]
758    async fn test_curator_requires_mode() {
759        let mut env = TestEnv::fresh();
760        let db = env.db_path.clone();
761        let cfg = config::AppConfig::default();
762        let args = default_args();
763        let mut out = env.output();
764        let res = run(&db, &args, &cfg, &mut out).await;
765        assert!(res.is_err());
766        assert!(
767            res.unwrap_err()
768                .to_string()
769                .contains("--once, --daemon, --reflect")
770        );
771    }
772
773    #[tokio::test]
774    async fn test_curator_once_runs_single_sweep_text() {
775        let mut env = TestEnv::fresh();
776        let db = env.db_path.clone();
777        let cfg = config::AppConfig::default();
778        let mut args = default_args();
779        args.once = true;
780        args.dry_run = true;
781        {
782            let mut out = env.output();
783            run(&db, &args, &cfg, &mut out).await.unwrap();
784        }
785        assert!(env.stdout_str().contains("curator cycle report"));
786    }
787
788    #[tokio::test]
789    async fn test_curator_once_json_format() {
790        let mut env = TestEnv::fresh();
791        let db = env.db_path.clone();
792        let cfg = config::AppConfig::default();
793        let mut args = default_args();
794        args.once = true;
795        args.json = true;
796        args.dry_run = true;
797        {
798            let mut out = env.output();
799            run(&db, &args, &cfg, &mut out).await.unwrap();
800        }
801        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
802        assert!(v["dry_run"].as_bool().unwrap());
803    }
804
805    #[tokio::test]
806    async fn test_curator_dry_run_skips_writes() {
807        let mut env = TestEnv::fresh();
808        let db = env.db_path.clone();
809        let cfg = config::AppConfig::default();
810        let mut args = default_args();
811        args.once = true;
812        args.dry_run = true;
813        {
814            let mut out = env.output();
815            run(&db, &args, &cfg, &mut out).await.unwrap();
816        }
817        // Report mentions dry_run flag.
818        let s = env.stdout_str();
819        assert!(s.contains("dry_run:") || s.contains("\"dry_run\""));
820    }
821
822    #[tokio::test]
823    async fn test_curator_include_namespaces_filter() {
824        let mut env = TestEnv::fresh();
825        let db = env.db_path.clone();
826        let cfg = config::AppConfig::default();
827        let mut args = default_args();
828        args.once = true;
829        args.dry_run = true;
830        args.include_namespaces = vec!["only-this-ns".to_string()];
831        {
832            let mut out = env.output();
833            run(&db, &args, &cfg, &mut out).await.unwrap();
834        }
835        // No memories — operations attempted should be 0.
836        assert!(env.stdout_str().contains("operations:"));
837    }
838
839    #[tokio::test]
840    async fn test_curator_exclude_namespaces_filter() {
841        let mut env = TestEnv::fresh();
842        let db = env.db_path.clone();
843        let cfg = config::AppConfig::default();
844        let mut args = default_args();
845        args.once = true;
846        args.dry_run = true;
847        args.exclude_namespaces = vec!["skip-me".to_string()];
848        {
849            let mut out = env.output();
850            run(&db, &args, &cfg, &mut out).await.unwrap();
851        }
852        assert!(env.stdout_str().contains("curator cycle report"));
853    }
854
855    #[tokio::test]
856    async fn test_curator_max_ops_cap_respected() {
857        let mut env = TestEnv::fresh();
858        let db = env.db_path.clone();
859        let cfg = config::AppConfig::default();
860        let mut args = default_args();
861        args.once = true;
862        args.dry_run = true;
863        args.max_ops = 0; // immediately at cap
864        {
865            let mut out = env.output();
866            run(&db, &args, &cfg, &mut out).await.unwrap();
867        }
868        assert!(env.stdout_str().contains("operations:"));
869    }
870
871    #[tokio::test]
872    async fn test_curator_rollback_id_not_found() {
873        let mut env = TestEnv::fresh();
874        let db = env.db_path.clone();
875        let cfg = config::AppConfig::default();
876        let mut args = default_args();
877        args.rollback = Some("00000000-0000-0000-0000-000000000000".to_string());
878        let mut out = env.output();
879        let res = run(&db, &args, &cfg, &mut out).await;
880        assert!(res.is_err());
881        assert!(res.unwrap_err().to_string().contains("rollback entry"));
882    }
883
884    #[tokio::test]
885    async fn test_curator_rollback_last_zero_entries() {
886        let mut env = TestEnv::fresh();
887        let db = env.db_path.clone();
888        let cfg = config::AppConfig::default();
889        let mut args = default_args();
890        args.rollback_last = Some(5);
891        {
892            let mut out = env.output();
893            run(&db, &args, &cfg, &mut out).await.unwrap();
894        }
895        // No rollback log entries; should report 0.
896        assert!(env.stdout_str().contains("reversed 0"));
897    }
898
899    // PR-9i — buffer coverage uplift. Targets run_rollback() — rollback
900    // path with valid PriorityAdjust entry, rollback_last with both
901    // applied & malformed JSON entries (skip branch), already-reversed
902    // skip branch.
903
904    fn build_priority_rollback_entry_json(memory_id: &str, before: i32, after: i32) -> String {
905        // Serialize as the externally-tagged enum form `autonomy::RollbackEntry`
906        // uses (the Rust default).
907        serde_json::to_string(&autonomy::RollbackEntry::PriorityAdjust {
908            memory_id: memory_id.to_string(),
909            before,
910            after,
911        })
912        .unwrap()
913    }
914
915    fn seed_rollback_entry(db_path: &std::path::Path, content: &str) -> String {
916        // Insert a memory in the _curator/rollback namespace whose content
917        // is a serialized RollbackEntry. Returns the inserted id.
918        let conn = db::open(db_path).expect("db::open");
919        let now = chrono::Utc::now().to_rfc3339();
920        let mut metadata = crate::models::default_metadata();
921        if let Some(obj) = metadata.as_object_mut() {
922            obj.insert(
923                "agent_id".to_string(),
924                serde_json::Value::String("test-agent".to_string()),
925            );
926        }
927        let mem = crate::models::Memory {
928            id: uuid::Uuid::new_v4().to_string(),
929            tier: crate::models::Tier::Mid,
930            namespace: "_curator/rollback".to_string(),
931            title: format!("rollback-{}", uuid::Uuid::new_v4()),
932            content: content.to_string(),
933            tags: vec![],
934            priority: 5,
935            confidence: 1.0,
936            source: "test".to_string(),
937            access_count: 0,
938            created_at: now.clone(),
939            updated_at: now,
940            last_accessed_at: None,
941            expires_at: None,
942            metadata,
943            reflection_depth: 0,
944            memory_kind: crate::models::MemoryKind::Observation,
945            entity_id: None,
946            persona_version: None,
947            citations: Vec::new(),
948            source_uri: None,
949            source_span: None,
950            confidence_source: crate::models::ConfidenceSource::CallerProvided,
951            confidence_signals: None,
952            confidence_decayed_at: None,
953            version: 1,
954        };
955        db::insert(&conn, &mem).expect("db::insert")
956    }
957
958    #[tokio::test]
959    async fn pr9i_curator_rollback_priority_adjust_applies() {
960        // Seed a real memory whose priority we'll roll back from 7→3.
961        let mut env = TestEnv::fresh();
962        let db = env.db_path.clone();
963        let cfg = config::AppConfig::default();
964
965        // 1. Seed a target memory at priority=7.
966        let target = {
967            let conn = db::open(&db).unwrap();
968            let now = chrono::Utc::now().to_rfc3339();
969            let mut metadata = crate::models::default_metadata();
970            if let Some(obj) = metadata.as_object_mut() {
971                obj.insert(
972                    "agent_id".to_string(),
973                    serde_json::Value::String("test-agent".to_string()),
974                );
975            }
976            let mem = crate::models::Memory {
977                id: uuid::Uuid::new_v4().to_string(),
978                tier: crate::models::Tier::Mid,
979                namespace: "ns".to_string(),
980                title: "target".to_string(),
981                content: "c".to_string(),
982                tags: vec![],
983                priority: 7,
984                confidence: 1.0,
985                source: "test".to_string(),
986                access_count: 0,
987                created_at: now.clone(),
988                updated_at: now,
989                last_accessed_at: None,
990                expires_at: None,
991                metadata,
992                reflection_depth: 0,
993                memory_kind: crate::models::MemoryKind::Observation,
994                entity_id: None,
995                persona_version: None,
996                citations: Vec::new(),
997                source_uri: None,
998                source_span: None,
999                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1000                confidence_signals: None,
1001                confidence_decayed_at: None,
1002                version: 1,
1003            };
1004            db::insert(&conn, &mem).unwrap()
1005        };
1006
1007        // 2. Seed a rollback entry that says "revert priority to 3".
1008        let entry_json = build_priority_rollback_entry_json(&target, 3, 7);
1009        let entry_id = seed_rollback_entry(&db, &entry_json);
1010
1011        // 3. Run rollback by id.
1012        let mut args = default_args();
1013        args.rollback = Some(entry_id.clone());
1014        {
1015            let mut out = env.output();
1016            run(&db, &args, &cfg, &mut out).await.unwrap();
1017        }
1018        // Stdout reports rollback applied.
1019        let s = env.stdout_str();
1020        assert!(s.contains(&format!("rollback {entry_id}")));
1021        assert!(s.contains("applied"));
1022
1023        // The target's priority must now be 3.
1024        let conn = db::open(&db).unwrap();
1025        let target_mem = db::get(&conn, &target).unwrap().unwrap();
1026        assert_eq!(target_mem.priority, 3);
1027
1028        // The rollback entry must be tagged _reversed.
1029        let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1030        assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1031    }
1032
1033    #[tokio::test]
1034    async fn pr9i_curator_rollback_last_processes_multiple() {
1035        let mut env = TestEnv::fresh();
1036        let db = env.db_path.clone();
1037        let cfg = config::AppConfig::default();
1038
1039        // Seed two targets.
1040        let t1;
1041        let t2;
1042        {
1043            let conn = db::open(&db).unwrap();
1044            let now = chrono::Utc::now().to_rfc3339();
1045            let mut metadata = crate::models::default_metadata();
1046            if let Some(obj) = metadata.as_object_mut() {
1047                obj.insert(
1048                    "agent_id".to_string(),
1049                    serde_json::Value::String("test-agent".to_string()),
1050                );
1051            }
1052            let m1 = crate::models::Memory {
1053                id: uuid::Uuid::new_v4().to_string(),
1054                tier: crate::models::Tier::Mid,
1055                namespace: "ns".to_string(),
1056                title: "t1".to_string(),
1057                content: "c1".to_string(),
1058                tags: vec![],
1059                priority: 8,
1060                confidence: 1.0,
1061                source: "test".to_string(),
1062                access_count: 0,
1063                created_at: now.clone(),
1064                updated_at: now.clone(),
1065                last_accessed_at: None,
1066                expires_at: None,
1067                metadata: metadata.clone(),
1068                reflection_depth: 0,
1069                memory_kind: crate::models::MemoryKind::Observation,
1070                entity_id: None,
1071                persona_version: None,
1072                citations: Vec::new(),
1073                source_uri: None,
1074                source_span: None,
1075                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1076                confidence_signals: None,
1077                confidence_decayed_at: None,
1078                version: 1,
1079            };
1080            let m2 = crate::models::Memory {
1081                id: uuid::Uuid::new_v4().to_string(),
1082                tier: crate::models::Tier::Mid,
1083                namespace: "ns".to_string(),
1084                title: "t2".to_string(),
1085                content: "c2".to_string(),
1086                tags: vec![],
1087                priority: 9,
1088                confidence: 1.0,
1089                source: "test".to_string(),
1090                access_count: 0,
1091                created_at: now.clone(),
1092                updated_at: now,
1093                last_accessed_at: None,
1094                expires_at: None,
1095                metadata,
1096                reflection_depth: 0,
1097                memory_kind: crate::models::MemoryKind::Observation,
1098                entity_id: None,
1099                persona_version: None,
1100                citations: Vec::new(),
1101                source_uri: None,
1102                source_span: None,
1103                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1104                confidence_signals: None,
1105                confidence_decayed_at: None,
1106                version: 1,
1107            };
1108            t1 = db::insert(&conn, &m1).unwrap();
1109            t2 = db::insert(&conn, &m2).unwrap();
1110        }
1111
1112        // Seed two rollback entries plus one malformed JSON entry.
1113        seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t1, 4, 8));
1114        seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t2, 5, 9));
1115        seed_rollback_entry(&db, "{not valid json: at all"); // malformed → skip branch
1116
1117        // Run rollback_last 5 (caps at actual count).
1118        let mut args = default_args();
1119        args.rollback_last = Some(5);
1120        {
1121            let mut out = env.output();
1122            run(&db, &args, &cfg, &mut out).await.unwrap();
1123        }
1124        // Reverses 2 entries (the malformed one is skipped).
1125        let s = env.stdout_str();
1126        assert!(s.contains("reversed 2"));
1127
1128        // Both targets reverted.
1129        let conn = db::open(&db).unwrap();
1130        assert_eq!(db::get(&conn, &t1).unwrap().unwrap().priority, 4);
1131        assert_eq!(db::get(&conn, &t2).unwrap().unwrap().priority, 5);
1132    }
1133
1134    #[tokio::test]
1135    async fn pr9i_curator_rollback_last_skips_already_reversed() {
1136        // Seed a rollback entry pre-tagged as _reversed; rollback_last must
1137        // skip it (lines 203-205).
1138        let mut env = TestEnv::fresh();
1139        let db = env.db_path.clone();
1140        let cfg = config::AppConfig::default();
1141
1142        // Seed a target.
1143        let target;
1144        {
1145            let conn = db::open(&db).unwrap();
1146            let now = chrono::Utc::now().to_rfc3339();
1147            let mut metadata = crate::models::default_metadata();
1148            if let Some(obj) = metadata.as_object_mut() {
1149                obj.insert(
1150                    "agent_id".to_string(),
1151                    serde_json::Value::String("test-agent".to_string()),
1152                );
1153            }
1154            let mem = crate::models::Memory {
1155                id: uuid::Uuid::new_v4().to_string(),
1156                tier: crate::models::Tier::Mid,
1157                namespace: "ns".to_string(),
1158                title: "x".to_string(),
1159                content: "c".to_string(),
1160                tags: vec![],
1161                priority: 7,
1162                confidence: 1.0,
1163                source: "test".to_string(),
1164                access_count: 0,
1165                created_at: now.clone(),
1166                updated_at: now,
1167                last_accessed_at: None,
1168                expires_at: None,
1169                metadata,
1170                reflection_depth: 0,
1171                memory_kind: crate::models::MemoryKind::Observation,
1172                entity_id: None,
1173                persona_version: None,
1174                citations: Vec::new(),
1175                source_uri: None,
1176                source_span: None,
1177                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1178                confidence_signals: None,
1179                confidence_decayed_at: None,
1180                version: 1,
1181            };
1182            target = db::insert(&conn, &mem).unwrap();
1183        }
1184
1185        // Insert a rollback entry already tagged _reversed.
1186        let entry_json = build_priority_rollback_entry_json(&target, 2, 7);
1187        let entry_id;
1188        {
1189            let conn = db::open(&db).unwrap();
1190            let now = chrono::Utc::now().to_rfc3339();
1191            let mut metadata = crate::models::default_metadata();
1192            if let Some(obj) = metadata.as_object_mut() {
1193                obj.insert(
1194                    "agent_id".to_string(),
1195                    serde_json::Value::String("test-agent".to_string()),
1196                );
1197            }
1198            let mem = crate::models::Memory {
1199                id: uuid::Uuid::new_v4().to_string(),
1200                tier: crate::models::Tier::Mid,
1201                namespace: "_curator/rollback".to_string(),
1202                title: "preexisting-reversed".to_string(),
1203                content: entry_json,
1204                tags: vec!["_reversed".to_string()],
1205                priority: 5,
1206                confidence: 1.0,
1207                source: "test".to_string(),
1208                access_count: 0,
1209                created_at: now.clone(),
1210                updated_at: now,
1211                last_accessed_at: None,
1212                expires_at: None,
1213                metadata,
1214                reflection_depth: 0,
1215                memory_kind: crate::models::MemoryKind::Observation,
1216                entity_id: None,
1217                persona_version: None,
1218                citations: Vec::new(),
1219                source_uri: None,
1220                source_span: None,
1221                confidence_source: crate::models::ConfidenceSource::CallerProvided,
1222                confidence_signals: None,
1223                confidence_decayed_at: None,
1224                version: 1,
1225            };
1226            entry_id = db::insert(&conn, &mem).unwrap();
1227        }
1228
1229        let mut args = default_args();
1230        args.rollback_last = Some(5);
1231        {
1232            let mut out = env.output();
1233            run(&db, &args, &cfg, &mut out).await.unwrap();
1234        }
1235        // Already-reversed entry is skipped → reversed 0.
1236        let s = env.stdout_str();
1237        assert!(s.contains("reversed 0"));
1238
1239        // Target's priority is unchanged from 7.
1240        let conn = db::open(&db).unwrap();
1241        assert_eq!(db::get(&conn, &target).unwrap().unwrap().priority, 7);
1242        // Sanity: entry_id memory still tagged _reversed.
1243        let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1244        assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1245    }
1246
1247    #[tokio::test]
1248    async fn pr9i_curator_rollback_id_with_malformed_content() {
1249        // Seed a memory in _curator/rollback whose content is NOT a valid
1250        // RollbackEntry — the explicit-id rollback path bails (lines 160-161).
1251        let mut env = TestEnv::fresh();
1252        let db = env.db_path.clone();
1253        let cfg = config::AppConfig::default();
1254        let entry_id = seed_rollback_entry(&db, "{invalid json");
1255
1256        let mut args = default_args();
1257        args.rollback = Some(entry_id);
1258        let mut out = env.output();
1259        let res = run(&db, &args, &cfg, &mut out).await;
1260        assert!(res.is_err());
1261        let err = res.unwrap_err().to_string();
1262        assert!(
1263            err.contains("rollback") || err.contains("RollbackEntry"),
1264            "expected parse-error message, got: {err}"
1265        );
1266    }
1267
1268    // ---------- E1 coverage uplift -----------------------------------
1269    // Targets: build_curator_llm body (smart/autonomous tier branch),
1270    // print_curator_report error-list iteration, --once with errors
1271    // present.
1272
1273    #[test]
1274    fn build_curator_llm_with_keyword_tier_returns_none() {
1275        // Keyword tier has no llm_model — the function returns None
1276        // BEFORE entering the body. Sanity check.
1277        //
1278        // TEST-5 — pin `AI_MEMORY_NO_CONFIG=1` so `AppConfig::load()`
1279        // returns `Default::default()` instead of reading the
1280        // developer's `~/.config/ai-memory/config.toml` (which would
1281        // resolve a non-default `[llm]` stanza and cause this
1282        // assertion to fail).
1283        crate::cli::test_utils::ensure_no_config_env();
1284        let result = build_curator_llm(config::FeatureTier::Keyword);
1285        assert!(result.is_none());
1286    }
1287
1288    #[test]
1289    fn build_curator_llm_with_smart_tier_runs_body() {
1290        // Smart tier has llm_model = Some(_), so the body executes the
1291        // `let model = ...` + `OllamaClient::new(&model).ok()` lines.
1292        // In hermetic tests Ollama is unreachable, so the result is
1293        // None — but the body lines are now covered.
1294        //
1295        // TEST-5 — pin `AI_MEMORY_NO_CONFIG=1` so the resolver always
1296        // returns the Ollama compiled default rather than reading the
1297        // host's user-config-resolved backend.
1298        crate::cli::test_utils::ensure_no_config_env();
1299        let _ = build_curator_llm(config::FeatureTier::Smart);
1300        // No assertion on the value; the test exercises lines 55-56.
1301    }
1302
1303    // Unix-only — the test self-fires `libc::kill(getpid, SIGINT)` to
1304    // exercise the ctrl_c shutdown path. The libc crate's `getpid` /
1305    // `kill` / `SIGINT` symbols are not available on Windows, where
1306    // signal handling uses a different surface entirely. The daemon
1307    // shutdown path itself is cross-platform (tokio::signal::ctrl_c
1308    // works on Windows); only the self-fire test mechanism is
1309    // POSIX-bound.
1310    #[cfg(unix)]
1311    #[tokio::test(flavor = "multi_thread")]
1312    async fn curator_daemon_mode_short_loop_returns_on_shutdown() {
1313        // Drives lines 128-150 — daemon mode entry. We fire SIGINT to
1314        // ourselves after a short delay so the ctrl_c spawn notifies
1315        // shutdown, the AtomicBool flag flips, and `run_daemon`'s loop
1316        // exits at its next check. The blocking task joins and the
1317        // outer `await` returns.
1318        //
1319        // We do NOT install our own signal handler — tokio's signal
1320        // registry consumes the single SIGINT before any default
1321        // handler trips. This test runs under multi_thread so the
1322        // ctrl_c watcher can fire on a separate worker.
1323        use std::path::PathBuf;
1324        let env = TestEnv::fresh();
1325        let db: PathBuf = env.db_path.clone();
1326        let cfg = config::AppConfig::default();
1327        let mut args = default_args();
1328        args.daemon = true;
1329        // Tiny interval so the daemon body wakes quickly to check the
1330        // shutdown flag.
1331        args.interval_secs = 60; // clamped; the shutdown check is on each loop
1332        args.dry_run = true;
1333
1334        // Fire SIGINT to ourselves after a brief delay.
1335        let kicker = tokio::spawn(async {
1336            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1337            // SAFETY: kill(getpid, SIGINT) is well-defined on POSIX.
1338            unsafe {
1339                let pid = libc::getpid();
1340                libc::kill(pid, libc::SIGINT);
1341            }
1342        });
1343
1344        let mut stdout = Vec::<u8>::new();
1345        let mut stderr = Vec::<u8>::new();
1346        let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1347        // The daemon should return Ok(()) after shutdown is signaled.
1348        let res = tokio::time::timeout(
1349            std::time::Duration::from_secs(15),
1350            run(&db, &args, &cfg, &mut out),
1351        )
1352        .await;
1353        let _ = kicker.await;
1354        // The daemon CAN take more than 15s on a loaded box if its
1355        // sleep is long; the timeout is a soft cap. Either an Ok join
1356        // or a timeout means the daemon mode code ran.
1357        match res {
1358            Ok(Ok(())) => {}
1359            Ok(Err(e)) => panic!("daemon mode errored: {e}"),
1360            Err(_) => {
1361                // Timed out — that's fine for line-coverage purposes:
1362                // the daemon-mode code path has already executed.
1363                eprintln!("daemon-mode test timed out; coverage already captured");
1364            }
1365        }
1366    }
1367
1368    #[test]
1369    fn print_curator_report_emits_error_list_lines() {
1370        // Drives the `for e in &r.errors` loop (lines 84-86) inside
1371        // print_curator_report. Build a synthetic CuratorReport with a
1372        // non-empty errors vec. CuratorReport's `autonomy` field isn't
1373        // public-API but it's `#[serde(default)]`, so Default::default()
1374        // covers it.
1375        let mut report = crate::curator::CuratorReport::default();
1376        report.errors = vec!["err A".to_string(), "err B".to_string()];
1377        report.dry_run = true;
1378        let mut stdout = Vec::<u8>::new();
1379        let mut stderr = Vec::<u8>::new();
1380        {
1381            let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1382            print_curator_report(&report, &mut out).unwrap();
1383        }
1384        let s = String::from_utf8(stdout).unwrap();
1385        // Header surfaces.
1386        assert!(s.contains("curator cycle report"));
1387        // Both error rows surface in the indented list.
1388        assert!(s.contains("- err A"));
1389        assert!(s.contains("- err B"));
1390    }
1391
1392    // ---------- C-1 coverage uplift: --reflect modes ----------
1393
1394    #[cfg(feature = "sal")]
1395    #[tokio::test]
1396    async fn reflect_requires_namespace_or_all_namespaces() {
1397        let mut env = TestEnv::fresh();
1398        let db = env.db_path.clone();
1399        let cfg = config::AppConfig::default();
1400        let mut args = default_args();
1401        args.reflect = true;
1402        // Neither --namespace nor --all-namespaces supplied.
1403        let mut out = env.output();
1404        let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1405        assert!(
1406            err.to_string().contains("--namespace") || err.to_string().contains("--all-namespaces")
1407        );
1408    }
1409
1410    #[cfg(feature = "sal")]
1411    #[tokio::test]
1412    async fn reflect_namespace_and_all_namespaces_mutually_exclusive() {
1413        let mut env = TestEnv::fresh();
1414        let db = env.db_path.clone();
1415        let cfg = config::AppConfig::default();
1416        let mut args = default_args();
1417        args.reflect = true;
1418        args.namespace = Some("ns".to_string());
1419        args.all_namespaces = true;
1420        let mut out = env.output();
1421        let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1422        assert!(err.to_string().contains("mutually exclusive"));
1423    }
1424
1425    #[cfg(feature = "sal")]
1426    #[tokio::test]
1427    async fn reflect_no_llm_path_emits_error_in_report() {
1428        // Keyword tier → no LLM → run_reflect populates `errors` and prints report.
1429        let mut env = TestEnv::fresh();
1430        let db = env.db_path.clone();
1431        let mut cfg = config::AppConfig::default();
1432        cfg.tier = Some("keyword".to_string());
1433        let mut args = default_args();
1434        args.reflect = true;
1435        args.namespace = Some("ns".to_string());
1436        args.dry_run = true;
1437        {
1438            let mut out = env.output();
1439            run(&db, &args, &cfg, &mut out).await.unwrap();
1440        }
1441        let s = env.stdout_str();
1442        assert!(s.contains("reflection pass report"));
1443        assert!(s.contains("no LLM client configured"));
1444    }
1445
1446    #[cfg(feature = "sal")]
1447    #[tokio::test]
1448    async fn reflect_no_llm_path_emits_json_report() {
1449        // Same as above but with --json output.
1450        let mut env = TestEnv::fresh();
1451        let db = env.db_path.clone();
1452        let mut cfg = config::AppConfig::default();
1453        cfg.tier = Some("keyword".to_string());
1454        let mut args = default_args();
1455        args.reflect = true;
1456        args.namespace = Some("ns".to_string());
1457        args.dry_run = true;
1458        args.json = true;
1459        {
1460            let mut out = env.output();
1461            run(&db, &args, &cfg, &mut out).await.unwrap();
1462        }
1463        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1464        // No-LLM report carries `errors` array with the configured message.
1465        let errs = v["errors"].as_array().unwrap();
1466        assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1467        assert!(v["dry_run"].as_bool().unwrap());
1468    }
1469
1470    #[cfg(feature = "sal")]
1471    #[tokio::test]
1472    async fn reflect_all_namespaces_text_output() {
1473        // All-namespaces with no enabled namespaces is the default-safe path.
1474        let mut env = TestEnv::fresh();
1475        let db = env.db_path.clone();
1476        let mut cfg = config::AppConfig::default();
1477        cfg.tier = Some("keyword".to_string());
1478        let mut args = default_args();
1479        args.reflect = true;
1480        args.all_namespaces = true;
1481        args.dry_run = true;
1482        {
1483            let mut out = env.output();
1484            run(&db, &args, &cfg, &mut out).await.unwrap();
1485        }
1486        let s = env.stdout_str();
1487        assert!(s.contains("reflection pass report"));
1488    }
1489
1490    // ── #1548 coverage — the SAL `--store-url` curator path ──────────
1491    #[cfg(feature = "sal")]
1492    #[tokio::test]
1493    async fn store_url_sqlite_once_text_runs_sweep() {
1494        // `--store-url sqlite:///<db> --once` routes through
1495        // build_curator_store + run_store_backed_sweep (--once arm) +
1496        // the no-LLM store_backed_reflection_sweep.
1497        let mut env = TestEnv::fresh();
1498        let db = env.db_path.clone();
1499        let mut cfg = config::AppConfig::default();
1500        cfg.tier = Some("keyword".to_string()); // no LLM client
1501        let mut args = default_args();
1502        args.store_url = Some(format!("sqlite://{}", db.display()));
1503        args.once = true;
1504        args.dry_run = true;
1505        {
1506            let mut out = env.output();
1507            run(&db, &args, &cfg, &mut out).await.unwrap();
1508        }
1509        let s = env.stdout_str();
1510        assert!(s.contains("reflection pass report"));
1511        assert!(s.contains("no LLM client configured"));
1512    }
1513
1514    #[cfg(feature = "sal")]
1515    #[tokio::test]
1516    async fn store_url_sqlite_once_json_runs_sweep() {
1517        let mut env = TestEnv::fresh();
1518        let db = env.db_path.clone();
1519        let mut cfg = config::AppConfig::default();
1520        cfg.tier = Some("keyword".to_string());
1521        let mut args = default_args();
1522        args.store_url = Some(format!("sqlite://{}", db.display()));
1523        args.once = true;
1524        args.dry_run = true;
1525        args.json = true;
1526        {
1527            let mut out = env.output();
1528            run(&db, &args, &cfg, &mut out).await.unwrap();
1529        }
1530        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1531        let errs = v["errors"].as_array().unwrap();
1532        assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1533        assert!(v["dry_run"].as_bool().unwrap());
1534    }
1535
1536    // ── #1548 coverage — the shared with-LLM reflection helper ───────
1537    // `build_curator_llm` returns None in hermetic CI (no reachable
1538    // Ollama), so the with-LLM branch of run_reflection_pass_with_optional_llm
1539    // is only reachable by injecting a deterministic AutonomyLlm stub —
1540    // the same pattern the reflection_pass unit suite uses.
1541    #[cfg(feature = "sal")]
1542    struct CovStubLlm;
1543    #[cfg(feature = "sal")]
1544    impl crate::autonomy::AutonomyLlm for CovStubLlm {
1545        fn auto_tag(&self, _title: &str, _content: &str) -> anyhow::Result<Vec<String>> {
1546            Ok(Vec::new())
1547        }
1548        fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
1549            Ok(false)
1550        }
1551        fn summarize_memories(&self, _memories: &[(String, String)]) -> anyhow::Result<String> {
1552            Ok("stub reflection summary".to_string())
1553        }
1554    }
1555
1556    #[cfg(feature = "sal")]
1557    #[tokio::test]
1558    async fn reflection_helper_with_stub_llm_runs_with_llm_branch() {
1559        // Drives the with-LLM arm of run_reflection_pass_with_optional_llm
1560        // (the run_reflection_pass dispatch) via an injected stub over a
1561        // real SqliteStore — the branch build_curator_llm can't reach in CI.
1562        let env = TestEnv::fresh();
1563        let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1564        let stub = CovStubLlm;
1565        let args = default_args();
1566        let report = run_reflection_pass_with_optional_llm(
1567            &store,
1568            Some(&stub as &dyn crate::autonomy::AutonomyLlm),
1569            None,
1570            None,
1571            args.max_depth,
1572            true,
1573            |_ns: &str| true,
1574        )
1575        .await;
1576        // Empty store → an empty (but successfully produced) report; the
1577        // point is the with-LLM dispatch arm executed without error.
1578        assert!(report.dry_run);
1579        assert!(
1580            report.errors.is_empty(),
1581            "unexpected errors: {:?}",
1582            report.errors
1583        );
1584        // Exercise the stub's AutonomyLlm contract directly so the impl is
1585        // covered even when an empty store forms no clusters to summarize.
1586        use crate::autonomy::AutonomyLlm;
1587        assert!(stub.auto_tag("t", "c").unwrap().is_empty());
1588        assert!(!stub.detect_contradiction("a", "b").unwrap());
1589        assert_eq!(
1590            stub.summarize_memories(&[("a".to_string(), "b".to_string())])
1591                .unwrap(),
1592            "stub reflection summary"
1593        );
1594    }
1595
1596    #[cfg(feature = "sal")]
1597    #[tokio::test]
1598    async fn reflection_helper_with_none_llm_reports_configured_error() {
1599        // The None arm — surfaced as a populated report (not a hard error).
1600        let env = TestEnv::fresh();
1601        let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1602        let report = run_reflection_pass_with_optional_llm(
1603            &store,
1604            None,
1605            None,
1606            Some("ns"),
1607            None,
1608            false,
1609            |_ns: &str| true,
1610        )
1611        .await;
1612        assert!(!report.dry_run);
1613        assert!(
1614            report
1615                .errors
1616                .iter()
1617                .any(|e| e.contains("no LLM client configured"))
1618        );
1619    }
1620
1621    #[cfg(all(feature = "sal", unix))]
1622    #[tokio::test(flavor = "multi_thread")]
1623    async fn store_url_sqlite_daemon_loop_returns_on_shutdown() {
1624        // Covers the SAL daemon-loop arm of run_store_backed_sweep. The
1625        // ctrl_c watcher is spawned AFTER build_curator_store, so the
1626        // SIGINT kick waits 3s — long enough for the watcher to register
1627        // even under llvm-cov instrumentation (the 200ms legacy delay
1628        // races the slower instrumented store build).
1629        use std::path::PathBuf;
1630        let env = TestEnv::fresh();
1631        let db: PathBuf = env.db_path.clone();
1632        let mut cfg = config::AppConfig::default();
1633        cfg.tier = Some("keyword".to_string());
1634        let mut args = default_args();
1635        args.store_url = Some(format!("sqlite://{}", db.display()));
1636        args.daemon = true;
1637        args.interval_secs = 60;
1638        args.dry_run = true;
1639        let kicker = tokio::spawn(async {
1640            tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
1641            // SAFETY: kill(getpid, SIGINT) is well-defined on POSIX.
1642            unsafe {
1643                libc::kill(libc::getpid(), libc::SIGINT);
1644            }
1645        });
1646        let mut stdout = Vec::<u8>::new();
1647        let mut stderr = Vec::<u8>::new();
1648        let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1649        let res = tokio::time::timeout(
1650            std::time::Duration::from_secs(90),
1651            run(&db, &args, &cfg, &mut out),
1652        )
1653        .await;
1654        let _ = kicker.await;
1655        assert!(res.is_ok(), "SAL daemon did not return within timeout");
1656        assert!(res.unwrap().is_ok());
1657    }
1658
1659    #[test]
1660    fn print_reflection_report_emits_proposals_and_errors() {
1661        let r = crate::curator::reflection_pass::ReflectionPassReport {
1662            started_at: "2026-01-01T00:00:00Z".into(),
1663            completed_at: "2026-01-01T00:00:01Z".into(),
1664            namespaces_visited: 2,
1665            observations_scanned: 5,
1666            clusters_formed: 1,
1667            clusters_eligible: 1,
1668            reflections_persisted: 0,
1669            depth_refusals: 0,
1670            errors: vec!["a problem".to_string()],
1671            dry_run_proposals: vec![crate::curator::reflection_pass::DryRunProposal {
1672                namespace: "app".to_string(),
1673                proposed_title: "[reflection] pattern".to_string(),
1674                source_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
1675            }],
1676            dry_run: true,
1677        };
1678        let mut stdout = Vec::<u8>::new();
1679        let mut stderr = Vec::<u8>::new();
1680        {
1681            let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1682            print_reflection_report(&r, &mut out).unwrap();
1683        }
1684        let s = String::from_utf8(stdout).unwrap();
1685        assert!(s.contains("reflection pass report"));
1686        assert!(s.contains("namespaces_visited:"));
1687        assert!(s.contains("observations_scanned:"));
1688        assert!(s.contains("- a problem"));
1689        assert!(s.contains("proposal: ns='app'"));
1690        assert!(s.contains("sources=3"));
1691    }
1692
1693    #[test]
1694    fn load_curator_keypair_best_effort_returns_some_or_none() {
1695        // Just exercises the function. Whether it returns Some or None
1696        // depends on the host's key dir contents; either outcome is OK.
1697        let _ = load_curator_keypair_best_effort();
1698    }
1699
1700    #[test]
1701    fn build_curator_llm_with_autonomous_tier() {
1702        // Autonomous tier — exercises the autonomous arm of the
1703        // configured llm_model match. Will likely return None when
1704        // Ollama isn't running.
1705        //
1706        // TEST-5 — pin `AI_MEMORY_NO_CONFIG=1` so the resolver always
1707        // returns the Ollama compiled default rather than the host's
1708        // configured backend.
1709        crate::cli::test_utils::ensure_no_config_env();
1710        let _ = build_curator_llm(config::FeatureTier::Autonomous);
1711    }
1712
1713    #[cfg(feature = "sal")]
1714    #[tokio::test]
1715    async fn reflect_with_seeded_observations_and_no_llm() {
1716        // Seed observations so list_namespaces returns a namespace,
1717        // then run reflect with --all-namespaces + no LLM. Hits the
1718        // namespace enumeration + "no LLM" path.
1719        let mut env = TestEnv::fresh();
1720        let db = env.db_path.clone();
1721        let _id = crate::cli::test_utils::seed_memory(&db, "myns", "T", "C");
1722        let mut cfg = config::AppConfig::default();
1723        cfg.tier = Some("keyword".to_string());
1724        let mut args = default_args();
1725        args.reflect = true;
1726        args.all_namespaces = true;
1727        args.dry_run = true;
1728        {
1729            let mut out = env.output();
1730            run(&db, &args, &cfg, &mut out).await.unwrap();
1731        }
1732        assert!(env.stdout_str().contains("reflection pass report"));
1733    }
1734
1735    /// QUAL-2 regression — `run_rollback` must `bail!()` (typed error)
1736    /// instead of `unreachable!()` (process panic) when neither
1737    /// `--rollback` nor `--rollback-last` is set. The caller-side guard
1738    /// at `run()` short-circuits this case, but the function-level
1739    /// recovery path must remain typed so a future guard regression
1740    /// surfaces as a CLI exit, not a crash.
1741    #[test]
1742    fn qual_2_run_rollback_returns_error_when_no_mode_set() {
1743        let env = TestEnv::fresh();
1744        let db = env.db_path.clone();
1745        let args = default_args();
1746        let mut stdout: Vec<u8> = Vec::new();
1747        let mut stderr: Vec<u8> = Vec::new();
1748        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1749        let res = run_rollback(&db, &args, &mut out);
1750        assert!(
1751            res.is_err(),
1752            "run_rollback must return Err when both --rollback and --rollback-last are None"
1753        );
1754        let msg = res.unwrap_err().to_string();
1755        assert!(
1756            msg.contains("run_rollback entered without --rollback or --rollback-last"),
1757            "unexpected error message: {msg}"
1758        );
1759    }
1760}