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
8use crate::cli::CliOutput;
9use crate::{autonomy, config, curator, db, llm};
10use anyhow::{Context, Result};
11use clap::Args;
12use std::path::Path;
13
14#[derive(Args)]
15#[allow(clippy::struct_excessive_bools)]
16pub struct CuratorArgs {
17    /// Run exactly one sweep and exit. Mutually exclusive with --daemon.
18    #[arg(long, conflicts_with = "daemon")]
19    pub once: bool,
20    /// Loop forever, sleeping --interval-secs between sweeps. SIGINT /
21    /// SIGTERM trigger a clean shutdown between cycles.
22    #[arg(long)]
23    pub daemon: bool,
24    /// Seconds between daemon sweeps. Clamped to [60, 86400].
25    #[arg(long, default_value_t = 3600)]
26    pub interval_secs: u64,
27    /// Hard cap on LLM-invoking operations per cycle.
28    #[arg(long, default_value_t = 100)]
29    pub max_ops: usize,
30    /// Emit the report without persisting any metadata changes.
31    #[arg(long)]
32    pub dry_run: bool,
33    /// Only curate memories in these namespaces. Repeat flag for multiple.
34    #[arg(long = "include-namespace")]
35    pub include_namespaces: Vec<String>,
36    /// Exclude these namespaces from curation. Repeat flag for multiple.
37    #[arg(long = "exclude-namespace")]
38    pub exclude_namespaces: Vec<String>,
39    /// Print the report as JSON rather than a human-readable summary.
40    #[arg(long)]
41    pub json: bool,
42    /// Reverse rollback-log entries instead of running a sweep. Accepts
43    /// a specific rollback-memory id, or `--last N` for the most recent.
44    /// Mutually exclusive with `--once` and `--daemon`.
45    #[arg(long, conflicts_with_all = ["once", "daemon"])]
46    pub rollback: Option<String>,
47    /// With `--rollback`, reverse the N most recent rollback-log entries
48    /// instead of a single id.
49    #[arg(long)]
50    pub rollback_last: Option<usize>,
51}
52
53fn build_curator_llm(tier: config::FeatureTier) -> Option<llm::OllamaClient> {
54    let llm_model = tier.config().llm_model?;
55    let model = llm_model.ollama_model_id().to_string();
56    llm::OllamaClient::new(&model).ok()
57}
58
59fn print_curator_report(r: &curator::CuratorReport, out: &mut CliOutput<'_>) -> Result<()> {
60    writeln!(out.stdout, "curator cycle report")?;
61    writeln!(out.stdout, "  started_at:        {}", r.started_at)?;
62    writeln!(out.stdout, "  completed_at:      {}", r.completed_at)?;
63    writeln!(out.stdout, "  duration_ms:       {}", r.cycle_duration_ms)?;
64    writeln!(out.stdout, "  memories_scanned:  {}", r.memories_scanned)?;
65    writeln!(out.stdout, "  memories_eligible: {}", r.memories_eligible)?;
66    writeln!(
67        out.stdout,
68        "  operations:        {}",
69        r.operations_attempted
70    )?;
71    writeln!(out.stdout, "  auto_tagged:       {}", r.auto_tagged)?;
72    writeln!(
73        out.stdout,
74        "  contradictions:    {}",
75        r.contradictions_found
76    )?;
77    writeln!(
78        out.stdout,
79        "  skipped (cap):     {}",
80        r.operations_skipped_cap
81    )?;
82    writeln!(out.stdout, "  errors:            {}", r.errors.len())?;
83    writeln!(out.stdout, "  dry_run:           {}", r.dry_run)?;
84    for e in &r.errors {
85        writeln!(out.stdout, "    - {e}")?;
86    }
87    Ok(())
88}
89
90/// `curator` handler. Daemon-mode delegates to `daemon_runtime`.
91pub async fn run(
92    db_path: &Path,
93    args: &CuratorArgs,
94    app_config: &config::AppConfig,
95    out: &mut CliOutput<'_>,
96) -> Result<()> {
97    if args.rollback.is_some() || args.rollback_last.is_some() {
98        return run_rollback(db_path, args, out);
99    }
100
101    if !args.once && !args.daemon {
102        anyhow::bail!("curator requires --once, --daemon, --rollback <id>, or --rollback-last N");
103    }
104
105    let cfg = curator::CuratorConfig {
106        interval_secs: args.interval_secs,
107        max_ops_per_cycle: args.max_ops,
108        dry_run: args.dry_run,
109        include_namespaces: args.include_namespaces.clone(),
110        exclude_namespaces: args.exclude_namespaces.clone(),
111    };
112
113    let feature_tier = app_config.effective_tier(None);
114    let llm = build_curator_llm(feature_tier);
115
116    if args.once {
117        let conn = db::open(db_path)?;
118        let report = curator::run_once(&conn, llm.as_ref(), &cfg)?;
119        if args.json {
120            writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
121        } else {
122            print_curator_report(&report, out)?;
123        }
124        return Ok(());
125    }
126
127    // Daemon mode — delegate to daemon_runtime.
128    let shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
129    let shutdown_for_signal = shutdown.clone();
130    tokio::spawn(async move {
131        let _ = tokio::signal::ctrl_c().await;
132        shutdown_for_signal.notify_one();
133    });
134
135    let ollama_model = feature_tier
136        .config()
137        .llm_model
138        .map(|m| m.ollama_model_id().to_string());
139
140    crate::daemon_runtime::run_curator_daemon_with_primitives(
141        db_path.to_path_buf(),
142        args.interval_secs,
143        args.max_ops,
144        args.dry_run,
145        args.include_namespaces.clone(),
146        args.exclude_namespaces.clone(),
147        ollama_model,
148        shutdown,
149    )
150    .await
151}
152
153fn run_rollback(db_path: &Path, args: &CuratorArgs, out: &mut CliOutput<'_>) -> Result<()> {
154    let conn = db::open(db_path)?;
155
156    if let Some(id) = &args.rollback {
157        let Some(mem) = db::get(&conn, id)? else {
158            anyhow::bail!("rollback entry {id} not found");
159        };
160        let entry: autonomy::RollbackEntry = serde_json::from_str(&mem.content)
161            .context("rollback entry content is not a valid RollbackEntry JSON")?;
162        let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
163        let mut tags = mem.tags.clone();
164        if !tags.iter().any(|t| t == "_reversed") {
165            tags.push("_reversed".to_string());
166            db::update(
167                &conn,
168                &mem.id,
169                None,
170                None,
171                None,
172                None,
173                Some(&tags),
174                None,
175                None,
176                None,
177                None,
178            )?;
179        }
180        writeln!(
181            out.stdout,
182            "rollback {id}: {}",
183            if applied { "applied" } else { "no-op" }
184        )?;
185        return Ok(());
186    }
187
188    if let Some(n) = args.rollback_last {
189        let log = db::list(
190            &conn,
191            Some("_curator/rollback"),
192            None,
193            n.max(1),
194            0,
195            None,
196            None,
197            None,
198            None,
199            None,
200        )?;
201        let mut reversed = 0usize;
202        for mem in &log {
203            if mem.tags.iter().any(|t| t == "_reversed") {
204                continue;
205            }
206            let Ok(entry) = serde_json::from_str::<autonomy::RollbackEntry>(&mem.content) else {
207                continue;
208            };
209            let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
210            if applied {
211                reversed += 1;
212                let mut tags = mem.tags.clone();
213                tags.push("_reversed".to_string());
214                db::update(
215                    &conn,
216                    &mem.id,
217                    None,
218                    None,
219                    None,
220                    None,
221                    Some(&tags),
222                    None,
223                    None,
224                    None,
225                    None,
226                )?;
227            }
228        }
229        writeln!(out.stdout, "reversed {reversed} rollback entries")?;
230        return Ok(());
231    }
232
233    unreachable!("run_rollback entered without --rollback or --rollback-last");
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::cli::test_utils::TestEnv;
240
241    fn default_args() -> CuratorArgs {
242        CuratorArgs {
243            once: false,
244            daemon: false,
245            interval_secs: 3600,
246            max_ops: 100,
247            dry_run: false,
248            include_namespaces: Vec::new(),
249            exclude_namespaces: Vec::new(),
250            json: false,
251            rollback: None,
252            rollback_last: None,
253        }
254    }
255
256    #[tokio::test]
257    async fn test_curator_requires_mode() {
258        let mut env = TestEnv::fresh();
259        let db = env.db_path.clone();
260        let cfg = config::AppConfig::default();
261        let args = default_args();
262        let mut out = env.output();
263        let res = run(&db, &args, &cfg, &mut out).await;
264        assert!(res.is_err());
265        assert!(
266            res.unwrap_err()
267                .to_string()
268                .contains("--once, --daemon, --rollback")
269        );
270    }
271
272    #[tokio::test]
273    async fn test_curator_once_runs_single_sweep_text() {
274        let mut env = TestEnv::fresh();
275        let db = env.db_path.clone();
276        let cfg = config::AppConfig::default();
277        let mut args = default_args();
278        args.once = true;
279        args.dry_run = true;
280        {
281            let mut out = env.output();
282            run(&db, &args, &cfg, &mut out).await.unwrap();
283        }
284        assert!(env.stdout_str().contains("curator cycle report"));
285    }
286
287    #[tokio::test]
288    async fn test_curator_once_json_format() {
289        let mut env = TestEnv::fresh();
290        let db = env.db_path.clone();
291        let cfg = config::AppConfig::default();
292        let mut args = default_args();
293        args.once = true;
294        args.json = true;
295        args.dry_run = true;
296        {
297            let mut out = env.output();
298            run(&db, &args, &cfg, &mut out).await.unwrap();
299        }
300        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
301        assert!(v["dry_run"].as_bool().unwrap());
302    }
303
304    #[tokio::test]
305    async fn test_curator_dry_run_skips_writes() {
306        let mut env = TestEnv::fresh();
307        let db = env.db_path.clone();
308        let cfg = config::AppConfig::default();
309        let mut args = default_args();
310        args.once = true;
311        args.dry_run = true;
312        {
313            let mut out = env.output();
314            run(&db, &args, &cfg, &mut out).await.unwrap();
315        }
316        // Report mentions dry_run flag.
317        let s = env.stdout_str();
318        assert!(s.contains("dry_run:") || s.contains("\"dry_run\""));
319    }
320
321    #[tokio::test]
322    async fn test_curator_include_namespaces_filter() {
323        let mut env = TestEnv::fresh();
324        let db = env.db_path.clone();
325        let cfg = config::AppConfig::default();
326        let mut args = default_args();
327        args.once = true;
328        args.dry_run = true;
329        args.include_namespaces = vec!["only-this-ns".to_string()];
330        {
331            let mut out = env.output();
332            run(&db, &args, &cfg, &mut out).await.unwrap();
333        }
334        // No memories — operations attempted should be 0.
335        assert!(env.stdout_str().contains("operations:"));
336    }
337
338    #[tokio::test]
339    async fn test_curator_exclude_namespaces_filter() {
340        let mut env = TestEnv::fresh();
341        let db = env.db_path.clone();
342        let cfg = config::AppConfig::default();
343        let mut args = default_args();
344        args.once = true;
345        args.dry_run = true;
346        args.exclude_namespaces = vec!["skip-me".to_string()];
347        {
348            let mut out = env.output();
349            run(&db, &args, &cfg, &mut out).await.unwrap();
350        }
351        assert!(env.stdout_str().contains("curator cycle report"));
352    }
353
354    #[tokio::test]
355    async fn test_curator_max_ops_cap_respected() {
356        let mut env = TestEnv::fresh();
357        let db = env.db_path.clone();
358        let cfg = config::AppConfig::default();
359        let mut args = default_args();
360        args.once = true;
361        args.dry_run = true;
362        args.max_ops = 0; // immediately at cap
363        {
364            let mut out = env.output();
365            run(&db, &args, &cfg, &mut out).await.unwrap();
366        }
367        assert!(env.stdout_str().contains("operations:"));
368    }
369
370    #[tokio::test]
371    async fn test_curator_rollback_id_not_found() {
372        let mut env = TestEnv::fresh();
373        let db = env.db_path.clone();
374        let cfg = config::AppConfig::default();
375        let mut args = default_args();
376        args.rollback = Some("00000000-0000-0000-0000-000000000000".to_string());
377        let mut out = env.output();
378        let res = run(&db, &args, &cfg, &mut out).await;
379        assert!(res.is_err());
380        assert!(res.unwrap_err().to_string().contains("rollback entry"));
381    }
382
383    #[tokio::test]
384    async fn test_curator_rollback_last_zero_entries() {
385        let mut env = TestEnv::fresh();
386        let db = env.db_path.clone();
387        let cfg = config::AppConfig::default();
388        let mut args = default_args();
389        args.rollback_last = Some(5);
390        {
391            let mut out = env.output();
392            run(&db, &args, &cfg, &mut out).await.unwrap();
393        }
394        // No rollback log entries; should report 0.
395        assert!(env.stdout_str().contains("reversed 0"));
396    }
397
398    // PR-9i — buffer coverage uplift. Targets run_rollback() — rollback
399    // path with valid PriorityAdjust entry, rollback_last with both
400    // applied & malformed JSON entries (skip branch), already-reversed
401    // skip branch.
402
403    fn build_priority_rollback_entry_json(memory_id: &str, before: i32, after: i32) -> String {
404        // Serialize as the externally-tagged enum form `autonomy::RollbackEntry`
405        // uses (the Rust default).
406        serde_json::to_string(&autonomy::RollbackEntry::PriorityAdjust {
407            memory_id: memory_id.to_string(),
408            before,
409            after,
410        })
411        .unwrap()
412    }
413
414    fn seed_rollback_entry(db_path: &std::path::Path, content: &str) -> String {
415        // Insert a memory in the _curator/rollback namespace whose content
416        // is a serialized RollbackEntry. Returns the inserted id.
417        let conn = db::open(db_path).expect("db::open");
418        let now = chrono::Utc::now().to_rfc3339();
419        let mut metadata = crate::models::default_metadata();
420        if let Some(obj) = metadata.as_object_mut() {
421            obj.insert(
422                "agent_id".to_string(),
423                serde_json::Value::String("test-agent".to_string()),
424            );
425        }
426        let mem = crate::models::Memory {
427            id: uuid::Uuid::new_v4().to_string(),
428            tier: crate::models::Tier::Mid,
429            namespace: "_curator/rollback".to_string(),
430            title: format!("rollback-{}", uuid::Uuid::new_v4()),
431            content: content.to_string(),
432            tags: vec![],
433            priority: 5,
434            confidence: 1.0,
435            source: "test".to_string(),
436            access_count: 0,
437            created_at: now.clone(),
438            updated_at: now,
439            last_accessed_at: None,
440            expires_at: None,
441            metadata,
442        };
443        db::insert(&conn, &mem).expect("db::insert")
444    }
445
446    #[tokio::test]
447    async fn pr9i_curator_rollback_priority_adjust_applies() {
448        // Seed a real memory whose priority we'll roll back from 7→3.
449        let mut env = TestEnv::fresh();
450        let db = env.db_path.clone();
451        let cfg = config::AppConfig::default();
452
453        // 1. Seed a target memory at priority=7.
454        let target = {
455            let conn = db::open(&db).unwrap();
456            let now = chrono::Utc::now().to_rfc3339();
457            let mut metadata = crate::models::default_metadata();
458            if let Some(obj) = metadata.as_object_mut() {
459                obj.insert(
460                    "agent_id".to_string(),
461                    serde_json::Value::String("test-agent".to_string()),
462                );
463            }
464            let mem = crate::models::Memory {
465                id: uuid::Uuid::new_v4().to_string(),
466                tier: crate::models::Tier::Mid,
467                namespace: "ns".to_string(),
468                title: "target".to_string(),
469                content: "c".to_string(),
470                tags: vec![],
471                priority: 7,
472                confidence: 1.0,
473                source: "test".to_string(),
474                access_count: 0,
475                created_at: now.clone(),
476                updated_at: now,
477                last_accessed_at: None,
478                expires_at: None,
479                metadata,
480            };
481            db::insert(&conn, &mem).unwrap()
482        };
483
484        // 2. Seed a rollback entry that says "revert priority to 3".
485        let entry_json = build_priority_rollback_entry_json(&target, 3, 7);
486        let entry_id = seed_rollback_entry(&db, &entry_json);
487
488        // 3. Run rollback by id.
489        let mut args = default_args();
490        args.rollback = Some(entry_id.clone());
491        {
492            let mut out = env.output();
493            run(&db, &args, &cfg, &mut out).await.unwrap();
494        }
495        // Stdout reports rollback applied.
496        let s = env.stdout_str();
497        assert!(s.contains(&format!("rollback {entry_id}")));
498        assert!(s.contains("applied"));
499
500        // The target's priority must now be 3.
501        let conn = db::open(&db).unwrap();
502        let target_mem = db::get(&conn, &target).unwrap().unwrap();
503        assert_eq!(target_mem.priority, 3);
504
505        // The rollback entry must be tagged _reversed.
506        let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
507        assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
508    }
509
510    #[tokio::test]
511    async fn pr9i_curator_rollback_last_processes_multiple() {
512        let mut env = TestEnv::fresh();
513        let db = env.db_path.clone();
514        let cfg = config::AppConfig::default();
515
516        // Seed two targets.
517        let t1;
518        let t2;
519        {
520            let conn = db::open(&db).unwrap();
521            let now = chrono::Utc::now().to_rfc3339();
522            let mut metadata = crate::models::default_metadata();
523            if let Some(obj) = metadata.as_object_mut() {
524                obj.insert(
525                    "agent_id".to_string(),
526                    serde_json::Value::String("test-agent".to_string()),
527                );
528            }
529            let m1 = crate::models::Memory {
530                id: uuid::Uuid::new_v4().to_string(),
531                tier: crate::models::Tier::Mid,
532                namespace: "ns".to_string(),
533                title: "t1".to_string(),
534                content: "c1".to_string(),
535                tags: vec![],
536                priority: 8,
537                confidence: 1.0,
538                source: "test".to_string(),
539                access_count: 0,
540                created_at: now.clone(),
541                updated_at: now.clone(),
542                last_accessed_at: None,
543                expires_at: None,
544                metadata: metadata.clone(),
545            };
546            let m2 = crate::models::Memory {
547                id: uuid::Uuid::new_v4().to_string(),
548                tier: crate::models::Tier::Mid,
549                namespace: "ns".to_string(),
550                title: "t2".to_string(),
551                content: "c2".to_string(),
552                tags: vec![],
553                priority: 9,
554                confidence: 1.0,
555                source: "test".to_string(),
556                access_count: 0,
557                created_at: now.clone(),
558                updated_at: now,
559                last_accessed_at: None,
560                expires_at: None,
561                metadata,
562            };
563            t1 = db::insert(&conn, &m1).unwrap();
564            t2 = db::insert(&conn, &m2).unwrap();
565        }
566
567        // Seed two rollback entries plus one malformed JSON entry.
568        seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t1, 4, 8));
569        seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t2, 5, 9));
570        seed_rollback_entry(&db, "{not valid json: at all"); // malformed → skip branch
571
572        // Run rollback_last 5 (caps at actual count).
573        let mut args = default_args();
574        args.rollback_last = Some(5);
575        {
576            let mut out = env.output();
577            run(&db, &args, &cfg, &mut out).await.unwrap();
578        }
579        // Reverses 2 entries (the malformed one is skipped).
580        let s = env.stdout_str();
581        assert!(s.contains("reversed 2"));
582
583        // Both targets reverted.
584        let conn = db::open(&db).unwrap();
585        assert_eq!(db::get(&conn, &t1).unwrap().unwrap().priority, 4);
586        assert_eq!(db::get(&conn, &t2).unwrap().unwrap().priority, 5);
587    }
588
589    #[tokio::test]
590    async fn pr9i_curator_rollback_last_skips_already_reversed() {
591        // Seed a rollback entry pre-tagged as _reversed; rollback_last must
592        // skip it (lines 203-205).
593        let mut env = TestEnv::fresh();
594        let db = env.db_path.clone();
595        let cfg = config::AppConfig::default();
596
597        // Seed a target.
598        let target;
599        {
600            let conn = db::open(&db).unwrap();
601            let now = chrono::Utc::now().to_rfc3339();
602            let mut metadata = crate::models::default_metadata();
603            if let Some(obj) = metadata.as_object_mut() {
604                obj.insert(
605                    "agent_id".to_string(),
606                    serde_json::Value::String("test-agent".to_string()),
607                );
608            }
609            let mem = crate::models::Memory {
610                id: uuid::Uuid::new_v4().to_string(),
611                tier: crate::models::Tier::Mid,
612                namespace: "ns".to_string(),
613                title: "x".to_string(),
614                content: "c".to_string(),
615                tags: vec![],
616                priority: 7,
617                confidence: 1.0,
618                source: "test".to_string(),
619                access_count: 0,
620                created_at: now.clone(),
621                updated_at: now,
622                last_accessed_at: None,
623                expires_at: None,
624                metadata,
625            };
626            target = db::insert(&conn, &mem).unwrap();
627        }
628
629        // Insert a rollback entry already tagged _reversed.
630        let entry_json = build_priority_rollback_entry_json(&target, 2, 7);
631        let entry_id;
632        {
633            let conn = db::open(&db).unwrap();
634            let now = chrono::Utc::now().to_rfc3339();
635            let mut metadata = crate::models::default_metadata();
636            if let Some(obj) = metadata.as_object_mut() {
637                obj.insert(
638                    "agent_id".to_string(),
639                    serde_json::Value::String("test-agent".to_string()),
640                );
641            }
642            let mem = crate::models::Memory {
643                id: uuid::Uuid::new_v4().to_string(),
644                tier: crate::models::Tier::Mid,
645                namespace: "_curator/rollback".to_string(),
646                title: "preexisting-reversed".to_string(),
647                content: entry_json,
648                tags: vec!["_reversed".to_string()],
649                priority: 5,
650                confidence: 1.0,
651                source: "test".to_string(),
652                access_count: 0,
653                created_at: now.clone(),
654                updated_at: now,
655                last_accessed_at: None,
656                expires_at: None,
657                metadata,
658            };
659            entry_id = db::insert(&conn, &mem).unwrap();
660        }
661
662        let mut args = default_args();
663        args.rollback_last = Some(5);
664        {
665            let mut out = env.output();
666            run(&db, &args, &cfg, &mut out).await.unwrap();
667        }
668        // Already-reversed entry is skipped → reversed 0.
669        let s = env.stdout_str();
670        assert!(s.contains("reversed 0"));
671
672        // Target's priority is unchanged from 7.
673        let conn = db::open(&db).unwrap();
674        assert_eq!(db::get(&conn, &target).unwrap().unwrap().priority, 7);
675        // Sanity: entry_id memory still tagged _reversed.
676        let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
677        assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
678    }
679
680    #[tokio::test]
681    async fn pr9i_curator_rollback_id_with_malformed_content() {
682        // Seed a memory in _curator/rollback whose content is NOT a valid
683        // RollbackEntry — the explicit-id rollback path bails (lines 160-161).
684        let mut env = TestEnv::fresh();
685        let db = env.db_path.clone();
686        let cfg = config::AppConfig::default();
687        let entry_id = seed_rollback_entry(&db, "{invalid json");
688
689        let mut args = default_args();
690        args.rollback = Some(entry_id);
691        let mut out = env.output();
692        let res = run(&db, &args, &cfg, &mut out).await;
693        assert!(res.is_err());
694        let err = res.unwrap_err().to_string();
695        assert!(
696            err.contains("rollback") || err.contains("RollbackEntry"),
697            "expected parse-error message, got: {err}"
698        );
699    }
700}