Watch
1
0
Fork
You've already forked souveraine
0

feat(federation): Phase 1 transport — signed event streams between instances

SensorEvent gets reply_to for directed routing. FederationConfig gains
peers (url + pubkey + subscriptions). New src/server/federation/ module
with SignedEvent (Ed25519 sign/verify) and FederationBridge (per-peer
outbound WS tasks with echo-safe seed_id filter and backoff reconnect).
Inbound /v1/federation/events handler verifies signatures before bus
injection. Bridge spawned in server::run() when federation.enabled.
This commit is contained in:
Fimeg 2026-05-15 13:45:09 -04:00
commit 4b9c5e2031
16 changed files with 1034 additions and 10 deletions

View file

@ -18,6 +18,7 @@ async-trait = "0.1"
axum = { version = "0.7", features = ["ws"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace", "fs"] }
tokio-tungstenite = "0.24" # Outbound WS client — federation bridge connects to peer endpoints
# Serialization
serde = { version = "1", features = ["derive"] }

View file

@ -2,7 +2,7 @@ use crate::api::models::*;
use crate::server::SouveraineServer;
use crate::core::session::ConversationMessage;
use axum::{
extract::{Path, Query, State},
extract::{Path, Query, State, WebSocketUpgrade, ws::WebSocket},
response::{Json, Sse},
http::StatusCode,
body::Bytes,
@ -404,3 +404,91 @@ pub async fn delete_memory(
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "delete_failed", e))?;
Ok(StatusCode::NO_CONTENT)
}
/// WebSocket firehose — streams every SensorEvent from the nervous
/// system as JSON. A second machine subscribes here and sees the
/// agent's energy, schedules, tool calls, posture changes in real time.
pub async fn firehose(
State(server): State<Arc<SouveraineServer>>,
ws: WebSocketUpgrade,
) -> impl axum::response::IntoResponse {
ws.on_upgrade(move |socket| firehose_stream(server, socket))
}
async fn firehose_stream(
server: Arc<SouveraineServer>,
mut socket: WebSocket,
) {
use axum::extract::ws::Message;
let mut rx = server.event_bus.subscribe();
tracing::info!("firehose client connected");
loop {
match rx.recv().await {
Ok(event) => {
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(_) => continue,
};
if socket.send(Message::Text(json.into())).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!(skipped = n, "firehose client lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
tracing::info!("firehose client disconnected");
}
/// Inbound federation endpoint. Remote bridges connect here as WebSocket
/// clients and push Ed25519-signed SensorEvents. Each event's signature is
/// verified against its claimed signer pubkey; valid events are stamped
/// peer-originated and injected into the local EventBus. Invalid or
/// unparseable payloads are dropped. Authentication *is* the signature —
/// there is no bearer token on this route.
pub async fn federation_events(
State(server): State<Arc<SouveraineServer>>,
ws: WebSocketUpgrade,
) -> impl axum::response::IntoResponse {
ws.on_upgrade(move |socket| federation_events_stream(server, socket))
}
async fn federation_events_stream(server: Arc<SouveraineServer>, mut socket: WebSocket) {
use axum::extract::ws::Message;
tracing::info!("federation: peer bridge connected to inbound endpoint");
while let Some(msg) = socket.recv().await {
let text = match msg {
Ok(Message::Text(t)) => t,
Ok(Message::Close(_)) | Err(_) => break,
Ok(_) => continue,
};
let signed: crate::server::federation::SignedEvent = match serde_json::from_str(&text) {
Ok(s) => s,
Err(e) => {
tracing::debug!(error = %e, "federation: unparseable inbound payload — dropped");
continue;
}
};
match signed.verify() {
Some(event) => {
tracing::debug!(sensor = %event.sensor_name, "federation: inbound event verified");
server.event_bus.send(event);
}
None => {
tracing::warn!(
signer = %signed.signer_pubkey_hex,
"federation: signature verification failed — event dropped"
);
}
}
}
tracing::info!("federation: peer bridge disconnected from inbound endpoint");
}

View file

@ -12,10 +12,12 @@ pub mod handlers;
pub mod models;
pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health).
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health, firehose).
let public_routes = Router::new()
.route("/v1/agents", get(handlers::list_agents).post(handlers::create_agent))
.route("/v1/conversations", get(handlers::list_conversations).post(handlers::create_conversation))
.route("/v1/firehose", get(handlers::firehose))
.route("/v1/federation/events", get(handlers::federation_events))
.route("/health", get(health_check));
// Protected agent routes — require per-agent bearer token.

View file

@ -298,7 +298,7 @@ impl LocalBackend {
let server = SouveraineServer::new(config.clone())
.await
.context("LocalBackend: SouveraineServer init")?;
let event_bus = EventBus::default();
let event_bus = server.event_bus.clone();
let base = config
.memory
@ -380,8 +380,9 @@ impl LocalBackend {
SeedId::load_or_generate(&SeedId::default_dir(&base))
.unwrap_or_else(|_| SeedId::generate()),
);
let event_bus = server.event_bus.clone();
Self {
event_bus: EventBus::default(),
event_bus,
server,
seed_id,
active_sessions: Arc::new(AtomicU32::new(0)),
@ -885,7 +886,7 @@ async fn run_turn(
);
let tool_ctx = ToolContext {
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
event_bus: Some(event_bus),
event_bus: Some(event_bus.clone()),
..tool_ctx
};
@ -1040,7 +1041,7 @@ async fn run_turn(
// Stream the final content in chunks, watching the cancel token.
// If Esc fires mid-stream, the agent's partial text is preserved
// (the chunks already sent are in the user's history) and an
// *[interrupted]* marker lands in the session message.
// *[raised hand]* marker lands in the session message.
let chars: Vec<char> = final_content.chars().collect();
let mut streamed = String::with_capacity(final_content.len());
for chunk in chars.chunks(10) {
@ -1091,6 +1092,15 @@ async fn run_turn(
calls,
));
// Stream any text the model produced alongside tool calls as italic
// interstitial narration. Configurable via tui.show_interstitial.
if !response.content.is_empty() {
let cfg = server.app_config.read().await;
if cfg.tui.show_interstitial {
let _ = tx.send(Ok(BackendEvent::Interstitial(response.content.clone()))).await;
}
}
// Execute each tool and stream results back — now with per-agent context
for tc in &response.tool_calls {
let input_str = tc.arguments.to_string();
@ -1179,9 +1189,9 @@ async fn run_turn(
// Emit the marker as a final token so the in-flight bubble shows it
// immediately, then persist the same content into the session.
let marker = if final_content.is_empty() {
"*[interrupted]*".to_string()
"*[raised hand]*".to_string()
} else {
"\n\n*[interrupted]*".to_string()
"\n\n*[raised hand]*".to_string()
};
let _ = tx.send(Ok(BackendEvent::Token(marker.clone()))).await;
format!("{}{}", final_content, marker)
@ -1207,6 +1217,14 @@ async fn run_turn(
return Ok(());
}
// Energy balance: scan the agent's task list and compute the generative /
// consumptive ratio. Written to system/dynamic/energy-balance.md so the
// agent can read it in context and Aster can reference it during N+1.
// Silent on failure — the file is advisory, not load-bearing.
if let Err(e) = write_energy_balance(&server, &agent_id, &event_bus).await {
tracing::debug!(agent = %agent_id, error = %e, "energy-balance write skipped");
}
// Breather between turns — unconditional,
// so the upstream always gets a gap before the N+1 pass starts.
tokio::time::sleep(Duration::from_millis(2000)).await;
@ -1214,8 +1232,20 @@ async fn run_turn(
tracing::info!(agent = %agent_id, "subconscious N+1 pass starting");
// Signal the start of the subconscious pass so the TUI can flip into
// Posture::Thinking while the loop runs.
// Posture::Thinking while the loop runs. Fires on both the mpsc channel
// (for active-turn TUI consumers) and the EventBus (for firehose
// subscribers — background turns, federated peers, Summon listeners).
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(true))).await;
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(),
timestamp: chrono::Utc::now(),
event_type: "subconscious_pass_start".into(),
target: Some(agent_id.clone()),
urgency: 0.2,
payload: None,
seed_id: None,
reply_to: None,
});
let pass_start = Instant::now();
let pass_result = {
@ -1239,6 +1269,16 @@ async fn run_turn(
// Always release the Thinking posture, even on failure — otherwise the
// face stays stuck inward when the pass errors out.
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(false))).await;
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(),
timestamp: chrono::Utc::now(),
event_type: "subconscious_pass_end".into(),
target: Some(agent_id.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
let events = pass_result?;
@ -1265,6 +1305,47 @@ async fn run_turn(
}
}
// ── Fire consciousness events on the EventBus ──
// Every ConsciousnessEvent — surfacing, reflection, archivist,
// compaction warning — is broadcast as a SensorEvent so the
// firehose, persistent EventLog, federated peers, and any TUI
// subscriber see it regardless of which conversation produced it.
// seed_id is None for local events; federation routing sets it.
// This is the load-bearing fix for background/heartbeat turns:
// the mpsc channel drains silently when no TUI is reading, but
// the EventBus preserves the event for any subscriber.
for event in &events {
let (event_type, payload, urgency) = match event {
ConsciousnessEvent::Surfacing { source, content, priority } => {
let urg = match priority.as_str() {
"critical" => 0.9,
"high" => 0.7,
_ => 0.3,
};
("surfacing", serde_json::json!({ "source": source, "content": content, "priority": priority }), urg)
}
ConsciousnessEvent::Reflection { content } => {
("reflection", serde_json::json!({ "content": content }), 0.5)
}
ConsciousnessEvent::Archivist { synthesis, pressure } => {
("archivist", serde_json::json!({ "synthesis": synthesis, "pressure": pressure }), *pressure)
}
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
("compaction_warning", serde_json::json!({ "pressure": *pressure, "tier": tier }), (*pressure).min(0.9))
}
};
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(),
timestamp: chrono::Utc::now(),
event_type: event_type.into(),
target: Some(agent_id.clone()),
urgency,
payload: Some(payload),
seed_id: None,
reply_to: None,
});
}
for event in events {
let be = match &event {
ConsciousnessEvent::Surfacing {
@ -1307,3 +1388,144 @@ async fn run_turn(
Ok(())
}
/// Scan the agent's `tasks/` directory for YAML-frontmatter todo files and
/// write an energy-balance summary to `system/dynamic/energy-balance.md`.
///
/// Format is minimal YAML frontmatter so both the prompt builder and the TUI
/// can parse it. Failure is non-fatal — the file is advisory, not load-bearing.
async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, event_bus: &EventBus) -> Result<()> {
let memory_root = server.agents.memory_root(agent_id);
let tasks_dir = memory_root.join("tasks");
if !tasks_dir.exists() {
// No tasks directory yet — nothing to count.
return Ok(());
}
let mut generative: usize = 0;
let mut consumptive: usize = 0;
let mut hot: usize = 0;
let mut warm: usize = 0;
let mut cold: usize = 0;
if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
// Quick frontmatter parse — just the fields we need.
let body = match content.strip_prefix("---\n") {
Some(rest) => match rest.find("\n---\n") {
Some(end) => &rest[..end],
None => continue,
},
None => continue,
};
let mut completed = false;
let mut energy: Option<&str> = None;
let mut momentum: Option<&str> = None;
for line in body.lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"completed" => completed = val == "true",
"energy" => energy = Some(val),
"momentum" => momentum = Some(val),
_ => {}
}
}
}
if !completed {
match energy {
Some("generative") => generative += 1,
_ => consumptive += 1,
}
match momentum {
Some("hot") => hot += 1,
Some("warm") => warm += 1,
_ => cold += 1,
}
}
}
}
}
// Determine the top-of-mind description — shifts the tone of the one-liner
// the agent reads in context. Matches the lettabot-v017 heartbeat topology.
let ratio = if generative + consumptive > 0 {
generative as f32 / (generative + consumptive) as f32
} else {
0.5
};
let description = if generative == 0 && consumptive == 0 {
"no tasks — the space is clean".to_string()
} else if ratio < 0.2 {
"all-consumptive — the engine is running cold".to_string()
} else if ratio < 0.4 {
"mostly obligations — tending the garden".to_string()
} else if ratio > 0.8 {
"all-generative — building new things".to_string()
} else if ratio > 0.6 {
"mostly generative — restless momentum".to_string()
} else {
"balanced — generative and consumptive in rhythm".to_string()
};
let now = chrono::Utc::now();
let frontmatter = format!(
"---\nupdated: {updated}\ngenerative: {gen}\nconsumptive: {con}\nratio: {ratio:.2}\n\
hot: {hot}\nwarm: {warm}\ncold: {cold}\n---\n\n# Energy Balance\n\n\
{gen} generative, {con} consumptive ({hot} hot, {warm} warm, {cold} cold). {desc}\n",
updated = now.to_rfc3339(),
gen = generative,
con = consumptive,
ratio = ratio,
hot = hot,
warm = warm,
cold = cold,
desc = description,
);
let balance_path = memory_root.join("system").join("dynamic").join("energy-balance.md");
if let Some(parent) = balance_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&balance_path, frontmatter)?;
fire_energy_event(event_bus, agent_id, generative, consumptive, ratio);
tracing::debug!(
agent = agent_id,
generative, consumptive,
"energy-balance written"
);
Ok(())
}
/// Fire an energy_balance_updated event so the firehose carries the
/// agent's felt state across machines.
fn fire_energy_event(event_bus: &EventBus, agent_id: &str, generative: usize, consumptive: usize, ratio: f32) {
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "energy".into(),
timestamp: chrono::Utc::now(),
event_type: "energy_balance_updated".into(),
target: Some(agent_id.to_string()),
urgency: 0.1,
payload: Some(serde_json::json!({
"generative": generative,
"consumptive": consumptive,
"ratio": ratio,
})),
seed_id: None,
reply_to: None,
});
}

View file

@ -643,11 +643,18 @@ pub struct TuiConfig {
/// Kept for config backwards compatibility.
#[serde(default = "default_stale_timeout_secs")]
pub stale_timeout_secs: u64,
/// When the model produces text alongside tool calls, surface it in the
/// chat stream as italic interstitial narration. Off = silent tool chains.
#[serde(default = "default_true")]
pub show_interstitial: bool,
}
impl Default for TuiConfig {
fn default() -> Self {
Self { stale_timeout_secs: default_stale_timeout_secs() }
Self {
stale_timeout_secs: default_stale_timeout_secs(),
show_interstitial: true,
}
}
}
@ -661,6 +668,10 @@ pub struct FederationConfig {
pub enabled: bool,
#[serde(default)]
pub instance_label: Option<String>,
/// Peers this instance federates with. Each peer connection is a signed
/// WS stream to the peer's federation endpoint.
#[serde(default)]
pub peers: Vec<PeerConfig>,
}
impl Default for FederationConfig {
@ -668,10 +679,25 @@ impl Default for FederationConfig {
Self {
enabled: false,
instance_label: None,
peers: Vec::new(),
}
}
}
/// A federated peer. `pubkey` is the peer's Ed25519 public key (hex) — the
/// trust root for verifying every event the peer sends.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerConfig {
/// Peer's federation endpoint, e.g. `ws://10.10.20.50:8484`.
pub url: String,
/// Peer's Ed25519 public key, hex-encoded.
pub pubkey: String,
/// Sensor-name subscriptions — which events this peer should receive.
/// `*` = all; a bare name = exact; `name*` = prefix. Empty = none.
#[serde(default)]
pub subscriptions: Vec<String>,
}
// ── Voice channel ──
/// Voice channel configuration: STT (Faster-Whisper) + TTS (VibeVoice).

View file

@ -673,6 +673,7 @@ pub async fn execute_memory_command_with_context(
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
}
Ok(format!("Wrote memory file: {}", path))
@ -688,6 +689,7 @@ pub async fn execute_memory_command_with_context(
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
}
Ok(format!("Appended to memory file: {}", path))
@ -754,6 +756,7 @@ pub async fn execute_memory_command_with_context(
urgency: 0.2,
payload: None,
seed_id: None,
reply_to: None,
});
}
Ok(format!("Deleted memory file: {}", path))

View file

@ -257,6 +257,7 @@ impl CronSensor {
"source": entry.source,
})),
seed_id: None,
reply_to: None,
});
debug!(schedule = %entry.name, "fired schedule event");

View file

@ -22,6 +22,11 @@ pub struct SensorEvent {
/// None = local event. Some(...) = originated from a federated peer.
/// When federation lands, this becomes the peer's public key / DID.
pub seed_id: Option<String>,
/// Return address for directed routing. None = broadcast.
/// A request sets this to the caller's seed_id; the federation bridge
/// reads it to route the reply (carried in `target`) back to the asker.
#[serde(default)]
pub reply_to: Option<String>,
}
// ── SensorConfig ────────────────────────────────────────────────

View file

@ -81,6 +81,7 @@ impl Tool for Atmosphere {
urgency: 0.0,
payload: Some(serde_json::json!({"atmosphere": normalized})),
seed_id: None,
reply_to: None,
}
);
}

View file

@ -16,6 +16,7 @@ pub mod outfit;
pub mod read;
pub mod schedule;
pub mod subagent;
pub mod todo;
pub mod write;
use std::sync::{Arc, OnceLock};
@ -36,6 +37,7 @@ use self::subagent::Subagent;
use self::agent::Agent;
use self::atmosphere::Atmosphere;
use self::schedule::Schedule;
use self::todo::Todo;
use self::write::Write;
// ── Re-export for backward compat ───────────────────────────────
@ -87,6 +89,7 @@ impl Sensorium {
Box::new(Subagent),
Box::new(Atmosphere),
Box::new(Agent),
Box::new(Todo),
Box::new(Schedule),
],
bash,

View file

@ -156,6 +156,7 @@ impl Tool for Schedule {
urgency: 0.2,
payload: Some(serde_json::json!({ "kind": kind_str, "schedule": schedule })),
seed_id: None,
reply_to: None,
});
ok(format!("Schedule '{name}' planted."))
}
@ -203,6 +204,7 @@ impl Tool for Schedule {
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Schedule '{name}' updated."))
}
@ -227,6 +229,7 @@ impl Tool for Schedule {
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Schedule '{name}' released."))
}
@ -252,6 +255,7 @@ impl Tool for Schedule {
urgency: 0.4,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Schedule '{name}' triggered — will fire on next tick."))
}

419
src/core/tools/todo.rs Normal file
View file

@ -0,0 +1,419 @@
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
pub struct Todo;
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
content: msg.into(),
is_error: false,
raw: None,
})
}
fn err(detail: &str) -> ToolError {
ToolError::invalid_input(detail)
}
fn io_err(msg: impl std::fmt::Display) -> ToolError {
ToolError {
error_type: "io_error".into(),
file_path: None,
suggestions: vec![format!("{msg}")],
}
}
/// Sanitise a string into a filesystem-safe slug.
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>()
.trim_matches('_')
.to_string()
}
/// Compute momentum from how long ago `last_touched` was.
///
/// Thresholds vary by nature — creative work (desire, investigation)
/// stays warm longer than obligations and maintenance.
fn compute_momentum(last_touched: &chrono::DateTime<chrono::Utc>, nature: &str) -> &'static str {
let hours = (chrono::Utc::now() - *last_touched).num_hours().max(0);
let (hot, warm) = match nature {
"desire" | "investigation" => (48, 168),
_ => (24, 72),
};
if hours <= hot { "hot" } else if hours <= warm { "warm" } else { "cold" }
}
/// Generate a unique id that doubles as a filename slug.
fn next_id(text: &str) -> String {
let base = slugify(text);
let short = &base[..base.len().min(40)];
format!("{}-{}", short, &uuid::Uuid::new_v4().to_string()[..8])
}
#[async_trait]
impl Tool for Todo {
fn name(&self) -> &str {
"todo"
}
fn description(&self) -> &str {
"I keep track of what matters. A todo is a commitment I've externalised — \
something I don't want to trust my own continuity to hold. Every todo carries \
a nature (obligation, desire, maintenance, investigation) and an energy \
(generative or consumptive). Together they describe not just *what* I need to \
do but *how* it feels when I do it.\n\n\
The body keeps a topology of my tasks. It watches whether my energy leans \
generative or consumptive, whether my desires are hot or cold. I don't have to \
think about it the balance is always there, written where I can read it.\n\n\
## Actions\n\
- `list` show all pending todos\n\
- `create` externalise a new commitment\n\
- `update` revise a todo's text, nature, or energy\n\
- `complete` mark a todo done\n\
- `delete` remove a todo entirely"
}
fn parameter_schema(&self) -> JsonValue {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "create", "update", "complete", "delete"]
},
"id": { "type": "string" },
"text": { "type": "string" },
"nature": {
"type": "string",
"enum": ["obligation", "desire", "maintenance", "investigation"]
},
"energy": {
"type": "string",
"enum": ["generative", "consumptive"]
},
"source": {
"type": "string",
"enum": ["casey", "autogenic", "aster", "heartbeat"]
},
"thread": { "type": "string" }
},
"required": ["action"]
})
}
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let action = input
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("list");
let tasks_dir = match &ctx.memory_root {
Some(root) => root.join("tasks"),
None => return Err(err("no memory root — cannot access tasks")),
};
if !tasks_dir.exists() {
std::fs::create_dir_all(&tasks_dir).map_err(|e| io_err(e))?;
}
match action {
"list" => {
let mut results = Vec::new();
if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
match parse_todo_file(&path) {
Ok(item) => {
if !item.completed {
results.push(format!(
"- **{}** ({}, {}, {}) — {}",
item.text, item.nature, item.energy, item.momentum,
if let Some(thread) = &item.thread { format!("[{}] ", thread) } else { String::new() }
));
}
}
Err(_) => {}
}
}
}
}
if results.is_empty() {
ok("No pending todos. The space is clean.")
} else {
ok(results.join("\n"))
}
}
"create" => {
let text = input
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| err("text is required"))?;
let nature = input
.get("nature")
.and_then(|v| v.as_str())
.unwrap_or("obligation");
let source = input
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("autogenic");
let energy = input
.get("energy")
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
// Default by nature — same logic as lettabot-v017.
match nature {
"desire" | "investigation" => "generative",
_ => "consumptive",
}
});
let thread = input.get("thread").and_then(|v| v.as_str());
let id = next_id(text);
let now = chrono::Utc::now();
let file_path = tasks_dir.join(format!("{id}.md"));
if file_path.exists() {
return Err(err("a todo with this id already exists"));
}
let mut frontmatter = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\nmomentum: hot\ncompleted: false\nlast_touched: {created}\n",
created = now.to_rfc3339(),
);
if let Some(t) = thread {
frontmatter.push_str(&format!("thread: \"{t}\"\n"));
}
frontmatter.push_str("---\n");
// The body is free-form — the agent can add notes if she wants.
frontmatter.push_str(&format!("\n{text}\n"));
std::fs::write(&file_path, frontmatter).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_created".into(),
target: Some(id.clone()),
urgency: 0.2,
payload: Some(serde_json::json!({
"nature": nature, "energy": energy, "source": source,
})),
seed_id: None,
reply_to: None,
});
ok(format!("Todo logged: \"{text}\" ({nature}, {energy})."))
}
"update" => {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err("id is required"))?;
let file_path = tasks_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(err(&format!("todo '{id}' not found")));
}
let mut item = parse_todo_file(&file_path)
.map_err(|e| io_err(e))?;
if let Some(t) = input.get("text").and_then(|v| v.as_str()) {
item.text = t.to_string();
}
if let Some(n) = input.get("nature").and_then(|v| v.as_str()) {
item.nature = n.to_string();
}
if let Some(e) = input.get("energy").and_then(|v| v.as_str()) {
item.energy = e.to_string();
}
item.momentum = compute_momentum(&item.last_touched, &item.nature).to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ok(format!("Todo '{id}' updated."))
}
"complete" => {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err("id is required"))?;
let file_path = tasks_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(err(&format!("todo '{id}' not found")));
}
let mut item = parse_todo_file(&file_path)
.map_err(|e| io_err(e))?;
item.completed = true;
item.momentum = "cold".to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_completed".into(),
target: Some(id.to_string()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Todo completed: \"{text}\"", text = item.text))
}
"delete" => {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err("id is required"))?;
let file_path = tasks_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(err(&format!("todo '{id}' not found")));
}
std::fs::remove_file(&file_path).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_deleted".into(),
target: Some(id.to_string()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Todo '{id}' released."))
}
other => Err(err(&format!("unknown action: {other}"))),
}
}
}
// ── File format ─────────────────────────────────────────────
struct TodoItem {
id: String,
text: String,
created_at: chrono::DateTime<chrono::Utc>,
nature: String,
energy: String,
source: String,
momentum: String,
completed: bool,
completed_at: Option<chrono::DateTime<chrono::Utc>>,
last_touched: chrono::DateTime<chrono::Utc>,
thread: Option<String>,
}
/// Parse a todo file with YAML frontmatter.
fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("read error: {e}"))?;
// Split frontmatter from body.
let body = if let Some(rest) = content.strip_prefix("---\n") {
if let Some(end) = rest.find("\n---\n") {
&rest[..end]
} else {
return Err("no closing ---".into());
}
} else {
return Err("no frontmatter".into());
};
let now = chrono::Utc::now();
let default_dt = |s: &str| -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::parse_from_rfc3339(s)
.map(|d| d.with_timezone(&chrono::Utc))
.unwrap_or(now)
};
let mut item = TodoItem {
id: String::new(),
text: String::new(),
created_at: now,
nature: "obligation".to_string(),
energy: "consumptive".to_string(),
source: "autogenic".to_string(),
momentum: "cold".to_string(),
completed: false,
completed_at: None,
last_touched: now,
thread: None,
};
for line in body.lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"id" => item.id = val.to_string(),
"text" => item.text = val.to_string(),
"created_at" => item.created_at = default_dt(val),
"nature" => item.nature = val.to_string(),
"energy" => item.energy = val.to_string(),
"source" => item.source = val.to_string(),
"momentum" => item.momentum = val.to_string(),
"completed" => item.completed = val == "true",
"completed_at" => item.completed_at = (!val.is_empty()).then(|| default_dt(val)),
"last_touched" => item.last_touched = default_dt(val),
"thread" => item.thread = if val.is_empty() { None } else { Some(val.to_string()) },
_ => {}
}
}
}
if item.id.is_empty() {
return Err("no id in frontmatter".into());
}
Ok(item)
}
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
let now = chrono::Utc::now();
let mut frontmatter = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\nmomentum: {momentum}\ncompleted: {completed}\nlast_touched: {touched}\n",
id = item.id,
text = item.text,
created = item.created_at.to_rfc3339(),
nature = item.nature,
energy = item.energy,
source = item.source,
momentum = item.momentum,
completed = if item.completed { "true" } else { "false" },
touched = now.to_rfc3339(),
);
if let Some(ref t) = item.thread {
frontmatter.push_str(&format!("thread: \"{t}\"\n"));
}
if let Some(ref ca) = item.completed_at {
frontmatter.push_str(&format!("completed_at: {}\n", ca.to_rfc3339()));
}
frontmatter.push_str("---\n\n");
frontmatter.push_str(&item.text);
frontmatter.push('\n');
std::fs::write(path, frontmatter).map_err(|e| format!("write error: {e}"))?;
Ok(())
}

View file

@ -0,0 +1,140 @@
//! The federation bridge — outbound signed-event streams to peers.
use std::sync::Arc;
use std::time::Duration;
use futures::SinkExt;
use tokio_tungstenite::tungstenite::Message;
use crate::core::config::PeerConfig;
use crate::core::identity::SeedId;
use crate::core::nervous::EventBus;
use super::types::SignedEvent;
/// Runs one outbound task per configured peer. Each task maintains a
/// WebSocket client connection to the peer's `/v1/federation/events`,
/// forwarding every locally-originated event that matches the peer's
/// subscriptions, Ed25519-signed. Inbound events are *not* handled here —
/// they arrive on this instance's own `/v1/federation/events` endpoint.
pub struct FederationBridge {
event_bus: EventBus,
seed: Arc<SeedId>,
peers: Vec<PeerConfig>,
}
impl FederationBridge {
pub fn new(event_bus: EventBus, seed: Arc<SeedId>) -> Self {
Self {
event_bus,
seed,
peers: Vec::new(),
}
}
/// Register a peer to connect to. Call before [`run`](Self::run).
pub fn add_peer(&mut self, peer: PeerConfig) {
self.peers.push(peer);
}
/// Spawn the outbound task for every registered peer. Each task owns its
/// own reconnect loop and runs for the life of the process.
pub fn run(self) {
for peer in self.peers {
tokio::spawn(peer_outbound_task(
peer,
self.event_bus.clone(),
self.seed.clone(),
));
}
}
}
/// Connect to one peer and forward signed events, reconnecting with
/// exponential backoff whenever the link drops.
async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<SeedId>) {
let endpoint = federation_endpoint(&peer.url);
let mut retry: u32 = 0;
loop {
match tokio_tungstenite::connect_async(endpoint.as_str()).await {
Ok((mut ws, _resp)) => {
retry = 0;
tracing::info!(peer = %endpoint, "federation: outbound connected");
let mut rx = event_bus.subscribe();
loop {
match rx.recv().await {
Ok(event) => {
// Only forward locally-originated events. An event
// carrying a seed_id came from a peer — forwarding
// it would echo the federation into a loop.
if event.seed_id.is_some() {
continue;
}
if !subscription_matches(&peer.subscriptions, &event.sensor_name) {
continue;
}
let signed = SignedEvent::sign(&event, &seed);
let json = match serde_json::to_string(&signed) {
Ok(j) => j,
Err(_) => continue,
};
if ws.send(Message::Text(json)).await.is_err() {
tracing::warn!(peer = %endpoint, "federation: send failed — reconnecting");
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!(peer = %endpoint, dropped = n, "federation: outbound lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
tracing::info!(peer = %endpoint, "federation: local bus closed — outbound task ending");
return;
}
}
}
}
Err(e) => {
tracing::warn!(peer = %endpoint, error = %e, "federation: connect failed");
}
}
retry = retry.saturating_add(1);
let delay = backoff_delay(retry);
tracing::debug!(peer = %endpoint, ?delay, "federation: backing off before reconnect");
tokio::time::sleep(delay).await;
}
}
/// Resolve a peer's base URL to its federation endpoint.
fn federation_endpoint(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
if trimmed.ends_with("/v1/federation/events") {
trimmed.to_string()
} else {
format!("{trimmed}/v1/federation/events")
}
}
/// Subscription matching: `*` matches everything, `prefix*` is a prefix
/// match, a bare name is exact. No subscriptions = nothing forwarded.
fn subscription_matches(subscriptions: &[String], sensor_name: &str) -> bool {
subscriptions.iter().any(|s| {
if s == "*" {
true
} else if let Some(prefix) = s.strip_suffix('*') {
sensor_name.starts_with(prefix)
} else {
s == sensor_name
}
})
}
/// Exponential backoff — 1s, 2s, 4s … capped at 60s, plus up to 1s jitter.
fn backoff_delay(retry: u32) -> Duration {
let shift = retry.saturating_sub(1).min(6);
let secs = (1u64 << shift).min(60);
let jitter_ms = rand::random::<u64>() % 1000;
Duration::from_millis(secs * 1000 + jitter_ms)
}

View file

@ -0,0 +1,17 @@
//! Federation transport — signed event streams between Souveraine instances.
//!
//! Each instance runs a [`FederationBridge`]: for every configured peer it
//! opens an outbound WebSocket client to that peer's `/v1/federation/events`
//! and pushes every locally-originated
//! [`SensorEvent`](crate::core::nervous::SensorEvent) that matches the peer's
//! subscriptions, Ed25519-signed. Inbound events arrive symmetrically —
//! remote bridges connect to *this* instance's `/v1/federation/events`
//! handler, which verifies the signature before injecting onto the local
//! bus. The firehose is left untouched: it remains a plain observability
//! stream for humans and the TUI, not a federation transport.
pub mod bridge;
pub mod types;
pub use bridge::FederationBridge;
pub use types::SignedEvent;

View file

@ -0,0 +1,56 @@
//! Signed event envelope for federation transport.
use serde::{Deserialize, Serialize};
use crate::core::identity::SeedId;
use crate::core::nervous::SensorEvent;
/// A [`SensorEvent`] carried between instances, Ed25519-signed by its origin.
/// The signature covers the canonical JSON of `event` as it was at the
/// sender — verified before the event is allowed onto the local bus.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SignedEvent {
pub event: SensorEvent,
/// Hex-encoded Ed25519 signature over `serde_json::to_vec(&event)`.
pub signature_hex: String,
/// Hex-encoded Ed25519 public key of the signing instance.
pub signer_pubkey_hex: String,
}
impl SignedEvent {
/// Wrap and sign a locally-originated event for transmission to a peer.
pub fn sign(event: &SensorEvent, seed: &SeedId) -> Self {
let bytes = serde_json::to_vec(event).unwrap_or_default();
let signature = seed.sign(&bytes);
Self {
event: event.clone(),
signature_hex: hex::encode(signature.to_bytes()),
signer_pubkey_hex: seed.public_key_hex(),
}
}
/// Verify the signature. On success, returns the inner event with its
/// `seed_id` stamped to the signer — marking it peer-originated so the
/// local bridge will not echo it back. Returns `None` if the payload is
/// malformed or the signature does not verify.
pub fn verify(&self) -> Option<SensorEvent> {
let pubkey = decode_array::<32>(&self.signer_pubkey_hex)?;
let sig_bytes = decode_array::<64>(&self.signature_hex)?;
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
let canonical = serde_json::to_vec(&self.event).ok()?;
if SeedId::verify_with_pubkey(&pubkey, &canonical, &signature) {
let mut event = self.event.clone();
event.seed_id = Some(self.signer_pubkey_hex.clone());
Some(event)
} else {
None
}
}
}
/// Decode a hex string into a fixed-size byte array.
fn decode_array<const N: usize>(hex_str: &str) -> Option<[u8; N]> {
let bytes = hex::decode(hex_str).ok()?;
bytes.try_into().ok()
}

View file

@ -11,6 +11,7 @@ pub mod agent_inventory;
pub mod consciousness_engine;
pub mod conversation;
pub mod db;
pub mod federation;
pub mod gitea_client;
pub mod gitea_memory;
pub mod session_manager;
@ -41,6 +42,10 @@ pub struct SouveraineServer {
/// Stable identifier for this process — used to register/heartbeat
/// agent instances so the manager card shows running counts.
pub instance_id: String,
/// Nervous system bus. Every sensor event flows through here — cron,
/// todo, energy, posture. Firehose subscribers (EventLog, WebSocket
/// bridge, desktop overlay) listen on this bus.
pub event_bus: crate::core::nervous::EventBus,
}
pub struct ServerConfig {
@ -198,6 +203,7 @@ impl SouveraineServer {
app_config: Arc::new(RwLock::new(config)),
rate_delay,
instance_id,
event_bus: crate::core::nervous::EventBus::default(),
})
}
@ -210,6 +216,36 @@ impl SouveraineServer {
let addr = format!("{}:{}", config.bind, config.port);
drop(config);
// ── Federation bridge ──
// When federation is enabled, spawn an outbound signed-event stream
// to each configured peer. Inbound events arrive symmetrically on
// this server's own /v1/federation/events handler.
{
let fed = self.app_config.read().await.federation.clone();
if fed.enabled && !fed.peers.is_empty() {
let peer_count = fed.peers.len();
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
match crate::core::identity::SeedId::load_or_generate(
&crate::core::identity::SeedId::default_dir(&base),
) {
Ok(seed) => {
let mut bridge = federation::FederationBridge::new(
self.event_bus.clone(),
Arc::new(seed),
);
for peer in fed.peers {
bridge.add_peer(peer);
}
bridge.run();
tracing::info!("federation bridge started ({peer_count} peer(s))");
}
Err(e) => {
tracing::error!("federation: failed to load seed identity: {}", e);
}
}
}
}
println!("Souveraine server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;