nebu-ctx 0.10.1

NebuCtx runtime for the nebu-ctx self-hosted client/server product.
Documentation
use crate::cli::has_flag;
use crate::cli::option_value;
use crate::config;
use crate::server_client::ServerClient;
use anyhow::{Context, Result};
use serde_json::{json, Map, Value};

const USAGE: &str = "Usage:
  nebu-ctx memory list [--type brain|knowledge] [--category <name>] [--source-type <type>]
                       [--since <window>] [--lifecycle-status <status>]
                       [--limit N] [--offset N]
                       [--sort-field <field>] [--sort-direction asc|desc]
                       [--promoted-from-session <id>] [--promoted-from-brain-key <key>]
                       [--json]
  nebu-ctx memory lifecycle <stats|promotions|stale|scoring>
                            [--type brain|knowledge]
                            [--days N] [--limit N]
                            [--category <name>] [--key <key>]
                            [--json]
  nebu-ctx memory export [--type brain|knowledge] [--category <name>] [--since <window>]
                          [--lifecycle-status <status>] [--limit N] [--output <file>]
  nebu-ctx memory import <file> [--type brain|knowledge] [--overwrite]";

pub fn cmd_memory(args: &[String]) {
    if has_flag(args, &["--help", "-h", "help"]) || args.is_empty() {
        println!("{USAGE}");
        return;
    }

    let sub = args[0].as_str();
    match sub {
        "list" => match run_memory_list(&args[1..]) {
            Ok(()) => {}
            Err(e) => {
                eprintln!("nebu-ctx memory list: {e}");
                eprintln!("\n{USAGE}");
                std::process::exit(1);
            }
        },
        "lifecycle" => match run_memory_lifecycle(&args[1..]) {
            Ok(()) => {}
            Err(e) => {
                eprintln!("nebu-ctx memory lifecycle: {e}");
                eprintln!("\n{USAGE}");
                std::process::exit(1);
            }
        },
        "export" => match run_memory_export(&args[1..]) {
            Ok(()) => {}
            Err(e) => {
                eprintln!("nebu-ctx memory export: {e}");
                eprintln!("\n{USAGE}");
                std::process::exit(1);
            }
        },
        "import" => match run_memory_import(&args[1..]) {
            Ok(()) => {}
            Err(e) => {
                eprintln!("nebu-ctx memory import: {e}");
                eprintln!("\n{USAGE}");
                std::process::exit(1);
            }
        },
        _ => {
            eprintln!("Unknown memory subcommand '{sub}'.");
            eprintln!("\n{USAGE}");
            std::process::exit(1);
        }
    }
}

fn run_memory_list(args: &[String]) -> Result<()> {
    let connection = config::load_connection()?.ok_or_else(|| {
        anyhow::anyhow!(
            "nebu-ctx memory list requires a configured host.\n\
Run: nebu-ctx connect --endpoint <url> --token <token>"
        )
    })?;
    let client = ServerClient::new(connection);

    let memory_type = option_value(args, &["--type", "-t"]).unwrap_or_else(|| "knowledge".to_string());
    let tool_name = match memory_type.as_str() {
        "brain" => "ctx_brain",
        "knowledge" => "ctx_knowledge",
        other => {
            anyhow::bail!("--type must be 'brain' or 'knowledge', got '{other}'");
        }
    };

    let mut arguments = Map::new();
    arguments.insert("action".into(), Value::String("list".into()));
    if let Some(v) = option_value(args, &["--category", "-c"]) {
        arguments.insert("category".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--source-type"]) {
        arguments.insert("source_type".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--lifecycle-status"]) {
        arguments.insert("lifecycle_status".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--since"]) {
        arguments.insert("since".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--limit", "-n"]) {
        let n: i64 = v
            .parse()
            .with_context(|| format!("invalid --limit value: {v}"))?;
        arguments.insert("limit".into(), json!(n));
    }
    if let Some(v) = option_value(args, &["--offset"]) {
        let n: i64 = v
            .parse()
            .with_context(|| format!("invalid --offset value: {v}"))?;
        arguments.insert("offset".into(), json!(n));
    }
    if let Some(v) = option_value(args, &["--sort-field"]) {
        arguments.insert("sort_field".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--sort-direction", "--sort-dir"]) {
        arguments.insert("sort_direction".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--promoted-from-session"]) {
        arguments.insert("promoted_from_session".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--promoted-from-brain-key"]) {
        arguments.insert("promoted_from_brain_key".into(), Value::String(v));
    }

    let project_context = crate::git_context::discover_project_context(
        &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
    );

    let result = client
        .call_tool(tool_name, arguments, &project_context)
        .with_context(|| format!("calling {tool_name} list"))?;

    if has_flag(args, &["--json"]) {
        println!(
            "{}",
            serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
        );
        return Ok(());
    }

    print_memory_list_human(&result);
    Ok(())
}

fn print_memory_list_human(result: &Value) {
    let memories = result
        .get("memories")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let total = result
        .get("total")
        .and_then(|v| v.as_i64())
        .unwrap_or(memories.len() as i64);
    let count = result
        .get("count")
        .and_then(|v| v.as_i64())
        .unwrap_or(memories.len() as i64);
    println!("{count} of {total} memories:");
    for (i, m) in memories.iter().enumerate() {
        let key = m.get("key").and_then(|v| v.as_str()).unwrap_or("?");
        let category = m.get("category").and_then(|v| v.as_str()).unwrap_or("-");
        let value = m
            .get("value")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let confidence = m
            .get("confidence")
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0);
        let status = m
            .get("lifecycle_status")
            .and_then(|v| v.as_str())
            .unwrap_or("?");
        println!(
            "  {i:>3}. [{category:>14}] {key}\n       conf={confidence:.2} status={status}\n       {value}"
        );
    }
    if memories.is_empty() {
        println!("  (no memories matched)");
    }
}

fn run_memory_lifecycle(args: &[String]) -> Result<()> {
    if args.is_empty() {
        anyhow::bail!("missing lifecycle sub-action: stats|promotions|stale|scoring");
    }
    let sub = args[0].as_str();
    if !matches!(sub, "stats" | "promotions" | "stale" | "scoring") {
        anyhow::bail!("unknown lifecycle sub-action '{sub}'. Use stats|promotions|stale|scoring");
    }

    let connection = config::load_connection()?.ok_or_else(|| {
        anyhow::anyhow!(
            "nebu-ctx memory lifecycle requires a configured host.\nRun: nebu-ctx connect --endpoint <url> --token <token>"
        )
    })?;
    let client = ServerClient::new(connection);

    let memory_type = option_value(args, &["--type", "-t"]).unwrap_or_else(|| "knowledge".to_string());
    let tool_name = match memory_type.as_str() {
        "brain" => "ctx_brain",
        "knowledge" => "ctx_knowledge",
        other => anyhow::bail!("--type must be 'brain' or 'knowledge', got '{other}'"),
    };

    let mut arguments = Map::new();
    arguments.insert("action".into(), Value::String("lifecycle".into()));
    arguments.insert("lifecycle_subaction".into(), Value::String(sub.into()));
    if let Some(v) = option_value(args, &["--days"]) {
        let n: i64 = v
            .parse()
            .with_context(|| format!("invalid --days value: {v}"))?;
        arguments.insert("lifecycle_days".into(), json!(n));
    }
    if let Some(v) = option_value(args, &["--limit", "-n"]) {
        let n: i64 = v
            .parse()
            .with_context(|| format!("invalid --limit value: {v}"))?;
        arguments.insert("limit".into(), json!(n));
    }
    if let Some(v) = option_value(args, &["--category", "-c"]) {
        arguments.insert("category".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--key", "-k"]) {
        arguments.insert("key".into(), Value::String(v));
    }

    let project_context = crate::git_context::discover_project_context(
        &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
    );
    let result = client
        .call_tool(tool_name, arguments, &project_context)
        .with_context(|| format!("calling {tool_name} lifecycle {sub}"))?;

    if has_flag(args, &["--json"]) {
        println!(
            "{}",
            serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
        );
        return Ok(());
    }
    println!(
        "{}",
        serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
    );
    Ok(())
}

fn run_memory_export(args: &[String]) -> Result<()> {
    let connection = config::load_connection()?.ok_or_else(|| {
        anyhow::anyhow!(
            "nebu-ctx memory export requires a configured host.\n\
Run: nebu-ctx connect --endpoint <url> --token <token>"
        )
    })?;
    let client = ServerClient::new(connection);

    let memory_type = option_value(args, &["--type", "-t"]).unwrap_or_else(|| "knowledge".to_string());
    let tool_name = match memory_type.as_str() {
        "brain" => "ctx_brain",
        "knowledge" => "ctx_knowledge",
        other => anyhow::bail!("--type must be 'brain' or 'knowledge', got '{other}'"),
    };

    let mut arguments = Map::new();
    arguments.insert("action".into(), Value::String("list".into()));
    if let Some(v) = option_value(args, &["--category", "-c"]) {
        arguments.insert("category".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--since"]) {
        arguments.insert("since".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--lifecycle-status"]) {
        arguments.insert("lifecycle_status".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--limit", "-n"]) {
        let n: i64 = v
            .parse()
            .with_context(|| format!("invalid --limit value: {v}"))?;
        arguments.insert("limit".into(), json!(n));
    }

    let project_context = crate::git_context::discover_project_context(
        &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
    );

    let result = client
        .call_tool(tool_name, arguments, &project_context)
        .with_context(|| format!("calling {tool_name} export"))?;

    let memories = result
        .get("memories")
        .cloned()
        .unwrap_or(Value::Array(Vec::new()));
    let count = memories.as_array().map(|a| a.len()).unwrap_or(0);

    let mut filters = Map::new();
    if let Some(v) = option_value(args, &["--category", "-c"]) {
        filters.insert("category".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--since"]) {
        filters.insert("since".into(), Value::String(v));
    }
    if let Some(v) = option_value(args, &["--lifecycle-status"]) {
        filters.insert("lifecycle_status".into(), Value::String(v));
    }

    let export = json!({
        "export_info": {
            "timestamp": chrono::Utc::now().to_rfc3339(),
            "version": env!("CARGO_PKG_VERSION"),
            "memory_type": memory_type,
            "filters_applied": filters,
            "memory_counts": {
                memory_type: count,
                "total": count,
            },
        },
        "memories": memories,
        "schema_version": "1",
    });

    let serialized =
        serde_json::to_string_pretty(&export).context("serializing export")?;

    if let Some(path) = option_value(args, &["--output", "-o"]) {
        std::fs::write(&path, &serialized)
            .with_context(|| format!("writing export to {path}"))?;
        println!("Wrote {count} memories to {path}");
    } else {
        println!("{serialized}");
    }
    Ok(())
}

fn run_memory_import(args: &[String]) -> Result<()> {
    if args.is_empty() {
        anyhow::bail!("missing import file path.\nUsage: nebu-ctx memory import <file> [--type brain|knowledge] [--overwrite]");
    }
    let path = &args[0];
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("reading import file {path}"))?;
    let payload: Value = serde_json::from_str(&raw)
        .with_context(|| format!("parsing import file {path} as JSON"))?;

    let memories = payload
        .get("memories")
        .and_then(|v| v.as_array())
        .ok_or_else(|| anyhow::anyhow!("import file is missing 'memories' array"))?;
    if memories.is_empty() {
        println!("Import file contains no memories; nothing to do.");
        return Ok(());
    }

    let connection = config::load_connection()?.ok_or_else(|| {
        anyhow::anyhow!(
            "nebu-ctx memory import requires a configured host.\n\
Run: nebu-ctx connect --endpoint <url> --token <token>"
        )
    })?;
    let client = ServerClient::new(connection);

    let memory_type = option_value(args, &["--type", "-t"]).unwrap_or_else(|| "knowledge".to_string());
    let tool_name = match memory_type.as_str() {
        "brain" => "ctx_brain",
        "knowledge" => "ctx_knowledge",
        other => anyhow::bail!("--type must be 'brain' or 'knowledge', got '{other}'"),
    };

    let mut arguments = Map::new();
    arguments.insert("action".into(), Value::String("import".into()));
    arguments.insert("import_payload".into(), json!({ "memories": memories }));
    if has_flag(args, &["--overwrite", "-f"]) {
        arguments.insert("overwrite".into(), Value::Bool(true));
    }

    let project_context = crate::git_context::discover_project_context(
        &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
    );

    let result = client
        .call_tool(tool_name, arguments, &project_context)
        .with_context(|| format!("calling {tool_name} import"))?;

    let added = result.get("added").and_then(|v| v.as_i64()).unwrap_or(0);
    let updated = result.get("updated").and_then(|v| v.as_i64()).unwrap_or(0);
    let skipped = result.get("skipped").and_then(|v| v.as_i64()).unwrap_or(0);
    println!(
        "Import complete: added={added} updated={updated} skipped={skipped} total={}",
        memories.len()
    );
    Ok(())
}