Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/main.rs

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)
}