kiromi-ai-cli 0.2.2

Operator and developer CLI for the kiromi-ai-memory store: append, search, snapshot, regenerate, migrate-scheme, gc, audit-tail.
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! `kiromi-ai-memory append` — write a memory.

use kiromi_ai_memory::{AppendOpts, Content, Partitions};
use tokio::io::AsyncReadExt;

use crate::cli::{AppendArgs, GlobalArgs};
use crate::error::{CliError, ExitCode};
use crate::runtime::Runtime;

pub(crate) async fn run(args: AppendArgs, globals: &GlobalArgs) -> Result<(), CliError> {
    let rt = Runtime::open(globals).await?;

    let body = read_body(&args).await?;
    let content = match args.kind.as_str() {
        "md" => Content::markdown(body),
        "txt" => Content::text(body),
        other => {
            return Err(CliError {
                kind: ExitCode::Config,
                source: anyhow::anyhow!("unknown --kind {other:?}; expected 'md' or 'txt'"),
            });
        }
    };

    let mut opts = AppendOpts::default();
    if let Some(path) = args.embedding {
        let raw = tokio::fs::read(&path).await.map_err(|e| CliError {
            kind: ExitCode::Config,
            source: anyhow::anyhow!("read embedding {}: {e}", path.display()),
        })?;
        let v: Vec<f32> = serde_json::from_slice(&raw).map_err(|e| CliError {
            kind: ExitCode::Config,
            source: anyhow::anyhow!("parse embedding {}: {e}", path.display()),
        })?;
        opts = opts.with_embedding(v);
    }
    if !args.links.is_empty() {
        return Err(CliError {
            kind: ExitCode::Config,
            source: anyhow::anyhow!(
                "passing --link at append time requires a separate `link add` step in slice 1; use `kiromi-ai-memory link add <src> <dst>` after append"
            ),
        });
    }

    let partitions = parse_partitions(&args.partition)?;
    let r = rt.mem.append(partitions, content, opts).await?;

    if globals.json {
        let payload = serde_json::json!({
            "id": r.id.to_string(),
            "partition": r.partition.as_str(),
        });
        println!(
            "{}",
            serde_json::to_string_pretty(&payload).unwrap_or_default()
        );
    } else {
        println!("{}", r.id);
    }
    rt.mem.close().await?;
    Ok(())
}

async fn read_body(args: &AppendArgs) -> Result<String, CliError> {
    if let Some(text) = &args.message {
        return Ok(text.clone());
    }
    if let Some(p) = &args.file {
        return tokio::fs::read_to_string(p).await.map_err(|e| CliError {
            kind: ExitCode::Config,
            source: anyhow::anyhow!("read --file {}: {e}", p.display()),
        });
    }
    if args.stdin {
        let mut s = String::new();
        tokio::io::stdin()
            .read_to_string(&mut s)
            .await
            .map_err(|e| CliError {
                kind: ExitCode::Backend,
                source: anyhow::anyhow!("read stdin: {e}"),
            })?;
        return Ok(s);
    }
    Err(CliError {
        kind: ExitCode::Config,
        source: anyhow::anyhow!("one of --file, --stdin, or --message is required"),
    })
}

/// Parse `key=value,key=value` into a `Partitions` builder.
pub(crate) fn parse_partitions(spec: &str) -> Result<Partitions, CliError> {
    let mut p = Partitions::new();
    for kv in spec.split(',').filter(|s| !s.is_empty()) {
        let (k, v) = kv.split_once('=').ok_or_else(|| CliError {
            kind: ExitCode::Config,
            source: anyhow::anyhow!("partition entry {kv:?} missing `=`"),
        })?;
        p = p.with(k, v);
    }
    Ok(p)
}