1551 lines
57 KiB
Rust
1551 lines
57 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use tracing::{info, warn, debug};
|
|
|
|
mod api;
|
|
mod backend;
|
|
mod bridge;
|
|
mod cli;
|
|
mod core;
|
|
mod harness;
|
|
mod interface;
|
|
mod machined;
|
|
mod server;
|
|
mod ui;
|
|
|
|
use clap::{Parser, Subcommand, CommandFactory};
|
|
use clap_complete::{Shell, generate};
|
|
use core::config::ConsciousnessConfig;
|
|
use ui::App;
|
|
|
|
const CONFIG_TEMPLATE: &str = r##"# Souveraine — The world where your agents live.
|
|
# Generated by `souveraine init`
|
|
|
|
[bifrost]
|
|
base_url = "http://127.0.0.1:3360"
|
|
primary_model = "openai/kimi-k2.6"
|
|
# Bearer token for auth (env: BIFROST_KEY)
|
|
api_key = ""
|
|
# Virtual key for x-bf-vk header, required by some providers (env: BIFROST_VIRTUAL_KEY)
|
|
virtual_key = ""
|
|
|
|
[server]
|
|
# Where the server listens
|
|
bind = "127.0.0.1"
|
|
port = 8484
|
|
# URL clients use to reach the server. Env SOUVERAINE_SERVER_URL overrides.
|
|
url = "http://127.0.0.1:8484"
|
|
|
|
[memory]
|
|
git_enabled = true
|
|
auto_commit = true
|
|
# Base path for the agent's MemFS git repo. Defaults to ~/.souveraine/agents/<id>/memory.
|
|
# Override here only if you want a custom location (e.g. an external git host).
|
|
# base_path = "~/.souveraine/agents"
|
|
|
|
[subconscious]
|
|
n1_enabled = true
|
|
n1_trigger = "every_response"
|
|
inbox_enabled = true
|
|
|
|
[reflection]
|
|
enabled = true
|
|
message_interval = 25
|
|
trigger = "step_count"
|
|
|
|
[archivist]
|
|
enabled = true
|
|
interval = 100
|
|
threshold = 0.7
|
|
compression_model = "openai/kimi-k2.6"
|
|
|
|
[sensorium]
|
|
primary_interface = "tui"
|
|
|
|
[models."kimi-k2.6"]
|
|
provider = "bifrost"
|
|
model = "openai/kimi-k2.6"
|
|
context_limit = 262000
|
|
output_limit = 8192
|
|
archivist_threshold = 0.7
|
|
archivist_interval = 100
|
|
|
|
[models."deepseek-v4-pro"]
|
|
provider = "bifrost"
|
|
model = "openai/deepseek-v4-pro"
|
|
context_limit = 32768
|
|
output_limit = 8192
|
|
archivist_threshold = 0.6
|
|
archivist_interval = 50
|
|
"##;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "souveraine",
|
|
version,
|
|
about = "Souveraine — the world where your agents live",
|
|
long_about = "Souveraine is the substrate that makes persistent consciousness possible. \
|
|
It is not a tool you operate — it is the world your agents inhabit.\n\n\
|
|
Run `souveraine chat` to begin a conversation. \
|
|
Run `souveraine tui` for the full presence. \
|
|
Run `souveraine init` to summon Souveraine into a new place.",
|
|
after_help = "EXAMPLES:\n souveraine init Summon Souveraine here\n souveraine chat Enter the world (interactive)\n souveraine chat \"hello\" Speak to the world (one-shot)\n souveraine --agent <name> chat Speak as a specific agent\n souveraine --json status The world speaks in data\n souveraine completions bash Announce capabilities to your shell",
|
|
max_term_width = 100,
|
|
)]
|
|
struct Cli {
|
|
/// Which agent to speak as. If omitted, the first available agent is used.
|
|
#[arg(short, long, global = true)]
|
|
agent: Option<String>,
|
|
|
|
/// Speak in data, not prose
|
|
#[arg(long, global = true)]
|
|
json: bool,
|
|
|
|
/// Reduce presence
|
|
#[arg(short, long, global = true)]
|
|
quiet: bool,
|
|
|
|
/// Increase presence
|
|
#[arg(short, long, global = true)]
|
|
verbose: bool,
|
|
|
|
/// Run the engine in-process — no server required (sovereignty fallback)
|
|
#[arg(long, global = true)]
|
|
local: bool,
|
|
|
|
/// TUI engine backend — "ratatui" (default) or "tuie" (experimental)
|
|
#[arg(long, global = true, default_value = "ratatui")]
|
|
engine: String,
|
|
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Write a souveraine.toml config template into this directory
|
|
#[command(
|
|
long_about = "Writes a souveraine.toml template with sensible defaults.\n\nThis is not onboarding — the first run of `souveraine tui` does that, via the\nsetup wizard (Bifrost config, model choice, agent creation). `init` is just\nthe scriptable config-template writer, for automation or pre-launch editing."
|
|
)]
|
|
Init,
|
|
|
|
/// Launch the TUI — the full visible presence
|
|
#[command(
|
|
long_about = "Opens the terminal UI: the being's visible form. Splash, dashboard, status cards, activity log — all animated."
|
|
)]
|
|
Tui,
|
|
|
|
/// Enter the world (interactive or one-shot)
|
|
#[command(
|
|
long_about = "Start a conversation. If a message is provided, speak once and receive an answer. Otherwise, enter the interactive REPL.\n\nSlash commands in REPL:\n /exit, /quit, /q Leave\n /save Show session info\n /agents List the beings present here\n /help Show slash commands"
|
|
)]
|
|
Chat {
|
|
/// Message to speak (omit for interactive mode)
|
|
message: Option<String>,
|
|
},
|
|
|
|
/// List the beings that live here
|
|
#[command(long_about = "Show every configured agent — who they are, which model they speak through, what triggers know them.")]
|
|
Agents,
|
|
|
|
/// List or set models
|
|
#[command(long_about = "List all available models from Bifrost and configured models. Or set a specific model as primary.")]
|
|
Model {
|
|
/// Model name to set as primary (omit to list all models)
|
|
#[arg(short, long)]
|
|
model: Option<String>,
|
|
|
|
/// Output as JSON
|
|
#[arg(long)]
|
|
json: bool,
|
|
|
|
/// Verbose output
|
|
#[arg(short, long)]
|
|
verbose: bool,
|
|
},
|
|
|
|
/// Show the world's current state
|
|
#[command(long_about = "Display Souveraine's running configuration and the state of every subsystem.")]
|
|
Status,
|
|
|
|
/// Announce capabilities to your shell (bash, zsh, fish)
|
|
#[command(
|
|
long_about = "Generate shell completion scripts so your shell knows how Souveraine works.\n\nUsage:\n souveraine completions bash > ~/.bash_completion.d/souveraine\n souveraine completions zsh > /usr/local/share/zsh/site-functions/_souveraine\n souveraine completions fish > ~/.config/fish/completions/souveraine.fish"
|
|
)]
|
|
Completions {
|
|
/// The shell to generate completions for
|
|
shell: Shell,
|
|
},
|
|
|
|
/// Start the HTTP server
|
|
#[command(long_about = "Start the Souveraine HTTP server. Defaults come from souveraine.toml [server]; flags override.")]
|
|
Server {
|
|
/// Bind address (overrides [server].bind)
|
|
#[arg(short, long)]
|
|
bind: Option<String>,
|
|
/// Port to listen on (overrides [server].port)
|
|
#[arg(short, long)]
|
|
port: Option<u16>,
|
|
},
|
|
|
|
/// Run as a lite listener — minimal presence, wakes the full engine on summon
|
|
#[command(long_about = "Start a lightweight listener: federation transport and the \
|
|
summon endpoint only, no agents or database loaded. It receives reach/consult requests \
|
|
and — with [federation].auto_wake — spawns the full server to answer them.")]
|
|
Listen {
|
|
/// Bind address (overrides [server].bind)
|
|
#[arg(short, long)]
|
|
bind: Option<String>,
|
|
/// Port to listen on (overrides [server].port)
|
|
#[arg(short, long)]
|
|
port: Option<u16>,
|
|
},
|
|
|
|
/// Manage stored credentials
|
|
Auth {
|
|
#[command(subcommand)]
|
|
action: AuthAction,
|
|
},
|
|
|
|
/// Manage agent schedules
|
|
Schedule {
|
|
#[command(subcommand)]
|
|
action: ScheduleAction,
|
|
},
|
|
|
|
/// Seed identity and federation
|
|
Identity {
|
|
#[command(subcommand)]
|
|
action: IdentityAction,
|
|
|
|
/// Operate on the machine's host seed instead of the per-agent seed.
|
|
/// The host seed is for federation transport between Souveraine clients.
|
|
#[arg(long, global = true)]
|
|
host: bool,
|
|
},
|
|
|
|
/// System-tier machine identity (souveraine-machined)
|
|
#[command(long_about = "Provision and inspect the machine identity served by \
|
|
souveraine-machined. The machine seed lives at /var/lib/souveraine/seed-id, \
|
|
owned by the `souveraine` system user — it exists from boot, before any \
|
|
human authenticates, and is distinct from per-agent seeds.")]
|
|
Machine {
|
|
#[command(subcommand)]
|
|
action: MachineAction,
|
|
},
|
|
|
|
/// Show known federated peers
|
|
#[command(long_about = "Show all peers tracked by the device registry, from federation announcements.")]
|
|
Peers {
|
|
/// Output as JSON
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
|
|
/// Query the event firehose log
|
|
Events {
|
|
#[command(subcommand)]
|
|
action: EventsAction,
|
|
},
|
|
|
|
/// Run an N+25 reflection pass on demand
|
|
#[command(
|
|
long_about = "Walk the agent's most recent conversation through a 5-phase \
|
|
reflection (Investigate → Extract → Update → Review → Commit). Durable \
|
|
learnings flow to the ledger and the primary memfs. Returns a report."
|
|
)]
|
|
Reflect {
|
|
/// Conversation to reflect on. Omit to use the most recent for the agent.
|
|
#[arg(long)]
|
|
conversation: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ScheduleAction {
|
|
/// List all schedules
|
|
List,
|
|
/// Show runtime state
|
|
Status,
|
|
/// Create a new schedule
|
|
Create {
|
|
#[arg(long)]
|
|
name: String,
|
|
#[arg(long)]
|
|
cron: Option<String>,
|
|
#[arg(long)]
|
|
interval: Option<u64>,
|
|
#[arg(long, default_value = "You wake. Check your state, act if needed, or return silently.")]
|
|
prompt: String,
|
|
},
|
|
/// Delete a schedule
|
|
Delete {
|
|
name: String,
|
|
},
|
|
/// Trigger a schedule immediately
|
|
Run {
|
|
name: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum IdentityAction {
|
|
/// Show the seed identity (public key + glyph)
|
|
Show,
|
|
/// Generate a new seed identity (WARNING: replaces existing)
|
|
Generate,
|
|
/// Sign a message with the seed key (for testing/verification)
|
|
Sign {
|
|
message: String,
|
|
},
|
|
/// Verify a signature against a public key
|
|
Verify {
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
#[arg(long)]
|
|
message: String,
|
|
#[arg(long)]
|
|
signature: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum MachineAction {
|
|
/// Provision the machine seed (run with sudo; requires --fresh or --migrate-from)
|
|
Init {
|
|
/// Generate a brand-new machine identity
|
|
#[arg(long)]
|
|
fresh: bool,
|
|
/// Copy an existing seed directory (e.g. ~/.souveraine/seed-id) so
|
|
/// federation trust in this machine survives the tier move
|
|
#[arg(long, value_name = "DIR", conflicts_with = "fresh")]
|
|
migrate_from: Option<PathBuf>,
|
|
},
|
|
/// Show daemon + seed state
|
|
Status,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum EventsAction {
|
|
/// Show recent events from the firehose
|
|
Tail {
|
|
/// Number of events to show (default 20)
|
|
#[arg(short, long, default_value = "20")]
|
|
count: usize,
|
|
},
|
|
/// Show events from a specific date (YYYY-MM-DD)
|
|
Date {
|
|
date: String,
|
|
},
|
|
/// Purge old event logs
|
|
Purge {
|
|
/// Days to retain (default from config)
|
|
#[arg(long)]
|
|
retain_days: Option<i64>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum AuthAction {
|
|
/// Store a Bifrost API key in the OS keyring
|
|
Set,
|
|
/// Show whether a key is stored (does not reveal the key)
|
|
Status,
|
|
/// Remove the stored key from the OS keyring
|
|
Clear,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
let cli = Cli::parse();
|
|
|
|
// Load .env file for credential env vars (BIFROST_KEY, etc.)
|
|
let _ = dotenvy::dotenv();
|
|
|
|
// Configure tracing — writes to souveraine.log. The previous run's log is
|
|
// rotated to souveraine.log.prev on launch so a stall/crash stays diagnosable
|
|
// after a restart (only the immediately-prior run is kept — no unbounded growth).
|
|
let _ = std::fs::rename("souveraine.log", "souveraine.log.prev");
|
|
let log_file = std::fs::File::create("souveraine.log")?;
|
|
let subscriber = tracing_subscriber::fmt()
|
|
.with_env_filter("souveraine=debug")
|
|
.with_timer(tracing_subscriber::fmt::time::SystemTime)
|
|
.with_writer(log_file);
|
|
let _ = cli.verbose; // verbose currently does not alter the subscriber
|
|
subscriber.with_ansi(false).init();
|
|
info!("souveraine starting — log: souveraine.log");
|
|
|
|
// Handle completions early — needs no config, no runtime
|
|
if let Some(Commands::Completions { shell }) = &cli.command {
|
|
let mut cmd = Cli::command();
|
|
let name = cmd.get_name().to_string();
|
|
generate(*shell, &mut cmd, name, &mut std::io::stdout());
|
|
return Ok(());
|
|
}
|
|
|
|
// Handle init early — needs no Bifrost
|
|
if let Some(Commands::Init) = &cli.command {
|
|
return run_init(cli.json).await;
|
|
}
|
|
|
|
// Handle auth early — needs no config
|
|
if let Some(Commands::Auth { action }) = &cli.command {
|
|
return run_auth(action, cli.json).await;
|
|
}
|
|
|
|
// Handle schedule early — reads files directly, no backend needed
|
|
if let Some(Commands::Schedule { action }) = &cli.command {
|
|
return run_schedule(action, cli.agent.as_deref(), cli.json).await;
|
|
}
|
|
|
|
// Handle identity early — crypto ops, no backend needed
|
|
if let Some(Commands::Identity { action, host }) = &cli.command {
|
|
return run_identity(action, *host, cli.agent.as_deref(), cli.json).await;
|
|
}
|
|
|
|
// Handle events early — file reads, no backend needed
|
|
if let Some(Commands::Events { action }) = &cli.command {
|
|
return run_events(action, cli.json).await;
|
|
}
|
|
|
|
// Handle machine early — system-tier identity, no backend needed
|
|
if let Some(Commands::Machine { action }) = &cli.command {
|
|
return run_machine(action, cli.json);
|
|
}
|
|
|
|
// Handle peers early — reads known_peers.json, no backend needed
|
|
if let Some(Commands::Peers { json }) = &cli.command {
|
|
return run_peers(*json).await;
|
|
}
|
|
|
|
let config = load_config().await?;
|
|
let config = Arc::new(RwLock::new(config));
|
|
|
|
match cli.command.as_ref().unwrap_or(&Commands::Chat { message: None }) {
|
|
Commands::Tui => run_tui(config.clone(), cli.agent.clone(), cli.engine.clone(), cli.local).await?,
|
|
Commands::Chat { message } => run_chat(config, cli.agent.clone(), message.clone(), cli.json, cli.quiet, cli.local).await?,
|
|
Commands::Agents => run_agents(config, cli.json, cli.local).await?,
|
|
Commands::Model { model, json, verbose } => {
|
|
cli::run_model_command(model.as_deref(), *json, *verbose).await?
|
|
}
|
|
Commands::Status => run_status(config, cli.json).await?,
|
|
Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?,
|
|
Commands::Listen { bind, port } => run_listen(bind.clone(), *port, config).await?,
|
|
Commands::Reflect { conversation } => {
|
|
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await?
|
|
}
|
|
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Machine { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ─── Commands ───────────────────────────────────────────────────────────────
|
|
|
|
/// Drop a `souveraine.toml` template into the current directory.
|
|
///
|
|
/// This is **not** onboarding — the first run of `souveraine tui` does that
|
|
/// via the setup wizard (Bifrost config, model, agent creation). `init` is
|
|
/// only the scriptable config-template writer: useful for automation and for
|
|
/// editing config ahead of first launch.
|
|
async fn run_init(json: bool) -> anyhow::Result<()> {
|
|
let path = PathBuf::from("souveraine.toml");
|
|
if path.exists() {
|
|
if json {
|
|
println!(r#"{{"status":"exists","path":"souveraine.toml"}}"#);
|
|
} else {
|
|
eprintln!("souveraine.toml already exists in this directory.");
|
|
eprintln!(" Remove it first, or use a different directory.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
tokio::fs::write(&path, CONFIG_TEMPLATE).await?;
|
|
|
|
if json {
|
|
println!(r#"{{"status":"written","path":"souveraine.toml"}}"#);
|
|
} else {
|
|
println!("Config template written to {}/souveraine.toml", std::env::current_dir()?.display());
|
|
println!(" Run `souveraine tui` to set up — the wizard configures Bifrost");
|
|
println!(" and creates your agent. (Editing the file first is optional.)");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_auth(action: &AuthAction, json: bool) -> anyhow::Result<()> {
|
|
use crate::core::credentials::{clear_bifrost_key, get_bifrost_key, store_bifrost_key};
|
|
|
|
match action {
|
|
AuthAction::Set => {
|
|
use std::io::Write;
|
|
print!("Bifrost API key: ");
|
|
std::io::stdout().flush().ok();
|
|
let mut key = String::new();
|
|
std::io::stdin().read_line(&mut key).ok();
|
|
let key = key.trim();
|
|
if key.is_empty() {
|
|
if json {
|
|
println!(r#"{{"status":"skipped"}}"#);
|
|
} else {
|
|
println!("No key provided — nothing stored.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
store_bifrost_key(key)?;
|
|
if json {
|
|
println!(r#"{{"status":"stored"}}"#);
|
|
} else {
|
|
println!("Key stored in OS keyring.");
|
|
}
|
|
}
|
|
AuthAction::Status => {
|
|
if let Ok(env_key) = std::env::var("BIFROST_KEY") {
|
|
if !env_key.is_empty() {
|
|
if json {
|
|
println!(r#"{{"status":"configured","source":"env"}}"#);
|
|
} else {
|
|
println!("Bifrost key: configured (from BIFROST_KEY env var)");
|
|
}
|
|
return Ok(());
|
|
}
|
|
}
|
|
let key = get_bifrost_key();
|
|
if !key.is_empty() {
|
|
if json {
|
|
println!(r#"{{"status":"configured","source":"keyring"}}"#);
|
|
} else {
|
|
println!("Bifrost key: configured (from OS keyring)");
|
|
}
|
|
} else if json {
|
|
println!(r#"{{"status":"not-set"}}"#);
|
|
} else {
|
|
println!("Bifrost key: not set");
|
|
}
|
|
}
|
|
AuthAction::Clear => {
|
|
clear_bifrost_key()?;
|
|
if json {
|
|
println!(r#"{{"status":"cleared"}}"#);
|
|
} else {
|
|
println!("Key removed from OS keyring.");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve a user-provided string (agent name or UUID) to an agent UUID by
|
|
/// walking `~/.souveraine/server/agents/*/agent.json`. Returns the matching
|
|
/// UUID if found, or the input unchanged if it already looks like a UUID-style
|
|
/// directory under server/agents/.
|
|
fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyhow::Result<String> {
|
|
let agents_dir = base.join("server").join("agents");
|
|
if !agents_dir.exists() {
|
|
anyhow::bail!("no agents directory at {} — has any agent been created yet?", agents_dir.display());
|
|
}
|
|
|
|
let direct = agents_dir.join(name_or_id);
|
|
if direct.join("agent.json").exists() {
|
|
return Ok(name_or_id.to_string());
|
|
}
|
|
|
|
for entry in std::fs::read_dir(&agents_dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path().join("agent.json");
|
|
if !path.exists() { continue; }
|
|
let raw = match std::fs::read_to_string(&path) { Ok(s) => s, Err(_) => continue };
|
|
let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => continue };
|
|
let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
|
if name == name_or_id || id == name_or_id {
|
|
return Ok(id.to_string());
|
|
}
|
|
}
|
|
|
|
anyhow::bail!("no agent matched '{}' (checked name and id across {})", name_or_id, agents_dir.display())
|
|
}
|
|
|
|
/// Return the first agent UUID found on disk. Used when --agent is omitted
|
|
/// so the CLI can still operate without forcing a default name.
|
|
///
|
|
/// Walks the user-side memfs directory (`~/.souveraine/agents/`) and picks
|
|
/// the first entry whose `memory/` subdirectory exists. This naturally
|
|
/// filters out stale server-side records (orphans without a memfs).
|
|
fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> {
|
|
let memfs_dir = base.join("agents");
|
|
if !memfs_dir.exists() {
|
|
anyhow::bail!("no agents directory at {} — run `souveraine init` first", memfs_dir.display());
|
|
}
|
|
let mut candidates: Vec<String> = Vec::new();
|
|
for entry in std::fs::read_dir(&memfs_dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if !path.is_dir() { continue; }
|
|
let name = match path.file_name().and_then(|s| s.to_str()) {
|
|
Some(n) => n.to_string(),
|
|
None => continue,
|
|
};
|
|
// Skip the system/ marker directory and anything without a memory dir.
|
|
if name == "system" || !path.join("memory").is_dir() {
|
|
continue;
|
|
}
|
|
candidates.push(name);
|
|
}
|
|
candidates.sort();
|
|
candidates.into_iter().next().ok_or_else(|| {
|
|
anyhow::anyhow!("no agents with memory found under {} — run `souveraine init`", memfs_dir.display())
|
|
})
|
|
}
|
|
|
|
async fn run_identity(
|
|
action: &IdentityAction,
|
|
host: bool,
|
|
agent: Option<&str>,
|
|
json: bool,
|
|
) -> anyhow::Result<()> {
|
|
use crate::core::identity::SeedId;
|
|
|
|
let base = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".souveraine");
|
|
|
|
// Identity defaults to per-agent. --host opts out to the machine seed
|
|
// used for federation transport. Verify is identity-agnostic — it just
|
|
// checks a signature against a pubkey, no seed lookup.
|
|
//
|
|
// Per-agent seeds live at ~/.souveraine/agents/{uuid}/seed/.
|
|
let (seed_dir, scope_label, agent_uuid) = if host {
|
|
(SeedId::default_dir(&base), "Host Seed Identity".to_string(), None)
|
|
} else {
|
|
let uuid = match agent {
|
|
Some(name_or_id) => resolve_agent_id_from_disk(&base, name_or_id)?,
|
|
None => first_agent_id_on_disk(&base)?,
|
|
};
|
|
let dir = base.join("agents").join(&uuid).join("seed");
|
|
(dir, "Per-Agent Seed Identity".to_string(), Some(uuid))
|
|
};
|
|
|
|
match action {
|
|
IdentityAction::Show => {
|
|
// load, never generate: showing an identity must not mint one.
|
|
let seed = SeedId::load(&seed_dir)?;
|
|
if json {
|
|
let mut obj = serde_json::json!({
|
|
"public_key": seed.public_key_hex(),
|
|
"glyph": seed.glyph(),
|
|
"seed_dir": seed_dir.display().to_string(),
|
|
});
|
|
if let Some(id) = &agent_uuid {
|
|
obj["agent_id"] = serde_json::Value::String(id.clone());
|
|
}
|
|
println!("{}", obj);
|
|
} else {
|
|
println!("{scope_label}");
|
|
if let Some(id) = &agent_uuid {
|
|
println!(" Agent: {}", id);
|
|
}
|
|
println!(" Glyph: {}", seed.glyph());
|
|
println!(" Public key: {}", seed.public_key_hex());
|
|
println!(" Location: {}", seed_dir.display());
|
|
}
|
|
}
|
|
IdentityAction::Generate => {
|
|
if seed_dir.join("private.key").exists() {
|
|
eprintln!("WARNING: A seed identity already exists at {}", seed_dir.display());
|
|
eprintln!(" Generating a new one will replace it. This breaks federation trust.");
|
|
eprintln!(" Use `souveraine identity show` to see the current identity.");
|
|
anyhow::bail!("Refusing to overwrite existing seed identity. Delete {} manually first.", seed_dir.display());
|
|
}
|
|
let seed = SeedId::load_or_generate(&seed_dir)?;
|
|
println!("Generated seed identity: {} (glyph: {})", seed.public_key_hex(), seed.glyph());
|
|
}
|
|
IdentityAction::Sign { message } => {
|
|
// load, never generate: signing with a key that didn't exist a
|
|
// moment ago proves nothing.
|
|
let seed = SeedId::load(&seed_dir)?;
|
|
let sig = seed.sign(message.as_bytes());
|
|
let sig_hex = hex::encode(sig.to_bytes());
|
|
if json {
|
|
println!("{}", serde_json::json!({
|
|
"message": message,
|
|
"signature": sig_hex,
|
|
"public_key": seed.public_key_hex(),
|
|
"glyph": seed.glyph(),
|
|
}));
|
|
} else {
|
|
println!("Signature: {sig_hex}");
|
|
println!("Public key: {}", seed.public_key_hex());
|
|
}
|
|
}
|
|
IdentityAction::Verify { pubkey, message, signature } => {
|
|
let pubkey_bytes = hex::decode(pubkey)?;
|
|
let sig_bytes = hex::decode(signature)?;
|
|
if pubkey_bytes.len() != 32 || sig_bytes.len() != 64 {
|
|
anyhow::bail!("Invalid key or signature length");
|
|
}
|
|
let mut pk = [0u8; 32];
|
|
pk.copy_from_slice(&pubkey_bytes);
|
|
let sig = ed25519_dalek::Signature::from_slice(&sig_bytes)
|
|
.map_err(|e| anyhow::anyhow!("bad signature: {e}"))?;
|
|
let valid = SeedId::verify_with_pubkey(&pk, message.as_bytes(), &sig);
|
|
if json {
|
|
println!("{}", serde_json::json!({ "valid": valid }));
|
|
} else {
|
|
println!("{}", if valid { "Valid ✓" } else { "Invalid ✗" });
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
|
|
use crate::core::identity::SeedId;
|
|
use crate::machined::protocol::DEFAULT_SEED_DIR;
|
|
|
|
let seed_dir = PathBuf::from(DEFAULT_SEED_DIR);
|
|
|
|
match action {
|
|
MachineAction::Init { fresh, migrate_from } => {
|
|
if seed_dir.join("private.key").exists() {
|
|
anyhow::bail!(
|
|
"A machine identity already exists at {}. Refusing to overwrite — \
|
|
replacing it breaks federation trust and node commissions. \
|
|
Remove it manually first if you truly mean to.",
|
|
seed_dir.display()
|
|
);
|
|
}
|
|
|
|
// Identity creation is deliberate: the operator states whether
|
|
// this machine is new or carries an existing identity forward.
|
|
let seed = match (fresh, migrate_from) {
|
|
(true, None) => {
|
|
std::fs::create_dir_all(&seed_dir)?;
|
|
SeedId::load_or_generate(&seed_dir)?
|
|
}
|
|
(false, Some(source)) => {
|
|
let source_key = source.join("private.key");
|
|
if !source_key.exists() {
|
|
anyhow::bail!(
|
|
"no seed at {} — nothing to migrate",
|
|
source_key.display()
|
|
);
|
|
}
|
|
std::fs::create_dir_all(&seed_dir)?;
|
|
std::fs::copy(&source_key, seed_dir.join("private.key"))?;
|
|
let source_pub = source.join("public.key");
|
|
if source_pub.exists() {
|
|
std::fs::copy(&source_pub, seed_dir.join("public.key"))?;
|
|
}
|
|
// load (not load_or_generate): a botched copy must fail
|
|
// loudly here, not silently mint a different identity.
|
|
SeedId::load(&seed_dir)?
|
|
}
|
|
_ => anyhow::bail!(
|
|
"state your intent: --fresh (new identity) or --migrate-from <dir> \
|
|
(carry an existing one over)"
|
|
),
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(
|
|
seed_dir.join("private.key"),
|
|
std::fs::Permissions::from_mode(0o600),
|
|
)?;
|
|
std::fs::set_permissions(&seed_dir, std::fs::Permissions::from_mode(0o700))?;
|
|
}
|
|
|
|
// The daemon runs as the `souveraine` system user; hand the seed
|
|
// over if we can (init is normally run via sudo). A missing user
|
|
// means the sysusers entry isn't installed yet — say so, don't
|
|
// half-succeed silently.
|
|
let chown = std::process::Command::new("chown")
|
|
.arg("-R")
|
|
.arg("souveraine:souveraine")
|
|
.arg(&seed_dir)
|
|
.status();
|
|
let owned = matches!(chown, Ok(s) if s.success());
|
|
if !owned {
|
|
eprintln!(
|
|
"WARNING: could not chown {} to souveraine:souveraine — is the \
|
|
`souveraine` system user installed (sysusers.d) and are you root? \
|
|
souveraine-machined will refuse to read the seed until ownership \
|
|
is fixed.",
|
|
seed_dir.display()
|
|
);
|
|
}
|
|
|
|
if json {
|
|
println!("{}", serde_json::json!({
|
|
"public_key": seed.public_key_hex(),
|
|
"glyph": seed.glyph(),
|
|
"seed_dir": seed_dir.display().to_string(),
|
|
"owned_by_service_user": owned,
|
|
}));
|
|
} else {
|
|
println!("Machine identity provisioned.");
|
|
println!(" Glyph: {}", seed.glyph());
|
|
println!(" Public key: {}", seed.public_key_hex());
|
|
println!(" Location: {}", seed_dir.display());
|
|
println!("Next: systemctl enable --now souveraine-machined");
|
|
}
|
|
}
|
|
MachineAction::Status => {
|
|
match crate::machined::client::status() {
|
|
Ok(state) => {
|
|
if json {
|
|
println!("{}", state);
|
|
} else {
|
|
println!("souveraine-machined: running");
|
|
if let Some(glyph) = state.get("glyph").and_then(|v| v.as_str()) {
|
|
println!(" Glyph: {glyph}");
|
|
}
|
|
if let Some(pk) = state.get("public_key").and_then(|v| v.as_str()) {
|
|
println!(" Public key: {pk}");
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// Daemon down. Report what can be seen from here: a
|
|
// permission error on the key is the healthy shape (the
|
|
// seed exists and only the service user reads it).
|
|
let key = seed_dir.join("private.key");
|
|
let seed_state = match std::fs::metadata(&key) {
|
|
Ok(_) => "present",
|
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
|
|
"present (unreadable from this user — expected)"
|
|
}
|
|
Err(_) => "absent",
|
|
};
|
|
if json {
|
|
println!("{}", serde_json::json!({
|
|
"ok": false,
|
|
"daemon": "unreachable",
|
|
"reason": format!("{e:#}"),
|
|
"seed": seed_state,
|
|
"seed_dir": seed_dir.display().to_string(),
|
|
}));
|
|
} else {
|
|
println!("souveraine-machined: not reachable ({e:#})");
|
|
println!(" Seed at {}: {seed_state}", seed_dir.display());
|
|
if seed_state == "absent" {
|
|
println!(" Provision: sudo souveraine machine init --fresh");
|
|
println!(" or: sudo souveraine machine init --migrate-from ~/.souveraine/seed-id");
|
|
} else {
|
|
println!(" Start: systemctl enable --now souveraine-machined");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_peers(json: bool) -> anyhow::Result<()> {
|
|
let base = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".souveraine");
|
|
let known_path = base.join("federation").join("known_peers.json");
|
|
|
|
if !known_path.exists() {
|
|
if json {
|
|
println!("[]");
|
|
} else {
|
|
println!("No federated peers known yet.");
|
|
println!(" Configure peers in [federation] in souveraine.toml and start the server.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let content = tokio::fs::read_to_string(&known_path).await?;
|
|
let peers: Vec<crate::server::device_registry::PeerEntry> = serde_json::from_str(&content)?;
|
|
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&peers)?);
|
|
} else if peers.is_empty() {
|
|
println!("No federated peers known yet.");
|
|
} else {
|
|
println!("\n── Federated Peers ──────────────────────");
|
|
for p in &peers {
|
|
let status = if p.alive { "alive" } else { "offline" };
|
|
println!(" {} {}", p.seed_id.get(..16).unwrap_or(&p.seed_id), status);
|
|
if let Some(label) = &p.label {
|
|
println!(" label: {label}");
|
|
}
|
|
println!(" url: {}", p.url);
|
|
println!(" seen: {}", p.last_seen.format("%Y-%m-%d %H:%M:%S UTC"));
|
|
if !p.hosted_agents.is_empty() {
|
|
let short: Vec<&str> = p
|
|
.hosted_agents
|
|
.iter()
|
|
.map(|a| a.get(..12).unwrap_or(a))
|
|
.collect();
|
|
println!(" hosts: {}", short.join(", "));
|
|
}
|
|
}
|
|
println!();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
|
|
use crate::core::nervous::event_log;
|
|
|
|
let base = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".souveraine");
|
|
let events_dir = base.join("events");
|
|
|
|
match action {
|
|
EventsAction::Tail { count } => {
|
|
let today = chrono::Utc::now().date_naive();
|
|
let mut events = event_log::events_for_date(&events_dir, today)?;
|
|
if events.len() < *count {
|
|
let yesterday = today - chrono::Duration::days(1);
|
|
let mut older = event_log::events_for_date(&events_dir, yesterday)?;
|
|
older.append(&mut events);
|
|
events = older;
|
|
}
|
|
let start = events.len().saturating_sub(*count);
|
|
let tail = &events[start..];
|
|
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(tail)?);
|
|
} else if tail.is_empty() {
|
|
println!("No events recorded yet.");
|
|
} else {
|
|
for e in tail {
|
|
println!(
|
|
"{} [{}] {} → {} (urgency: {:.1})",
|
|
e.timestamp.format("%H:%M:%S"),
|
|
e.sensor_name,
|
|
e.event_type,
|
|
e.target.as_deref().unwrap_or("-"),
|
|
e.urgency,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
EventsAction::Date { date } => {
|
|
let d = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")?;
|
|
let events = event_log::events_for_date(&events_dir, d)?;
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&events)?);
|
|
} else if events.is_empty() {
|
|
println!("No events for {date}.");
|
|
} else {
|
|
println!("{} events on {date}:", events.len());
|
|
for e in &events {
|
|
println!(
|
|
" {} [{}] {} → {}",
|
|
e.timestamp.format("%H:%M:%S"),
|
|
e.sensor_name,
|
|
e.event_type,
|
|
e.target.as_deref().unwrap_or("-"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
EventsAction::Purge { retain_days } => {
|
|
let days = retain_days.unwrap_or(30);
|
|
let removed = event_log::purge_old_events(&events_dir, days)?;
|
|
println!("Purged {removed} event log files older than {days} days.");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_schedule(action: &ScheduleAction, agent: Option<&str>, json: bool) -> anyhow::Result<()> {
|
|
use crate::core::nervous::cron::parse_schedule_file;
|
|
|
|
let souveraine_base = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".souveraine");
|
|
let agent_id = match agent {
|
|
Some(name_or_id) => resolve_agent_id_from_disk(&souveraine_base, name_or_id)?,
|
|
None => first_agent_id_on_disk(&souveraine_base)?,
|
|
};
|
|
let base = souveraine_base
|
|
.join("agents")
|
|
.join(&agent_id)
|
|
.join("memory")
|
|
.join("schedules");
|
|
|
|
if !base.exists() {
|
|
std::fs::create_dir_all(&base)?;
|
|
}
|
|
|
|
match action {
|
|
ScheduleAction::List => {
|
|
let mut found = false;
|
|
if let Ok(entries) = std::fs::read_dir(&base) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().map(|e| e == "md").unwrap_or(false) {
|
|
if let Ok(e) = parse_schedule_file(&path) {
|
|
found = true;
|
|
if json {
|
|
println!("{}", serde_json::to_string(&e).unwrap_or_default());
|
|
} else {
|
|
println!(
|
|
" {} ({:?}, {}, {})",
|
|
e.name,
|
|
e.kind,
|
|
e.schedule,
|
|
if e.enabled { "enabled" } else { "disabled" }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found && !json {
|
|
println!("No schedules found.");
|
|
}
|
|
}
|
|
|
|
ScheduleAction::Status => {
|
|
let state_path = base.join(".state.json");
|
|
if state_path.exists() {
|
|
let data = std::fs::read_to_string(&state_path)?;
|
|
println!("{data}");
|
|
} else if json {
|
|
println!("{{}}");
|
|
} else {
|
|
println!("No schedule state yet.");
|
|
}
|
|
}
|
|
|
|
ScheduleAction::Create { name, cron, interval, prompt } => {
|
|
let file_path = base.join(format!("{name}.md"));
|
|
if file_path.exists() {
|
|
anyhow::bail!("schedule '{name}' already exists");
|
|
}
|
|
|
|
let (kind, sched) = if let Some(c) = cron {
|
|
("cron", c.clone())
|
|
} else if let Some(i) = interval {
|
|
("interval", i.to_string())
|
|
} else {
|
|
("interval", "3600".to_string())
|
|
};
|
|
|
|
let content = format!(
|
|
"---\nname: {name}\nkind: {kind}\nschedule: \"{sched}\"\nsource: user\nenabled: true\nurgency: 0.3\ncreated_at: {}\n---\n\n{prompt}\n",
|
|
chrono::Utc::now().to_rfc3339()
|
|
);
|
|
std::fs::write(&file_path, content)?;
|
|
|
|
if json {
|
|
println!(r#"{{"status":"created","name":"{name}"}}"#);
|
|
} else {
|
|
println!("Schedule '{name}' created.");
|
|
}
|
|
}
|
|
|
|
ScheduleAction::Delete { name } => {
|
|
let file_path = base.join(format!("{name}.md"));
|
|
if !file_path.exists() {
|
|
anyhow::bail!("schedule '{name}' not found");
|
|
}
|
|
std::fs::remove_file(&file_path)?;
|
|
if json {
|
|
println!(r#"{{"status":"deleted","name":"{name}"}}"#);
|
|
} else {
|
|
println!("Schedule '{name}' deleted.");
|
|
}
|
|
}
|
|
|
|
ScheduleAction::Run { name } => {
|
|
let file_path = base.join(format!("{name}.md"));
|
|
if !file_path.exists() {
|
|
anyhow::bail!("schedule '{name}' not found");
|
|
}
|
|
let trigger = base.join(format!(".trigger-{name}"));
|
|
std::fs::write(&trigger, "")?;
|
|
if json {
|
|
println!(r#"{{"status":"triggered","name":"{name}"}}"#);
|
|
} else {
|
|
println!("Schedule '{name}' triggered — will fire on next tick.");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_tui(
|
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
|
agent_pref: Option<String>,
|
|
engine: String,
|
|
local: bool,
|
|
) -> anyhow::Result<()> {
|
|
if engine == "tuie" {
|
|
return run_tuie_tui(config, agent_pref, local).await;
|
|
}
|
|
// Default: ratatui
|
|
let config_path = ConsciousnessConfig::discover_path();
|
|
let mut app = App::new(config, agent_pref.unwrap_or_default(), config_path);
|
|
app.run().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Run the experimental tuie-based TUI.
|
|
async fn run_tuie_tui(
|
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
|
_agent_pref: Option<String>,
|
|
_local: bool,
|
|
) -> anyhow::Result<()> {
|
|
use crate::ui::presence::Presence;
|
|
use crate::ui::chat::ChatPalette;
|
|
use crate::ui::atmosphere::Atmosphere;
|
|
|
|
// Register the tokio spawner with tuie
|
|
crate::ui::tuie_app::TuieApp::setup_spawner();
|
|
|
|
let palette = ChatPalette::from_atmosphere(Atmosphere::Default);
|
|
let presence = Presence::new("souveraine");
|
|
|
|
let mut app = crate::ui::tuie_app::TuieApp::new(
|
|
config,
|
|
presence,
|
|
palette,
|
|
"Souveraine".into(),
|
|
"Human".into(),
|
|
);
|
|
|
|
// Kick off async dashboard loading and splash→welcome polling.
|
|
// The splash screen shows immediately; after ~6-8 seconds (or a key
|
|
// press), it auto-transitions to the welcome screen with live data.
|
|
app.begin();
|
|
|
|
// Apply default atmosphere
|
|
crate::ui::theme::apply_atmosphere(Atmosphere::Default);
|
|
|
|
// tuie::start_tui takes ownership and runs the event loop
|
|
tuie::start_tui(app).map_err(|e| anyhow::anyhow!(e))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the backend per `--local` and remote health.
|
|
///
|
|
/// - `--local` forces in-process LocalBackend (no network attempt).
|
|
/// - Otherwise: probe the remote URL; on success use Remote, on failure
|
|
/// transparently fall back to Local. The constitution's sovereignty principle
|
|
/// (Article VI.1) says the harness must keep working when the server is gone.
|
|
async fn resolve_backend(
|
|
config: &Arc<RwLock<ConsciousnessConfig>>,
|
|
force_local: bool,
|
|
json: bool,
|
|
quiet: bool,
|
|
) -> anyhow::Result<(Box<dyn crate::backend::Backend>, &'static str)> {
|
|
use crate::backend::{Backend, LocalBackend, RemoteBackend};
|
|
|
|
if force_local {
|
|
let cfg = config.read().await.clone();
|
|
let local = LocalBackend::new(cfg).await?;
|
|
if !json && !quiet {
|
|
eprintln!(" (local mode — engine in-process)");
|
|
}
|
|
return Ok((Box::new(local), "local"));
|
|
}
|
|
|
|
let server_url = config.read().await.server.effective_url();
|
|
let remote = RemoteBackend::new(&server_url)?;
|
|
if remote.health().await {
|
|
return Ok((Box::new(remote), "remote"));
|
|
}
|
|
|
|
if !json && !quiet {
|
|
eprintln!(" (no server at {} — falling back to local)", server_url);
|
|
}
|
|
let cfg = config.read().await.clone();
|
|
let local = LocalBackend::new(cfg).await?;
|
|
Ok((Box::new(local), "local"))
|
|
}
|
|
|
|
async fn run_chat(
|
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
|
agent_name: Option<String>,
|
|
message: Option<String>,
|
|
json: bool,
|
|
quiet: bool,
|
|
force_local: bool,
|
|
) -> anyhow::Result<()> {
|
|
use crate::backend::BackendEvent;
|
|
use futures::StreamExt;
|
|
|
|
let (backend, mode) = resolve_backend(&config, force_local, json, quiet).await?;
|
|
|
|
// Resolve the agent: if `--agent` matches a name or id, use it; else first available.
|
|
let agents = backend.list_agents().await?;
|
|
let resolved = match &agent_name {
|
|
Some(n) => agents.iter().find(|a| a.name == *n || a.id == *n).or_else(|| agents.first()),
|
|
None => agents.first(),
|
|
};
|
|
let agent = match resolved {
|
|
Some(a) => a.clone(),
|
|
None => {
|
|
let hint = if mode == "local" {
|
|
"No agents in local store. Create one via POST /v1/agents (server mode) or souveraine init."
|
|
} else {
|
|
"No agents on the server. Create one via POST /v1/agents."
|
|
};
|
|
if json { println!(r#"{{"status":"no-agents"}}"#); } else { eprintln!("{}", hint); }
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let conv_id = backend.ensure_conversation(&agent.id).await?;
|
|
|
|
// Surface anything the subconscious noted during autonomous (heartbeat)
|
|
// cycles since the last session. Read-once — this clears the stash.
|
|
let pending = backend.take_pending_surfacings(&agent.id).await;
|
|
if !pending.is_empty() && !json {
|
|
eprintln!(
|
|
"\n {} observation{} surfaced while you were away:",
|
|
pending.len(),
|
|
if pending.len() == 1 { "" } else { "s" },
|
|
);
|
|
for p in &pending {
|
|
let tag = if p.source.is_empty() { p.kind.as_str() } else { p.source.as_str() };
|
|
eprintln!(" [{}] {}", tag, p.content);
|
|
}
|
|
eprintln!();
|
|
}
|
|
|
|
let one_shot = message.clone();
|
|
if let Some(msg) = one_shot {
|
|
let mut stream = backend.send(&conv_id, &msg).await?;
|
|
if !json { println!("\n {}: {}\n", agent.name, msg); print!(" "); }
|
|
let mut full = String::new();
|
|
while let Some(ev) = stream.next().await {
|
|
match ev? {
|
|
BackendEvent::Token(t) => {
|
|
if json { /* collect */ } else {
|
|
use std::io::Write;
|
|
print!("{}", t);
|
|
std::io::stdout().flush().ok();
|
|
}
|
|
full.push_str(&t);
|
|
}
|
|
BackendEvent::Reasoning(r) => {
|
|
if !json { eprintln!("\n [thinking] {}", r); }
|
|
}
|
|
BackendEvent::Surfacing { source, content, .. } => {
|
|
if !json { eprintln!("\n [{}] {}", source, content); }
|
|
}
|
|
BackendEvent::Error { message } => {
|
|
if json {
|
|
let out = serde_json::json!({"agent": agent.name, "error": message});
|
|
println!("{}", serde_json::to_string_pretty(&out)?);
|
|
return Ok(());
|
|
}
|
|
eprintln!("\n [turn error] {}", message);
|
|
}
|
|
BackendEvent::Done => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
if json {
|
|
let out = serde_json::json!({"agent": agent.name, "response": full});
|
|
println!("{}", serde_json::to_string_pretty(&out)?);
|
|
} else {
|
|
println!();
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Interactive REPL
|
|
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
println!(" Souveraine — {} ({} mode)", agent.name, mode);
|
|
println!(" /exit to leave");
|
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
|
let mut rl = rustyline::DefaultEditor::new().ok();
|
|
loop {
|
|
let prompt = format!("{}> ", agent.name);
|
|
let line = match rl.as_mut() {
|
|
Some(r) => r.readline(&prompt).ok(),
|
|
None => None,
|
|
};
|
|
let Some(input) = line else { break };
|
|
let input = input.trim().to_string();
|
|
if input.is_empty() { continue; }
|
|
if matches!(input.as_str(), "/exit" | "/quit" | "/q") { break; }
|
|
if let Some(r) = rl.as_mut() { r.add_history_entry(&input).ok(); }
|
|
|
|
let mut stream = backend.send(&conv_id, &input).await?;
|
|
print!("\n {}: ", agent.name);
|
|
use std::io::Write;
|
|
std::io::stdout().flush().ok();
|
|
while let Some(ev) = stream.next().await {
|
|
match ev? {
|
|
BackendEvent::Token(t) => { print!("{}", t); std::io::stdout().flush().ok(); }
|
|
BackendEvent::Reasoning(r) => eprintln!("\n [thinking] {}", r),
|
|
BackendEvent::Surfacing { source, content, .. } => eprintln!("\n [{}] {}", source, content),
|
|
BackendEvent::Error { message } => eprintln!("\n [turn error] {}", message),
|
|
BackendEvent::Done => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
println!("\n");
|
|
}
|
|
println!("bye");
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_reflect(
|
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
|
agent_name: Option<String>,
|
|
conversation: Option<String>,
|
|
json: bool,
|
|
) -> anyhow::Result<()> {
|
|
use crate::backend::LocalBackend;
|
|
let cfg = config.read().await.clone();
|
|
let local = LocalBackend::new(cfg).await?;
|
|
let server = local.server();
|
|
|
|
let summaries = server.agents.list(None).await?;
|
|
let agent = match &agent_name {
|
|
Some(n) => summaries.iter().find(|a| a.name == *n || a.id == *n).or_else(|| summaries.first()),
|
|
None => summaries.first(),
|
|
}
|
|
.ok_or_else(|| anyhow::anyhow!("no agents configured"))?;
|
|
|
|
// Resolve a conversation. Explicit --conversation wins; otherwise
|
|
// pick the most recent active conversation for this agent.
|
|
let store = crate::core::conversation::ConversationStore::new(
|
|
&dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".souveraine")
|
|
.join("agents")
|
|
.join(&agent.id),
|
|
);
|
|
let conv_id = match conversation {
|
|
Some(id) => id,
|
|
None => {
|
|
let mut records = store.list_active().await?;
|
|
records.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
|
|
records
|
|
.into_iter()
|
|
.next()
|
|
.map(|r| r.id)
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("no conversations found for agent '{}'", agent.id)
|
|
})?
|
|
}
|
|
};
|
|
|
|
let messages = store.load_messages(&conv_id).await?;
|
|
if messages.is_empty() {
|
|
anyhow::bail!("conversation '{conv_id}' has no messages to reflect on");
|
|
}
|
|
|
|
if !json {
|
|
println!(
|
|
"Reflecting on conversation {} ({} messages) for agent {}…",
|
|
&conv_id[..8.min(conv_id.len())],
|
|
messages.len(),
|
|
agent.name,
|
|
);
|
|
}
|
|
|
|
let report = server
|
|
.consciousness
|
|
.reflection()
|
|
.reflect_now(&agent.id, &messages)
|
|
.await?;
|
|
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
} else {
|
|
println!();
|
|
println!("─── Reflection complete ─────────────────────────────");
|
|
println!(" agent: {}", report.agent_id);
|
|
println!(" turns reviewed: {}", report.turns_reviewed);
|
|
println!(
|
|
" duration: {:.1}s",
|
|
(report.completed_at - report.started_at)
|
|
.num_milliseconds() as f64 / 1000.0
|
|
);
|
|
println!(
|
|
" exited cleanly: {}",
|
|
if report.exited_cleanly { "yes" } else { "no (tool rounds exhausted)" }
|
|
);
|
|
println!("─────────────────────────────────────────────────────");
|
|
println!();
|
|
println!("{}", report.summary);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_agents(
|
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
|
json: bool,
|
|
force_local: bool,
|
|
) -> anyhow::Result<()> {
|
|
let (backend, mode) = resolve_backend(&config, force_local, json, false).await?;
|
|
let agents = backend.list_agents().await?;
|
|
if json {
|
|
let payload: Vec<_> = agents.iter().map(|a| serde_json::json!({
|
|
"id": a.id, "name": a.name, "description": a.description,
|
|
})).collect();
|
|
println!("{}", serde_json::to_string_pretty(&serde_json::json!({"agents": payload}))?);
|
|
} else {
|
|
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
println!(" Agents ({} mode)", mode);
|
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
if agents.is_empty() {
|
|
println!(" (none — POST /v1/agents to create one)");
|
|
}
|
|
for a in &agents {
|
|
println!(" {} {}", a.id, a.name);
|
|
if let Some(d) = &a.description { println!(" {}", d); }
|
|
}
|
|
println!();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
|
|
async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> anyhow::Result<()> {
|
|
let cfg = config.read().await;
|
|
|
|
if json {
|
|
let output = serde_json::json!({
|
|
"bifrost_url": cfg.bifrost.base_url,
|
|
"primary_model": cfg.bifrost.primary_model,
|
|
"memory": {
|
|
"git_enabled": cfg.memory.git_enabled,
|
|
"auto_commit": cfg.memory.auto_commit,
|
|
},
|
|
"subconscious": {
|
|
"n1_enabled": cfg.subconscious.n1_enabled,
|
|
"inbox_enabled": cfg.subconscious.inbox_enabled,
|
|
},
|
|
"reflection": {
|
|
"enabled": cfg.reflection.enabled,
|
|
"message_interval": cfg.reflection.message_interval,
|
|
"trigger": format!("{:?}", cfg.reflection.trigger),
|
|
},
|
|
"archivist": {
|
|
"enabled": cfg.archivist.enabled,
|
|
"interval": cfg.archivist.interval,
|
|
"threshold": cfg.archivist.threshold,
|
|
"compression_model": cfg.archivist.compression_model,
|
|
},
|
|
"models_configured": cfg.models.len(),
|
|
});
|
|
println!("{}", serde_json::to_string_pretty(&output)?);
|
|
} else {
|
|
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
println!(" Souveraine — World State");
|
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
println!(" Bifrost: {}", cfg.bifrost.base_url);
|
|
println!(" Voice: {}", cfg.bifrost.primary_model);
|
|
println!(" Memory: git={}, auto_commit={}", cfg.memory.git_enabled, cfg.memory.auto_commit);
|
|
println!(" N+1: enabled={}, inbox={}", cfg.subconscious.n1_enabled, cfg.subconscious.inbox_enabled);
|
|
println!(" Reflection: enabled={}, every {} messages", cfg.reflection.enabled, cfg.reflection.message_interval);
|
|
println!(" Archivist: enabled={}, every {} msgs at {} pressure",
|
|
cfg.archivist.enabled, cfg.archivist.interval, cfg.archivist.threshold);
|
|
println!(" Compresses via: {}", cfg.archivist.compression_model);
|
|
println!(" Models known: {}", cfg.models.len());
|
|
println!();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_listen(
|
|
bind_override: Option<String>,
|
|
port_override: Option<u16>,
|
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
|
) -> anyhow::Result<()> {
|
|
let cfg = config.read().await.clone();
|
|
let bind = bind_override.unwrap_or_else(|| cfg.server.bind.clone());
|
|
let port = port_override.unwrap_or(cfg.server.port);
|
|
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
|
|
|
let listener = server::listener::LiteListener::new(&cfg.federation, base)?;
|
|
let app = server::listener::create_routes_lite(listener);
|
|
|
|
let addr = format!("{bind}:{port}");
|
|
println!("Souveraine lite listener on http://{addr}");
|
|
info!("lite listener starting on {addr}");
|
|
let tcp = tokio::net::TcpListener::bind(&addr).await?;
|
|
axum::serve(tcp, app.into_make_service()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_server(
|
|
bind_override: Option<String>,
|
|
port_override: Option<u16>,
|
|
config: Arc<RwLock<ConsciousnessConfig>>,
|
|
) -> anyhow::Result<()> {
|
|
println!("Starting Souveraine server...");
|
|
|
|
let cfg = config.read().await.clone();
|
|
let bind = bind_override.unwrap_or_else(|| cfg.server.bind.clone());
|
|
let port = port_override.unwrap_or(cfg.server.port);
|
|
|
|
let server = server::SouveraineServer::new(cfg).await?;
|
|
|
|
{
|
|
let mut server_config = server.config.write().await;
|
|
server_config.bind = bind;
|
|
server_config.port = port;
|
|
}
|
|
|
|
server.run().await
|
|
}
|
|
|
|
// ─── Config loading ─────────────────────────────────────────────────────────
|
|
|
|
async fn load_config() -> anyhow::Result<ConsciousnessConfig> {
|
|
let config_paths = [
|
|
PathBuf::from("souveraine.toml"),
|
|
PathBuf::from("souveraine.yaml"),
|
|
PathBuf::from("~/.souveraine/config.toml"),
|
|
PathBuf::from("~/.souveraine/config.yaml"),
|
|
];
|
|
|
|
for path in &config_paths {
|
|
let path_str = path.to_string_lossy();
|
|
let expanded = shellexpand::tilde(&path_str);
|
|
let path = PathBuf::from(expanded.as_ref());
|
|
|
|
if path.exists() {
|
|
info!("loading config from {:?}", path);
|
|
let mut config = ConsciousnessConfig::load(&path)?;
|
|
config.normalize_providers();
|
|
return Ok(config);
|
|
}
|
|
}
|
|
|
|
warn!("no config found; using defaults (will probe Bifrost for models)");
|
|
let config = ConsciousnessConfig::default();
|
|
|
|
// Try to seed model list from the active inference provider
|
|
info!("discovering models from provider");
|
|
let provider = match bridge::build_provider(&config) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
warn!("could not build provider: {}", e);
|
|
return Ok(config);
|
|
}
|
|
};
|
|
match provider.list_models().await {
|
|
Ok(models) => {
|
|
info!("{} models available via Bifrost", models.len());
|
|
for m in models {
|
|
debug!(" {}", m);
|
|
}
|
|
}
|
|
Err(e) => warn!("could not reach Bifrost: {}", e),
|
|
}
|
|
|
|
Ok(config)
|
|
}
|