Watch
1
0
Fork
You've already forked souveraine
0

seed(identity): per-agent seeds + CLI redesign

Each agent now has its own Ed25519 keypair at
~/.souveraine/agents/{uuid}/seed/, alongside its memfs. AgentInventory
gains seed_dir() / seed_id() getters and auto-initializes the per-agent
seed in create(). Federation can now reference agent identities directly
instead of routing everything through the host seed.

SeedId::glyph() produces a deterministic 4-char rendering of the pubkey
drawn from a 16-glyph geometric-shapes palette — terminal-friendly badge
for the manager-card SeedID display (and future federation peer lists).

CLI redesign — `souveraine identity` is now per-agent by default:

  souveraine identity show              # current agent's seed
  souveraine --agent Annie identity show # Annie's seed
  souveraine identity show --host       # machine seed (federation)
  souveraine identity sign "msg" --agent Annie
  souveraine identity verify --pubkey ... --signature ... --message ...

The global --agent flag is now Option<String> with no default (was
hardcoded "Ani"). When omitted, the CLI falls through to the first
available agent on disk via first_agent_id_on_disk(), which walks the
user-side memfs dir and filters orphans by requiring a memory/
subdirectory. run_chat / run_tui / run_reflect / run_schedule all
updated to take Option<String> and pass through to existing
first-available fallback logic.

Help text example replaced "souveraine --agent Ani chat" with the
neutral "souveraine --agent <name> chat".

Build clean. 111/111 tests pass (gained 2 for glyph rendering).
This commit is contained in:
Fimeg 2026-05-12 13:56:29 -04:00
commit f495f179b5
4 changed files with 208 additions and 32 deletions

View file

@ -1,3 +1,3 @@
pub mod seed;
pub use seed::SeedId;
pub use seed::{glyph_from_pubkey, SeedId};

View file

@ -87,6 +87,14 @@ impl SeedId {
self.verifying_key.to_bytes()
}
/// Deterministic 4-glyph rendering of the public key. Used as a
/// terminal-friendly per-agent badge in the manager view. Drawn from
/// a 16-symbol palette indexed by 4-bit nibbles of the first two
/// bytes of the public key.
pub fn glyph(&self) -> String {
glyph_from_pubkey(&self.public_key_bytes())
}
pub fn sign(&self, data: &[u8]) -> Signature {
self.signing_key.sign(data)
}
@ -114,6 +122,22 @@ impl SeedId {
}
}
/// Standalone glyph renderer — also usable on remote agents we only know
/// the pubkey bytes for. Reads the first two bytes of `pubkey` and emits
/// four glyphs from the geometric-shapes palette.
pub fn glyph_from_pubkey(pubkey: &[u8]) -> String {
const PALETTE: [char; 16] = [
'◇', '◆', '○', '●', '△', '▲', '▽', '▼',
'□', '■', '◐', '◑', '◒', '◓', '☆', '★',
];
let mut out = String::with_capacity(4);
for byte in pubkey.iter().take(2) {
out.push(PALETTE[(byte >> 4) as usize]);
out.push(PALETTE[(byte & 0x0f) as usize]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@ -143,4 +167,20 @@ mod tests {
let seed2 = SeedId::load_or_generate(dir.path()).unwrap();
assert_eq!(seed1.public_key_hex(), seed2.public_key_hex());
}
#[test]
fn test_glyph_is_deterministic_and_four_chars() {
let seed = SeedId::generate();
let g1 = seed.glyph();
let g2 = seed.glyph();
assert_eq!(g1, g2);
assert_eq!(g1.chars().count(), 4);
}
#[test]
fn test_glyph_known_input() {
// pubkey bytes [0x00, 0xff, ...] → nibbles 0,0,f,f → ◇◇★★
let g = glyph_from_pubkey(&[0x00, 0xff]);
assert_eq!(g, "◇◇★★");
}
}

View file

@ -89,13 +89,13 @@ 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 Ani chat Speak as Ani\n souveraine --json status The world speaks in data\n souveraine completions bash Announce capabilities to your shell",
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
#[arg(short, long, global = true, default_value = "Ani")]
agent: String,
/// 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)]
@ -200,6 +200,11 @@ enum Commands {
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,
},
/// Query the event firehose log
@ -250,7 +255,7 @@ enum ScheduleAction {
#[derive(Subcommand)]
enum IdentityAction {
/// Show this instance's seed identity (public key)
/// Show the seed identity (public key + glyph)
Show,
/// Generate a new seed identity (WARNING: replaces existing)
Generate,
@ -344,12 +349,12 @@ async fn main() -> anyhow::Result<()> {
// Handle schedule early — reads files directly, no backend needed
if let Some(Commands::Schedule { action }) = &cli.command {
return run_schedule(action, &cli.agent, cli.json).await;
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 }) = &cli.command {
return run_identity(action, cli.json).await;
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
@ -362,7 +367,7 @@ async fn main() -> anyhow::Result<()> {
match cli.command.as_ref().unwrap_or(&Commands::Chat { message: None }) {
Commands::Tui => run_tui(config.clone(), cli.agent.clone()).await?,
Commands::Chat { message } => run_chat(config, cli.agent, message.clone(), cli.json, cli.quiet, 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?
@ -480,24 +485,116 @@ async fn run_auth(action: &AuthAction, json: bool) -> anyhow::Result<()> {
Ok(())
}
async fn run_identity(action: &IdentityAction, json: bool) -> anyhow::Result<()> {
/// 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");
let seed_dir = SeedId::default_dir(&base);
// 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 => {
let seed = SeedId::load_or_generate(&seed_dir)?;
if json {
println!("{}", serde_json::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!("Seed Identity");
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());
}
@ -510,7 +607,7 @@ async fn run_identity(action: &IdentityAction, json: bool) -> anyhow::Result<()>
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: {}", seed.public_key_hex());
println!("Generated seed identity: {} (glyph: {})", seed.public_key_hex(), seed.glyph());
}
IdentityAction::Sign { message } => {
let seed = SeedId::load_or_generate(&seed_dir)?;
@ -521,6 +618,7 @@ async fn run_identity(action: &IdentityAction, json: bool) -> anyhow::Result<()>
"message": message,
"signature": sig_hex,
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
}));
} else {
println!("Signature: {sig_hex}");
@ -615,14 +713,19 @@ async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
Ok(())
}
async fn run_schedule(action: &ScheduleAction, agent: &str, json: bool) -> anyhow::Result<()> {
async fn run_schedule(action: &ScheduleAction, agent: Option<&str>, json: bool) -> anyhow::Result<()> {
use crate::core::nervous::cron::parse_schedule_file;
let base = dirs::home_dir()
let souveraine_base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine")
.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)
.join(&agent_id)
.join("memory")
.join("schedules");
@ -730,9 +833,9 @@ async fn run_schedule(action: &ScheduleAction, agent: &str, json: bool) -> anyho
async fn run_tui(
config: Arc<RwLock<ConsciousnessConfig>>,
agent_pref: String,
agent_pref: Option<String>,
) -> anyhow::Result<()> {
let mut app = App::new(config, agent_pref);
let mut app = App::new(config, agent_pref.unwrap_or_default());
app.run().await?;
Ok(())
}
@ -776,7 +879,7 @@ async fn resolve_backend(
async fn run_chat(
config: Arc<RwLock<ConsciousnessConfig>>,
agent_name: String,
agent_name: Option<String>,
message: Option<String>,
json: bool,
quiet: bool,
@ -787,10 +890,12 @@ async fn run_chat(
let (backend, mode) = resolve_backend(&config, force_local, json, quiet).await?;
// Resolve the agent: if `--agent` matches a name on the backend, use its id.
// Resolve the agent: if `--agent` matches a name or id, use it; else first available.
let agents = backend.list_agents().await?;
let resolved = agents.iter().find(|a| a.name == agent_name || a.id == agent_name)
.or_else(|| agents.first());
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 => {
@ -879,7 +984,7 @@ async fn run_chat(
async fn run_reflect(
config: Arc<RwLock<ConsciousnessConfig>>,
agent_name: String,
agent_name: Option<String>,
conversation: Option<String>,
json: bool,
) -> anyhow::Result<()> {
@ -889,11 +994,11 @@ async fn run_reflect(
let server = local.server();
let summaries = server.agents.list(None).await?;
let agent = summaries
.iter()
.find(|a| a.name == agent_name || a.id == agent_name)
.or_else(|| summaries.first())
.ok_or_else(|| anyhow::anyhow!("no agents configured"))?;
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.

View file

@ -68,6 +68,19 @@ impl AgentInventory {
self.subconscious_dir.join(&sub_id).join("memory.git")
}
/// Per-agent seed directory: `~/.souveraine/agents/{id}/seed/`.
/// Lives alongside the memfs so the agent's identity travels with its
/// memory — federation can later sync this directory as one unit.
pub fn seed_dir(&self, agent_id: &str) -> PathBuf {
self.memfs_dir.join(agent_id).join("seed")
}
/// Load or initialize a per-agent Ed25519 seed identity. First call
/// generates and persists; subsequent calls return the same keypair.
pub fn seed_id(&self, agent_id: &str) -> anyhow::Result<crate::core::identity::SeedId> {
crate::core::identity::SeedId::load_or_generate(&self.seed_dir(agent_id))
}
pub async fn list(&self, filters: Option<String>) -> anyhow::Result<Vec<AgentSummary>> {
let query = if let Some(filter) = filters {
sqlx::query_as::<_, AgentSummaryRow>(
@ -117,6 +130,24 @@ impl AgentInventory {
let repo = git2::Repository::init(&memfs)?;
drop(repo);
// Initialize this agent's per-agent seed alongside its memfs.
// Failure is non-fatal — the agent can still be created and we'll
// try again on first use via seed_id().
let seed_dir = self.memfs_dir.join(&uuid).join("seed");
match crate::core::identity::SeedId::load_or_generate(&seed_dir) {
Ok(seed) => {
tracing::info!(
agent_id = %uuid,
glyph = %seed.glyph(),
pubkey_prefix = %&seed.public_key_hex()[..16],
"initialized per-agent seed",
);
}
Err(e) => {
tracing::warn!(agent_id = %uuid, error = %e, "per-agent seed init failed (will retry on demand)");
}
}
let mut blocks = request.memory_blocks;
if blocks.is_empty() {
blocks.push(MemoryBlock {