use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use clap::Args;
use serde::Serialize;
use crate::atomisation::curator::{Curator, LlmCurator};
use crate::atomisation::{AtomiseError, Atomiser, AtomiserConfig};
use crate::cli::CliOutput;
use crate::config::{AppConfig, FeatureTier};
use crate::db;
use crate::identity::keypair as identity_keypair;
use crate::llm::OllamaClient;
#[derive(Args, Debug, Clone)]
pub struct AtomiseArgs {
pub memory_id: String,
#[arg(long, default_value_t = 200)]
pub max_atom_tokens: u32,
#[arg(long, default_value_t = false)]
pub force: bool,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub quiet: bool,
}
#[derive(Debug, Serialize)]
struct SuccessEnvelope<'a> {
source_id: &'a str,
atom_ids: &'a [String],
atom_count: usize,
archived_at: &'a str,
}
#[derive(Debug, Serialize)]
struct ErrorEnvelope<'a> {
error: &'static str,
message: String,
exit_code: i32,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<serde_json::Value>,
source_id: &'a str,
}
#[must_use]
pub fn exit_code(err: &AtomiseError) -> i32 {
match err {
AtomiseError::AlreadyAtomised { .. } | AtomiseError::SourceTooSmall => 1,
AtomiseError::NotFound => 2,
AtomiseError::TierLocked => 3,
AtomiseError::CuratorFailed(_) => 4,
AtomiseError::GovernanceRefused(_) => 5,
AtomiseError::DbError(_) | AtomiseError::SignerError(_) => 6,
AtomiseError::DepthExceeded { .. } => 7,
}
}
#[must_use]
pub fn error_slug(err: &AtomiseError) -> &'static str {
match err {
AtomiseError::AlreadyAtomised { .. } => "already_atomised",
AtomiseError::SourceTooSmall => "source_too_small",
AtomiseError::NotFound => "not_found",
AtomiseError::TierLocked => "tier_locked",
AtomiseError::CuratorFailed(_) => "curator_failed",
AtomiseError::GovernanceRefused(_) => crate::errors::error_codes::GOVERNANCE_REFUSED,
AtomiseError::DbError(_) => "db_error",
AtomiseError::SignerError(_) => "signer_error",
AtomiseError::DepthExceeded { .. } => "ATOMISATION_DEPTH_EXCEEDED",
}
}
#[must_use]
pub fn human_error_message(err: &AtomiseError, source_id: &str) -> String {
match err {
AtomiseError::NotFound => format!("Memory ID {source_id} not found"),
AtomiseError::AlreadyAtomised {
source_id: sid,
existing_atom_ids,
} => {
let ids = existing_atom_ids.join(", ");
format!(
"Memory {sid} already atomised into {n} atoms. Use --force to re-atomise. \
Existing atom IDs: {ids}",
n = existing_atom_ids.len()
)
}
AtomiseError::TierLocked => {
"memory_atomise requires smart tier or higher. Current tier: keyword. \
Upgrade your deployment or use --tier semantic when running ai-memory mcp."
.to_string()
}
AtomiseError::CuratorFailed(detail) => {
format!("Curator pass failed: {detail}. Check Ollama availability or retry.")
}
AtomiseError::SourceTooSmall => format!(
"Memory {source_id} body already at or under max_atom_tokens. \
No atomisation needed."
),
AtomiseError::GovernanceRefused(detail) => {
format!("Atomisation refused: {detail}")
}
AtomiseError::SignerError(detail) => format!("Signer error: {detail}"),
AtomiseError::DbError(detail) => format!("Database error: {detail}"),
AtomiseError::DepthExceeded { attempted, cap } => format!(
"Atomisation refused: depth {attempted} would exceed compiled \
max_atomisation_depth {cap}. A recursive atomisation chain hit \
the cycle-depth cap — inspect the curator / pre_store hook stack \
that re-entered atomise."
),
}
}
#[must_use]
fn error_details(err: &AtomiseError) -> Option<serde_json::Value> {
match err {
AtomiseError::AlreadyAtomised {
existing_atom_ids, ..
} => Some(serde_json::json!({
"existing_atom_ids": existing_atom_ids,
"existing_atom_count": existing_atom_ids.len(),
})),
_ => None,
}
}
pub fn run(
db_path: &Path,
args: &AtomiseArgs,
app_config: &AppConfig,
cli_agent_id: Option<&str>,
out: &mut CliOutput<'_>,
) -> Result<i32> {
run_with_curator(db_path, args, app_config, cli_agent_id, out, None)
}
pub fn run_with_curator(
db_path: &Path,
args: &AtomiseArgs,
app_config: &AppConfig,
cli_agent_id: Option<&str>,
out: &mut CliOutput<'_>,
curator_override: Option<Box<dyn Curator>>,
) -> Result<i32> {
let tier = app_config.effective_tier(None);
if tier == FeatureTier::Keyword {
let err = AtomiseError::TierLocked;
return emit_error(&err, &args.memory_id, args.json, out);
}
let calling_agent_id = match crate::identity::resolve_agent_id(cli_agent_id, None) {
Ok(id) => id,
Err(e) => {
let err = AtomiseError::DbError(format!("agent_id resolution failed: {e}"));
return emit_error(&err, &args.memory_id, args.json, out);
}
};
let conn = match db::open(db_path) {
Ok(c) => c,
Err(e) => {
let err = AtomiseError::DbError(format!("open {}: {e}", db_path.display()));
return emit_error(&err, &args.memory_id, args.json, out);
}
};
let (curator, curator_model): (Box<dyn Curator>, String) = if let Some(c) = curator_override {
(c, "unknown".to_string())
} else {
match build_llm_curator(tier) {
Ok((c, model)) => (c, model),
Err(e) => {
let err = AtomiseError::CuratorFailed(e);
return emit_error(&err, &args.memory_id, args.json, out);
}
}
};
let keypair = load_keypair_best_effort(&calling_agent_id);
let atomiser = Atomiser::new(curator, keypair, AtomiserConfig::default(), tier)
.with_curator_model(curator_model);
match atomiser.atomise_sync(
&conn,
&args.memory_id,
args.max_atom_tokens,
args.force,
&calling_agent_id,
) {
Ok(result) => emit_success(&result, args.json, out),
Err(e) => emit_error(&e, &args.memory_id, args.json, out),
}
}
fn build_llm_curator(tier: FeatureTier) -> std::result::Result<(Box<dyn Curator>, String), String> {
let _ = tier;
let app_config = AppConfig::load();
let resolved = app_config.resolve_llm(None, None, None);
match OllamaClient::build_from_resolved(&resolved) {
Ok(Some(client)) => {
let model = client.model_name().to_string();
Ok((Box::new(LlmCurator::new(client)), model))
}
Ok(None) => Err(format!(
"atomise: LLM resolver returned no client \
(backend={}, source={}); atomise requires a curator LLM",
resolved.backend,
resolved.source.as_str()
)),
Err(e) => Err(format!(
"atomise: LLM init failed (backend={}, source={}): {e}",
resolved.backend,
resolved.source.as_str()
)),
}
}
fn load_keypair_best_effort(agent_id: &str) -> Option<Arc<crate::identity::keypair::AgentKeypair>> {
let dir = identity_keypair::default_key_dir().ok()?;
identity_keypair::load(agent_id, &dir).ok().map(Arc::new)
}
fn emit_success(
result: &crate::atomisation::AtomiseResult,
json: bool,
out: &mut CliOutput<'_>,
) -> Result<i32> {
if json {
let env = SuccessEnvelope {
source_id: &result.source_id,
atom_ids: &result.atom_ids,
atom_count: result.atom_count,
archived_at: &result.archived_at,
};
writeln!(out.stdout, "{}", serde_json::to_string(&env)?)?;
} else {
let ids = result.atom_ids.join(", ");
writeln!(
out.stdout,
"Atomised memory {src} into {n} atoms. Source archived at {ts}. Atom IDs: {ids}",
src = result.source_id,
n = result.atom_count,
ts = result.archived_at,
)?;
}
Ok(0)
}
fn emit_error(
err: &AtomiseError,
source_id: &str,
json: bool,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let code = exit_code(err);
let message = human_error_message(err, source_id);
if json {
let env = ErrorEnvelope {
error: error_slug(err),
message: message.clone(),
exit_code: code,
details: error_details(err),
source_id,
};
writeln!(out.stderr, "{}", serde_json::to_string(&env)?)?;
} else {
writeln!(out.stderr, "{message}")?;
}
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exit_code_maps_every_variant() {
assert_eq!(exit_code(&AtomiseError::NotFound), 2);
assert_eq!(exit_code(&AtomiseError::TierLocked), 3);
assert_eq!(exit_code(&AtomiseError::CuratorFailed("x".into())), 4);
assert_eq!(exit_code(&AtomiseError::GovernanceRefused("x".into())), 5);
assert_eq!(exit_code(&AtomiseError::SourceTooSmall), 1);
assert_eq!(exit_code(&AtomiseError::DbError("x".into())), 6);
assert_eq!(exit_code(&AtomiseError::SignerError("x".into())), 6);
assert_eq!(
exit_code(&AtomiseError::AlreadyAtomised {
source_id: "s".into(),
existing_atom_ids: vec!["a".into()]
}),
1
);
assert_eq!(
exit_code(&AtomiseError::DepthExceeded {
attempted: 4,
cap: crate::atomisation::MAX_ATOMISATION_DEPTH,
}),
7
);
}
#[test]
fn error_slug_maps_every_variant() {
assert_eq!(error_slug(&AtomiseError::NotFound), "not_found");
assert_eq!(error_slug(&AtomiseError::TierLocked), "tier_locked");
assert_eq!(
error_slug(&AtomiseError::CuratorFailed("x".into())),
"curator_failed"
);
assert_eq!(
error_slug(&AtomiseError::GovernanceRefused("x".into())),
"GOVERNANCE_REFUSED"
);
assert_eq!(
error_slug(&AtomiseError::SourceTooSmall),
"source_too_small"
);
assert_eq!(error_slug(&AtomiseError::DbError("x".into())), "db_error");
assert_eq!(
error_slug(&AtomiseError::SignerError("x".into())),
"signer_error"
);
assert_eq!(
error_slug(&AtomiseError::AlreadyAtomised {
source_id: "s".into(),
existing_atom_ids: vec!["a".into()]
}),
"already_atomised"
);
assert_eq!(
error_slug(&AtomiseError::DepthExceeded {
attempted: 4,
cap: crate::atomisation::MAX_ATOMISATION_DEPTH,
}),
"ATOMISATION_DEPTH_EXCEEDED"
);
}
#[test]
fn human_error_message_tier_locked_carries_upgrade_hint() {
let msg = human_error_message(&AtomiseError::TierLocked, "src");
assert!(msg.contains("requires smart tier"));
assert!(msg.contains("keyword"));
assert!(msg.contains("Upgrade your deployment"));
}
#[test]
fn human_error_message_not_found_carries_source_id() {
let msg = human_error_message(&AtomiseError::NotFound, "src-123");
assert!(msg.contains("src-123"), "got: {msg}");
assert!(msg.contains("not found"));
}
#[test]
fn human_error_message_already_atomised_lists_existing_ids() {
let err = AtomiseError::AlreadyAtomised {
source_id: "src-9".into(),
existing_atom_ids: vec!["a1".into(), "a2".into(), "a3".into()],
};
let msg = human_error_message(&err, "src-9");
assert!(msg.contains("src-9"));
assert!(msg.contains("3 atoms"));
assert!(msg.contains("--force"));
assert!(msg.contains("a1, a2, a3"));
}
#[test]
fn human_error_message_source_too_small_carries_source_id() {
let msg = human_error_message(&AtomiseError::SourceTooSmall, "src-x");
assert!(msg.contains("src-x"));
assert!(msg.contains("max_atom_tokens"));
}
#[test]
fn human_error_message_curator_failed_carries_detail() {
let msg = human_error_message(&AtomiseError::CuratorFailed("ollama down".into()), "src");
assert!(msg.contains("ollama down"));
assert!(msg.contains("Ollama"));
}
#[test]
fn human_error_message_governance_refused_carries_detail() {
let msg = human_error_message(
&AtomiseError::GovernanceRefused("atom[2]: policy".into()),
"src",
);
assert!(msg.contains("policy"));
assert!(msg.contains("atom[2]"));
}
#[test]
fn human_error_message_signer_error_and_db_error_carry_detail() {
let sig = human_error_message(&AtomiseError::SignerError("key revoked".into()), "src");
assert!(sig.starts_with("Signer error:"));
assert!(sig.contains("key revoked"));
let db = human_error_message(&AtomiseError::DbError("disk full".into()), "src");
assert!(db.starts_with("Database error:"));
assert!(db.contains("disk full"));
}
#[test]
fn run_wrapper_delegates_to_run_with_curator_keyword_tier_short_circuits() {
use crate::config::AppConfig;
let mut cfg = AppConfig::default();
cfg.tier = Some("keyword".to_string());
let args = AtomiseArgs {
memory_id: "src-id".to_string(),
max_atom_tokens: 100,
force: false,
json: false,
quiet: false,
};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("atomise-cli.db");
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let code = run(&db_path, &args, &cfg, None, &mut out).unwrap();
assert_eq!(code, 3);
let s = String::from_utf8(stderr).unwrap();
assert!(
s.contains("requires smart tier") || s.contains("tier"),
"got stderr: {s}",
);
}
#[test]
fn error_details_already_atomised_carries_payload() {
let err = AtomiseError::AlreadyAtomised {
source_id: "s".into(),
existing_atom_ids: vec!["a".into(), "b".into()],
};
let det = error_details(&err).expect("details populated");
assert_eq!(det["existing_atom_ids"][0].as_str().unwrap(), "a");
assert_eq!(det["existing_atom_count"].as_i64().unwrap(), 2);
}
#[test]
fn error_details_other_variants_are_none() {
assert!(error_details(&AtomiseError::NotFound).is_none());
assert!(error_details(&AtomiseError::TierLocked).is_none());
assert!(error_details(&AtomiseError::SourceTooSmall).is_none());
assert!(error_details(&AtomiseError::CuratorFailed("x".into())).is_none());
}
#[test]
fn emit_error_writes_human_message_to_stderr() {
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let code = emit_error(&AtomiseError::NotFound, "src-xyz", false, &mut out).unwrap();
assert_eq!(code, 2);
assert!(stdout.is_empty());
let s = String::from_utf8(stderr).unwrap();
assert!(s.contains("src-xyz"));
assert!(s.contains("not found"));
}
#[test]
fn emit_error_writes_json_envelope_to_stderr() {
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let err = AtomiseError::AlreadyAtomised {
source_id: "src-1".into(),
existing_atom_ids: vec!["a".into(), "b".into()],
};
let code = emit_error(&err, "src-1", true, &mut out).unwrap();
assert_eq!(code, 1);
let s = String::from_utf8(stderr).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["error"], "already_atomised");
assert_eq!(v["exit_code"], 1);
assert_eq!(v["source_id"], "src-1");
assert_eq!(v["details"]["existing_atom_count"], 2);
}
#[test]
fn emit_success_writes_human_summary_to_stdout() {
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let r = crate::atomisation::AtomiseResult {
source_id: "src-1".into(),
atom_ids: vec!["a1".into(), "a2".into()],
atom_count: 2,
archived_at: "2026-05-14T00:00:00Z".into(),
};
let code = emit_success(&r, false, &mut out).unwrap();
assert_eq!(code, 0);
assert!(stderr.is_empty());
let s = String::from_utf8(stdout).unwrap();
assert!(s.contains("src-1"));
assert!(s.contains("2 atoms"));
assert!(s.contains("2026-05-14T00:00:00Z"));
assert!(s.contains("a1, a2"));
}
#[test]
fn emit_success_writes_json_envelope_to_stdout() {
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let mut out = CliOutput {
stdout: &mut stdout,
stderr: &mut stderr,
};
let r = crate::atomisation::AtomiseResult {
source_id: "src-1".into(),
atom_ids: vec!["a1".into(), "a2".into()],
atom_count: 2,
archived_at: "2026-05-14T00:00:00Z".into(),
};
let code = emit_success(&r, true, &mut out).unwrap();
assert_eq!(code, 0);
assert!(stderr.is_empty());
let s = String::from_utf8(stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["source_id"], "src-1");
assert_eq!(v["atom_count"], 2);
assert_eq!(v["atom_ids"][0], "a1");
assert_eq!(v["archived_at"], "2026-05-14T00:00:00Z");
}
}