1use std::path::Path;
42use std::sync::Arc;
43
44use anyhow::Result;
45use clap::Args;
46use serde::Serialize;
47
48use crate::atomisation::curator::{Curator, LlmCurator};
49use crate::atomisation::{AtomiseError, Atomiser, AtomiserConfig};
50use crate::cli::CliOutput;
51use crate::config::{AppConfig, FeatureTier};
52use crate::db;
53use crate::identity::keypair as identity_keypair;
54use crate::llm::OllamaClient;
55
56#[derive(Args, Debug, Clone)]
58pub struct AtomiseArgs {
59 pub memory_id: String,
62
63 #[arg(long, default_value_t = 200)]
67 pub max_atom_tokens: u32,
68
69 #[arg(long, default_value_t = false)]
73 pub force: bool,
74
75 #[arg(long, default_value_t = false)]
78 pub json: bool,
79
80 #[arg(long, default_value_t = false)]
84 pub quiet: bool,
85}
86
87#[derive(Debug, Serialize)]
92struct SuccessEnvelope<'a> {
93 source_id: &'a str,
94 atom_ids: &'a [String],
95 atom_count: usize,
96 archived_at: &'a str,
97}
98
99#[derive(Debug, Serialize)]
101struct ErrorEnvelope<'a> {
102 error: &'static str,
104 message: String,
107 exit_code: i32,
109 #[serde(skip_serializing_if = "Option::is_none")]
112 details: Option<serde_json::Value>,
113 source_id: &'a str,
115}
116
117#[must_use]
122pub fn exit_code(err: &AtomiseError) -> i32 {
123 match err {
124 AtomiseError::AlreadyAtomised { .. } | AtomiseError::SourceTooSmall => 1,
125 AtomiseError::NotFound => 2,
126 AtomiseError::TierLocked => 3,
127 AtomiseError::CuratorFailed(_) => 4,
128 AtomiseError::GovernanceRefused(_) => 5,
129 AtomiseError::DbError(_) | AtomiseError::SignerError(_) => 6,
130 AtomiseError::DepthExceeded { .. } => 7,
134 }
135}
136
137#[must_use]
141pub fn error_slug(err: &AtomiseError) -> &'static str {
142 match err {
143 AtomiseError::AlreadyAtomised { .. } => "already_atomised",
144 AtomiseError::SourceTooSmall => "source_too_small",
145 AtomiseError::NotFound => "not_found",
146 AtomiseError::TierLocked => "tier_locked",
147 AtomiseError::CuratorFailed(_) => "curator_failed",
148 AtomiseError::GovernanceRefused(_) => crate::errors::error_codes::GOVERNANCE_REFUSED,
155 AtomiseError::DbError(_) => "db_error",
156 AtomiseError::SignerError(_) => "signer_error",
157 AtomiseError::DepthExceeded { .. } => "ATOMISATION_DEPTH_EXCEEDED",
162 }
163}
164
165#[must_use]
170pub fn human_error_message(err: &AtomiseError, source_id: &str) -> String {
171 match err {
172 AtomiseError::NotFound => format!("Memory ID {source_id} not found"),
173 AtomiseError::AlreadyAtomised {
174 source_id: sid,
175 existing_atom_ids,
176 } => {
177 let ids = existing_atom_ids.join(", ");
178 format!(
179 "Memory {sid} already atomised into {n} atoms. Use --force to re-atomise. \
180 Existing atom IDs: {ids}",
181 n = existing_atom_ids.len()
182 )
183 }
184 AtomiseError::TierLocked => {
185 "memory_atomise requires smart tier or higher. Current tier: keyword. \
186 Upgrade your deployment or use --tier semantic when running ai-memory mcp."
187 .to_string()
188 }
189 AtomiseError::CuratorFailed(detail) => {
190 format!("Curator pass failed: {detail}. Check Ollama availability or retry.")
191 }
192 AtomiseError::SourceTooSmall => format!(
193 "Memory {source_id} body already at or under max_atom_tokens. \
194 No atomisation needed."
195 ),
196 AtomiseError::GovernanceRefused(detail) => {
197 format!("Atomisation refused: {detail}")
198 }
199 AtomiseError::SignerError(detail) => format!("Signer error: {detail}"),
200 AtomiseError::DbError(detail) => format!("Database error: {detail}"),
201 AtomiseError::DepthExceeded { attempted, cap } => format!(
202 "Atomisation refused: depth {attempted} would exceed compiled \
203 max_atomisation_depth {cap}. A recursive atomisation chain hit \
204 the cycle-depth cap — inspect the curator / pre_store hook stack \
205 that re-entered atomise."
206 ),
207 }
208}
209
210#[must_use]
215fn error_details(err: &AtomiseError) -> Option<serde_json::Value> {
216 match err {
217 AtomiseError::AlreadyAtomised {
218 existing_atom_ids, ..
219 } => Some(serde_json::json!({
220 "existing_atom_ids": existing_atom_ids,
221 "existing_atom_count": existing_atom_ids.len(),
222 })),
223 _ => None,
224 }
225}
226
227pub fn run(
242 db_path: &Path,
243 args: &AtomiseArgs,
244 app_config: &AppConfig,
245 cli_agent_id: Option<&str>,
246 out: &mut CliOutput<'_>,
247) -> Result<i32> {
248 run_with_curator(db_path, args, app_config, cli_agent_id, out, None)
249}
250
251pub fn run_with_curator(
258 db_path: &Path,
259 args: &AtomiseArgs,
260 app_config: &AppConfig,
261 cli_agent_id: Option<&str>,
262 out: &mut CliOutput<'_>,
263 curator_override: Option<Box<dyn Curator>>,
264) -> Result<i32> {
265 let tier = app_config.effective_tier(None);
269
270 if tier == FeatureTier::Keyword {
274 let err = AtomiseError::TierLocked;
275 return emit_error(&err, &args.memory_id, args.json, out);
276 }
277
278 let calling_agent_id = match crate::identity::resolve_agent_id(cli_agent_id, None) {
281 Ok(id) => id,
282 Err(e) => {
283 let err = AtomiseError::DbError(format!("agent_id resolution failed: {e}"));
284 return emit_error(&err, &args.memory_id, args.json, out);
285 }
286 };
287
288 let conn = match db::open(db_path) {
290 Ok(c) => c,
291 Err(e) => {
292 let err = AtomiseError::DbError(format!("open {}: {e}", db_path.display()));
293 return emit_error(&err, &args.memory_id, args.json, out);
294 }
295 };
296
297 let (curator, curator_model): (Box<dyn Curator>, String) = if let Some(c) = curator_override {
304 (c, "unknown".to_string())
307 } else {
308 match build_llm_curator(tier) {
309 Ok((c, model)) => (c, model),
310 Err(e) => {
311 let err = AtomiseError::CuratorFailed(e);
312 return emit_error(&err, &args.memory_id, args.json, out);
313 }
314 }
315 };
316
317 let keypair = load_keypair_best_effort(&calling_agent_id);
320
321 let atomiser = Atomiser::new(curator, keypair, AtomiserConfig::default(), tier)
322 .with_curator_model(curator_model);
323
324 match atomiser.atomise_sync(
325 &conn,
326 &args.memory_id,
327 args.max_atom_tokens,
328 args.force,
329 &calling_agent_id,
330 ) {
331 Ok(result) => emit_success(&result, args.json, out),
332 Err(e) => emit_error(&e, &args.memory_id, args.json, out),
333 }
334}
335
336fn build_llm_curator(tier: FeatureTier) -> std::result::Result<(Box<dyn Curator>, String), String> {
354 let _ = tier;
365 let app_config = AppConfig::load();
366 let resolved = app_config.resolve_llm(None, None, None);
367 match OllamaClient::build_from_resolved(&resolved) {
368 Ok(Some(client)) => {
369 let model = client.model_name().to_string();
370 Ok((Box::new(LlmCurator::new(client)), model))
371 }
372 Ok(None) => Err(format!(
373 "atomise: LLM resolver returned no client \
374 (backend={}, source={}); atomise requires a curator LLM",
375 resolved.backend,
376 resolved.source.as_str()
377 )),
378 Err(e) => Err(format!(
379 "atomise: LLM init failed (backend={}, source={}): {e}",
380 resolved.backend,
381 resolved.source.as_str()
382 )),
383 }
384}
385
386fn load_keypair_best_effort(agent_id: &str) -> Option<Arc<crate::identity::keypair::AgentKeypair>> {
392 let dir = identity_keypair::default_key_dir().ok()?;
393 identity_keypair::load(agent_id, &dir).ok().map(Arc::new)
394}
395
396fn emit_success(
398 result: &crate::atomisation::AtomiseResult,
399 json: bool,
400 out: &mut CliOutput<'_>,
401) -> Result<i32> {
402 if json {
403 let env = SuccessEnvelope {
404 source_id: &result.source_id,
405 atom_ids: &result.atom_ids,
406 atom_count: result.atom_count,
407 archived_at: &result.archived_at,
408 };
409 writeln!(out.stdout, "{}", serde_json::to_string(&env)?)?;
410 } else {
411 let ids = result.atom_ids.join(", ");
412 writeln!(
413 out.stdout,
414 "Atomised memory {src} into {n} atoms. Source archived at {ts}. Atom IDs: {ids}",
415 src = result.source_id,
416 n = result.atom_count,
417 ts = result.archived_at,
418 )?;
419 }
420 Ok(0)
421}
422
423fn emit_error(
425 err: &AtomiseError,
426 source_id: &str,
427 json: bool,
428 out: &mut CliOutput<'_>,
429) -> Result<i32> {
430 let code = exit_code(err);
431 let message = human_error_message(err, source_id);
432 if json {
433 let env = ErrorEnvelope {
434 error: error_slug(err),
435 message: message.clone(),
436 exit_code: code,
437 details: error_details(err),
438 source_id,
439 };
440 writeln!(out.stderr, "{}", serde_json::to_string(&env)?)?;
441 } else {
442 writeln!(out.stderr, "{message}")?;
443 }
444 Ok(code)
445}
446
447#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn exit_code_maps_every_variant() {
458 assert_eq!(exit_code(&AtomiseError::NotFound), 2);
459 assert_eq!(exit_code(&AtomiseError::TierLocked), 3);
460 assert_eq!(exit_code(&AtomiseError::CuratorFailed("x".into())), 4);
461 assert_eq!(exit_code(&AtomiseError::GovernanceRefused("x".into())), 5);
462 assert_eq!(exit_code(&AtomiseError::SourceTooSmall), 1);
463 assert_eq!(exit_code(&AtomiseError::DbError("x".into())), 6);
464 assert_eq!(exit_code(&AtomiseError::SignerError("x".into())), 6);
465 assert_eq!(
466 exit_code(&AtomiseError::AlreadyAtomised {
467 source_id: "s".into(),
468 existing_atom_ids: vec!["a".into()]
469 }),
470 1
471 );
472 assert_eq!(
476 exit_code(&AtomiseError::DepthExceeded {
477 attempted: 4,
478 cap: crate::atomisation::MAX_ATOMISATION_DEPTH,
479 }),
480 7
481 );
482 }
483
484 #[test]
485 fn error_slug_maps_every_variant() {
486 assert_eq!(error_slug(&AtomiseError::NotFound), "not_found");
487 assert_eq!(error_slug(&AtomiseError::TierLocked), "tier_locked");
488 assert_eq!(
489 error_slug(&AtomiseError::CuratorFailed("x".into())),
490 "curator_failed"
491 );
492 assert_eq!(
499 error_slug(&AtomiseError::GovernanceRefused("x".into())),
500 "GOVERNANCE_REFUSED"
501 );
502 assert_eq!(
503 error_slug(&AtomiseError::SourceTooSmall),
504 "source_too_small"
505 );
506 assert_eq!(error_slug(&AtomiseError::DbError("x".into())), "db_error");
507 assert_eq!(
508 error_slug(&AtomiseError::SignerError("x".into())),
509 "signer_error"
510 );
511 assert_eq!(
512 error_slug(&AtomiseError::AlreadyAtomised {
513 source_id: "s".into(),
514 existing_atom_ids: vec!["a".into()]
515 }),
516 "already_atomised"
517 );
518 assert_eq!(
522 error_slug(&AtomiseError::DepthExceeded {
523 attempted: 4,
524 cap: crate::atomisation::MAX_ATOMISATION_DEPTH,
525 }),
526 "ATOMISATION_DEPTH_EXCEEDED"
527 );
528 }
529
530 #[test]
531 fn human_error_message_tier_locked_carries_upgrade_hint() {
532 let msg = human_error_message(&AtomiseError::TierLocked, "src");
533 assert!(msg.contains("requires smart tier"));
534 assert!(msg.contains("keyword"));
535 assert!(msg.contains("Upgrade your deployment"));
536 }
537
538 #[test]
539 fn human_error_message_not_found_carries_source_id() {
540 let msg = human_error_message(&AtomiseError::NotFound, "src-123");
541 assert!(msg.contains("src-123"), "got: {msg}");
542 assert!(msg.contains("not found"));
543 }
544
545 #[test]
546 fn human_error_message_already_atomised_lists_existing_ids() {
547 let err = AtomiseError::AlreadyAtomised {
548 source_id: "src-9".into(),
549 existing_atom_ids: vec!["a1".into(), "a2".into(), "a3".into()],
550 };
551 let msg = human_error_message(&err, "src-9");
552 assert!(msg.contains("src-9"));
553 assert!(msg.contains("3 atoms"));
554 assert!(msg.contains("--force"));
555 assert!(msg.contains("a1, a2, a3"));
556 }
557
558 #[test]
559 fn human_error_message_source_too_small_carries_source_id() {
560 let msg = human_error_message(&AtomiseError::SourceTooSmall, "src-x");
561 assert!(msg.contains("src-x"));
562 assert!(msg.contains("max_atom_tokens"));
563 }
564
565 #[test]
566 fn human_error_message_curator_failed_carries_detail() {
567 let msg = human_error_message(&AtomiseError::CuratorFailed("ollama down".into()), "src");
568 assert!(msg.contains("ollama down"));
569 assert!(msg.contains("Ollama"));
570 }
571
572 #[test]
573 fn human_error_message_governance_refused_carries_detail() {
574 let msg = human_error_message(
575 &AtomiseError::GovernanceRefused("atom[2]: policy".into()),
576 "src",
577 );
578 assert!(msg.contains("policy"));
579 assert!(msg.contains("atom[2]"));
580 }
581
582 #[test]
583 fn human_error_message_signer_error_and_db_error_carry_detail() {
584 let sig = human_error_message(&AtomiseError::SignerError("key revoked".into()), "src");
586 assert!(sig.starts_with("Signer error:"));
587 assert!(sig.contains("key revoked"));
588 let db = human_error_message(&AtomiseError::DbError("disk full".into()), "src");
589 assert!(db.starts_with("Database error:"));
590 assert!(db.contains("disk full"));
591 }
592
593 #[test]
594 fn run_wrapper_delegates_to_run_with_curator_keyword_tier_short_circuits() {
595 use crate::config::AppConfig;
599 let mut cfg = AppConfig::default();
600 cfg.tier = Some("keyword".to_string());
601 let args = AtomiseArgs {
602 memory_id: "src-id".to_string(),
603 max_atom_tokens: 100,
604 force: false,
605 json: false,
606 quiet: false,
607 };
608 let dir = tempfile::tempdir().unwrap();
609 let db_path = dir.path().join("atomise-cli.db");
610 let mut stdout = Vec::<u8>::new();
611 let mut stderr = Vec::<u8>::new();
612 let mut out = CliOutput {
613 stdout: &mut stdout,
614 stderr: &mut stderr,
615 };
616 let code = run(&db_path, &args, &cfg, None, &mut out).unwrap();
617 assert_eq!(code, 3);
619 let s = String::from_utf8(stderr).unwrap();
620 assert!(
621 s.contains("requires smart tier") || s.contains("tier"),
622 "got stderr: {s}",
623 );
624 }
625
626 #[test]
627 fn error_details_already_atomised_carries_payload() {
628 let err = AtomiseError::AlreadyAtomised {
629 source_id: "s".into(),
630 existing_atom_ids: vec!["a".into(), "b".into()],
631 };
632 let det = error_details(&err).expect("details populated");
633 assert_eq!(det["existing_atom_ids"][0].as_str().unwrap(), "a");
634 assert_eq!(det["existing_atom_count"].as_i64().unwrap(), 2);
635 }
636
637 #[test]
638 fn error_details_other_variants_are_none() {
639 assert!(error_details(&AtomiseError::NotFound).is_none());
640 assert!(error_details(&AtomiseError::TierLocked).is_none());
641 assert!(error_details(&AtomiseError::SourceTooSmall).is_none());
642 assert!(error_details(&AtomiseError::CuratorFailed("x".into())).is_none());
643 }
644
645 #[test]
646 fn emit_error_writes_human_message_to_stderr() {
647 let mut stdout = Vec::<u8>::new();
648 let mut stderr = Vec::<u8>::new();
649 let mut out = CliOutput {
650 stdout: &mut stdout,
651 stderr: &mut stderr,
652 };
653 let code = emit_error(&AtomiseError::NotFound, "src-xyz", false, &mut out).unwrap();
654 assert_eq!(code, 2);
655 assert!(stdout.is_empty());
656 let s = String::from_utf8(stderr).unwrap();
657 assert!(s.contains("src-xyz"));
658 assert!(s.contains("not found"));
659 }
660
661 #[test]
662 fn emit_error_writes_json_envelope_to_stderr() {
663 let mut stdout = Vec::<u8>::new();
664 let mut stderr = Vec::<u8>::new();
665 let mut out = CliOutput {
666 stdout: &mut stdout,
667 stderr: &mut stderr,
668 };
669 let err = AtomiseError::AlreadyAtomised {
670 source_id: "src-1".into(),
671 existing_atom_ids: vec!["a".into(), "b".into()],
672 };
673 let code = emit_error(&err, "src-1", true, &mut out).unwrap();
674 assert_eq!(code, 1);
675 let s = String::from_utf8(stderr).unwrap();
676 let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
677 assert_eq!(v["error"], "already_atomised");
678 assert_eq!(v["exit_code"], 1);
679 assert_eq!(v["source_id"], "src-1");
680 assert_eq!(v["details"]["existing_atom_count"], 2);
681 }
682
683 #[test]
684 fn emit_success_writes_human_summary_to_stdout() {
685 let mut stdout = Vec::<u8>::new();
686 let mut stderr = Vec::<u8>::new();
687 let mut out = CliOutput {
688 stdout: &mut stdout,
689 stderr: &mut stderr,
690 };
691 let r = crate::atomisation::AtomiseResult {
692 source_id: "src-1".into(),
693 atom_ids: vec!["a1".into(), "a2".into()],
694 atom_count: 2,
695 archived_at: "2026-05-14T00:00:00Z".into(),
696 };
697 let code = emit_success(&r, false, &mut out).unwrap();
698 assert_eq!(code, 0);
699 assert!(stderr.is_empty());
700 let s = String::from_utf8(stdout).unwrap();
701 assert!(s.contains("src-1"));
702 assert!(s.contains("2 atoms"));
703 assert!(s.contains("2026-05-14T00:00:00Z"));
704 assert!(s.contains("a1, a2"));
705 }
706
707 #[test]
708 fn emit_success_writes_json_envelope_to_stdout() {
709 let mut stdout = Vec::<u8>::new();
710 let mut stderr = Vec::<u8>::new();
711 let mut out = CliOutput {
712 stdout: &mut stdout,
713 stderr: &mut stderr,
714 };
715 let r = crate::atomisation::AtomiseResult {
716 source_id: "src-1".into(),
717 atom_ids: vec!["a1".into(), "a2".into()],
718 atom_count: 2,
719 archived_at: "2026-05-14T00:00:00Z".into(),
720 };
721 let code = emit_success(&r, true, &mut out).unwrap();
722 assert_eq!(code, 0);
723 assert!(stderr.is_empty());
724 let s = String::from_utf8(stdout).unwrap();
725 let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
726 assert_eq!(v["source_id"], "src-1");
727 assert_eq!(v["atom_count"], 2);
728 assert_eq!(v["atom_ids"][0], "a1");
729 assert_eq!(v["archived_at"], "2026-05-14T00:00:00Z");
730 }
731}