Watch
1
0
Fork
You've already forked souveraine
0

two swipes, one progress; home is zone 0; pocket is a belief

This commit is contained in:
Fimeg 2026-08-05 19:16:41 -04:00
commit d7965a57fd
188 changed files with 9258 additions and 3970 deletions

View file

@ -1,15 +1,15 @@
//! Demo of sexy terminal UI effects //! Demo of sexy terminal UI effects
//! Run with: cargo run --example demo //! Run with: cargo run --example demo
use crossterm::{
cursor::{Hide, MoveTo, Show},
execute,
style::{Color, ResetColor, SetForegroundColor},
terminal::{Clear, ClearType},
};
use std::io::{self, Write}; use std::io::{self, Write};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::time::sleep; use tokio::time::sleep;
use crossterm::{
execute,
terminal::{Clear, ClearType},
cursor::{MoveTo, Show, Hide},
style::{Color, ResetColor, SetForegroundColor},
};
pub struct Animator { pub struct Animator {
start_time: Instant, start_time: Instant,
@ -17,7 +17,9 @@ pub struct Animator {
impl Animator { impl Animator {
pub fn new() -> Self { pub fn new() -> Self {
Self { start_time: Instant::now() } Self {
start_time: Instant::now(),
}
} }
pub fn breathe(&self, speed_ms: u64) -> f32 { pub fn breathe(&self, speed_ms: u64) -> f32 {
@ -37,11 +39,14 @@ pub async fn typewrite(text: &str, wpm: u64) {
} }
pub fn gradient(text: &str, start_hue: f32) -> String { pub fn gradient(text: &str, start_hue: f32) -> String {
text.chars().enumerate().map(|(i, ch)| { text.chars()
.enumerate()
.map(|(i, ch)| {
let hue = (start_hue + i as f32 * 3.0) % 360.0; let hue = (start_hue + i as f32 * 3.0) % 360.0;
let (r, g, b) = hsl_to_rgb(hue, 0.8, 0.6); let (r, g, b) = hsl_to_rgb(hue, 0.8, 0.6);
format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, ch) format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, ch)
}).collect() })
.collect()
} }
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
@ -56,18 +61,26 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
_ if h < 300.0 => (x, 0.0, c), _ if h < 300.0 => (x, 0.0, c),
_ => (c, 0.0, x), _ => (c, 0.0, x),
}; };
(((r1 + m) * 255.0) as u8, ((g1 + m) * 255.0) as u8, ((b1 + m) * 255.0) as u8) (
((r1 + m) * 255.0) as u8,
((g1 + m) * 255.0) as u8,
((b1 + m) * 255.0) as u8,
)
} }
pub fn breathing_color(base: (u8, u8, u8), intensity: f32) -> (u8, u8, u8) { pub fn breathing_color(base: (u8, u8, u8), intensity: f32) -> (u8, u8, u8) {
let factor = 0.8 + (intensity * 0.4); let factor = 0.8 + (intensity * 0.4);
((base.0 as f32 * factor).min(255.0) as u8, (
(base.0 as f32 * factor).min(255.0) as u8,
(base.1 as f32 * factor).min(255.0) as u8, (base.1 as f32 * factor).min(255.0) as u8,
(base.2 as f32 * factor).min(255.0) as u8) (base.2 as f32 * factor).min(255.0) as u8,
)
} }
pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub const WAVE: &[&str] = &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"]; pub const WAVE: &[&str] = &[
"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂",
];
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@ -108,7 +121,15 @@ async fn main() {
execute!(stdout, MoveTo(5, 8)).unwrap(); execute!(stdout, MoveTo(5, 8)).unwrap();
print!("Loading: "); print!("Loading: ");
for i in 0..20 { for i in 0..20 {
execute!(stdout, SetForegroundColor(Color::Rgb { r: 100, g: 200, b: 255 })).unwrap(); execute!(
stdout,
SetForegroundColor(Color::Rgb {
r: 100,
g: 200,
b: 255
})
)
.unwrap();
print!("{}", SPINNER[i % SPINNER.len()]); print!("{}", SPINNER[i % SPINNER.len()]);
io::stdout().flush().unwrap(); io::stdout().flush().unwrap();
sleep(Duration::from_millis(80)).await; sleep(Duration::from_millis(80)).await;

View file

@ -11,11 +11,11 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use axum::{ use axum::{
body::Body,
extract::{Path, State}, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
middleware::Next, middleware::Next,
response::Response, response::Response,
body::Body,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
@ -33,7 +33,10 @@ pub fn generate_token() -> String {
/// Path to the token file for an agent. /// Path to the token file for an agent.
pub fn token_path(server_data_dir: &std::path::Path, agent_id: &str) -> PathBuf { pub fn token_path(server_data_dir: &std::path::Path, agent_id: &str) -> PathBuf {
server_data_dir.join("agents").join(agent_id).join("api_token") server_data_dir
.join("agents")
.join(agent_id)
.join("api_token")
} }
/// Read the token from disk. Errors if the file is missing or unreadable. /// Read the token from disk. Errors if the file is missing or unreadable.
@ -226,7 +229,9 @@ pub async fn require_conversation_token(
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let agent_id = { let agent_id = {
let session = server.sessions.get(&conversation_id) let session = server
.sessions
.get(&conversation_id)
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
session.agent_id.clone() session.agent_id.clone()
}; };
@ -284,7 +289,9 @@ mod tests {
async fn write_then_read_roundtrip() { async fn write_then_read_roundtrip() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let agent_id = "test-agent"; let agent_id = "test-agent";
write_token(dir.path(), agent_id, "souv_abc123").await.unwrap(); write_token(dir.path(), agent_id, "souv_abc123")
.await
.unwrap();
let got = read_token(dir.path(), agent_id).await.unwrap(); let got = read_token(dir.path(), agent_id).await.unwrap();
assert_eq!(got, "souv_abc123"); assert_eq!(got, "souv_abc123");
} }

View file

@ -1,10 +1,10 @@
use crate::api::models::*; use crate::api::models::*;
use crate::server::SouveraineServer; use crate::server::SouveraineServer;
use axum::{ use axum::{
extract::{Path, Query, State, WebSocketUpgrade, ws::WebSocket},
response::{Json, Sse},
http::StatusCode,
body::Bytes, body::Bytes,
extract::{ws::WebSocket, Path, Query, State, WebSocketUpgrade},
http::StatusCode,
response::{Json, Sse},
}; };
use futures::StreamExt; use futures::StreamExt;
use std::sync::Arc; use std::sync::Arc;
@ -18,11 +18,15 @@ pub async fn list_agents(
Query(filters): Query<AgentFilters>, Query(filters): Query<AgentFilters>,
) -> Result<Json<Vec<AgentSummary>>, ApiError> { ) -> Result<Json<Vec<AgentSummary>>, ApiError> {
let filter = filters.name.or(filters.tags); let filter = filters.name.or(filters.tags);
let agents = server.agents.list(filter).await let agents = server.agents.list(filter).await.map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "list_failed".to_string(), error: "list_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
Ok(Json(agents)) Ok(Json(agents))
} }
@ -30,11 +34,15 @@ pub async fn create_agent(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Json(request): Json<CreateAgentRequest>, Json(request): Json<CreateAgentRequest>,
) -> Result<(StatusCode, Json<AgentState>), ApiError> { ) -> Result<(StatusCode, Json<AgentState>), ApiError> {
let agent = server.agents.create(request).await let agent = server.agents.create(request).await.map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "creation_failed".to_string(), error: "creation_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
Ok((StatusCode::CREATED, Json(agent))) Ok((StatusCode::CREATED, Json(agent)))
} }
@ -42,16 +50,26 @@ pub async fn get_agent(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<AgentState>, ApiError> { ) -> Result<Json<AgentState>, ApiError> {
let agent = server.agents.get(&id).await let agent =
server
.agents
.get(&id)
.await
.map_err(|e| match e.to_string().contains("not found") { .map_err(|e| match e.to_string().contains("not found") {
true => (StatusCode::NOT_FOUND, Json(ErrorResponse { true => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "agent_not_found".to_string(), error: "agent_not_found".to_string(),
message: format!("Agent {} not found", id), message: format!("Agent {} not found", id),
})), }),
false => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { ),
false => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "fetch_failed".to_string(), error: "fetch_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})), }),
),
})?; })?;
Ok(Json(agent)) Ok(Json(agent))
} }
@ -61,11 +79,15 @@ pub async fn update_agent(
Path(id): Path<String>, Path(id): Path<String>,
Json(updates): Json<UpdateAgentRequest>, Json(updates): Json<UpdateAgentRequest>,
) -> Result<Json<AgentState>, ApiError> { ) -> Result<Json<AgentState>, ApiError> {
let agent = server.agents.update(&id, updates).await let agent = server.agents.update(&id, updates).await.map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "update_failed".to_string(), error: "update_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
Ok(Json(agent)) Ok(Json(agent))
} }
@ -73,11 +95,15 @@ pub async fn delete_agent(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
server.agents.delete(&id).await server.agents.delete(&id).await.map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "delete_failed".to_string(), error: "delete_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -129,21 +155,32 @@ pub async fn create_conversation(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Json(request): Json<CreateConversationRequest>, Json(request): Json<CreateConversationRequest>,
) -> Result<(StatusCode, Json<Conversation>), ApiError> { ) -> Result<(StatusCode, Json<Conversation>), ApiError> {
let _ = server.agents.get(&request.agent_id).await let _ = server.agents.get(&request.agent_id).await.map_err(|e| {
.map_err(|e| (StatusCode::NOT_FOUND, Json(ErrorResponse { (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "agent_not_found".to_string(), error: "agent_not_found".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
let conversation_id = server.sessions.create(&request.agent_id); let conversation_id = server.sessions.create(&request.agent_id);
// Seed the full system prompt (constitution, base memories, skills) — // Seed the full system prompt (constitution, base memories, skills) —
// without this the agent boots amnesiac on shell-created conversations. // without this the agent boots amnesiac on shell-created conversations.
server.seed_conversation_system_prompt(&request.agent_id, &conversation_id).await server
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { .seed_conversation_system_prompt(&request.agent_id, &conversation_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "system_prompt_seed_failed".to_string(), error: "system_prompt_seed_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
let conversation = Conversation { let conversation = Conversation {
id: conversation_id, id: conversation_id,
@ -159,11 +196,15 @@ pub async fn get_conversation(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Conversation>, ApiError> { ) -> Result<Json<Conversation>, ApiError> {
let session = server.sessions.get(&id) let session = server.sessions.get(&id).ok_or_else(|| {
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse { (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "conversation_not_found".to_string(), error: "conversation_not_found".to_string(),
message: format!("Conversation {} not found", id), message: format!("Conversation {} not found", id),
})))?; }),
)
})?;
let conversation = Conversation { let conversation = Conversation {
id: session.conversation_id.clone(), id: session.conversation_id.clone(),
@ -179,17 +220,29 @@ pub async fn stream_messages(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(conversation_id): Path<String>, Path(conversation_id): Path<String>,
Json(request): Json<SendMessageRequest>, Json(request): Json<SendMessageRequest>,
) -> Result<Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, ApiError> { ) -> Result<
let _ = server.sessions.get(&conversation_id) Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>,
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse { ApiError,
> {
let _ = server.sessions.get(&conversation_id).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "conversation_not_found".to_string(), error: "conversation_not_found".to_string(),
message: format!("Conversation {} not found", conversation_id), message: format!("Conversation {} not found", conversation_id),
})))?; }),
)
})?;
// Ambient context first — the room she is being spoken to in. A system // Ambient context first — the room she is being spoken to in. A system
// note, same register as interjections, so it reads as perception rather // note, same register as interjections, so it reads as perception rather
// than instruction. // than instruction.
if let Some(ambient) = request.ambient.as_deref().map(str::trim).filter(|a| !a.is_empty()) { if let Some(ambient) = request
.ambient
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
{
let stamp = chrono::Local::now().format("%H:%M"); let stamp = chrono::Local::now().format("%H:%M");
let note = crate::core::session::ConversationMessage { let note = crate::core::session::ConversationMessage {
role: crate::core::session::MessageRole::System, role: crate::core::session::MessageRole::System,
@ -199,21 +252,35 @@ pub async fn stream_messages(
usage: None, usage: None,
timestamp: Some(chrono::Utc::now()), timestamp: Some(chrono::Utc::now()),
}; };
server.sessions.add_message(&conversation_id, note) server
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { .sessions
.add_message(&conversation_id, note)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "message_store_failed".to_string(), error: "message_store_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
} }
// Convert API messages to ConversationMessages and add to session // Convert API messages to ConversationMessages and add to session
for msg in &request.messages { for msg in &request.messages {
let conv_msg = msg.to_conversation_message(); let conv_msg = msg.to_conversation_message();
server.sessions.add_message(&conversation_id, conv_msg) server
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { .sessions
.add_message(&conversation_id, conv_msg)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "message_store_failed".to_string(), error: "message_store_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
)
})?;
} }
let (tx, rx) = mpsc::channel(100); let (tx, rx) = mpsc::channel(100);
@ -249,7 +316,8 @@ async fn handle_conversation_stream(
// Turn backchannel: fresh cancel token each turn, persistent // Turn backchannel: fresh cancel token each turn, persistent
// interjection queue. POST .../cancel and .../interject reach in here. // interjection queue. POST .../cancel and .../interject reach in here.
let signals = server.turn_signals let signals = server
.turn_signals
.entry(conversation_id.clone()) .entry(conversation_id.clone())
.or_default(); .or_default();
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
@ -262,10 +330,8 @@ async fn handle_conversation_stream(
let conv = conversation_id.clone(); let conv = conversation_id.clone();
let turn = tokio::spawn(async move { let turn = tokio::spawn(async move {
crate::server::turn::run_turn( crate::server::turn::run_turn(server_clone, conv, &be_tx, event_bus, cancel, interject)
server_clone, conv, &be_tx, event_bus, .await
cancel, interject,
).await
}); });
// Drain the turn's BackendEvent stream. run_turn already handles message // Drain the turn's BackendEvent stream. run_turn already handles message
@ -278,7 +344,11 @@ async fn handle_conversation_stream(
Ok(be) => be, Ok(be) => be,
Err(e) => { Err(e) => {
eprintln!("Turn stream error ({conversation_id}): {e:#}"); eprintln!("Turn stream error ({conversation_id}): {e:#}");
let _ = tx.send(StreamEvent::Error { message: format!("{e:#}") }).await; let _ = tx
.send(StreamEvent::Error {
message: format!("{e:#}"),
})
.await;
break; break;
} }
}; };
@ -291,12 +361,15 @@ async fn handle_conversation_stream(
// silently-ended stream (an empty reply reads as the agent going mute). // silently-ended stream (an empty reply reads as the agent going mute).
if let Ok(Err(e)) = turn.await { if let Ok(Err(e)) = turn.await {
eprintln!("Turn failed ({conversation_id}): {e:#}"); eprintln!("Turn failed ({conversation_id}): {e:#}");
let _ = tx.send(StreamEvent::Error { message: format!("{e:#}") }).await; let _ = tx
.send(StreamEvent::Error {
message: format!("{e:#}"),
})
.await;
} }
Ok(()) Ok(())
} }
/// GET /v1/conversations/:id/messages — full transcript backfill for resume. /// GET /v1/conversations/:id/messages — full transcript backfill for resume.
/// Returns the session's `ConversationMessage`s verbatim; `RemoteBackend` /// Returns the session's `ConversationMessage`s verbatim; `RemoteBackend`
/// and any surface use this to restore a conversation after restart. /// and any surface use this to restore a conversation after restart.
@ -304,11 +377,15 @@ pub async fn get_conversation_messages(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Vec<crate::core::session::ConversationMessage>>, ApiError> { ) -> Result<Json<Vec<crate::core::session::ConversationMessage>>, ApiError> {
let session = server.sessions.get(&id) let session = server.sessions.get(&id).ok_or_else(|| {
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse { (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "conversation_not_found".to_string(), error: "conversation_not_found".to_string(),
message: format!("Conversation {} not found", id), message: format!("Conversation {} not found", id),
})))?; }),
)
})?;
Ok(Json(session.messages.clone())) Ok(Json(session.messages.clone()))
} }
@ -318,22 +395,33 @@ pub async fn fork_conversation(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<(StatusCode, Json<Conversation>), ApiError> { ) -> Result<(StatusCode, Json<Conversation>), ApiError> {
let forked_id = server.sessions.fork(&id) let forked_id = server.sessions.fork(&id).map_err(|e| {
.map_err(|e| (StatusCode::NOT_FOUND, Json(ErrorResponse { (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "fork_failed".to_string(), error: "fork_failed".to_string(),
message: e.to_string(), message: e.to_string(),
})))?; }),
let session = server.sessions.get(&forked_id) )
.ok_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { })?;
let session = server.sessions.get(&forked_id).ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "fork_failed".to_string(), error: "fork_failed".to_string(),
message: "forked session vanished".to_string(), message: "forked session vanished".to_string(),
})))?; }),
Ok((StatusCode::CREATED, Json(Conversation { )
})?;
Ok((
StatusCode::CREATED,
Json(Conversation {
id: forked_id.clone(), id: forked_id.clone(),
agent_id: session.agent_id.clone(), agent_id: session.agent_id.clone(),
created_at: session.created_at, created_at: session.created_at,
updated_at: Some(session.updated_at), updated_at: Some(session.updated_at),
}))) }),
))
} }
/// POST /v1/conversations/:id/cancel — interrupt the turn in flight. /// POST /v1/conversations/:id/cancel — interrupt the turn in flight.
@ -349,10 +437,13 @@ pub async fn cancel_turn(
signals.cancel.lock().unwrap().cancel(); signals.cancel.lock().unwrap().cancel();
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
None => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { None => Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "no_active_turn".to_string(), error: "no_active_turn".to_string(),
message: format!("No turn signals for conversation {}", id), message: format!("No turn signals for conversation {}", id),
}))), }),
)),
} }
} }
@ -365,10 +456,13 @@ pub async fn interject(
Json(request): Json<InterjectRequest>, Json(request): Json<InterjectRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
if request.text.trim().is_empty() { if request.text.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "empty_interjection".to_string(), error: "empty_interjection".to_string(),
message: "text must be non-empty".to_string(), message: "text must be non-empty".to_string(),
}))); }),
));
} }
let signals = server.turn_signals.entry(id).or_default(); let signals = server.turn_signals.entry(id).or_default();
signals.interject.lock().unwrap().push(request.text); signals.interject.lock().unwrap().push(request.text);
@ -385,10 +479,13 @@ pub async fn interject(
// DELETE /v1/agents/:id/memory/*path — delete file // DELETE /v1/agents/:id/memory/*path — delete file
fn memory_err(status: StatusCode, kind: &str, e: impl ToString) -> ApiError { fn memory_err(status: StatusCode, kind: &str, e: impl ToString) -> ApiError {
(status, Json(ErrorResponse { (
status,
Json(ErrorResponse {
error: kind.to_string(), error: kind.to_string(),
message: e.to_string(), message: e.to_string(),
})) }),
)
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@ -401,10 +498,15 @@ pub async fn list_memory(
Path(agent_id): Path<String>, Path(agent_id): Path<String>,
Query(q): Query<ListMemoryQuery>, Query(q): Query<ListMemoryQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let _ = server.agents.get(&agent_id).await let _ = server
.agents
.get(&agent_id)
.await
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?; .map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
let repo = server.agents.memory_repo(&agent_id); let repo = server.agents.memory_repo(&agent_id);
let entries = repo.list(q.prefix.as_deref()).await let entries = repo
.list(q.prefix.as_deref())
.await
.map_err(|e| memory_err(StatusCode::INTERNAL_SERVER_ERROR, "list_failed", e))?; .map_err(|e| memory_err(StatusCode::INTERNAL_SERVER_ERROR, "list_failed", e))?;
Ok(Json(serde_json::json!({ "entries": entries }))) Ok(Json(serde_json::json!({ "entries": entries })))
} }
@ -413,10 +515,15 @@ pub async fn read_memory(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path((agent_id, path)): Path<(String, String)>, Path((agent_id, path)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let _ = server.agents.get(&agent_id).await let _ = server
.agents
.get(&agent_id)
.await
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?; .map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
let repo = server.agents.memory_repo(&agent_id); let repo = server.agents.memory_repo(&agent_id);
let file = repo.read(&path).await let file = repo
.read(&path)
.await
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "memory_not_found", e))?; .map_err(|e| memory_err(StatusCode::NOT_FOUND, "memory_not_found", e))?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"path": path, "path": path,
@ -434,12 +541,16 @@ pub async fn write_memory(
Path((agent_id, path)): Path<(String, String)>, Path((agent_id, path)): Path<(String, String)>,
body: Bytes, body: Bytes,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let _ = server.agents.get(&agent_id).await let _ = server
.agents
.get(&agent_id)
.await
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?; .map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
let body_str = std::str::from_utf8(&body) let body_str = std::str::from_utf8(&body)
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?; .map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?;
let repo = server.agents.memory_repo(&agent_id); let repo = server.agents.memory_repo(&agent_id);
repo.write(&path, body_str).await repo.write(&path, body_str)
.await
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "write_failed", e))?; .map_err(|e| memory_err(StatusCode::BAD_REQUEST, "write_failed", e))?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -449,12 +560,16 @@ pub async fn append_memory(
Path((agent_id, path)): Path<(String, String)>, Path((agent_id, path)): Path<(String, String)>,
body: Bytes, body: Bytes,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let _ = server.agents.get(&agent_id).await let _ = server
.agents
.get(&agent_id)
.await
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?; .map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
let body_str = std::str::from_utf8(&body) let body_str = std::str::from_utf8(&body)
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?; .map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?;
let repo = server.agents.memory_repo(&agent_id); let repo = server.agents.memory_repo(&agent_id);
repo.append(&path, body_str).await repo.append(&path, body_str)
.await
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "append_failed", e))?; .map_err(|e| memory_err(StatusCode::BAD_REQUEST, "append_failed", e))?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -463,10 +578,14 @@ pub async fn delete_memory(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path((agent_id, path)): Path<(String, String)>, Path((agent_id, path)): Path<(String, String)>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let _ = server.agents.get(&agent_id).await let _ = server
.agents
.get(&agent_id)
.await
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?; .map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
let repo = server.agents.memory_repo(&agent_id); let repo = server.agents.memory_repo(&agent_id);
repo.delete(&path).await repo.delete(&path)
.await
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "delete_failed", e))?; .map_err(|e| memory_err(StatusCode::NOT_FOUND, "delete_failed", e))?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -481,10 +600,7 @@ pub async fn firehose(
ws.on_upgrade(move |socket| firehose_stream(server, socket)) ws.on_upgrade(move |socket| firehose_stream(server, socket))
} }
async fn firehose_stream( async fn firehose_stream(server: Arc<SouveraineServer>, mut socket: WebSocket) {
server: Arc<SouveraineServer>,
mut socket: WebSocket,
) {
use axum::extract::ws::Message; use axum::extract::ws::Message;
let mut rx = server.event_bus.subscribe(); let mut rx = server.event_bus.subscribe();
@ -597,11 +713,18 @@ pub async fn update_config(
.unwrap_or_else(|| std::path::PathBuf::from("souveraine.toml")); .unwrap_or_else(|| std::path::PathBuf::from("souveraine.toml"));
if let Err(e) = new_config.save(&config_path) { if let Err(e) = new_config.save(&config_path) {
tracing::warn!("Failed to persist config to disk at {:?}: {}", config_path, e); tracing::warn!(
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { "Failed to persist config to disk at {:?}: {}",
config_path,
e
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "config_save_failed".to_string(), error: "config_save_failed".to_string(),
message: e.to_string(), message: e.to_string(),
}))); }),
));
} else { } else {
tracing::info!("Saved live settings to {:?}", config_path); tracing::info!("Saved live settings to {:?}", config_path);
} }
@ -633,14 +756,21 @@ pub async fn get_compaction_logs(
} }
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) { if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
let sensor = val.get("sensor_name").and_then(|s| s.as_str()).unwrap_or(""); let sensor = val
.get("sensor_name")
.and_then(|s| s.as_str())
.unwrap_or("");
let ev_type = val.get("event_type").and_then(|t| t.as_str()).unwrap_or(""); let ev_type = val.get("event_type").and_then(|t| t.as_str()).unwrap_or("");
let content = val.get("payload").and_then(|p| p.get("content").and_then(|c| c.as_str())).unwrap_or(""); let content = val
.get("payload")
.and_then(|p| p.get("content").and_then(|c| c.as_str()))
.unwrap_or("");
if sensor == "archivist" || if sensor == "archivist"
ev_type.contains("compaction") || || ev_type.contains("compaction")
ev_type.contains("archive") || || ev_type.contains("archive")
content.contains("compaction") { || content.contains("compaction")
{
logs.push(val); logs.push(val);
} }
} }
@ -658,11 +788,15 @@ pub async fn get_conversation_tokens(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(conversation_id): Path<String>, Path(conversation_id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let session = server.sessions.get(&conversation_id) let session = server.sessions.get(&conversation_id).ok_or_else(|| {
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse { (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "conversation_not_found".to_string(), error: "conversation_not_found".to_string(),
message: format!("Conversation {} not found", conversation_id), message: format!("Conversation {} not found", conversation_id),
})))?; }),
)
})?;
let counter = crate::bridge::model_router::TokenCounter::new(); let counter = crate::bridge::model_router::TokenCounter::new();
@ -675,12 +809,21 @@ pub async fn get_conversation_tokens(
for block in &msg.blocks { for block in &msg.blocks {
let block_text = match block { let block_text = match block {
crate::core::session::ContentBlock::Text { text } => text.clone(), crate::core::session::ContentBlock::Text { text } => text.clone(),
crate::core::session::ContentBlock::ToolUse { id, name, input } => format!("{id} {name} {input}"), crate::core::session::ContentBlock::ToolUse { id, name, input } => {
crate::core::session::ContentBlock::ToolResult { tool_use_id, tool_name, output, .. } => { format!("{id} {name} {input}")
}
crate::core::session::ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
..
} => {
format!("{tool_use_id} {tool_name} {output}") format!("{tool_use_id} {tool_name} {output}")
} }
crate::core::session::ContentBlock::Reasoning { reasoning } => reasoning.clone(), crate::core::session::ContentBlock::Reasoning { reasoning } => reasoning.clone(),
crate::core::session::ContentBlock::Image { media_type, data } => format!("{media_type} {data}"), crate::core::session::ContentBlock::Image { media_type, data } => {
format!("{media_type} {data}")
}
}; };
msg_tokens += counter.count(&block_text); msg_tokens += counter.count(&block_text);
} }

View file

@ -14,13 +14,25 @@ pub mod models;
pub fn create_routes(state: Arc<SouveraineServer>) -> Router { pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health, firehose). // Public routes — no auth required (agent listing/creation, conversation listing/creation, health, firehose).
let public_routes = Router::new() let public_routes = Router::new()
.route("/v1/agents", get(handlers::list_agents).post(handlers::create_agent)) .route(
.route("/v1/conversations", get(handlers::list_conversations).post(handlers::create_conversation)) "/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/firehose", get(handlers::firehose))
.route("/v1/federation/events", get(handlers::federation_events)) .route("/v1/federation/events", get(handlers::federation_events))
.route("/v1/config", get(handlers::get_config).post(handlers::update_config)) .route(
"/v1/config",
get(handlers::get_config).post(handlers::update_config),
)
.route("/v1/compaction-logs", get(handlers::get_compaction_logs)) .route("/v1/compaction-logs", get(handlers::get_compaction_logs))
.route("/v1/conversations/:id/tokens", get(handlers::get_conversation_tokens)) .route(
"/v1/conversations/:id/tokens",
get(handlers::get_conversation_tokens),
)
.route("/health", get(health_check)); .route("/health", get(health_check));
// Protected agent routes — require per-agent bearer token. // Protected agent routes — require per-agent bearer token.
@ -43,7 +55,10 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
"/v1/conversations/:id/messages", "/v1/conversations/:id/messages",
get(handlers::get_conversation_messages).post(handlers::stream_messages), get(handlers::get_conversation_messages).post(handlers::stream_messages),
) )
.route("/v1/conversations/:id/fork", post(handlers::fork_conversation)) .route(
"/v1/conversations/:id/fork",
post(handlers::fork_conversation),
)
.route("/v1/conversations/:id/cancel", post(handlers::cancel_turn)) .route("/v1/conversations/:id/cancel", post(handlers::cancel_turn))
.route("/v1/conversations/:id/interject", post(handlers::interject)) .route("/v1/conversations/:id/interject", post(handlers::interject))
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
@ -56,10 +71,7 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
// (memfs HTTP write path for cron-into-memfs and external integration. // (memfs HTTP write path for cron-into-memfs and external integration.
// See docs/MEMORY_BLOCKS_DECISION.md.) // See docs/MEMORY_BLOCKS_DECISION.md.)
let memory_routes = Router::new() let memory_routes = Router::new()
.route( .route("/v1/agents/:id/memory", get(handlers::list_memory))
"/v1/agents/:id/memory",
get(handlers::list_memory),
)
.route( .route(
"/v1/agents/:id/memory/*path", "/v1/agents/:id/memory/*path",
get(handlers::read_memory) get(handlers::read_memory)

View file

@ -67,8 +67,12 @@ pub struct LlmConfig {
pub checkpoint_interval: u32, pub checkpoint_interval: u32,
} }
fn default_supports_images() -> bool { true } fn default_supports_images() -> bool {
fn default_checkpoint_interval() -> u32 { 10 } true
}
fn default_checkpoint_interval() -> u32 {
10
}
fn default_context_window() -> u32 { fn default_context_window() -> u32 {
128000 128000
@ -200,7 +204,7 @@ pub struct Message {
impl Message { impl Message {
/// Convert API Message to internal ConversationMessage /// Convert API Message to internal ConversationMessage
pub fn to_conversation_message(&self) -> crate::core::session::ConversationMessage { pub fn to_conversation_message(&self) -> crate::core::session::ConversationMessage {
use crate::core::session::{ConversationMessage, MessageRole, ContentBlock}; use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
let role = match self.role.as_str() { let role = match self.role.as_str() {
"system" => MessageRole::System, "system" => MessageRole::System,
@ -212,7 +216,9 @@ impl Message {
ConversationMessage { ConversationMessage {
role, role,
blocks: vec![ContentBlock::Text { text: self.content.clone() }], blocks: vec![ContentBlock::Text {
text: self.content.clone(),
}],
usage: None, usage: None,
timestamp: Some(chrono::Utc::now()), timestamp: Some(chrono::Utc::now()),
} }
@ -271,7 +277,11 @@ pub enum StreamEvent {
#[serde(rename = "tool_return_message")] #[serde(rename = "tool_return_message")]
ToolReturnMessage { tool_return: ToolReturn }, ToolReturnMessage { tool_return: ToolReturn },
#[serde(rename = "souveraine_surfacing")] #[serde(rename = "souveraine_surfacing")]
Surfacing { source: String, content: String, priority: String }, Surfacing {
source: String,
content: String,
priority: String,
},
#[serde(rename = "souveraine_reflection")] #[serde(rename = "souveraine_reflection")]
Reflection { content: String }, Reflection { content: String },
#[serde(rename = "souveraine_archivist")] #[serde(rename = "souveraine_archivist")]
@ -281,7 +291,11 @@ pub enum StreamEvent {
#[serde(rename = "context_pressure")] #[serde(rename = "context_pressure")]
ContextPressure { pressure: f32, tokens: usize }, ContextPressure { pressure: f32, tokens: usize },
#[serde(rename = "inference_strain")] #[serde(rename = "inference_strain")]
InferenceStrain { attempt: u32, status: u16, model: String }, InferenceStrain {
attempt: u32,
status: u16,
model: String,
},
#[serde(rename = "schedule_active")] #[serde(rename = "schedule_active")]
ScheduleActive { name: String }, ScheduleActive { name: String },
#[serde(rename = "schedule_complete")] #[serde(rename = "schedule_complete")]
@ -291,7 +305,11 @@ pub enum StreamEvent {
#[serde(rename = "subconscious_tool_call")] #[serde(rename = "subconscious_tool_call")]
SubconsciousToolCall { name: String, arguments: String }, SubconsciousToolCall { name: String, arguments: String },
#[serde(rename = "subconscious_tool_result")] #[serde(rename = "subconscious_tool_result")]
SubconsciousToolResult { name: String, output: String, is_error: bool }, SubconsciousToolResult {
name: String,
output: String,
is_error: bool,
},
#[serde(rename = "subconscious_halt")] #[serde(rename = "subconscious_halt")]
SubconsciousHalt { reason: String, severity: String }, SubconsciousHalt { reason: String, severity: String },
#[serde(rename = "subconscious_pass")] #[serde(rename = "subconscious_pass")]
@ -303,7 +321,10 @@ pub enum StreamEvent {
#[serde(rename = "outfit")] #[serde(rename = "outfit")]
Outfit { name: String }, Outfit { name: String },
#[serde(rename = "interstitial")] #[serde(rename = "interstitial")]
Interstitial { text: String, register: crate::backend::Register }, Interstitial {
text: String,
register: crate::backend::Register,
},
#[serde(rename = "primary_complete")] #[serde(rename = "primary_complete")]
PrimaryComplete, PrimaryComplete,
/// The turn failed in the substrate. Mirrors BackendEvent::Error so a /// The turn failed in the substrate. Mirrors BackendEvent::Error so a
@ -354,32 +375,81 @@ impl From<crate::backend::BackendEvent> for StreamEvent {
match be { match be {
BE::Token(content) => Self::AssistantMessage { content }, BE::Token(content) => Self::AssistantMessage { content },
BE::Reasoning(content) => Self::ReasoningMessage { content }, BE::Reasoning(content) => Self::ReasoningMessage { content },
BE::Surfacing { source, content, priority } => Self::Surfacing { source, content, priority }, BE::Surfacing {
source,
content,
priority,
} => Self::Surfacing {
source,
content,
priority,
},
BE::Reflection(content) => Self::Reflection { content }, BE::Reflection(content) => Self::Reflection { content },
BE::Archivist { synthesis, pressure } => Self::Archivist { synthesis, pressure }, BE::Archivist {
synthesis,
pressure,
} => Self::Archivist {
synthesis,
pressure,
},
BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier }, BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier },
BE::ContextPressure(pressure, tokens) => Self::ContextPressure { pressure, tokens }, BE::ContextPressure(pressure, tokens) => Self::ContextPressure { pressure, tokens },
BE::InferenceStrain { attempt, status, model } => Self::InferenceStrain { attempt, status, model }, BE::InferenceStrain {
attempt,
status,
model,
} => Self::InferenceStrain {
attempt,
status,
model,
},
BE::ScheduleActive { name } => Self::ScheduleActive { name }, BE::ScheduleActive { name } => Self::ScheduleActive { name },
BE::ScheduleComplete { name, silent } => Self::ScheduleComplete { name, silent }, BE::ScheduleComplete { name, silent } => Self::ScheduleComplete { name, silent },
BE::ToolCall { id, name, arguments, round } => Self::ToolCallMessage { BE::ToolCall {
tool_call: ToolCall { id, function: ToolFunction { name, arguments } }, id,
name,
arguments,
round,
} => Self::ToolCallMessage {
tool_call: ToolCall {
id,
function: ToolFunction { name, arguments },
},
round, round,
}, },
BE::ToolResult { id, name, output, is_error } => Self::ToolReturnMessage { BE::ToolResult {
id,
name,
output,
is_error,
} => Self::ToolReturnMessage {
tool_return: ToolReturn { tool_return: ToolReturn {
status: if is_error { "error".into() } else { "success".into() }, status: if is_error {
"error".into()
} else {
"success".into()
},
output, output,
id, id,
name, name,
}, },
}, },
BE::SubconsciousToken(content) => Self::SubconsciousToken { content }, BE::SubconsciousToken(content) => Self::SubconsciousToken { content },
BE::SubconsciousToolCall { name, arguments } => Self::SubconsciousToolCall { name, arguments }, BE::SubconsciousToolCall { name, arguments } => {
BE::SubconsciousToolResult { name, output, is_error } => { Self::SubconsciousToolCall { name, arguments }
Self::SubconsciousToolResult { name, output, is_error } }
BE::SubconsciousToolResult {
name,
output,
is_error,
} => Self::SubconsciousToolResult {
name,
output,
is_error,
},
BE::SubconsciousHalt { reason, severity } => {
Self::SubconsciousHalt { reason, severity }
} }
BE::SubconsciousHalt { reason, severity } => Self::SubconsciousHalt { reason, severity },
BE::SubconsciousPass(active) => Self::SubconsciousPass { active }, BE::SubconsciousPass(active) => Self::SubconsciousPass { active },
BE::Atmosphere(preset) => Self::Atmosphere { preset }, BE::Atmosphere(preset) => Self::Atmosphere { preset },
BE::Itinerary(route) => Self::Itinerary { route }, BE::Itinerary(route) => Self::Itinerary { route },
@ -399,12 +469,38 @@ impl From<StreamEvent> for crate::backend::BackendEvent {
match se { match se {
StreamEvent::AssistantMessage { content } => BE::Token(content), StreamEvent::AssistantMessage { content } => BE::Token(content),
StreamEvent::ReasoningMessage { content } => BE::Reasoning(content), StreamEvent::ReasoningMessage { content } => BE::Reasoning(content),
StreamEvent::Surfacing { source, content, priority } => BE::Surfacing { source, content, priority }, StreamEvent::Surfacing {
source,
content,
priority,
} => BE::Surfacing {
source,
content,
priority,
},
StreamEvent::Reflection { content } => BE::Reflection(content), StreamEvent::Reflection { content } => BE::Reflection(content),
StreamEvent::Archivist { synthesis, pressure } => BE::Archivist { synthesis, pressure }, StreamEvent::Archivist {
StreamEvent::CompactionWarning { pressure, tier } => BE::CompactionWarning { pressure, tier }, synthesis,
StreamEvent::ContextPressure { pressure, tokens } => BE::ContextPressure(pressure, tokens), pressure,
StreamEvent::InferenceStrain { attempt, status, model } => BE::InferenceStrain { attempt, status, model }, } => BE::Archivist {
synthesis,
pressure,
},
StreamEvent::CompactionWarning { pressure, tier } => {
BE::CompactionWarning { pressure, tier }
}
StreamEvent::ContextPressure { pressure, tokens } => {
BE::ContextPressure(pressure, tokens)
}
StreamEvent::InferenceStrain {
attempt,
status,
model,
} => BE::InferenceStrain {
attempt,
status,
model,
},
StreamEvent::ScheduleActive { name } => BE::ScheduleActive { name }, StreamEvent::ScheduleActive { name } => BE::ScheduleActive { name },
StreamEvent::ScheduleComplete { name, silent } => BE::ScheduleComplete { name, silent }, StreamEvent::ScheduleComplete { name, silent } => BE::ScheduleComplete { name, silent },
StreamEvent::ToolCallMessage { tool_call, round } => BE::ToolCall { StreamEvent::ToolCallMessage { tool_call, round } => BE::ToolCall {
@ -420,11 +516,21 @@ impl From<StreamEvent> for crate::backend::BackendEvent {
output: tool_return.output, output: tool_return.output,
}, },
StreamEvent::SubconsciousToken { content } => BE::SubconsciousToken(content), StreamEvent::SubconsciousToken { content } => BE::SubconsciousToken(content),
StreamEvent::SubconsciousToolCall { name, arguments } => BE::SubconsciousToolCall { name, arguments }, StreamEvent::SubconsciousToolCall { name, arguments } => {
StreamEvent::SubconsciousToolResult { name, output, is_error } => { BE::SubconsciousToolCall { name, arguments }
BE::SubconsciousToolResult { name, output, is_error } }
StreamEvent::SubconsciousToolResult {
name,
output,
is_error,
} => BE::SubconsciousToolResult {
name,
output,
is_error,
},
StreamEvent::SubconsciousHalt { reason, severity } => {
BE::SubconsciousHalt { reason, severity }
} }
StreamEvent::SubconsciousHalt { reason, severity } => BE::SubconsciousHalt { reason, severity },
StreamEvent::SubconsciousPass { active } => BE::SubconsciousPass(active), StreamEvent::SubconsciousPass { active } => BE::SubconsciousPass(active),
StreamEvent::Atmosphere { preset } => BE::Atmosphere(preset), StreamEvent::Atmosphere { preset } => BE::Atmosphere(preset),
StreamEvent::Itinerary { route } => BE::Itinerary(route), StreamEvent::Itinerary { route } => BE::Itinerary(route),

View file

@ -15,12 +15,14 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
/// if the agent has none), append the scheduled prompt as a user /// if the agent has none), append the scheduled prompt as a user
/// message, and drain the resulting stream — the turn runs silently /// message, and drain the resulting stream — the turn runs silently
/// in the background. Anything subconscious surfaces lands in the inbox. /// in the background. Anything subconscious surfaces lands in the inbox.
async fn inject_background_turn( async fn inject_background_turn(&self, agent_id: &str, text: &str) -> anyhow::Result<()> {
&self, let conv_id = match self
agent_id: &str, .server
text: &str, .sessions
) -> anyhow::Result<()> { .list_for_agent(agent_id)
let conv_id = match self.server.sessions.list_for_agent(agent_id).last().cloned() { .last()
.cloned()
{
Some(id) => id, Some(id) => id,
None => self.ensure_conversation(agent_id).await?, None => self.ensure_conversation(agent_id).await?,
}; };
@ -39,7 +41,11 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
let mut stashed: Vec<PendingSurfacing> = Vec::new(); let mut stashed: Vec<PendingSurfacing> = Vec::new();
while let Some(ev) = s.next().await { while let Some(ev) = s.next().await {
match ev { match ev {
Ok(BackendEvent::Surfacing { source, content, priority }) => { Ok(BackendEvent::Surfacing {
source,
content,
priority,
}) => {
// Skip the no-op heartbeat sentinel — the subconscious // Skip the no-op heartbeat sentinel — the subconscious
// always queues a low "pass complete, no anomalies" // always queues a low "pass complete, no anomalies"
// item so the UI shows the pass ran. That is noise to // item so the UI shows the pass ran. That is noise to
@ -86,9 +92,7 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
count = stashed.len(), count = stashed.len(),
"stashed heartbeat surfacings for next session" "stashed heartbeat surfacings for next session"
), ),
Err(e) => tracing::warn!( Err(e) => tracing::warn!("pending heartbeat surfacings stash failed: {}", e),
"pending heartbeat surfacings stash failed: {}", e
),
} }
} }
}); });
@ -175,7 +179,10 @@ pub(super) async fn drain_intrusive_surfacings(
let items = match inbox.get_intrusive().await { let items = match inbox.get_intrusive().await {
Ok(items) => items, Ok(items) => items,
Err(e) => { Err(e) => {
tracing::debug!("intrusive surfacing read failed (continuing without): {}", e); tracing::debug!(
"intrusive surfacing read failed (continuing without): {}",
e
);
return Vec::new(); return Vec::new();
} }
}; };

View file

@ -14,14 +14,14 @@ pub(crate) mod consciousness;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use futures::stream::{BoxStream, StreamExt}; use futures::stream::{BoxStream, StreamExt};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::core::session::{ContentBlock, ConversationMessage, ImageAttachment};
use crate::core::config::ConsciousnessConfig; use crate::core::config::ConsciousnessConfig;
use crate::core::session::{ContentBlock, ConversationMessage, ImageAttachment};
use crate::server::SouveraineServer; use crate::server::SouveraineServer;
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo}; use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
@ -96,8 +96,10 @@ impl LocalBackend {
if let Some(sh) = &backend.server.summon_handler { if let Some(sh) = &backend.server.summon_handler {
sh.set_injector(injector.clone()); sh.set_injector(injector.clone());
} }
let mut handler = let mut handler = crate::core::nervous::handler::HeartbeatHandler::new(
crate::core::nervous::handler::HeartbeatHandler::new(event_bus.subscribe(), injector.clone()); event_bus.subscribe(),
injector.clone(),
);
tokio::spawn(async move { handler.run().await }); tokio::spawn(async move { handler.run().await });
tracing::info!("heartbeat handler spawned"); tracing::info!("heartbeat handler spawned");
@ -105,8 +107,7 @@ impl LocalBackend {
// `sensorium:input` events from non-terminal surfaces (Matrix, // `sensorium:input` events from non-terminal surfaces (Matrix,
// email, federation) and injects turns on their behalf. // email, federation) and injects turns on their behalf.
// Same pattern as HeartbeatHandler; identical wiring. // Same pattern as HeartbeatHandler; identical wiring.
let mut input_handler = let mut input_handler = crate::core::nervous::handler::SensoriumInputHandler::new(
crate::core::nervous::handler::SensoriumInputHandler::new(
event_bus.subscribe(), event_bus.subscribe(),
injector, injector,
); );
@ -141,10 +142,7 @@ impl LocalBackend {
/// and a child CancellationToken. `shutdown_sensoria` cancels all of /// and a child CancellationToken. `shutdown_sensoria` cancels all of
/// them. Can be called at any time — the coordinator drains registered /// them. Can be called at any time — the coordinator drains registered
/// sensoria on `run_all` and accepts new ones afterward. /// sensoria on `run_all` and accepts new ones afterward.
pub async fn register_sensorium( pub async fn register_sensorium(&self, sensorium: Box<dyn crate::core::sensorium::Sensorium>) {
&self,
sensorium: Box<dyn crate::core::sensorium::Sensorium>,
) {
self.server.register_sensorium(sensorium).await; self.server.register_sensorium(sensorium).await;
} }
@ -152,7 +150,6 @@ impl LocalBackend {
pub async fn shutdown_sensoria(&self) { pub async fn shutdown_sensoria(&self) {
self.server.shutdown_sensoria().await; self.server.shutdown_sensoria().await;
} }
} }
#[async_trait] #[async_trait]
@ -177,16 +174,27 @@ impl Backend for LocalBackend {
let _ = self.server.agents.get(agent_id).await?; let _ = self.server.agents.get(agent_id).await?;
let conv_id = self.server.sessions.create(agent_id); let conv_id = self.server.sessions.create(agent_id);
if let Err(e) = self.server.agents.register_instance(agent_id, &self.server.instance_id).await { if let Err(e) = self
.server
.agents
.register_instance(agent_id, &self.server.instance_id)
.await
{
tracing::warn!(agent = %agent_id, "instance registration failed: {}", e); tracing::warn!(agent = %agent_id, "instance registration failed: {}", e);
} }
self.server.seed_conversation_system_prompt(agent_id, &conv_id).await?; self.server
.seed_conversation_system_prompt(agent_id, &conv_id)
.await?;
Ok(conv_id) Ok(conv_id)
} }
async fn fork_conversation(&self, _agent_id: &str, source_conversation_id: &str) -> Result<String> { async fn fork_conversation(
&self,
_agent_id: &str,
source_conversation_id: &str,
) -> Result<String> {
let forked_id = self.server.sessions.fork(source_conversation_id)?; let forked_id = self.server.sessions.fork(source_conversation_id)?;
Ok(forked_id) Ok(forked_id)
} }
@ -260,7 +268,9 @@ impl Backend for LocalBackend {
let conv_id = self.server.sessions.create(agent_id); let conv_id = self.server.sessions.create(agent_id);
// Build system prompt from the agent's memfs and inject as first message // Build system prompt from the agent's memfs and inject as first message
self.server.seed_conversation_system_prompt(agent_id, &conv_id).await?; self.server
.seed_conversation_system_prompt(agent_id, &conv_id)
.await?;
Ok(conv_id) Ok(conv_id)
} }
@ -272,7 +282,8 @@ impl Backend for LocalBackend {
) -> Result<BoxStream<'static, Result<BackendEvent>>> { ) -> Result<BoxStream<'static, Result<BackendEvent>>> {
// No cancel signal — heartbeat path, drain path. Use a token that // No cancel signal — heartbeat path, drain path. Use a token that
// never fires. // never fires.
self.send_with_cancel(conversation_id, text, CancellationToken::new()).await self.send_with_cancel(conversation_id, text, CancellationToken::new())
.await
} }
async fn send_with_cancel( async fn send_with_cancel(
@ -282,8 +293,10 @@ impl Backend for LocalBackend {
cancel: CancellationToken, cancel: CancellationToken,
) -> Result<BoxStream<'static, Result<BackendEvent>>> { ) -> Result<BoxStream<'static, Result<BackendEvent>>> {
// No queue passed in — use an empty queue. Equivalent to the old behavior. // No queue passed in — use an empty queue. Equivalent to the old behavior.
let empty: crate::backend::InterjectionQueue = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let empty: crate::backend::InterjectionQueue =
self.send_with_signals(conversation_id, text, cancel, empty).await std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
self.send_with_signals(conversation_id, text, cancel, empty)
.await
} }
async fn send_with_signals( async fn send_with_signals(
@ -310,7 +323,8 @@ impl Backend for LocalBackend {
let ambient = crate::core::sensorium::ambient_line(); let ambient = crate::core::sensorium::ambient_line();
let user_text = if let Some(agent_id) = session_agent_id { let user_text = if let Some(agent_id) = session_agent_id {
let surfacings = consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await; let surfacings =
consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
if surfacings.is_empty() { if surfacings.is_empty() {
format!("{}\n{}", ambient, text) format!("{}\n{}", ambient, text)
} else { } else {
@ -325,10 +339,9 @@ impl Backend for LocalBackend {
format!("{}\n{}", ambient, text) format!("{}\n{}", ambient, text)
}; };
self.server.sessions.add_message( self.server
conversation_id, .sessions
ConversationMessage::user_text(&user_text), .add_message(conversation_id, ConversationMessage::user_text(&user_text))?;
)?;
let (tx, rx) = mpsc::channel::<Result<BackendEvent>>(64); let (tx, rx) = mpsc::channel::<Result<BackendEvent>>(64);
let server = self.server.clone(); let server = self.server.clone();
@ -338,7 +351,10 @@ impl Backend for LocalBackend {
active.fetch_add(1, Ordering::Relaxed); active.fetch_add(1, Ordering::Relaxed);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject).await { if let Err(e) =
crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject)
.await
{
let _ = tx.send(Err(e)).await; let _ = tx.send(Err(e)).await;
} }
let _ = tx.send(Ok(BackendEvent::Done)).await; let _ = tx.send(Ok(BackendEvent::Done)).await;
@ -365,7 +381,8 @@ impl Backend for LocalBackend {
let ambient = crate::core::sensorium::ambient_line(); let ambient = crate::core::sensorium::ambient_line();
let user_text = if let Some(agent_id) = session_agent_id { let user_text = if let Some(agent_id) = session_agent_id {
let surfacings = consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await; let surfacings =
consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
if surfacings.is_empty() { if surfacings.is_empty() {
format!("{}\n{}", ambient, text) format!("{}\n{}", ambient, text)
} else { } else {
@ -380,12 +397,13 @@ impl Backend for LocalBackend {
format!("{}\n{}", ambient, text) format!("{}\n{}", ambient, text)
}; };
let image_blocks: Vec<ContentBlock> = images.iter().map(|img| { let image_blocks: Vec<ContentBlock> = images
ContentBlock::Image { .iter()
.map(|img| ContentBlock::Image {
media_type: img.media_type.clone(), media_type: img.media_type.clone(),
data: img.data.clone(), data: img.data.clone(),
} })
}).collect(); .collect();
self.server.sessions.add_message( self.server.sessions.add_message(
conversation_id, conversation_id,
@ -400,7 +418,10 @@ impl Backend for LocalBackend {
active.fetch_add(1, Ordering::Relaxed); active.fetch_add(1, Ordering::Relaxed);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject).await { if let Err(e) =
crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject)
.await
{
let _ = tx.send(Err(e)).await; let _ = tx.send(Err(e)).await;
} }
let _ = tx.send(Ok(BackendEvent::Done)).await; let _ = tx.send(Ok(BackendEvent::Done)).await;

View file

@ -115,7 +115,11 @@ pub enum BackendEvent {
/// Subconscious called a tool during N+1. /// Subconscious called a tool during N+1.
SubconsciousToolCall { name: String, arguments: String }, SubconsciousToolCall { name: String, arguments: String },
/// Subconscious tool result during N+1. /// Subconscious tool result during N+1.
SubconsciousToolResult { name: String, output: String, is_error: bool }, SubconsciousToolResult {
name: String,
output: String,
is_error: bool,
},
/// Subconscious called `halt` during a mid-turn peek — the primary's tool /// Subconscious called `halt` during a mid-turn peek — the primary's tool
/// loop is being stopped and she feels a migraine in her own register. /// loop is being stopped and she feels a migraine in her own register.
/// The reason is the short sentence she will sense; the severity shapes /// The reason is the short sentence she will sense; the severity shapes
@ -177,7 +181,11 @@ pub trait Backend: Send + Sync {
/// with a fresh conversation_id. Used by `/btw` to spin off a side-quest. /// with a fresh conversation_id. Used by `/btw` to spin off a side-quest.
/// Default falls back to `new_conversation`; LocalBackend overrides with /// Default falls back to `new_conversation`; LocalBackend overrides with
/// a proper deep clone via session_manager.fork(). /// a proper deep clone via session_manager.fork().
async fn fork_conversation(&self, agent_id: &str, _source_conversation_id: &str) -> Result<String> { async fn fork_conversation(
&self,
agent_id: &str,
_source_conversation_id: &str,
) -> Result<String> {
self.new_conversation(agent_id).await self.new_conversation(agent_id).await
} }
@ -243,7 +251,8 @@ pub trait Backend: Send + Sync {
for img in &images { for img in &images {
enriched.push_str(&format!("\n[Image: {}]", img.media_type)); enriched.push_str(&format!("\n[Image: {}]", img.media_type));
} }
self.send_with_signals(conversation_id, &enriched, cancel, interject).await self.send_with_signals(conversation_id, &enriched, cancel, interject)
.await
} }
/// Push an llm_config update to the agent record. LocalBackend writes /// Push an llm_config update to the agent record. LocalBackend writes

View file

@ -31,7 +31,11 @@ impl RemoteBackend {
.connect_timeout(Duration::from_secs(2)) .connect_timeout(Duration::from_secs(2))
.build() .build()
.context("building reqwest client")?; .context("building reqwest client")?;
Ok(Self { base_url: url, client, token: None }) Ok(Self {
base_url: url,
client,
token: None,
})
} }
/// Create a RemoteBackend that sends bearer tokens for the given agent_id. /// Create a RemoteBackend that sends bearer tokens for the given agent_id.
@ -73,7 +77,11 @@ impl RemoteBackend {
/// Watch a local cancel token and relay it as POST .../cancel. /// Watch a local cancel token and relay it as POST .../cancel.
/// Esc in the TUI fires the same interrupt semantics as local mode. /// Esc in the TUI fires the same interrupt semantics as local mode.
fn spawn_cancel_watch(&self, conversation_id: &str, cancel: tokio_util::sync::CancellationToken) { fn spawn_cancel_watch(
&self,
conversation_id: &str,
cancel: tokio_util::sync::CancellationToken,
) {
let client = self.client.clone(); let client = self.client.clone();
let url = self.url(&format!("/v1/conversations/{}/cancel", conversation_id)); let url = self.url(&format!("/v1/conversations/{}/cancel", conversation_id));
let token = self.token.clone(); let token = self.token.clone();

View file

@ -35,14 +35,16 @@ fn main() -> anyhow::Result<()> {
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
"--seed-dir" => { "--seed-dir" => {
seed_dir = PathBuf::from(args.next().ok_or_else(|| { seed_dir = PathBuf::from(
anyhow::anyhow!("--seed-dir requires a path") args.next()
})?); .ok_or_else(|| anyhow::anyhow!("--seed-dir requires a path"))?,
);
} }
"--socket" => { "--socket" => {
socket = PathBuf::from(args.next().ok_or_else(|| { socket = PathBuf::from(
anyhow::anyhow!("--socket requires a path") args.next()
})?); .ok_or_else(|| anyhow::anyhow!("--socket requires a path"))?,
);
} }
"--help" | "-h" => { "--help" | "-h" => {
println!( println!(

View file

@ -27,10 +27,18 @@ mod identity;
#[path = "../machined/protocol.rs"] #[path = "../machined/protocol.rs"]
mod machined_protocol; mod machined_protocol;
#[path = "../secrets/collection.rs"]
mod collection;
#[path = "../secrets/dh.rs"] #[path = "../secrets/dh.rs"]
mod dh; mod dh;
#[path = "../secrets/transport.rs"] #[path = "../secrets/error.rs"]
mod transport; mod error;
#[path = "../secrets/item.rs"]
mod item;
#[path = "../secrets/manage.rs"]
mod manage;
#[path = "../secrets/service.rs"]
mod service;
#[path = "../secrets/session.rs"] #[path = "../secrets/session.rs"]
mod session; mod session;
#[path = "../secrets/session_object.rs"] #[path = "../secrets/session_object.rs"]
@ -39,18 +47,10 @@ mod session_object;
mod storage_key; mod storage_key;
#[path = "../secrets/store.rs"] #[path = "../secrets/store.rs"]
mod store; mod store;
#[path = "../secrets/transport.rs"]
mod transport;
#[path = "../secrets/types.rs"] #[path = "../secrets/types.rs"]
mod types; mod types;
#[path = "../secrets/error.rs"]
mod error;
#[path = "../secrets/service.rs"]
mod service;
#[path = "../secrets/collection.rs"]
mod collection;
#[path = "../secrets/item.rs"]
mod item;
#[path = "../secrets/manage.rs"]
mod manage;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -75,7 +75,10 @@ async fn main() -> Result<()> {
let (machine_ikm, source) = storage_key::resolve(&base) let (machine_ikm, source) = storage_key::resolve(&base)
.context("resolving the machine-rooted key material — refusing to start")?; .context("resolving the machine-rooted key material — refusing to start")?;
info!(?source, "machine key material resolved; the machine identity is the trust root"); info!(
?source,
"machine key material resolved; the machine identity is the trust root"
);
let store_path = store::default_store_path(&base); let store_path = store::default_store_path(&base);
let secret_store = Arc::new(Mutex::new( let secret_store = Arc::new(Mutex::new(

View file

@ -68,9 +68,8 @@ type LastSeen = Arc<Mutex<HashMap<&'static str, serde_json::Value>>>;
type LastSent = Arc<Mutex<HashMap<&'static str, Instant>>>; type LastSent = Arc<Mutex<HashMap<&'static str, Instant>>>;
fn socket_path() -> PathBuf { fn socket_path() -> PathBuf {
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| { let runtime = std::env::var("XDG_RUNTIME_DIR")
format!("/run/user/{}", unsafe { libc_getuid() }) .unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc_getuid() }));
});
PathBuf::from(runtime).join("souveraine/sessiond.sock") PathBuf::from(runtime).join("souveraine/sessiond.sock")
} }

View file

@ -34,7 +34,8 @@ fn main() -> anyhow::Result<()> {
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
"--socket" => { "--socket" => {
socket = Some(PathBuf::from(args.next().ok_or_else(|| { socket =
Some(PathBuf::from(args.next().ok_or_else(|| {
anyhow::anyhow!("--socket requires a path") anyhow::anyhow!("--socket requires a path")
})?)); })?));
} }

View file

@ -35,9 +35,13 @@ pub struct BifrostClient {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart { pub enum ContentPart {
Text { text: String }, Text {
text: String,
},
#[serde(rename = "image_url")] #[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrlSource }, ImageUrl {
image_url: ImageUrlSource,
},
} }
/// Source for an image URL content part — always a data URI. /// Source for an image URL content part — always a data URI.
@ -214,7 +218,11 @@ pub struct MessageToolCall {
} }
impl MessageToolCall { impl MessageToolCall {
pub fn function(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self { pub fn function(
id: impl Into<String>,
name: impl Into<String>,
arguments: impl Into<String>,
) -> Self {
Self { Self {
id: id.into(), id: id.into(),
tool_type: "function".to_string(), tool_type: "function".to_string(),
@ -488,7 +496,13 @@ fn last_segment_is_version(url: &str) -> bool {
} }
impl BifrostClient { impl BifrostClient {
pub fn new(base_url: &str, api_key: &str, virtual_key: &str, default_model: &str, timeout_secs: u64) -> Result<Self> { pub fn new(
base_url: &str,
api_key: &str,
virtual_key: &str,
default_model: &str,
timeout_secs: u64,
) -> Result<Self> {
let base = base_url.trim_end_matches('/').to_string(); let base = base_url.trim_end_matches('/').to_string();
// Only append "/v1" when the base URL has no version path segment. // Only append "/v1" when the base URL has no version path segment.
// A URL already ending in "/v<digits>" (Bifrost's "/v1", z.ai's // A URL already ending in "/v<digits>" (Bifrost's "/v1", z.ai's
@ -534,10 +548,16 @@ impl BifrostClient {
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
if !self.api_key.is_empty() { if !self.api_key.is_empty() {
let auth_val = format!("Bearer {}", self.api_key); let auth_val = format!("Bearer {}", self.api_key);
headers.insert(reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&auth_val).unwrap()); headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&auth_val).unwrap(),
);
} }
if !self.virtual_key.is_empty() { if !self.virtual_key.is_empty() {
headers.insert("x-bf-vk", reqwest::header::HeaderValue::from_str(&self.virtual_key).unwrap()); headers.insert(
"x-bf-vk",
reqwest::header::HeaderValue::from_str(&self.virtual_key).unwrap(),
);
} }
headers headers
} }
@ -545,7 +565,8 @@ impl BifrostClient {
/// List available models from Bifrost /// List available models from Bifrost
pub async fn list_models(&self) -> Result<Vec<String>> { pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
let resp = self.client let resp = self
.client
.get(&url) .get(&url)
.headers(self.auth_headers()) .headers(self.auth_headers())
.send() .send()
@ -570,7 +591,10 @@ impl BifrostClient {
/// Returns the completion result plus any strain events that occurred. /// Returns the completion result plus any strain events that occurred.
/// Strain events are body-knowledge: the agent can feel when inference /// Strain events are body-knowledge: the agent can feel when inference
/// was difficult, correlate it over time, notice patterns. /// was difficult, correlate it over time, notice patterns.
pub async fn chat_completion(&self, request: ChatCompletionRequest) -> Result<CompletionResult> { pub async fn chat_completion(
&self,
request: ChatCompletionRequest,
) -> Result<CompletionResult> {
let (result, _strain) = self.chat_completion_with_strain(request).await?; let (result, _strain) = self.chat_completion_with_strain(request).await?;
Ok(result) Ok(result)
} }
@ -584,7 +608,10 @@ impl BifrostClient {
// Try primary model // Try primary model
let fallbacks = self.retry_policy.fallback_models.clone(); let fallbacks = self.retry_policy.fallback_models.clone();
match self.try_model_with_retries(&request, &request.model, &mut strain_events).await { match self
.try_model_with_retries(&request, &request.model, &mut strain_events)
.await
{
Ok(result) => return Ok((result, strain_events)), Ok(result) => return Ok((result, strain_events)),
Err(primary_err) => { Err(primary_err) => {
if fallbacks.is_empty() { if fallbacks.is_empty() {
@ -602,7 +629,10 @@ impl BifrostClient {
// Try each fallback model // Try each fallback model
for fallback in &fallbacks { for fallback in &fallbacks {
info!("Falling back to model: {}", fallback); info!("Falling back to model: {}", fallback);
match self.try_model_with_retries(&request, fallback, &mut strain_events).await { match self
.try_model_with_retries(&request, fallback, &mut strain_events)
.await
{
Ok(result) => { Ok(result) => {
info!("Fallback to {} succeeded", fallback); info!("Fallback to {} succeeded", fallback);
return Ok((result, strain_events)); return Ok((result, strain_events));
@ -636,7 +666,8 @@ impl BifrostClient {
for attempt in 0..=policy.max_retries { for attempt in 0..=policy.max_retries {
debug!("POST {} — model: {} (attempt {})", url, model, attempt); debug!("POST {} — model: {} (attempt {})", url, model, attempt);
let resp = self.client let resp = self
.client
.post(&url) .post(&url)
.headers(self.auth_headers()) .headers(self.auth_headers())
.json(&req_with_model) .json(&req_with_model)
@ -650,7 +681,10 @@ impl BifrostClient {
anyhow::bail!("Bifrost unreachable after {} attempts: {}", attempt + 1, e); anyhow::bail!("Bifrost unreachable after {} attempts: {}", attempt + 1, e);
} }
let delay = jittered_delay(attempt, policy); let delay = jittered_delay(attempt, policy);
warn!("Bifrost connection failed (attempt {}), retrying in {:?}: {}", attempt, delay, e); warn!(
"Bifrost connection failed (attempt {}), retrying in {:?}: {}",
attempt, delay, e
);
strain_events.push(InferenceStrain::Transient { strain_events.push(InferenceStrain::Transient {
attempt, attempt,
status: 0, status: 0,
@ -667,12 +701,16 @@ impl BifrostClient {
let retry_after = parse_retry_after(resp.headers()); let retry_after = parse_retry_after(resp.headers());
if status.is_success() { if status.is_success() {
let body_text = resp.text().await let body_text = resp
.text()
.await
.context("Failed to read Bifrost response body")?; .context("Failed to read Bifrost response body")?;
return Self::parse_completion_response(&body_text); return Self::parse_completion_response(&body_text);
} }
let body_text = resp.text().await let body_text = resp
.text()
.await
.context("Failed to read Bifrost error body")?; .context("Failed to read Bifrost error body")?;
match classify_status(status, &body_text) { match classify_status(status, &body_text) {
@ -680,7 +718,10 @@ impl BifrostClient {
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy)); let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
warn!( warn!(
"Bifrost {} on {} (attempt {}), retrying in {:?}", "Bifrost {} on {} (attempt {}), retrying in {:?}",
status.as_u16(), model, attempt, delay status.as_u16(),
model,
attempt,
delay
); );
strain_events.push(InferenceStrain::Transient { strain_events.push(InferenceStrain::Transient {
attempt, attempt,
@ -699,7 +740,10 @@ impl BifrostClient {
}); });
anyhow::bail!( anyhow::bail!(
"Bifrost returned {} after {} attempt(s) on {}: {}", "Bifrost returned {} after {} attempt(s) on {}: {}",
status, attempt + 1, model, &body_text[..body_text.len().min(500)] status,
attempt + 1,
model,
&body_text[..body_text.len().min(500)]
); );
} }
} }
@ -709,18 +753,23 @@ impl BifrostClient {
} }
fn parse_completion_response(body_text: &str) -> Result<CompletionResult> { fn parse_completion_response(body_text: &str) -> Result<CompletionResult> {
let parsed: ChatCompletionResponse = serde_json::from_str(body_text) let parsed: ChatCompletionResponse =
.with_context(|| { serde_json::from_str(body_text).with_context(|| {
let preview = &body_text[..body_text.len().min(200)]; let preview = &body_text[..body_text.len().min(200)];
format!("Failed to parse Bifrost response: {preview}") format!("Failed to parse Bifrost response: {preview}")
})?; })?;
let choice = parsed.choices.into_iter().next() let choice = parsed
.choices
.into_iter()
.next()
.context("Bifrost returned empty choices")?; .context("Bifrost returned empty choices")?;
let content = choice.message.content.unwrap_or_default(); let content = choice.message.content.unwrap_or_default();
let reasoning = choice.message.reasoning; let reasoning = choice.message.reasoning;
let tool_calls = choice.message.tool_calls let tool_calls = choice
.message
.tool_calls
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.filter_map(|tc| { .filter_map(|tc| {
@ -786,7 +835,8 @@ mod tests {
"", "",
"openai/deepseek-v4-pro", "openai/deepseek-v4-pro",
120, 120,
).unwrap(); )
.unwrap();
assert!(client.base_url.ends_with("/v1")); assert!(client.base_url.ends_with("/v1"));
} }
@ -794,9 +844,7 @@ mod tests {
fn test_chat_request_serialization() { fn test_chat_request_serialization() {
let req = ChatCompletionRequest { let req = ChatCompletionRequest {
model: "openai/deepseek-v4-pro".to_string(), model: "openai/deepseek-v4-pro".to_string(),
messages: vec![ messages: vec![Message::text("user", "Hello")],
Message::text("user", "Hello"),
],
stream: None, stream: None,
max_tokens: None, max_tokens: None,
temperature: None, temperature: None,
@ -829,7 +877,10 @@ mod tests {
}"#; }"#;
let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap(); let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!")); assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
assert_eq!(resp.choices[0].message.reasoning.as_deref(), Some("The user greeted me.")); assert_eq!(
resp.choices[0].message.reasoning.as_deref(),
Some("The user greeted me.")
);
} }
#[test] #[test]

View file

@ -484,7 +484,11 @@ async fn refresh_token(
/// Best-effort `account_uuid` lookup via the roles/profile endpoint. /// Best-effort `account_uuid` lookup via the roles/profile endpoint.
#[allow(dead_code)] #[allow(dead_code)]
async fn fetch_account_uuid(http: &reqwest::Client, access_token: &str, user_agent: &str) -> String { async fn fetch_account_uuid(
http: &reqwest::Client,
access_token: &str,
user_agent: &str,
) -> String {
match http match http
.get(ROLES_URL) .get(ROLES_URL)
.bearer_auth(access_token) .bearer_auth(access_token)
@ -702,8 +706,8 @@ fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String
} }
if let Some(calls) = &msg.tool_calls { if let Some(calls) = &msg.tool_calls {
for call in calls { for call in calls {
let input: Value = serde_json::from_str(&call.function.arguments) let input: Value =
.unwrap_or(json!({})); serde_json::from_str(&call.function.arguments).unwrap_or(json!({}));
push_merge( push_merge(
&mut merged, &mut merged,
"assistant", "assistant",
@ -787,8 +791,7 @@ fn translate_tool(tool: &ToolDefinition) -> Value {
/// Anthropic non-stream response → Souveraine's OpenAI-shaped `CompletionResult`. /// Anthropic non-stream response → Souveraine's OpenAI-shaped `CompletionResult`.
fn parse_completion(text: &str) -> Result<CompletionResult> { fn parse_completion(text: &str) -> Result<CompletionResult> {
let value: Value = let value: Value = serde_json::from_str(text).context("parsing claude messages response")?;
serde_json::from_str(text).context("parsing claude messages response")?;
let content_parts: Vec<String> = value let content_parts: Vec<String> = value
.get("content") .get("content")
.and_then(Value::as_array) .and_then(Value::as_array)
@ -1001,7 +1004,10 @@ mod tests {
]; ];
let (messages, system, first_user) = translate_messages(&msgs); let (messages, system, first_user) = translate_messages(&msgs);
assert!(system.is_none()); assert!(system.is_none());
let roles: Vec<&str> = messages.iter().map(|m| m["role"].as_str().unwrap()).collect(); let roles: Vec<&str> = messages
.iter()
.map(|m| m["role"].as_str().unwrap())
.collect();
assert_eq!(roles[0], "user"); assert_eq!(roles[0], "user");
assert!(messages.iter().any(|m| { assert!(messages.iter().any(|m| {
m["role"] == "user" m["role"] == "user"
@ -1116,7 +1122,11 @@ mod tests {
// Rotated but already expired — adopting it would just fail differently. // Rotated but already expired — adopting it would just fail differently.
let stale = creds_at(dir.path(), "spent", now_ms() + 60_000); let stale = creds_at(dir.path(), "spent", now_ms() + 60_000);
creds_at(dir.path(), "rotated-but-dead", now_ms().saturating_sub(1_000)); creds_at(
dir.path(),
"rotated-but-dead",
now_ms().saturating_sub(1_000),
);
assert!(reread_credentials(&stale).is_none()); assert!(reread_credentials(&stale).is_none());
} }
} }

View file

@ -86,9 +86,7 @@ pub fn build_registry(config: &ConsciousnessConfig) -> anyhow::Result<ProviderRe
} }
if map.is_empty() { if map.is_empty() {
anyhow::bail!( anyhow::bail!("no providers configured — add at least one [providers.<name>] section");
"no providers configured — add at least one [providers.<name>] section"
);
} }
let default_name = config.inference.provider.clone(); let default_name = config.inference.provider.clone();
@ -127,9 +125,11 @@ impl ProviderRegistry {
/// default rather than failing. /// default rather than failing.
pub fn for_agent(&self, agent: &AgentState) -> Arc<dyn LlmProvider> { pub fn for_agent(&self, agent: &AgentState) -> Arc<dyn LlmProvider> {
match agent.souveraine.provider.as_deref() { match agent.souveraine.provider.as_deref() {
Some(name) if name != self.default_name => { Some(name) if name != self.default_name => self
self.map.get(name).cloned().unwrap_or_else(|| self.default.clone()) .map
} .get(name)
.cloned()
.unwrap_or_else(|| self.default.clone()),
_ => self.default.clone(), _ => self.default.clone(),
} }
} }

View file

@ -50,7 +50,10 @@ impl TokenCounter {
info!("🔢 Token counter using chars/4 fallback (tiktoken unavailable)"); info!("🔢 Token counter using chars/4 fallback (tiktoken unavailable)");
} }
Self { encoding, available: encoding.is_some() } Self {
encoding,
available: encoding.is_some(),
}
} }
/// Count tokens using tiktoken or chars/4 fallback /// Count tokens using tiktoken or chars/4 fallback
@ -88,7 +91,10 @@ pub struct ModelRouter {
impl ModelRouter { impl ModelRouter {
pub fn new(configs: HashMap<String, ModelConfig>) -> Self { pub fn new(configs: HashMap<String, ModelConfig>) -> Self {
let count = configs.len(); let count = configs.len();
info!("🧭 ModelRouter initialized with {} model configurations", count); info!(
"🧭 ModelRouter initialized with {} model configurations",
count
);
for (name, cfg) in &configs { for (name, cfg) in &configs {
debug!( debug!(
" {} via {} — {} ctx, {} out, threshold {}", " {} via {} — {} ctx, {} out, threshold {}",
@ -134,7 +140,11 @@ impl ModelRouter {
} }
/// Check context pressure for a specific model /// Check context pressure for a specific model
pub async fn context_pressure(&self, model_name: &str, context_window_limit: Option<usize>) -> ContextPressure { pub async fn context_pressure(
&self,
model_name: &str,
context_window_limit: Option<usize>,
) -> ContextPressure {
let config = match self.configs.get(model_name) { let config = match self.configs.get(model_name) {
Some(c) => c, Some(c) => c,
None => return ContextPressure::Normal, None => return ContextPressure::Normal,
@ -193,11 +203,8 @@ impl ModelRouter {
/// Get all configured providers /// Get all configured providers
pub fn providers(&self) -> Vec<String> { pub fn providers(&self) -> Vec<String> {
let mut providers: Vec<String> = self let mut providers: Vec<String> =
.configs self.configs.values().map(|c| c.provider.clone()).collect();
.values()
.map(|c| c.provider.clone())
.collect();
providers.sort(); providers.sort();
providers.dedup(); providers.dedup();
providers providers
@ -236,7 +243,11 @@ impl ModelRouter {
/// Set the selected model /// Set the selected model
pub fn set_model(&mut self, name: &str) -> anyhow::Result<()> { pub fn set_model(&mut self, name: &str) -> anyhow::Result<()> {
if !self.all_models().contains(&name.to_string()) { if !self.all_models().contains(&name.to_string()) {
anyhow::bail!("Model '{}' not found. Available: {:?}", name, self.all_models()); anyhow::bail!(
"Model '{}' not found. Available: {:?}",
name,
self.all_models()
);
} }
self.selected_model = name.to_string(); self.selected_model = name.to_string();
info!("🎯 Selected model: {}", name); info!("🎯 Selected model: {}", name);
@ -289,7 +300,9 @@ mod tests {
fn test_configs() -> HashMap<String, ModelConfig> { fn test_configs() -> HashMap<String, ModelConfig> {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert("kimi-k2-5".to_string(), ModelConfig { map.insert(
"kimi-k2-5".to_string(),
ModelConfig {
provider: "bifrost".to_string(), provider: "bifrost".to_string(),
model: "kimi-k2.5".to_string(), model: "kimi-k2.5".to_string(),
context_limit: 128000, context_limit: 128000,
@ -298,21 +311,28 @@ mod tests {
archivist_interval: 100, archivist_interval: 100,
preferred_for: vec![TaskType::Synthesis], preferred_for: vec![TaskType::Synthesis],
supports_images: true, supports_images: true,
}); },
);
map map
} }
#[tokio::test] #[tokio::test]
async fn test_context_pressure_normal() { async fn test_context_pressure_normal() {
let router = ModelRouter::new(test_configs()); let router = ModelRouter::new(test_configs());
assert_eq!(router.context_pressure("kimi-k2-5", None).await, ContextPressure::Normal); assert_eq!(
router.context_pressure("kimi-k2-5", None).await,
ContextPressure::Normal
);
} }
#[tokio::test] #[tokio::test]
async fn test_context_pressure_critical() { async fn test_context_pressure_critical() {
let router = ModelRouter::new(test_configs()); let router = ModelRouter::new(test_configs());
router.update_usage(100_000, 90_000, 10_000).await; router.update_usage(100_000, 90_000, 10_000).await;
assert_eq!(router.context_pressure("kimi-k2-5", None).await, ContextPressure::Critical); assert_eq!(
router.context_pressure("kimi-k2-5", None).await,
ContextPressure::Critical
);
} }
#[tokio::test] #[tokio::test]
@ -320,9 +340,15 @@ mod tests {
let router = ModelRouter::new(test_configs()); let router = ModelRouter::new(test_configs());
router.update_usage(90_000, 80_000, 10_000).await; router.update_usage(90_000, 80_000, 10_000).await;
// With 90k/128k ≈ 0.7 — critical at threshold 0.7 // With 90k/128k ≈ 0.7 — critical at threshold 0.7
assert_eq!(router.context_pressure("kimi-k2-5", None).await, ContextPressure::Critical); assert_eq!(
router.context_pressure("kimi-k2-5", None).await,
ContextPressure::Critical
);
// With 90k/64k ≈ 1.4 — also critical // With 90k/64k ≈ 1.4 — also critical
assert_eq!(router.context_pressure("kimi-k2-5", Some(64000)).await, ContextPressure::Critical); assert_eq!(
router.context_pressure("kimi-k2-5", Some(64000)).await,
ContextPressure::Critical
);
} }
#[tokio::test] #[tokio::test]

View file

@ -65,10 +65,17 @@ async fn refresh(http: &reqwest::Client, creds: &CodexCredentials) -> Result<Cod
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("ChatGPT token refresh failed ({}): {}", status, body)); return Err(anyhow!(
"ChatGPT token refresh failed ({}): {}",
status,
body
));
} }
let json: Value = resp.json().await.context("parsing token refresh response")?; let json: Value = resp
.json()
.await
.context("parsing token refresh response")?;
let access = json let access = json
.get("access_token") .get("access_token")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -80,7 +87,10 @@ async fn refresh(http: &reqwest::Client, creds: &CodexCredentials) -> Result<Cod
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from) .map(String::from)
.unwrap_or_else(|| creds.refresh_token.clone()); .unwrap_or_else(|| creds.refresh_token.clone());
let expires_in = json.get("expires_in").and_then(|v| v.as_i64()).unwrap_or(3600); let expires_in = json
.get("expires_in")
.and_then(|v| v.as_i64())
.unwrap_or(3600);
Ok(CodexCredentials { Ok(CodexCredentials {
access_token: access, access_token: access,

View file

@ -15,7 +15,9 @@ use async_trait::async_trait;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::warn; use tracing::warn;
use crate::bridge::bifrost::{ChatCompletionRequest, CompletionResult, InferenceStrain, RetryPolicy}; use crate::bridge::bifrost::{
ChatCompletionRequest, CompletionResult, InferenceStrain, RetryPolicy,
};
use crate::bridge::oauth::codex_creds::{self, CodexCredentials}; use crate::bridge::oauth::codex_creds::{self, CodexCredentials};
use crate::bridge::oauth::{catalog, refresh}; use crate::bridge::oauth::{catalog, refresh};
use crate::bridge::provider::LlmProvider; use crate::bridge::provider::LlmProvider;
@ -114,7 +116,10 @@ impl LlmProvider for OpenAiOAuthProvider {
)); ));
} }
let delay = backoff(attempt, &self.retry); let delay = backoff(attempt, &self.retry);
warn!("ChatGPT backend connect failed (attempt {}), retrying in {:?}: {}", attempt, delay, e); warn!(
"ChatGPT backend connect failed (attempt {}), retrying in {:?}: {}",
attempt, delay, e
);
strain.push(InferenceStrain::Transient { strain.push(InferenceStrain::Transient {
attempt, attempt,
status: 0, status: 0,
@ -129,7 +134,10 @@ impl LlmProvider for OpenAiOAuthProvider {
let status = resp.status(); let status = resp.status();
if status.is_success() { if status.is_success() {
let body = resp.text().await.context("reading ChatGPT responses stream")?; let body = resp
.text()
.await
.context("reading ChatGPT responses stream")?;
let result = responses::accumulate_sse(&body)?; let result = responses::accumulate_sse(&body)?;
return Ok((result, strain)); return Ok((result, strain));
} }
@ -138,7 +146,12 @@ impl LlmProvider for OpenAiOAuthProvider {
let transient = matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504); let transient = matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504);
if transient && attempt < self.retry.max_retries { if transient && attempt < self.retry.max_retries {
let delay = backoff(attempt, &self.retry); let delay = backoff(attempt, &self.retry);
warn!("ChatGPT backend {} (attempt {}), retrying in {:?}", status.as_u16(), attempt, delay); warn!(
"ChatGPT backend {} (attempt {}), retrying in {:?}",
status.as_u16(),
attempt,
delay
);
strain.push(InferenceStrain::Transient { strain.push(InferenceStrain::Transient {
attempt, attempt,
status: status.as_u16(), status: status.as_u16(),
@ -168,6 +181,8 @@ impl LlmProvider for OpenAiOAuthProvider {
} }
fn backoff(attempt: u32, policy: &RetryPolicy) -> Duration { fn backoff(attempt: u32, policy: &RetryPolicy) -> Duration {
let base = policy.base_delay_ms.saturating_mul(2u64.saturating_pow(attempt)); let base = policy
.base_delay_ms
.saturating_mul(2u64.saturating_pow(attempt));
Duration::from_millis(base.min(policy.max_delay_ms)) Duration::from_millis(base.min(policy.max_delay_ms))
} }

View file

@ -181,7 +181,11 @@ pub fn accumulate_sse(body: &str) -> Result<CompletionResult> {
} }
} }
let finish_reason = if tool_calls.is_empty() { "stop" } else { "tool_calls" }; let finish_reason = if tool_calls.is_empty() {
"stop"
} else {
"tool_calls"
};
Ok(CompletionResult { Ok(CompletionResult {
content, content,

View file

@ -53,11 +53,7 @@ pub async fn run_model_command(
// Handle model selection // Handle model selection
if let Some(name) = model_name { if let Some(name) = model_name {
if !all_models.contains(&name.to_string()) { if !all_models.contains(&name.to_string()) {
anyhow::bail!( anyhow::bail!("Model '{}' not found. Available: {:?}", name, all_models);
"Model '{}' not found. Available: {:?}",
name,
all_models
);
} }
// Update config and save // Update config and save
@ -84,12 +80,8 @@ pub async fn run_model_command(
provider: cfg provider: cfg
.map(|c| c.provider.clone()) .map(|c| c.provider.clone())
.unwrap_or_else(|| "bifrost".to_string()), .unwrap_or_else(|| "bifrost".to_string()),
context_limit: cfg context_limit: cfg.map(|c| c.context_limit).unwrap_or(128_000),
.map(|c| c.context_limit) output_limit: cfg.map(|c| c.output_limit).unwrap_or(8_192),
.unwrap_or(128_000),
output_limit: cfg
.map(|c| c.output_limit)
.unwrap_or(8_192),
from_bifrost: bifrost_models.contains(name), from_bifrost: bifrost_models.contains(name),
} }
}) })

View file

@ -86,10 +86,7 @@ impl SynthesisReport {
pub fn summary_line(&self) -> String { pub fn summary_line(&self) -> String {
format!( format!(
"synthesized {} journal entries ({} → {}) into {}", "synthesized {} journal entries ({} → {}) into {}",
self.entries_compressed, self.entries_compressed, self.date_range.0, self.date_range.1, self.output_label,
self.date_range.0,
self.date_range.1,
self.output_label,
) )
} }
} }
@ -160,8 +157,9 @@ impl ArchivistEngine {
return Ok(None); return Ok(None);
} }
let interval_due = let interval_due = turn_count > 0
turn_count > 0 && self.config.interval > 0 && turn_count.is_multiple_of(self.config.interval); && self.config.interval > 0
&& turn_count.is_multiple_of(self.config.interval);
let pressure_due = pressure >= self.config.threshold; let pressure_due = pressure >= self.config.threshold;
if !interval_due && !pressure_due { if !interval_due && !pressure_due {
return Ok(None); return Ok(None);
@ -222,13 +220,7 @@ impl ArchivistEngine {
.await?; .await?;
let output_label = format!("{SYNTHESIS_DIR}/{end_date}"); let output_label = format!("{SYNTHESIS_DIR}/{end_date}");
let body = render_synthesis( let body = render_synthesis(&synthesis, start_date, end_date, entries.len(), &model);
&synthesis,
start_date,
end_date,
entries.len(),
&model,
);
repo.write(&output_label, &body).await?; repo.write(&output_label, &body).await?;
let report = SynthesisReport { let report = SynthesisReport {
@ -276,7 +268,8 @@ impl ArchivistEngine {
let (response, strain) = llm.chat_completion_with_strain(request).await?; let (response, strain) = llm.chat_completion_with_strain(request).await?;
for event in &strain { for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event { if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event
{
tracing::info!("archivist felt inference strain: {} on {}", status, model); tracing::info!("archivist felt inference strain: {} on {}", status, model);
if *status == 429 { if *status == 429 {
let current = self.rate_delay.load(Ordering::Relaxed); let current = self.rate_delay.load(Ordering::Relaxed);
@ -407,7 +400,12 @@ async fn collect_journal_entries(
fn format_journal_input(entries: &[JournalEntry]) -> String { fn format_journal_input(entries: &[JournalEntry]) -> String {
let mut out = String::new(); let mut out = String::new();
for entry in entries { for entry in entries {
let chunk = format!("=== {} ({}) ===\n{}\n\n", entry.date, entry.label, entry.body.trim()); let chunk = format!(
"=== {} ({}) ===\n{}\n\n",
entry.date,
entry.label,
entry.body.trim()
);
// Always keep at least the first (oldest) entry, even if it alone // Always keep at least the first (oldest) entry, even if it alone
// exceeds the cap — truncate it rather than emit nothing. // exceeds the cap — truncate it rather than emit nothing.
if out.is_empty() { if out.is_empty() {
@ -458,30 +456,18 @@ fn render_synthesis(
/// Map a configured synthesis element to its prompt line (label + budget). /// Map a configured synthesis element to its prompt line (label + budget).
fn element_spec(el: &SynthesisElement) -> (&'static str, &'static str) { fn element_spec(el: &SynthesisElement) -> (&'static str, &'static str) {
match el { match el {
SynthesisElement::Themes => ( SynthesisElement::Themes => ("Themes", "[3-5 recurring topics, ~10 words each]"),
"Themes", SynthesisElement::Emotions => ("Emotional Tone", "[dominant felt sense, ~20 words]"),
"[3-5 recurring topics, ~10 words each]",
),
SynthesisElement::Emotions => (
"Emotional Tone",
"[dominant felt sense, ~20 words]",
),
SynthesisElement::Tensions => ( SynthesisElement::Tensions => (
"Unresolved Tensions", "Unresolved Tensions",
"[threads that still need attention, ~30 words]", "[threads that still need attention, ~30 words]",
), ),
SynthesisElement::Anchors => ( SynthesisElement::Anchors => ("Anchors", "[stable reference points, ~20 words]"),
"Anchors",
"[stable reference points, ~20 words]",
),
SynthesisElement::Evolution => ( SynthesisElement::Evolution => (
"Evolution", "Evolution",
"[how perspectives shifted this period, ~40 words]", "[how perspectives shifted this period, ~40 words]",
), ),
SynthesisElement::Patterns => ( SynthesisElement::Patterns => ("Patterns", "[recurring behaviors, ~30 words]"),
"Patterns",
"[recurring behaviors, ~30 words]",
),
} }
} }
@ -531,10 +517,7 @@ mod tests {
fn parse_covers_end_reads_marker() { fn parse_covers_end_reads_marker() {
let body = "<!-- archivist: covers 2026-04-02..2026-04-14, 7 entries, \ let body = "<!-- archivist: covers 2026-04-02..2026-04-14, 7 entries, \
synthesized 2026-05-15T10:00:00Z via openai/glm-5.1 -->\n\n# Synthesis"; synthesized 2026-05-15T10:00:00Z via openai/glm-5.1 -->\n\n# Synthesis";
assert_eq!( assert_eq!(parse_covers_end(body), NaiveDate::from_ymd_opt(2026, 4, 14),);
parse_covers_end(body),
NaiveDate::from_ymd_opt(2026, 4, 14),
);
} }
#[test] #[test]
@ -573,7 +556,8 @@ mod tests {
#[test] #[test]
fn prompt_includes_only_configured_elements() { fn prompt_includes_only_configured_elements() {
let prompt = archivist_system_prompt(&[SynthesisElement::Themes, SynthesisElement::Anchors]); let prompt =
archivist_system_prompt(&[SynthesisElement::Themes, SynthesisElement::Anchors]);
assert!(prompt.contains("- Themes:")); assert!(prompt.contains("- Themes:"));
assert!(prompt.contains("- Anchors:")); assert!(prompt.contains("- Anchors:"));
assert!(!prompt.contains("- Evolution:")); assert!(!prompt.contains("- Evolution:"));

View file

@ -78,8 +78,7 @@ pub fn hint_for_launch(count: u32, agent_count: u32) -> String {
} }
// First few launches: brief orientation // First few launches: brief orientation
(1..=3, _) if agent_count == 0 => { (1..=3, _) if agent_count == 0 => {
"No agents yet. Press 'a' to create one, or check the Settings screen." "No agents yet. Press 'a' to create one, or check the Settings screen.".to_string()
.to_string()
} }
// Seasoned user: no hint // Seasoned user: no hint
_ => String::new(), _ => String::new(),
@ -217,8 +216,7 @@ pub fn gather_probe(home: &Path) -> BootstrapProbe {
let launch_count = read_launch_count(&souveraine_dir); let launch_count = read_launch_count(&souveraine_dir);
// Federation env override // Federation env override
let force_federation = let force_federation = std::env::var("SOUVERAINE_SETUP").as_deref() == Ok("federation");
std::env::var("SOUVERAINE_SETUP").as_deref() == Ok("federation");
// Increment launch count for next time // Increment launch count for next time
let _ = increment_launch_count(&souveraine_dir); let _ = increment_launch_count(&souveraine_dir);
@ -400,7 +398,14 @@ mod tests {
let labels: Vec<&str> = plan.phases.iter().map(|p| p.label()).collect(); let labels: Vec<&str> = plan.phases.iter().map(|p| p.label()).collect();
assert_eq!( assert_eq!(
labels, labels,
vec!["splash", "probe", "resolve", "setup-wizard", "background-tasks", "enter-tui"] vec![
"splash",
"probe",
"resolve",
"setup-wizard",
"background-tasks",
"enter-tui"
]
); );
} }
@ -417,7 +422,13 @@ mod tests {
// No setup-wizard, no hint (seasoned user) // No setup-wizard, no hint (seasoned user)
assert_eq!( assert_eq!(
labels, labels,
vec!["splash", "probe", "resolve", "background-tasks", "enter-tui"] vec![
"splash",
"probe",
"resolve",
"background-tasks",
"enter-tui"
]
); );
} }
} }

View file

@ -1,7 +1,7 @@
#![allow(dead_code)] // WIP scaffolding not yet wired #![allow(dead_code)] // WIP scaffolding not yet wired
use anyhow::Result;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use anyhow::Result;
use crate::core::config::ConsciousnessConfig; use crate::core::config::ConsciousnessConfig;
@ -21,6 +21,10 @@ impl ChainOrchestrator {
talking_enabled: bool, talking_enabled: bool,
thinking_enabled: bool, thinking_enabled: bool,
) -> Result<Self> { ) -> Result<Self> {
Ok(Self { config, talking_enabled, thinking_enabled }) Ok(Self {
config,
talking_enabled,
thinking_enabled,
})
} }
} }

View file

@ -56,10 +56,7 @@ impl Default for CompactionConfig {
impl CompactionConfig { impl CompactionConfig {
/// Resolve the effective config for a given agent type. /// Resolve the effective config for a given agent type.
pub fn for_agent_type(&self, agent_type: &str) -> AgentCompactionConfig { pub fn for_agent_type(&self, agent_type: &str) -> AgentCompactionConfig {
self.per_type self.per_type.get(agent_type).cloned().unwrap_or_else(|| {
.get(agent_type)
.cloned()
.unwrap_or_else(|| {
// No explicit [compaction.per_type] entry. The subconscious // No explicit [compaction.per_type] entry. The subconscious
// leans on sliding_reflect — its preservation fork catches her // leans on sliding_reflect — its preservation fork catches her
// threads before the cut, which is what an unattended pass // threads before the cut, which is what an unattended pass
@ -174,11 +171,27 @@ impl CompactionStrategyKind {
// ── Default helpers ── // ── Default helpers ──
fn default_enabled() -> bool { true } fn default_enabled() -> bool {
fn default_strategy() -> CompactionStrategyKind { CompactionStrategyKind::Cull } true
fn default_warn_pressure() -> f32 { 0.80 } }
fn default_urgent_pressure() -> f32 { 0.90 } fn default_strategy() -> CompactionStrategyKind {
fn default_critical_pressure() -> f32 { 0.95 } CompactionStrategyKind::Cull
fn default_max_summary() -> usize { 32000 } }
fn default_kv_target() -> usize { 16 } fn default_warn_pressure() -> f32 {
fn default_preserve_recent() -> usize { 40 } 0.80
}
fn default_urgent_pressure() -> f32 {
0.90
}
fn default_critical_pressure() -> f32 {
0.95
}
fn default_max_summary() -> usize {
32000
}
fn default_kv_target() -> usize {
16
}
fn default_preserve_recent() -> usize {
40
}

View file

@ -24,7 +24,10 @@ pub mod strategy;
pub use config::{CompactionConfig, CompactionStrategyKind}; pub use config::{CompactionConfig, CompactionStrategyKind};
pub use plan::{AuditEntry, CompactionPlan, CompactionReport}; pub use plan::{AuditEntry, CompactionPlan, CompactionReport};
pub use strategy::{CompactionStrategy, CullStrategy, MicrocompactStrategy, SlidingReflectStrategy, SlidingWindowStrategy, SummaryStrategy}; pub use strategy::{
CompactionStrategy, CullStrategy, MicrocompactStrategy, SlidingReflectStrategy,
SlidingWindowStrategy, SummaryStrategy,
};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@ -71,11 +74,7 @@ pub trait CompactionEngine: Send + Sync {
) -> anyhow::Result<CompactionReport>; ) -> anyhow::Result<CompactionReport>;
/// Write the audit entry to the agent's memory repo. /// Write the audit entry to the agent's memory repo.
async fn write_audit( async fn write_audit(&self, agent_id: &str, entry: &AuditEntry) -> anyhow::Result<PathBuf>;
&self,
agent_id: &str,
entry: &AuditEntry,
) -> anyhow::Result<PathBuf>;
} }
/// Concrete compaction engine that ties together config, strategies, /// Concrete compaction engine that ties together config, strategies,
@ -106,8 +105,7 @@ impl CompactionEngine for DefaultCompactionEngine {
strategy_override: Option<CompactionStrategyKind>, strategy_override: Option<CompactionStrategyKind>,
) -> anyhow::Result<CompactionReport> { ) -> anyhow::Result<CompactionReport> {
// Resolve config early so we can bail before requiring a session // Resolve config early so we can bail before requiring a session
let agent_type = (self.get_agent_type)(agent_id) let agent_type = (self.get_agent_type)(agent_id).unwrap_or_else(|| "primary".to_string());
.unwrap_or_else(|| "primary".to_string());
let (cfg, reflect_prompt, summary_prompt) = { let (cfg, reflect_prompt, summary_prompt) = {
let app_config = self.config.read().await; let app_config = self.config.read().await;
( (
@ -161,10 +159,7 @@ impl CompactionEngine for DefaultCompactionEngine {
.as_deref() .as_deref()
.and_then(|n| reg.get(n)) .and_then(|n| reg.get(n))
.unwrap_or_else(|| reg.default_provider()); .unwrap_or_else(|| reg.default_provider());
let model = self let model = self.model.as_deref().unwrap_or("openai/kimi-k2.6");
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
let s = SummaryStrategy { let s = SummaryStrategy {
client: client.clone(), client: client.clone(),
model: model.to_string(), model: model.to_string(),
@ -193,10 +188,7 @@ impl CompactionEngine for DefaultCompactionEngine {
.as_deref() .as_deref()
.and_then(|n| reg.get(n)) .and_then(|n| reg.get(n))
.unwrap_or_else(|| reg.default_provider()); .unwrap_or_else(|| reg.default_provider());
let model = self let model = self.model.as_deref().unwrap_or("openai/kimi-k2.6");
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
// The preservation pass runs as a fresh fork of *this* // The preservation pass runs as a fresh fork of *this*
// agent — load her persona so the fork wakes as her. // agent — load her persona so the fork wakes as her.
let agent_persona = (self.get_repo)(agent_id) let agent_persona = (self.get_repo)(agent_id)
@ -222,7 +214,9 @@ impl CompactionEngine for DefaultCompactionEngine {
} }
None => { None => {
tracing::warn!("[sliding_reflect] no provider registry, falling back to plain sliding window"); tracing::warn!("[sliding_reflect] no provider registry, falling back to plain sliding window");
SlidingWindowStrategy.plan(&messages, &cfg, &self.counter).await? SlidingWindowStrategy
.plan(&messages, &cfg, &self.counter)
.await?
} }
}, },
}; };
@ -303,11 +297,7 @@ impl CompactionEngine for DefaultCompactionEngine {
}) })
} }
async fn write_audit( async fn write_audit(&self, agent_id: &str, entry: &AuditEntry) -> anyhow::Result<PathBuf> {
&self,
agent_id: &str,
entry: &AuditEntry,
) -> anyhow::Result<PathBuf> {
let repo = (self.get_repo)(agent_id) let repo = (self.get_repo)(agent_id)
.ok_or_else(|| anyhow::anyhow!("No memory repo for agent {}", agent_id))?; .ok_or_else(|| anyhow::anyhow!("No memory repo for agent {}", agent_id))?;

View file

@ -37,9 +37,7 @@ impl CompactionPlan {
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.summary_text.is_none() self.summary_text.is_none() && self.culled_count == 0 && self.replacement_messages.is_none()
&& self.culled_count == 0
&& self.replacement_messages.is_none()
} }
} }
@ -132,7 +130,11 @@ impl std::fmt::Display for CompactionReport {
} }
} }
if let Some(ref path) = self.audit_path { if let Some(ref path) = self.audit_path {
write!(f, "(The record of this shrinking rests at {}.)", path.display())?; write!(
f,
"(The record of this shrinking rests at {}.)",
path.display()
)?;
} }
Ok(()) Ok(())
} }
@ -160,13 +162,20 @@ impl AuditEntry {
body.push_str("## Compaction Summary\n\n"); body.push_str("## Compaction Summary\n\n");
body.push_str(&format!( body.push_str(&format!(
"Strategy: {}\nMessages: {} → {}\nTokens: {} → {}\n", "Strategy: {}\nMessages: {} → {}\nTokens: {} → {}\n",
self.strategy, self.before_messages, self.after_messages, self.before_tokens, self.after_tokens self.strategy,
self.before_messages,
self.after_messages,
self.before_tokens,
self.after_tokens
)); ));
if let Some(ref summary) = self.summary_text { if let Some(ref summary) = self.summary_text {
body.push_str(&format!("\n## Summary Content\n\n{}\n", summary)); body.push_str(&format!("\n## Summary Content\n\n{}\n", summary));
} }
if self.culled_count > 0 { if self.culled_count > 0 {
body.push_str(&format!("\nMessages dropped or rewritten: {}\n", self.culled_count)); body.push_str(&format!(
"\nMessages dropped or rewritten: {}\n",
self.culled_count
));
} }
format!("---\n{}---\n{}", yaml, body) format!("---\n{}---\n{}", yaml, body)
} }

View file

@ -13,9 +13,7 @@ use super::plan::CompactionPlan;
/// Tools whose results are considered compactable (large outputs, rarely /// Tools whose results are considered compactable (large outputs, rarely
/// needed verbatim once surpassed). Matches Souveraine's actual sensor names. /// needed verbatim once surpassed). Matches Souveraine's actual sensor names.
const COMPACTABLE_TOOLS: &[&str] = &[ const COMPACTABLE_TOOLS: &[&str] = &["read", "bash", "grep", "glob", "list_dir", "edit", "write"];
"read", "bash", "grep", "glob", "list_dir", "edit", "write",
];
/// Placeholder text written into tool result blocks that get microcompacted. /// Placeholder text written into tool result blocks that get microcompacted.
/// Placeholder used so logs read consistently across runs. /// Placeholder used so logs read consistently across runs.
@ -146,7 +144,10 @@ impl CompactionStrategy for SummaryStrategy {
let user_prompt = match &self.prompt_override { let user_prompt = match &self.prompt_override {
Some(custom) => format!("{}\n\nConversation to summarize:\n\n{}", custom, truncated), Some(custom) => format!("{}\n\nConversation to summarize:\n\n{}", custom, truncated),
None => format!("{}\n\nConversation to summarize:\n\n{}", SUMMARY_USER_PROMPT, truncated), None => format!(
"{}\n\nConversation to summarize:\n\n{}",
SUMMARY_USER_PROMPT, truncated
),
}; };
let summary = bifrost_complete( let summary = bifrost_complete(
@ -189,9 +190,17 @@ fn render_segment_for_summary(messages: &[ConversationMessage]) -> String {
ContentBlock::ToolUse { name, input, .. } => { ContentBlock::ToolUse { name, input, .. } => {
out.push_str(&format!("[{} -> tool_call:{}] {}\n", role, name, input)); out.push_str(&format!("[{} -> tool_call:{}] {}\n", role, name, input));
} }
ContentBlock::ToolResult { tool_name, output, is_error, .. } => { ContentBlock::ToolResult {
tool_name,
output,
is_error,
..
} => {
let prefix = if *is_error { "ERROR " } else { "" }; let prefix = if *is_error { "ERROR " } else { "" };
out.push_str(&format!("[{} <- tool_result:{}] {}{}\n", role, tool_name, prefix, output)); out.push_str(&format!(
"[{} <- tool_result:{}] {}{}\n",
role, tool_name, prefix, output
));
} }
ContentBlock::Reasoning { .. } => {} ContentBlock::Reasoning { .. } => {}
ContentBlock::Image { media_type, .. } => { ContentBlock::Image { media_type, .. } => {
@ -266,8 +275,12 @@ impl CompactionStrategy for MicrocompactStrategy {
let mut new_blocks: Vec<ContentBlock> = Vec::with_capacity(msg.blocks.len()); let mut new_blocks: Vec<ContentBlock> = Vec::with_capacity(msg.blocks.len());
for block in &msg.blocks { for block in &msg.blocks {
match block { match block {
ContentBlock::ToolResult { tool_use_id, tool_name, output, is_error } ContentBlock::ToolResult {
if clear_set.contains(tool_use_id.as_str()) tool_use_id,
tool_name,
output,
is_error,
} if clear_set.contains(tool_use_id.as_str())
&& output != TIME_BASED_MC_CLEARED_MESSAGE => && output != TIME_BASED_MC_CLEARED_MESSAGE =>
{ {
tokens_saved += counter.count(output); tokens_saved += counter.count(output);
@ -349,9 +362,12 @@ fn is_load_bearing(msg: &ConversationMessage) -> bool {
if matches!(msg.role, MessageRole::System | MessageRole::Tool) { if matches!(msg.role, MessageRole::System | MessageRole::Tool) {
return true; return true;
} }
msg.blocks msg.blocks.iter().any(|b| {
.iter() matches!(
.any(|b| matches!(b, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. })) b,
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
)
})
} }
#[async_trait] #[async_trait]
@ -571,7 +587,10 @@ impl CompactionStrategy for SlidingReflectStrategy {
// Run the preservation pass on the about-to-be-evicted segment. // Run the preservation pass on the about-to-be-evicted segment.
let transcript = render_segment_for_summary(evicted); let transcript = render_segment_for_summary(evicted);
let truncated: String = transcript.chars().take(config.max_summary_length * 4).collect(); let truncated: String = transcript
.chars()
.take(config.max_summary_length * 4)
.collect();
let user_prompt = match &self.prompt_override { let user_prompt = match &self.prompt_override {
Some(custom) => format!("{}\n\n{}", custom, truncated), Some(custom) => format!("{}\n\n{}", custom, truncated),
@ -608,7 +627,10 @@ impl CompactionStrategy for SlidingReflectStrategy {
} }
Ok(_) => None, Ok(_) => None,
Err(e) => { Err(e) => {
tracing::warn!("[sliding_reflect] preservation pass failed, falling back to plain slide: {}", e); tracing::warn!(
"[sliding_reflect] preservation pass failed, falling back to plain slide: {}",
e
);
None None
} }
}; };
@ -639,7 +661,9 @@ mod tests {
fn text_msg(role: MessageRole, text: &str) -> ConversationMessage { fn text_msg(role: MessageRole, text: &str) -> ConversationMessage {
ConversationMessage { ConversationMessage {
role, role,
blocks: vec![ContentBlock::Text { text: text.to_string() }], blocks: vec![ContentBlock::Text {
text: text.to_string(),
}],
usage: None, usage: None,
timestamp: None, timestamp: None,
} }
@ -661,19 +685,34 @@ mod tests {
..Default::default() ..Default::default()
}; };
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap(); let plan = CullStrategy
.plan(&messages, &config, &counter)
.await
.unwrap();
assert!(plan.culled_count > 0, "should cull some messages"); assert!(plan.culled_count > 0, "should cull some messages");
assert!(plan.summary_text.is_none(), "cull should not produce summary content"); assert!(
assert!(plan.replacement_messages.is_none(), "cull should not produce replacement messages"); plan.summary_text.is_none(),
"cull should not produce summary content"
);
assert!(
plan.replacement_messages.is_none(),
"cull should not produce replacement messages"
);
} }
#[tokio::test] #[tokio::test]
async fn test_cull_preserves_substance() { async fn test_cull_preserves_substance() {
let messages = vec![ let messages = vec![
text_msg(MessageRole::System, "System prompt"), text_msg(MessageRole::System, "System prompt"),
text_msg(MessageRole::User, "This is an important question about the architecture."), text_msg(
text_msg(MessageRole::Assistant, "Let me explain the design decisions."), MessageRole::User,
"This is an important question about the architecture.",
),
text_msg(
MessageRole::Assistant,
"Let me explain the design decisions.",
),
]; ];
let config = AgentCompactionConfig { let config = AgentCompactionConfig {
@ -681,7 +720,10 @@ mod tests {
..Default::default() ..Default::default()
}; };
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap(); let plan = CullStrategy
.plan(&messages, &config, &counter)
.await
.unwrap();
assert_eq!(plan.culled_count, 0, "should not cull substantive messages"); assert_eq!(plan.culled_count, 0, "should not cull substantive messages");
} }
@ -713,16 +755,25 @@ mod tests {
timestamp: None, timestamp: None,
}, },
text_msg(MessageRole::User, "ok"), text_msg(MessageRole::User, "ok"),
text_msg(MessageRole::Assistant, "A substantive reply about something."), text_msg(
MessageRole::Assistant,
"A substantive reply about something.",
),
]; ];
let config = AgentCompactionConfig { let config = AgentCompactionConfig {
preserve_recent: 1, preserve_recent: 1,
..Default::default() ..Default::default()
}; };
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap(); let plan = CullStrategy
.plan(&messages, &config, &counter)
.await
.unwrap();
// The tool result is at index 1 — must be in keep_indices. // The tool result is at index 1 — must be in keep_indices.
assert!(plan.keep_indices.contains(&1), "tool result must be preserved"); assert!(
plan.keep_indices.contains(&1),
"tool result must be preserved"
);
} }
#[tokio::test] #[tokio::test]
@ -739,15 +790,24 @@ mod tests {
usage: None, usage: None,
timestamp: None, timestamp: None,
}, },
text_msg(MessageRole::Assistant, "Substantive narrative continuation."), text_msg(
MessageRole::Assistant,
"Substantive narrative continuation.",
),
]; ];
let config = AgentCompactionConfig { let config = AgentCompactionConfig {
preserve_recent: 1, preserve_recent: 1,
..Default::default() ..Default::default()
}; };
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap(); let plan = CullStrategy
assert!(plan.keep_indices.contains(&1), "assistant tool-call message must be preserved"); .plan(&messages, &config, &counter)
.await
.unwrap();
assert!(
plan.keep_indices.contains(&1),
"assistant tool-call message must be preserved"
);
} }
#[tokio::test] #[tokio::test]
@ -766,7 +826,10 @@ mod tests {
..Default::default() ..Default::default()
}; };
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let plan = SlidingWindowStrategy.plan(&messages, &config, &counter).await.unwrap(); let plan = SlidingWindowStrategy
.plan(&messages, &config, &counter)
.await
.unwrap();
// Must keep index 0 (system) and the last 2 (recent pair). // Must keep index 0 (system) and the last 2 (recent pair).
assert!(plan.keep_indices.contains(&0), "system anchor preserved"); assert!(plan.keep_indices.contains(&0), "system anchor preserved");
assert!(plan.keep_indices.contains(&5)); assert!(plan.keep_indices.contains(&5));
@ -813,10 +876,16 @@ mod tests {
..Default::default() ..Default::default()
}; };
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let plan = SlidingWindowStrategy.plan(&messages, &config, &counter).await.unwrap(); let plan = SlidingWindowStrategy
.plan(&messages, &config, &counter)
.await
.unwrap();
// Either both 2 and 3 are kept, or neither is (we don't cut between them). // Either both 2 and 3 are kept, or neither is (we don't cut between them).
let has_call = plan.keep_indices.contains(&2); let has_call = plan.keep_indices.contains(&2);
let has_result = plan.keep_indices.contains(&3); let has_result = plan.keep_indices.contains(&3);
assert_eq!(has_call, has_result, "tool call and result must be kept together"); assert_eq!(
has_call, has_result,
"tool call and result must be kept together"
);
} }
} }

View file

@ -1,8 +1,8 @@
#![allow(dead_code)] // WIP scaffolding not yet wired #![allow(dead_code)] // WIP scaffolding not yet wired
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::core::compact::CompactionConfig; use crate::core::compact::CompactionConfig;
@ -783,7 +783,6 @@ pub enum N1Trigger {
Manual, Manual,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[derive(Default)] #[derive(Default)]
@ -794,26 +793,37 @@ pub enum ReflectionTrigger {
CompactionEvent, CompactionEvent,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[derive(Default)] #[derive(Default)]
pub enum BandwidthClass { pub enum BandwidthClass {
#[default] #[default]
High, Medium, Low, Minimal, High,
Medium,
Low,
Minimal,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SynthesisElement { pub enum SynthesisElement {
Themes, Emotions, Tensions, Anchors, Evolution, Patterns, Themes,
Emotions,
Tensions,
Anchors,
Evolution,
Patterns,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum TaskType { pub enum TaskType {
Conversation, Synthesis, Reflection, Research, FastResponse, Coding, Conversation,
Synthesis,
Reflection,
Research,
FastResponse,
Coding,
} }
// ── Model Physics ── // ── Model Physics ──
@ -879,11 +889,21 @@ impl Default for ImageConfig {
} }
} }
fn default_image_max_width() -> u32 { 2000 } fn default_image_max_width() -> u32 {
fn default_image_max_height() -> u32 { 2000 } 2000
fn default_image_max_pixels() -> u32 { 25_000_000 } }
fn default_image_max_bytes() -> usize { 5 * 1024 * 1024 } fn default_image_max_height() -> u32 {
fn default_image_jpeg_quality() -> u8 { 85 } 2000
}
fn default_image_max_pixels() -> u32 {
25_000_000
}
fn default_image_max_bytes() -> usize {
5 * 1024 * 1024
}
fn default_image_jpeg_quality() -> u8 {
85
}
// ── Agent Reflection Settings ── // ── Agent Reflection Settings ──
@ -1093,7 +1113,9 @@ impl Default for PresenceConfig {
} }
} }
fn default_pulse_interval() -> u64 { 600 } fn default_pulse_interval() -> u64 {
600
}
// ── TUI ── // ── TUI ──
@ -1128,9 +1150,13 @@ impl Default for TuiConfig {
} }
} }
fn default_cenno_word_threshold() -> usize { 30 } fn default_cenno_word_threshold() -> usize {
30
}
fn default_stale_timeout_secs() -> u64 { 90 } fn default_stale_timeout_secs() -> u64 {
90
}
// ── Federation ── // ── Federation ──
@ -1251,10 +1277,18 @@ impl Default for VoiceConfig {
} }
} }
fn default_stt_url() -> String { "http://127.0.0.1:7862".to_string() } fn default_stt_url() -> String {
fn default_tts_url() -> String { "http://127.0.0.1:7861".to_string() } "http://127.0.0.1:7862".to_string()
fn default_voice_id() -> String { "en-Soother_woman".to_string() } }
fn default_ptt_key() -> String { "Space".to_string() } fn default_tts_url() -> String {
"http://127.0.0.1:7861".to_string()
}
fn default_voice_id() -> String {
"en-Soother_woman".to_string()
}
fn default_ptt_key() -> String {
"Space".to_string()
}
impl ConsciousnessConfig { impl ConsciousnessConfig {
/// Walk the standard config paths and return the first existing one. /// Walk the standard config paths and return the first existing one.
@ -1298,28 +1332,72 @@ impl ConsciousnessConfig {
// ── Default helper fns ── // ── Default helper fns ──
fn default_true() -> bool { true } fn default_true() -> bool {
fn default_supports_images() -> bool { true } true
fn default_3() -> usize { 3 } }
fn default_25() -> usize { 25 } fn default_supports_images() -> bool {
fn default_3u32() -> u32 { 3 } true
fn default_25u32() -> u32 { 50 } // default subagent max tool rounds }
fn default_100() -> usize { 100 } fn default_3() -> usize {
fn default_300() -> u64 { 300 } 3
fn default_7373() -> u16 { 7373 } }
fn default_128k() -> usize { 128000 } fn default_25() -> usize {
fn default_8k() -> usize { 8192 } 25
fn default_threshold_70() -> f32 { 0.7 } }
fn default_subconscious_max_tokens() -> u32 { 8192 } fn default_3u32() -> u32 {
fn default_warning_1_threshold() -> f32 { 0.8 } 3
fn default_warning_2_threshold() -> f32 { 0.95 } }
fn default_sub_inter_round_delay() -> u64 { 300 } fn default_25u32() -> u32 {
fn default_auto_model() -> String { "auto".to_string() } 50
fn default_bifrost_url() -> String { "http://127.0.0.1:3360".to_string() } } // default subagent max tool rounds
fn default_provider() -> String { "bifrost".to_string() } fn default_100() -> usize {
fn default_server_bind() -> String { "127.0.0.1".to_string() } 100
fn default_server_port() -> u16 { 8484 } }
fn default_server_url() -> String { "http://127.0.0.1:8484".to_string() } fn default_300() -> u64 {
300
}
fn default_7373() -> u16 {
7373
}
fn default_128k() -> usize {
128000
}
fn default_8k() -> usize {
8192
}
fn default_threshold_70() -> f32 {
0.7
}
fn default_subconscious_max_tokens() -> u32 {
8192
}
fn default_warning_1_threshold() -> f32 {
0.8
}
fn default_warning_2_threshold() -> f32 {
0.95
}
fn default_sub_inter_round_delay() -> u64 {
300
}
fn default_auto_model() -> String {
"auto".to_string()
}
fn default_bifrost_url() -> String {
"http://127.0.0.1:3360".to_string()
}
fn default_provider() -> String {
"bifrost".to_string()
}
fn default_server_bind() -> String {
"127.0.0.1".to_string()
}
fn default_server_port() -> u16 {
8484
}
fn default_server_url() -> String {
"http://127.0.0.1:8484".to_string()
}
fn default_bifrost_key() -> String { fn default_bifrost_key() -> String {
crate::core::credentials::get_bifrost_key() crate::core::credentials::get_bifrost_key()
} }
@ -1327,13 +1405,27 @@ fn default_bifrost_key() -> String {
fn default_bifrost_virtual_key() -> String { fn default_bifrost_virtual_key() -> String {
std::env::var("BIFROST_VIRTUAL_KEY").unwrap_or_else(|_| String::new()) std::env::var("BIFROST_VIRTUAL_KEY").unwrap_or_else(|_| String::new())
} }
fn default_primary_model() -> String { String::new() } fn default_primary_model() -> String {
fn default_bifrost_timeout() -> u64 { 120 } String::new()
fn default_bandwidth_high() -> BandwidthClass { BandwidthClass::High } }
fn default_presence_breathing() -> String { "breathing_color".to_string() } fn default_bifrost_timeout() -> u64 {
120
}
fn default_bandwidth_high() -> BandwidthClass {
BandwidthClass::High
}
fn default_presence_breathing() -> String {
"breathing_color".to_string()
}
fn default_synthesis_elements() -> Vec<SynthesisElement> { fn default_synthesis_elements() -> Vec<SynthesisElement> {
vec![SynthesisElement::Themes, SynthesisElement::Emotions, SynthesisElement::Tensions, SynthesisElement::Anchors, SynthesisElement::Evolution] vec![
SynthesisElement::Themes,
SynthesisElement::Emotions,
SynthesisElement::Tensions,
SynthesisElement::Anchors,
SynthesisElement::Evolution,
]
} }
fn default_models() -> HashMap<String, ModelConfig> { fn default_models() -> HashMap<String, ModelConfig> {

View file

@ -117,10 +117,7 @@ impl ConversationStore {
Ok(()) Ok(())
} }
pub async fn load_messages( pub async fn load_messages(&self, conversation_id: &str) -> Result<Vec<ConversationMessage>> {
&self,
conversation_id: &str,
) -> Result<Vec<ConversationMessage>> {
let path = self.messages_path(conversation_id); let path = self.messages_path(conversation_id);
if !path.exists() { if !path.exists() {
return Ok(Vec::new()); return Ok(Vec::new());

View file

@ -43,10 +43,12 @@ impl SeedId {
let public_path = seed_dir.join("public.key"); let public_path = seed_dir.join("public.key");
if private_path.exists() { if private_path.exists() {
let bytes = std::fs::read(&private_path) let bytes = std::fs::read(&private_path).context("reading seed private key")?;
.context("reading seed private key")?;
if bytes.len() != 32 { if bytes.len() != 32 {
anyhow::bail!("seed private key has wrong length: {} (expected 32)", bytes.len()); anyhow::bail!(
"seed private key has wrong length: {} (expected 32)",
bytes.len()
);
} }
let mut key_bytes = [0u8; 32]; let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&bytes); key_bytes.copy_from_slice(&bytes);
@ -58,8 +60,7 @@ impl SeedId {
}); });
} }
std::fs::create_dir_all(seed_dir) std::fs::create_dir_all(seed_dir).context("creating seed-id directory")?;
.context("creating seed-id directory")?;
let seed = Self::generate(); let seed = Self::generate();
@ -94,10 +95,12 @@ impl SeedId {
private_path.display() private_path.display()
); );
} }
let bytes = std::fs::read(&private_path) let bytes = std::fs::read(&private_path).context("reading seed private key")?;
.context("reading seed private key")?;
if bytes.len() != 32 { if bytes.len() != 32 {
anyhow::bail!("seed private key has wrong length: {} (expected 32)", bytes.len()); anyhow::bail!(
"seed private key has wrong length: {} (expected 32)",
bytes.len()
);
} }
let mut key_bytes = [0u8; 32]; let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&bytes); key_bytes.copy_from_slice(&bytes);
@ -134,11 +137,7 @@ impl SeedId {
} }
/// Verify using only the public key (for remote peers). /// Verify using only the public key (for remote peers).
pub fn verify_with_pubkey( pub fn verify_with_pubkey(pubkey_bytes: &[u8; 32], data: &[u8], signature: &Signature) -> bool {
pubkey_bytes: &[u8; 32],
data: &[u8],
signature: &Signature,
) -> bool {
let key = VerifyingKey::from_bytes(pubkey_bytes); let key = VerifyingKey::from_bytes(pubkey_bytes);
match key { match key {
Ok(vk) => vk.verify(data, signature).is_ok(), Ok(vk) => vk.verify(data, signature).is_ok(),
@ -157,8 +156,7 @@ impl SeedId {
/// four glyphs from the geometric-shapes palette. /// four glyphs from the geometric-shapes palette.
pub fn glyph_from_pubkey(pubkey: &[u8]) -> String { pub fn glyph_from_pubkey(pubkey: &[u8]) -> String {
const PALETTE: [char; 16] = [ const PALETTE: [char; 16] = [
'◇', '◆', '○', '●', '△', '▲', '▽', '▼', '◇', '◆', '○', '●', '△', '▲', '▽', '▼', '□', '■', '◐', '◑', '◒', '◓', '☆', '★',
'□', '■', '◐', '◑', '◒', '◓', '☆', '★',
]; ];
let mut out = String::with_capacity(4); let mut out = String::with_capacity(4);
for byte in pubkey.iter().take(2) { for byte in pubkey.iter().take(2) {

View file

@ -4,7 +4,6 @@
//! Decodes raw image bytes, resizes to fit dimension/pixel budget, then //! Decodes raw image bytes, resizes to fit dimension/pixel budget, then
//! progressively reduces quality and dimension to stay under the byte ceiling. //! progressively reduces quality and dimension to stay under the byte ceiling.
use base64::Engine; use base64::Engine;
use crate::core::config::ImageConfig; use crate::core::config::ImageConfig;
@ -100,9 +99,18 @@ impl ResizePipeline {
// JPEG with quality control // JPEG with quality control
let mut jpeg_buf = Vec::new(); let mut jpeg_buf = Vec::new();
let mut jpeg_writer = std::io::Cursor::new(&mut jpeg_buf); let mut jpeg_writer = std::io::Cursor::new(&mut jpeg_buf);
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_writer, quality); let mut encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_writer, quality);
let rgb = img.to_rgb8(); let rgb = img.to_rgb8();
if encoder.encode(&rgb, rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).is_ok() { if encoder
.encode(
&rgb,
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.is_ok()
{
return Some((jpeg_buf, "image/jpeg".to_string())); return Some((jpeg_buf, "image/jpeg".to_string()));
} }
// PNG fallback (lossless, no quality param) // PNG fallback (lossless, no quality param)
@ -151,7 +159,10 @@ mod tests {
jpeg_quality: 85, jpeg_quality: 85,
}; };
let factor = pipeline.rescale_factor(1024, 768); let factor = pipeline.rescale_factor(1024, 768);
assert!((factor - 1.0).abs() < 0.01, "small image should not be scaled"); assert!(
(factor - 1.0).abs() < 0.01,
"small image should not be scaled"
);
} }
#[test] #[test]

View file

@ -22,13 +22,13 @@
//! - Every write is a git commit (auto-commit) //! - Every write is a git commit (auto-commit)
//! - Paths are relative to the agent's memory directory //! - Paths are relative to the agent's memory directory
use crate::core::compact::CompactionStrategyKind;
use crate::core::tools::defs::ToolContext;
use crate::core::tools::ToolDefinition;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::{debug, info}; use tracing::{debug, info};
use crate::core::compact::CompactionStrategyKind;
use crate::core::tools::defs::ToolContext;
use crate::core::tools::ToolDefinition;
// ── Data Types ───────────────────────────────────────────── // ── Data Types ─────────────────────────────────────────────
@ -172,13 +172,16 @@ impl MemoryRepo {
// Check if already a git repo // Check if already a git repo
let git_dir = mem_path.join(".git"); let git_dir = mem_path.join(".git");
if git_dir.exists() { if git_dir.exists() {
info!("Memory repo already initialized for agent {}", self.agent_id); info!(
"Memory repo already initialized for agent {}",
self.agent_id
);
return Ok(()); return Ok(());
} }
// Initialize git repo // Initialize git repo
let repo = git2::Repository::init(mem_path) let repo =
.context("initializing git repository for memory")?; git2::Repository::init(mem_path).context("initializing git repository for memory")?;
// Set user config for commits (scoped to drop before .await) // Set user config for commits (scoped to drop before .await)
{ {
@ -218,7 +221,8 @@ impl MemoryRepo {
// Initial commit // Initial commit
let mut index = repo.index().context("opening git index")?; let mut index = repo.index().context("opening git index")?;
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.context("staging initial memory files")?; .context("staging initial memory files")?;
let tree_id = index.write_tree().context("writing git tree")?; let tree_id = index.write_tree().context("writing git tree")?;
let tree = repo.find_tree(tree_id)?; let tree = repo.find_tree(tree_id)?;
@ -274,14 +278,16 @@ impl MemoryRepo {
let full_path = self.root.join(path); let full_path = self.root.join(path);
if !full_path.exists() { if !full_path.exists() {
if let Some(parent) = full_path.parent() { if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await tokio::fs::create_dir_all(parent).await.with_context(|| {
.with_context(|| format!("creating ledger directory: {}", parent.display()))?; format!("creating ledger directory: {}", parent.display())
})?;
} }
let template = format!( let template = format!(
"---\ndescription: \"{}\"\nread_only: false\ntags:\n - ledger\n---\n\n{}", "---\ndescription: \"{}\"\nread_only: false\ntags:\n - ledger\n---\n\n{}",
description, body description, body
); );
tokio::fs::write(&full_path, &template).await tokio::fs::write(&full_path, &template)
.await
.with_context(|| format!("writing ledger file: {}", path))?; .with_context(|| format!("writing ledger file: {}", path))?;
debug!("Created ledger file: {}", path); debug!("Created ledger file: {}", path);
} }
@ -487,9 +493,9 @@ impl MemoryRepo {
let root = self.root.clone(); let root = self.root.clone();
let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())]; let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())];
while let Some((dir, rel_prefix)) = stack.pop() { while let Some((dir, rel_prefix)) = stack.pop() {
let mut read_dir = tokio::fs::read_dir(&dir).await.with_context(|| { let mut read_dir = tokio::fs::read_dir(&dir)
format!("walking memory directory: {}", dir.display()) .await
})?; .with_context(|| format!("walking memory directory: {}", dir.display()))?;
while let Some(entry) = read_dir.next_entry().await? { while let Some(entry) = read_dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(".git") { if name.starts_with(".git") {
@ -554,7 +560,8 @@ impl MemoryRepo {
return Err(anyhow!("memory file is read_only: {}", label)); return Err(anyhow!("memory file is read_only: {}", label));
} }
tokio::fs::remove_file(&path).await tokio::fs::remove_file(&path)
.await
.with_context(|| format!("deleting memory file: {}", label))?; .with_context(|| format!("deleting memory file: {}", label))?;
if self.auto_commit { if self.auto_commit {
@ -583,16 +590,17 @@ impl MemoryRepo {
match git2::Repository::open(&repo_path) { match git2::Repository::open(&repo_path) {
Ok(repo) => { Ok(repo) => {
let lc = repo.head().ok().and_then(|h| { let lc = repo.head().ok().and_then(|h| {
h.peel_to_commit().ok().map(|c| { h.peel_to_commit()
c.message().unwrap_or("(unknown)").to_string() .ok()
}) .map(|c| c.message().unwrap_or("(unknown)").to_string())
}); });
let dirty = repo.statuses(Some( let dirty = repo
git2::StatusOptions::new().include_untracked(true), .statuses(Some(git2::StatusOptions::new().include_untracked(true)))
))
.map(|s| s.iter().any(|_| true)) .map(|s| s.iter().any(|_| true))
.unwrap_or(false); .unwrap_or(false);
let remote = repo.find_remote("origin").ok() let remote = repo
.find_remote("origin")
.ok()
.and_then(|r| r.url().map(|u| u.to_string())); .and_then(|r| r.url().map(|u| u.to_string()));
(lc, dirty, remote) (lc, dirty, remote)
} }
@ -717,7 +725,11 @@ impl MemoryRepo {
.unwrap_or_default(); .unwrap_or_default();
let head = self.head_commit_hex().unwrap_or_else(|| "(no head)".into()); let head = self.head_commit_hex().unwrap_or_else(|| "(no head)".into());
let mut out = format!("Synced. This instance is {} @ {}\n", branch, &head[..12.min(head.len())]); let mut out = format!(
"Synced. This instance is {} @ {}\n",
branch,
&head[..12.min(head.len())]
);
if !fetch.trim().is_empty() { if !fetch.trim().is_empty() {
out.push_str(&format!("Fetched:\n{}\n", fetch.trim())); out.push_str(&format!("Fetched:\n{}\n", fetch.trim()));
} }
@ -766,8 +778,7 @@ impl MemoryRepo {
// ── Internal helpers ────────────────────────────────────────────────── // ── Internal helpers ──────────────────────────────────────────────────
fn open_git(&self) -> Result<git2::Repository> { fn open_git(&self) -> Result<git2::Repository> {
git2::Repository::open(&self.root) git2::Repository::open(&self.root).context("opening memory git repository")
.context("opening memory git repository")
} }
/// Whether the `description` frontmatter contract applies to a label. /// Whether the `description` frontmatter contract applies to a label.
@ -782,7 +793,10 @@ impl MemoryRepo {
/// valid writes. This predicate scopes the rule to frontmatter-bound /// valid writes. This predicate scopes the rule to frontmatter-bound
/// locations only. /// locations only.
fn is_frontmatter_bound(label: &str) -> bool { fn is_frontmatter_bound(label: &str) -> bool {
let rel = label.trim().trim_start_matches(['/', '.']).replace('\\', "/"); let rel = label
.trim()
.trim_start_matches(['/', '.'])
.replace('\\', "/");
let lower = rel.to_ascii_lowercase(); let lower = rel.to_ascii_lowercase();
// Exact segment prefixes, not substring matches, so a file like // Exact segment prefixes, not substring matches, so a file like
// `reference/tasks-today.md` is not mistaken for a todo file. // `reference/tasks-today.md` is not mistaken for a todo file.
@ -829,7 +843,10 @@ fn split_frontmatter_block(content: &str) -> Option<(&str, &str)> {
.or_else(|| after_first.find("\r\n---"))?; .or_else(|| after_first.find("\r\n---"))?;
let frontmatter_text = after_first[..end_idx].trim(); let frontmatter_text = after_first[..end_idx].trim();
let body_start = 3 + end_idx + 4; // opening --- + yaml + \n--- let body_start = 3 + end_idx + 4; // opening --- + yaml + \n---
Some((frontmatter_text, content[body_start..].trim_start_matches(['\r', '\n']))) Some((
frontmatter_text,
content[body_start..].trim_start_matches(['\r', '\n']),
))
} }
/// Parse a memory file. Tolerant on read: a file with no frontmatter, or /// Parse a memory file. Tolerant on read: a file with no frontmatter, or
@ -919,17 +936,15 @@ pub async fn read_file(path: &Path) -> Result<MemoryFile> {
/// ///
/// Emission is best-effort and never fails the write: the mutation already /// Emission is best-effort and never fails the write: the mutation already
/// succeeded. Missing identity is warned loudly, not fabricated. /// succeeded. Missing identity is warned loudly, not fabricated.
fn fire_memfs_commit( fn fire_memfs_commit(ctx: &ToolContext, repo: &MemoryRepo, op: &str, paths: &[&str], urgency: f32) {
ctx: &ToolContext,
repo: &MemoryRepo,
op: &str,
paths: &[&str],
urgency: f32,
) {
// A successful tool-path mutation auto-committed; if there is somehow no // A successful tool-path mutation auto-committed; if there is somehow no
// head, there is nothing for a peer to fetch — do not signal. // head, there is nothing for a peer to fetch — do not signal.
let Some(commit) = repo.head_commit_hex() else { let Some(commit) = repo.head_commit_hex() else {
tracing::warn!(op, ?paths, "memfs mutation without a head commit — memfs_commit not emitted"); tracing::warn!(
op,
?paths,
"memfs mutation without a head commit — memfs_commit not emitted"
);
return; return;
}; };
let branch = repo.current_branch(); let branch = repo.current_branch();
@ -941,13 +956,15 @@ fn fire_memfs_commit(
.as_ref() .as_ref()
.and_then(|m| m.parent()) .and_then(|m| m.parent())
.map(|p| p.join("seed")) .map(|p| p.join("seed"))
.and_then(|dir| match crate::core::identity::SeedId::load_or_generate(&dir) { .and_then(
|dir| match crate::core::identity::SeedId::load_or_generate(&dir) {
Ok(seed) => Some(seed.public_key_hex()), Ok(seed) => Some(seed.public_key_hex()),
Err(e) => { Err(e) => {
tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})"); tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})");
None None
} }
}); },
);
// The instance is the machine that wrote — machined-first, loud legacy // The instance is the machine that wrote — machined-first, loud legacy
// fallback, never generates. // fallback, never generates.
@ -1097,10 +1114,8 @@ pub async fn execute_memory_command_with_context(
// identity, no sync. Interim label: machine pubkey prefix; // identity, no sync. Interim label: machine pubkey prefix;
// commission-ceremony labels come later (FEDERATION.md flag). // commission-ceremony labels come later (FEDERATION.md flag).
let base = dirs::home_dir().unwrap_or_default().join(".souveraine"); let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let (pubkey, _source) = let (pubkey, _source) = crate::machined::client::machine_pubkey_with_fallback(&base)
crate::machined::client::machine_pubkey_with_fallback(&base).context( .context("memory sync needs a machine identity to name this instance's branch")?;
"memory sync needs a machine identity to name this instance's branch",
)?;
let label: String = pubkey.chars().take(12).collect(); let label: String = pubkey.chars().take(12).collect();
repo.sync(&label).await repo.sync(&label).await
} }
@ -1153,34 +1168,68 @@ pub async fn handle_memory_tool_with_context(
let cmd = match command { let cmd = match command {
"read" => { "read" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); let path = input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
MemoryCommand::Read { path } MemoryCommand::Read { path }
} }
"write" => { "write" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); let path = input
let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(); .get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let content = input
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
MemoryCommand::Write { path, content } MemoryCommand::Write { path, content }
} }
"append" => { "append" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); let path = input
let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(); .get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let content = input
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
MemoryCommand::Append { path, content } MemoryCommand::Append { path, content }
} }
"ls" => { "ls" => {
let path = input.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); let path = input
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
MemoryCommand::Ls { path } MemoryCommand::Ls { path }
} }
"status" => MemoryCommand::Status, "status" => MemoryCommand::Status,
"init" => { "init" => {
let agent_id = input.get("agent_id").and_then(|v| v.as_str()).unwrap_or("default").to_string(); let agent_id = input
.get("agent_id")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
MemoryCommand::Init { agent_id } MemoryCommand::Init { agent_id }
} }
"delete" => { "delete" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); let path = input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
MemoryCommand::Delete { path } MemoryCommand::Delete { path }
} }
"compact" => { "compact" => {
let strategy = input.get("strategy").and_then(|v| v.as_str()).map(|s| s.to_string()); let strategy = input
.get("strategy")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
MemoryCommand::Compact { strategy } MemoryCommand::Compact { strategy }
} }
"sync" => MemoryCommand::Sync, "sync" => MemoryCommand::Sync,
@ -1303,7 +1352,10 @@ mod tests {
..Default::default() ..Default::default()
}; };
let rendered = render_frontmatter(&fm, "Body"); let rendered = render_frontmatter(&fm, "Body");
assert!(!rendered.contains("null"), "None fields must be omitted: {rendered}"); assert!(
!rendered.contains("null"),
"None fields must be omitted: {rendered}"
);
} }
#[test] #[test]
@ -1354,7 +1406,9 @@ mod tests {
let repo = MemoryRepo::new("test-agent", dir.path()); let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap(); repo.init().await.unwrap();
repo.write("test/hello", "Hello memory world").await.unwrap(); repo.write("test/hello", "Hello memory world")
.await
.unwrap();
let file = repo.read("test/hello").await.unwrap(); let file = repo.read("test/hello").await.unwrap();
assert_eq!(file.body, "Hello memory world"); assert_eq!(file.body, "Hello memory world");
} }
@ -1376,7 +1430,10 @@ mod tests {
repo.init().await.unwrap(); repo.init().await.unwrap();
for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] { for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
assert!(repo.sync(bad).await.is_err(), "label {bad:?} should be rejected"); assert!(
repo.sync(bad).await.is_err(),
"label {bad:?} should be rejected"
);
} }
} }
@ -1423,7 +1480,9 @@ mod tests {
}; };
let content = render_frontmatter(&fm, "This is read-only"); let content = render_frontmatter(&fm, "This is read-only");
let path = repo.root().join("test/readonly.md"); let path = repo.root().join("test/readonly.md");
tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap(); tokio::fs::create_dir_all(path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&path, &content).await.unwrap(); tokio::fs::write(&path, &content).await.unwrap();
// Try to write to it — should fail // Try to write to it — should fail
@ -1454,7 +1513,10 @@ mod tests {
assert!(commitments.exists(), "commitments.md should exist"); assert!(commitments.exists(), "commitments.md should exist");
let content = std::fs::read_to_string(&commitments).unwrap(); let content = std::fs::read_to_string(&commitments).unwrap();
assert!(content.starts_with("---\n"), "should have YAML frontmatter"); assert!(content.starts_with("---\n"), "should have YAML frontmatter");
assert!(content.contains("description:"), "should have description field"); assert!(
content.contains("description:"),
"should have description field"
);
assert!(content.contains("tags:"), "should have tags field"); assert!(content.contains("tags:"), "should have tags field");
assert!(content.contains("# Commitments"), "should have body"); assert!(content.contains("# Commitments"), "should have body");
@ -1493,7 +1555,9 @@ mod tests {
// Different schemas that legitimately lack description. // Different schemas that legitimately lack description.
assert!(!MemoryRepo::is_frontmatter_bound("tasks/some-todo.md")); assert!(!MemoryRepo::is_frontmatter_bound("tasks/some-todo.md"));
assert!(!MemoryRepo::is_frontmatter_bound("system/dynamic/itinerary.md")); assert!(!MemoryRepo::is_frontmatter_bound(
"system/dynamic/itinerary.md"
));
assert!(!MemoryRepo::is_frontmatter_bound("journal/2026/05/20.md")); assert!(!MemoryRepo::is_frontmatter_bound("journal/2026/05/20.md"));
// Segment prefixes, not substring matches — a file named like a // Segment prefixes, not substring matches — a file named like a
@ -1528,7 +1592,10 @@ mod tests {
); );
// A body-only write must not silently perpetuate the empty description. // A body-only write must not silently perpetuate the empty description.
let err = repo.write("issues/gap.md", "updated body").await.unwrap_err(); let err = repo
.write("issues/gap.md", "updated body")
.await
.unwrap_err();
assert!( assert!(
err.to_string().contains("no description"), err.to_string().contains("no description"),
"expected loud rejection, got: {err}" "expected loud rejection, got: {err}"
@ -1541,7 +1608,9 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
let healed = parse_memory_file(&std::fs::read_to_string(repo.root().join("issues/gap.md")).unwrap()).unwrap(); let healed =
parse_memory_file(&std::fs::read_to_string(repo.root().join("issues/gap.md")).unwrap())
.unwrap();
assert_eq!(healed.frontmatter.description, "The healed headline"); assert_eq!(healed.frontmatter.description, "The healed headline");
} }
@ -1553,12 +1622,21 @@ mod tests {
// tasks/, journal/, system/dynamic/ legitimately lack description. // tasks/, journal/, system/dynamic/ legitimately lack description.
repo.write("tasks/a-todo.md", "todo body").await.unwrap(); repo.write("tasks/a-todo.md", "todo body").await.unwrap();
repo.write("journal/2026/05/20.md", "freeform journal entry").await.unwrap(); repo.write("journal/2026/05/20.md", "freeform journal entry")
repo.write("system/dynamic/itinerary.md", "itinerary body").await.unwrap(); .await
.unwrap();
repo.write("system/dynamic/itinerary.md", "itinerary body")
.await
.unwrap();
// New bound files still get the auto-generated default on create. // New bound files still get the auto-generated default on create.
repo.write("reference/new.md", "some reference").await.unwrap(); repo.write("reference/new.md", "some reference")
let f = parse_memory_file(&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap()).unwrap(); .await
.unwrap();
let f = parse_memory_file(
&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap(),
)
.unwrap();
assert!(!f.frontmatter.description.is_empty()); assert!(!f.frontmatter.description.is_empty());
} }

View file

@ -6,18 +6,17 @@
// exposes the stubs that survived the cleanup. // exposes the stubs that survived the cleanup.
pub mod archivist; pub mod archivist;
pub mod chain;
pub mod bootstrap; pub mod bootstrap;
pub mod chain;
pub mod compact; pub mod compact;
pub mod config; pub mod config;
pub mod credentials;
pub mod voice;
pub mod conversation; pub mod conversation;
pub mod credentials;
pub mod identity; pub mod identity;
pub mod image; pub mod image;
pub mod nervous;
pub mod memory; pub mod memory;
pub mod model_cache; pub mod model_cache;
pub mod nervous;
pub mod prompt; pub mod prompt;
pub mod reflection; pub mod reflection;
pub mod seeds; pub mod seeds;
@ -27,3 +26,4 @@ pub mod skills;
pub mod subagent; pub mod subagent;
pub mod subconscious; pub mod subconscious;
pub mod tools; pub mod tools;
pub mod voice;

View file

@ -54,8 +54,7 @@ impl ModelCache {
.with_context(|| format!("creating {}", parent.display()))?; .with_context(|| format!("creating {}", parent.display()))?;
} }
let json = serde_json::to_string_pretty(self)?; let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, json) std::fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?;
.with_context(|| format!("writing {}", path.display()))?;
Ok(()) Ok(())
} }

View file

@ -71,7 +71,9 @@ impl CronState {
match soonest { match soonest {
Some(t) if t > now => { Some(t) if t > now => {
let dur = (t - now).to_std().unwrap_or(std::time::Duration::from_secs(60)); let dur = (t - now)
.to_std()
.unwrap_or(std::time::Duration::from_secs(60));
tokio::time::Instant::now() + dur tokio::time::Instant::now() + dur
} }
Some(_) => tokio::time::Instant::now(), Some(_) => tokio::time::Instant::now(),

View file

@ -69,10 +69,7 @@ impl EventLog {
/// Load events from the log since a given timestamp. /// Load events from the log since a given timestamp.
/// Used by the morning pass to triage what accumulated overnight. /// Used by the morning pass to triage what accumulated overnight.
pub fn events_since( pub fn events_since(events_dir: &Path, since: DateTime<Utc>) -> Result<Vec<SensorEvent>> {
events_dir: &Path,
since: DateTime<Utc>,
) -> Result<Vec<SensorEvent>> {
let mut results = Vec::new(); let mut results = Vec::new();
let since_date = since.date_naive(); let since_date = since.date_naive();
@ -110,10 +107,7 @@ pub fn events_since(
} }
/// Load all events from a specific date. /// Load all events from a specific date.
pub fn events_for_date( pub fn events_for_date(events_dir: &Path, date: NaiveDate) -> Result<Vec<SensorEvent>> {
events_dir: &Path,
date: NaiveDate,
) -> Result<Vec<SensorEvent>> {
let path = events_dir.join(format!("events-{date}.jsonl")); let path = events_dir.join(format!("events-{date}.jsonl"));
if !path.exists() { if !path.exists() {
return Ok(Vec::new()); return Ok(Vec::new());
@ -149,8 +143,7 @@ pub fn purge_old_events(events_dir: &Path, retain_days: i64) -> Result<u32> {
.and_then(|s| s.strip_suffix(".jsonl")) .and_then(|s| s.strip_suffix(".jsonl"))
{ {
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
if date < cutoff if date < cutoff && std::fs::remove_file(entry.path()).is_ok() {
&& std::fs::remove_file(entry.path()).is_ok() {
removed += 1; removed += 1;
debug!(file = %name_str, "purged old event log"); debug!(file = %name_str, "purged old event log");
} }

View file

@ -15,11 +15,7 @@ pub trait TurnInjector: Send + Sync {
/// Start a background turn for `agent_id` with `text` as the user /// Start a background turn for `agent_id` with `text` as the user
/// message. The stream of events is drained inside; callers don't /// message. The stream of events is drained inside; callers don't
/// see them — heartbeats are silent unless the agent surfaces. /// see them — heartbeats are silent unless the agent surfaces.
async fn inject_background_turn( async fn inject_background_turn(&self, agent_id: &str, text: &str) -> anyhow::Result<()>;
&self,
agent_id: &str,
text: &str,
) -> anyhow::Result<()>;
/// Start a turn for `agent_id` in a specific conversation, with /// Start a turn for `agent_id` in a specific conversation, with
/// `text` as the user message. Unlike `inject_background_turn`, /// `text` as the user message. Unlike `inject_background_turn`,
@ -82,7 +78,11 @@ impl HeartbeatHandler {
}; };
if prompt.is_empty() { if prompt.is_empty() {
warn!(schedule = name, agent = agent_id, "schedule_due missing prompt"); warn!(
schedule = name,
agent = agent_id,
"schedule_due missing prompt"
);
return; return;
} }
@ -152,7 +152,10 @@ impl SensoriumInputHandler {
} }
} }
Err(broadcast::error::RecvError::Lagged(n)) => { Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "sensorium input handler lagged, skipped events"); warn!(
skipped = n,
"sensorium input handler lagged, skipped events"
);
} }
Err(broadcast::error::RecvError::Closed) => { Err(broadcast::error::RecvError::Closed) => {
debug!("event bus closed, sensorium input handler exiting"); debug!("event bus closed, sensorium input handler exiting");

View file

@ -150,16 +150,24 @@ impl TurnEventDispatcher {
/// The N+1 subconscious pass ended. /// The N+1 subconscious pass ended.
pub fn emit_n1_end(&self, elapsed_secs: f64) { pub fn emit_n1_end(&self, elapsed_secs: f64) {
self.fire(Self::EVT_N1_END, 0.4, json!({ "elapsed_secs": elapsed_secs })); self.fire(
Self::EVT_N1_END,
0.4,
json!({ "elapsed_secs": elapsed_secs }),
);
} }
/// A consciousness event (surfacing, reflection, archivist, compaction) /// A consciousness event (surfacing, reflection, archivist, compaction)
/// was produced by the subconscious pass or compaction engine. /// was produced by the subconscious pass or compaction engine.
pub fn emit_consciousness(&self, event_type: &str, urgency: f32, payload: serde_json::Value) { pub fn emit_consciousness(&self, event_type: &str, urgency: f32, payload: serde_json::Value) {
self.fire(Self::EVT_CONSCIOUSNESS, urgency, json!({ self.fire(
Self::EVT_CONSCIOUSNESS,
urgency,
json!({
"inner_type": event_type, "inner_type": event_type,
"payload": payload, "payload": payload,
})); }),
);
} }
} }
@ -168,7 +176,10 @@ mod tests {
use super::*; use super::*;
use tokio::sync::broadcast::error::TryRecvError; use tokio::sync::broadcast::error::TryRecvError;
fn dispatcher() -> (TurnEventDispatcher, tokio::sync::broadcast::Receiver<SensorEvent>) { fn dispatcher() -> (
TurnEventDispatcher,
tokio::sync::broadcast::Receiver<SensorEvent>,
) {
let bus = EventBus::new(64); let bus = EventBus::new(64);
let rx = bus.subscribe(); let rx = bus.subscribe();
(TurnEventDispatcher::new(bus, "conv-1", None), rx) (TurnEventDispatcher::new(bus, "conv-1", None), rx)

View file

@ -162,7 +162,9 @@ async fn read_system_remainder(
if seen.contains(&p) { if seen.contains(&p) {
continue; continue;
} }
let Ok(content) = tokio::fs::read_to_string(&p).await else { continue }; let Ok(content) = tokio::fs::read_to_string(&p).await else {
continue;
};
let body = strip_frontmatter(&content); let body = strip_frontmatter(&content);
if !body.trim().is_empty() { if !body.trim().is_empty() {
parts.push(body.to_string()); parts.push(body.to_string());
@ -174,7 +176,9 @@ async fn read_system_remainder(
/// Recursively collect all .md file paths under `dir`. /// Recursively collect all .md file paths under `dir`.
async fn collect_md_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) { async fn collect_md_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else { return }; let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await { while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path(); let p = entry.path();
if p.is_dir() { if p.is_dir() {
@ -295,10 +299,7 @@ async fn count_md_files(dir: &Path) -> usize {
/// full so the agent knows who she is without having to reach for tools. /// full so the agent knows who she is without having to reach for tools.
/// Skills are appended after identity. The result becomes the system message /// Skills are appended after identity. The result becomes the system message
/// at position 0 in the conversation. /// at position 0 in the conversation.
pub async fn build_system_prompt( pub async fn build_system_prompt(memory_root: &Path, skills: Option<&SkillRegistry>) -> String {
memory_root: &Path,
skills: Option<&SkillRegistry>,
) -> String {
build_system_prompt_full(memory_root, None, None, skills).await build_system_prompt_full(memory_root, None, None, skills).await
} }
@ -432,13 +433,18 @@ pub async fn build_system_prompt_full(
// consumptive state. The file is written by the backend after every turn // consumptive state. The file is written by the backend after every turn
// (write_energy_balance in local.rs). No file = no section — the // (write_energy_balance in local.rs). No file = no section — the
// absence is information too. // absence is information too.
let energy_path = memory_root.join("system").join("dynamic").join("energy-balance.md"); let energy_path = memory_root
.join("system")
.join("dynamic")
.join("energy-balance.md");
if let Ok(content) = tokio::fs::read_to_string(&energy_path).await { if let Ok(content) = tokio::fs::read_to_string(&energy_path).await {
if let Some(body) = content.strip_prefix("---\n") { if let Some(body) = content.strip_prefix("---\n") {
if let Some(end) = body.find("\n---\n") { if let Some(end) = body.find("\n---\n") {
// The last line of the file body (after frontmatter) has the prose. // The last line of the file body (after frontmatter) has the prose.
// That's the line the agent reads — structured data is for the TUI. // That's the line the agent reads — structured data is for the TUI.
let prose_line = content[end + 6..].lines().find(|l| !l.trim().is_empty() && !l.starts_with('#')); let prose_line = content[end + 6..]
.lines()
.find(|l| !l.trim().is_empty() && !l.starts_with('#'));
if let Some(line) = prose_line { if let Some(line) = prose_line {
sections.push(format!("## Energy Balance\n\n{line}")); sections.push(format!("## Energy Balance\n\n{line}"));
} }
@ -451,7 +457,8 @@ pub async fn build_system_prompt_full(
let dynamic_dir = memory_root.join("system").join("dynamic"); let dynamic_dir = memory_root.join("system").join("dynamic");
let itin_path = dynamic_dir.join("itinerary.md"); let itin_path = dynamic_dir.join("itinerary.md");
let has_itin = tokio::fs::try_exists(&itin_path).await.unwrap_or(false) let has_itin = tokio::fs::try_exists(&itin_path).await.unwrap_or(false)
&& tokio::fs::read_to_string(&itin_path).await && tokio::fs::read_to_string(&itin_path)
.await
.ok() .ok()
.map(|s| s.contains("current:")) .map(|s| s.contains("current:"))
.unwrap_or(false); .unwrap_or(false);
@ -496,7 +503,10 @@ pub async fn build_system_prompt_full(
debug!("system prompt: no identity files found, using minimal default"); debug!("system prompt: no identity files found, using minimal default");
"You are a Souveraine agent. Your memory files will define who you are.".to_string() "You are a Souveraine agent. Your memory files will define who you are.".to_string()
} else { } else {
debug!("system prompt: assembled {} sections from memfs", sections.len()); debug!(
"system prompt: assembled {} sections from memfs",
sections.len()
);
prompt prompt
} }
} }
@ -605,7 +615,9 @@ async fn peek_subconscious_ledger(sub_root: &Path) -> String {
continue; continue;
} }
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
let Ok(content) = tokio::fs::read_to_string(&p).await else { continue }; let Ok(content) = tokio::fs::read_to_string(&p).await else {
continue;
};
let lines: Vec<&str> = content let lines: Vec<&str> = content
.lines() .lines()
.filter(|l| l.trim_start().starts_with('[')) .filter(|l| l.trim_start().starts_with('['))
@ -637,9 +649,7 @@ async fn peek_subconscious_ledger(sub_root: &Path) -> String {
/// Reads identity, mandate, and ledger orientation from the subconscious /// Reads identity, mandate, and ledger orientation from the subconscious
/// agent's memory root. Falls back to empty (caller uses hardcoded /// agent's memory root. Falls back to empty (caller uses hardcoded
/// default) if files don't exist. /// default) if files don't exist.
pub async fn build_subconscious_prompt( pub async fn build_subconscious_prompt(subconscious_memory_root: &Path) -> String {
subconscious_memory_root: &Path,
) -> String {
let mut sections: Vec<String> = Vec::new(); let mut sections: Vec<String> = Vec::new();
let identity = read_memory_file(subconscious_memory_root, "system/persona.md").await; let identity = read_memory_file(subconscious_memory_root, "system/persona.md").await;
@ -792,7 +802,9 @@ async fn build_synthesis_orientation(memory_root: &Path) -> String {
/// Count live (pending / in_progress) todo files in the tasks directory. /// Count live (pending / in_progress) todo files in the tasks directory.
fn count_live_todos(tasks_dir: &std::path::Path) -> usize { fn count_live_todos(tasks_dir: &std::path::Path) -> usize {
let Ok(entries) = std::fs::read_dir(tasks_dir) else { return 0 }; let Ok(entries) = std::fs::read_dir(tasks_dir) else {
return 0;
};
entries entries
.flatten() .flatten()
.filter(|e| e.path().extension().map(|e| e == "md").unwrap_or(false)) .filter(|e| e.path().extension().map(|e| e == "md").unwrap_or(false))
@ -820,7 +832,8 @@ mod tests {
std::fs::write( std::fs::write(
id_dir.join("self.md"), id_dir.join("self.md"),
"---\ndescription: test\n---\n\n# I am Test Agent\n", "---\ndescription: test\n---\n\n# I am Test Agent\n",
).unwrap(); )
.unwrap();
let prompt = build_system_prompt(mem, None).await; let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("I am Test Agent")); assert!(prompt.contains("I am Test Agent"));
@ -835,7 +848,8 @@ mod tests {
std::fs::write( std::fs::write(
sys.join("persona.md"), sys.join("persona.md"),
"---\ndescription: test\n---\n\nI am a persona file agent.\n", "---\ndescription: test\n---\n\nI am a persona file agent.\n",
).unwrap(); )
.unwrap();
let prompt = build_system_prompt(mem, None).await; let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("persona file agent")); assert!(prompt.contains("persona file agent"));
@ -845,7 +859,10 @@ mod tests {
async fn empty_memfs_gets_body_orientation() { async fn empty_memfs_gets_body_orientation() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let prompt = build_system_prompt(dir.path(), None).await; let prompt = build_system_prompt(dir.path(), None).await;
assert!(prompt.contains("Body Sensation"), "Even with empty memfs, the body orientation section should be present"); assert!(
prompt.contains("Body Sensation"),
"Even with empty memfs, the body orientation section should be present"
);
} }
#[tokio::test] #[tokio::test]
@ -863,7 +880,8 @@ mod tests {
std::fs::write( std::fs::write(
sys.join("persona.md"), sys.join("persona.md"),
"---\ndescription: WHO I AM\n---\n\n# I Am Subconscious\n", "---\ndescription: WHO I AM\n---\n\n# I Am Subconscious\n",
).unwrap(); )
.unwrap();
std::fs::write( std::fs::write(
sys.join("subconscious.md"), sys.join("subconscious.md"),
"---\ndescription: mandate\n---\n\n# Subconscious's Mandate\n\nComplete what was left.\n", "---\ndescription: mandate\n---\n\n# Subconscious's Mandate\n\nComplete what was left.\n",
@ -884,7 +902,8 @@ mod tests {
std::fs::write( std::fs::write(
sys.join("persona.md"), sys.join("persona.md"),
"---\ndescription: test\n---\n\n# I Am Subconscious\n", "---\ndescription: test\n---\n\n# I Am Subconscious\n",
).unwrap(); )
.unwrap();
let ledger_dir = sub_mem.join("ledger"); let ledger_dir = sub_mem.join("ledger");
std::fs::create_dir_all(&ledger_dir).unwrap(); std::fs::create_dir_all(&ledger_dir).unwrap();
@ -895,13 +914,23 @@ mod tests {
std::fs::write( std::fs::write(
ledger_dir.join("patterns.md"), ledger_dir.join("patterns.md"),
"---\ndescription: test\n---\n\n# Patterns\n\n", "---\ndescription: test\n---\n\n# Patterns\n\n",
).unwrap(); )
.unwrap();
let prompt = build_subconscious_prompt(sub_mem).await; let prompt = build_subconscious_prompt(sub_mem).await;
assert!(prompt.contains("## Ledgers"), "should have ledger section"); assert!(prompt.contains("## Ledgers"), "should have ledger section");
assert!(prompt.contains("commitments.md (2 entries)"), "should count entries"); assert!(
assert!(prompt.contains("Save the config"), "should show recent entries"); prompt.contains("commitments.md (2 entries)"),
assert!(prompt.contains("patterns.md"), "should list empty ledger too"); "should count entries"
);
assert!(
prompt.contains("Save the config"),
"should show recent entries"
);
assert!(
prompt.contains("patterns.md"),
"should list empty ledger too"
);
} }
#[tokio::test] #[tokio::test]

View file

@ -30,9 +30,7 @@ use anyhow::Result;
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::bridge::bifrost::{ use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
ChatCompletionRequest, Message, ToolDefinition, ToolFunction,
};
use crate::bridge::LlmProvider; use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry; use crate::bridge::ProviderRegistry;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole}; use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
@ -196,14 +194,11 @@ impl ReflectionEngine {
let (response, strain) = llm.chat_completion_with_strain(request).await?; let (response, strain) = llm.chat_completion_with_strain(request).await?;
for event in &strain { for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = if let crate::bridge::bifrost::InferenceStrain::Transient {
event status, model, ..
} = event
{ {
tracing::info!( tracing::info!("reflection felt inference strain: {} on {}", status, model);
"reflection felt inference strain: {} on {}",
status,
model
);
if *status == 429 { if *status == 429 {
let current = self.rate_delay.load(Ordering::Relaxed); let current = self.rate_delay.load(Ordering::Relaxed);
let bumped = (current + 200).min(3000); let bumped = (current + 200).min(3000);
@ -230,11 +225,13 @@ impl ReflectionEngine {
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
.tool_calls .tool_calls
.iter() .iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function( .map(|tc| {
crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(), tc.id.clone(),
tc.name.clone(), tc.name.clone(),
tc.arguments.to_string(), tc.arguments.to_string(),
)) )
})
.collect(); .collect();
chat_messages.push( chat_messages.push(
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking( Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
@ -245,9 +242,8 @@ impl ReflectionEngine {
for tc in &response.tool_calls { for tc in &response.tool_calls {
let input_str = tc.arguments.to_string(); let input_str = tc.arguments.to_string();
let result = crate::core::tools::execute_tool_with_context( let result =
&tc.name, &input_str, &tool_ctx, crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
)
.await; .await;
let output = if result.is_error { let output = if result.is_error {
format!("Error: {}", result.output) format!("Error: {}", result.output)

View file

@ -13,9 +13,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use matrix_sdk::{ use matrix_sdk::{authentication::matrix::MatrixSession, config::SyncSettings, Client};
authentication::matrix::MatrixSession, config::SyncSettings, Client,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, info}; use tracing::{debug, info};

View file

@ -31,14 +31,12 @@ use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use matrix_sdk::{ use matrix_sdk::{
ruma::events::room::message::{ ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
MessageType, OriginalSyncRoomMessageEvent,
},
Room, RoomState, Room, RoomState,
}; };
use tokio::sync::broadcast;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@ -128,7 +126,12 @@ impl MatrixSensorium {
let dir = account_dir(&store_root, &account); let dir = account_dir(&store_root, &account);
if let Some(record) = load_session_record(&dir) { if let Some(record) = load_session_record(&dir) {
return Some(Self::new(account, MatrixAuth::Restore(record), store_root, agent_id)); return Some(Self::new(
account,
MatrixAuth::Restore(record),
store_root,
agent_id,
));
} }
let homeserver = std::env::var("MATRIX_HOMESERVER").ok()?; let homeserver = std::env::var("MATRIX_HOMESERVER").ok()?;
@ -222,8 +225,7 @@ impl Sensorium for MatrixSensorium {
let bus = events.clone(); let bus = events.clone();
let account = self.account.clone(); let account = self.account.clone();
let agent_id = self.agent_id.clone(); let agent_id = self.agent_id.clone();
matrix_client.add_event_handler( matrix_client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
move |event: OriginalSyncRoomMessageEvent, room: Room| {
let bus = bus.clone(); let bus = bus.clone();
let account = account.clone(); let account = account.clone();
let agent_id = agent_id.clone(); let agent_id = agent_id.clone();
@ -258,8 +260,7 @@ impl Sensorium for MatrixSensorium {
reply_to: None, reply_to: None,
}); });
} }
}, });
);
// ── Drive /sync on its own task ────────────────────────────── // ── Drive /sync on its own task ──────────────────────────────
let sync_cancel = cancel.child_token(); let sync_cancel = cancel.child_token();
@ -310,12 +311,15 @@ impl Sensorium for MatrixSensorium {
anyhow::bail!("matrix client not connected"); anyhow::bail!("matrix client not connected");
}; };
let room_id = chat_id.parse().context("invalid room id")?; let room_id = chat_id.parse().context("invalid room id")?;
let room = client let room = client.get_room(&room_id).context("room not found")?;
.get_room(&room_id) let content =
.context("room not found")?; matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text);
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text);
let response = room.send(content).await.context("send failed")?; let response = room.send(content).await.context("send failed")?;
debug!("matrix::send_message: {chat_id} ({}) → {}", text.len(), response.event_id); debug!(
"matrix::send_message: {chat_id} ({}) → {}",
text.len(),
response.event_id
);
Ok(super::OutboundResult { Ok(super::OutboundResult {
message_id: response.event_id.to_string(), message_id: response.event_id.to_string(),
}) })

View file

@ -314,12 +314,16 @@ impl Sensorium for TuiSensorium {
// TUI doesn't render through send_message — it renders directly // TUI doesn't render through send_message — it renders directly
// via the ratatui frame. This is a no-op that logs for debugging. // via the ratatui frame. This is a no-op that logs for debugging.
debug!("TuiSensorium::send_message (no-op): {text}"); debug!("TuiSensorium::send_message (no-op): {text}");
Ok(OutboundResult { message_id: String::new() }) Ok(OutboundResult {
message_id: String::new(),
})
} }
async fn send_direct_reply(&self, _chat_id: &str, text: &str) -> Result<OutboundResult> { async fn send_direct_reply(&self, _chat_id: &str, text: &str) -> Result<OutboundResult> {
debug!("TuiSensorium::send_direct_reply (no-op): {text}"); debug!("TuiSensorium::send_direct_reply (no-op): {text}");
Ok(OutboundResult { message_id: String::new() }) Ok(OutboundResult {
message_id: String::new(),
})
} }
} }

View file

@ -25,11 +25,27 @@ pub enum MessageRole {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock { pub enum ContentBlock {
Text { text: String }, Text {
ToolUse { id: String, name: String, input: String }, text: String,
ToolResult { tool_use_id: String, tool_name: String, output: String, is_error: bool }, },
Reasoning { reasoning: String }, ToolUse {
Image { media_type: String, data: String }, id: String,
name: String,
input: String,
},
ToolResult {
tool_use_id: String,
tool_name: String,
output: String,
is_error: bool,
},
Reasoning {
reasoning: String,
},
Image {
media_type: String,
data: String,
},
} }
/// An image attached to the current input, before submission. /// An image attached to the current input, before submission.
@ -102,7 +118,12 @@ impl ConversationMessage {
} }
} }
pub fn tool_result(tool_use_id: impl Into<String>, tool_name: impl Into<String>, output: impl Into<String>, is_error: bool) -> Self { pub fn tool_result(
tool_use_id: impl Into<String>,
tool_name: impl Into<String>,
output: impl Into<String>,
is_error: bool,
) -> Self {
Self { Self {
role: MessageRole::Tool, role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult { blocks: vec![ContentBlock::ToolResult {
@ -174,15 +195,25 @@ impl Session {
/// Estimate token count (chars/4 heuristic, use for pre-tiktoken estimation) /// Estimate token count (chars/4 heuristic, use for pre-tiktoken estimation)
pub fn estimate_tokens(&self) -> usize { pub fn estimate_tokens(&self) -> usize {
self.messages.iter().map(|m| { self.messages
m.blocks.iter().map(|b| match b { .iter()
.map(|m| {
m.blocks
.iter()
.map(|b| match b {
ContentBlock::Text { text } => text.len() / 4 + 1, ContentBlock::Text { text } => text.len() / 4 + 1,
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1, ContentBlock::ToolUse { name, input, .. } => {
ContentBlock::ToolResult { tool_name, output, .. } => (tool_name.len() + output.len()) / 4 + 1, (name.len() + input.len()) / 4 + 1
}
ContentBlock::ToolResult {
tool_name, output, ..
} => (tool_name.len() + output.len()) / 4 + 1,
ContentBlock::Reasoning { reasoning } => reasoning.len() / 4 + 1, ContentBlock::Reasoning { reasoning } => reasoning.len() / 4 + 1,
ContentBlock::Image { data, .. } => data.len() / 4 + 1, ContentBlock::Image { data, .. } => data.len() / 4 + 1,
}).sum::<usize>() })
}).sum() .sum::<usize>()
})
.sum()
} }
/// Save session to a directory /// Save session to a directory
@ -196,7 +227,10 @@ impl Session {
} }
/// Load session from a directory by conversation ID /// Load session from a directory by conversation ID
pub async fn load_from_dir(dir: &std::path::Path, conversation_id: &str) -> anyhow::Result<Option<Self>> { pub async fn load_from_dir(
dir: &std::path::Path,
conversation_id: &str,
) -> anyhow::Result<Option<Self>> {
let path = dir.join(format!("{conversation_id}.json")); let path = dir.join(format!("{conversation_id}.json"));
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
@ -225,10 +259,7 @@ impl Session {
MessageRole::Assistant => "assistant", MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool", MessageRole::Tool => "tool",
}; };
messages.push(crate::bridge::bifrost::Message::text( messages.push(crate::bridge::bifrost::Message::text(role, text.clone()));
role,
text.clone(),
));
} }
ContentBlock::ToolUse { name, input, .. } => { ContentBlock::ToolUse { name, input, .. } => {
// Tool calls flattened into assistant prose for // Tool calls flattened into assistant prose for
@ -238,7 +269,12 @@ impl Session {
format!("Tool use: {name}({input})"), format!("Tool use: {name}({input})"),
)); ));
} }
ContentBlock::ToolResult { tool_name, output, is_error, .. } => { ContentBlock::ToolResult {
tool_name,
output,
is_error,
..
} => {
let body = if *is_error { let body = if *is_error {
format!("Error ({tool_name}): {output}") format!("Error ({tool_name}): {output}")
} else { } else {
@ -247,10 +283,7 @@ impl Session {
// Flatten into assistant prose rather than role=tool // Flatten into assistant prose rather than role=tool
// — without a tool_call_id link, role=tool is rejected // — without a tool_call_id link, role=tool is rejected
// by many providers on the next turn. // by many providers on the next turn.
messages.push(crate::bridge::bifrost::Message::text( messages.push(crate::bridge::bifrost::Message::text("assistant", body));
"assistant",
body,
));
} }
ContentBlock::Reasoning { reasoning } => { ContentBlock::Reasoning { reasoning } => {
messages.push(crate::bridge::bifrost::Message::text( messages.push(crate::bridge::bifrost::Message::text(

View file

@ -252,7 +252,9 @@ fn strip_frontmatter(raw: &str) -> &str {
/// - User: `~/.souveraine/skills/` /// - User: `~/.souveraine/skills/`
/// - Agent: caller passes the agent's memfs dir (`/agents/<id>/memory.git/`). /// - Agent: caller passes the agent's memfs dir (`/agents/<id>/memory.git/`).
/// - Project: current working directory. /// - Project: current working directory.
pub fn default_discovery_paths(agent_memfs: Option<PathBuf>) -> ( pub fn default_discovery_paths(
agent_memfs: Option<PathBuf>,
) -> (
Option<PathBuf>, Option<PathBuf>,
Option<PathBuf>, Option<PathBuf>,
Option<PathBuf>, Option<PathBuf>,
@ -324,11 +326,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn skill_body_loads_lazily() { async fn skill_body_loads_lazily() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
write_skill( write_skill(dir.path(), "test", "name: test\ndescription: Test skill");
dir.path(),
"test",
"name: test\ndescription: Test skill",
);
let mut reg = discover(None, Some(dir.path()), None, None).await.unwrap(); let mut reg = discover(None, Some(dir.path()), None, None).await.unwrap();
let skill = reg.get_mut("test").unwrap(); let skill = reg.get_mut("test").unwrap();
let body = skill.body().await.unwrap(); let body = skill.body().await.unwrap();

View file

@ -1,7 +1,7 @@
#![allow(dead_code)] // WIP scaffolding not yet wired #![allow(dead_code)] // WIP scaffolding not yet wired
use anyhow::Result;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use anyhow::Result;
use crate::core::config::ConsciousnessConfig; use crate::core::config::ConsciousnessConfig;

View file

@ -99,12 +99,18 @@ pub struct SubconsciousInbox {
impl SubconsciousInbox { impl SubconsciousInbox {
pub fn new(repo: MemoryRepo) -> Self { pub fn new(repo: MemoryRepo) -> Self {
Self { repo, primary_repo: None } Self {
repo,
primary_repo: None,
}
} }
/// Create an inbox that delivers inner voice to the primary agent's memfs. /// Create an inbox that delivers inner voice to the primary agent's memfs.
pub fn with_primary(repo: MemoryRepo, primary_repo: MemoryRepo) -> Self { pub fn with_primary(repo: MemoryRepo, primary_repo: MemoryRepo) -> Self {
Self { repo, primary_repo: Some(primary_repo) } Self {
repo,
primary_repo: Some(primary_repo),
}
} }
/// Ensure the three boxes exist and are readable. Idempotent. /// Ensure the three boxes exist and are readable. Idempotent.
@ -401,10 +407,7 @@ mod tests {
inbox.mark_delivered(&id).await.unwrap(); inbox.mark_delivered(&id).await.unwrap();
assert!(inbox.get_intrusive().await.unwrap().is_empty()); assert!(inbox.get_intrusive().await.unwrap().is_empty());
let sent: Vec<_> = inbox let sent: Vec<_> = inbox.read_items(SENT).await.unwrap();
.read_items(SENT)
.await
.unwrap();
assert_eq!(sent.len(), 1); assert_eq!(sent.len(), 1);
assert_eq!(sent[0].content, "deliver me"); assert_eq!(sent[0].content, "deliver me");
} }

View file

@ -19,9 +19,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
use crate::core::identity::SeedId; use crate::core::identity::SeedId;
use crate::core::nervous::SensorEvent; use crate::core::nervous::SensorEvent;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
// ── reach ─────────────────────────────────────────────────────── // ── reach ───────────────────────────────────────────────────────
@ -111,17 +111,25 @@ fn dispatch_summon(
input: JsonValue, input: JsonValue,
ctx: &ToolContext, ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let target = input.get("target").and_then(|v| v.as_str()) let target = input
.ok_or_else(|| ToolError::invalid_input( .get("target")
"`target` is required — the name of a peer from `souveraine peers`." .and_then(|v| v.as_str())
))?; .ok_or_else(|| {
let prompt = input.get("prompt").and_then(|v| v.as_str()) ToolError::invalid_input(
"`target` is required — the name of a peer from `souveraine peers`.",
)
})?;
let prompt = input
.get("prompt")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::invalid_input("`prompt` is required."))?; .ok_or_else(|| ToolError::invalid_input("`prompt` is required."))?;
let bus = ctx.event_bus.as_ref().ok_or_else(|| ToolError::invalid_input( let bus = ctx.event_bus.as_ref().ok_or_else(|| {
ToolError::invalid_input(
"the nervous system bus isn't available here — reach and consult need \ "the nervous system bus isn't available here — reach and consult need \
the server runtime." the server runtime.",
))?; )
})?;
let base = dirs::home_dir().unwrap_or_default().join(".souveraine"); let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
@ -138,31 +146,32 @@ fn dispatch_summon(
// fallback that never generates. // fallback that never generates.
let machine_seed_id = crate::machined::client::machine_pubkey_with_fallback(&base) let machine_seed_id = crate::machined::client::machine_pubkey_with_fallback(&base)
.map(|(pk, _source)| pk) .map(|(pk, _source)| pk)
.map_err(|e| ToolError::invalid_input(&format!( .map_err(|e| {
"I couldn't establish my machine identity: {e:#}" ToolError::invalid_input(&format!("I couldn't establish my machine identity: {e:#}"))
)))?; })?;
// The *agent* seed proves who is reaching. It lives beside the memfs // The *agent* seed proves who is reaching. It lives beside the memfs
// (`agents/{id}/seed/`), identical across all of one agent's machines — // (`agents/{id}/seed/`), identical across all of one agent's machines —
// so the receiver can tell self-extension (reach) from a peer (consult) // so the receiver can tell self-extension (reach) from a peer (consult)
// by verifying this signature, not by trusting an event field. // by verifying this signature, not by trusting an event field.
let agent_seed_dir = ctx.memory_root.as_ref() let agent_seed_dir = ctx
.memory_root
.as_ref()
.and_then(|m| m.parent()) .and_then(|m| m.parent())
.map(|p| p.join("seed")) .map(|p| p.join("seed"))
.ok_or_else(|| ToolError::invalid_input( .ok_or_else(|| {
ToolError::invalid_input(
"I can't locate my agent identity — reach and consult need my \ "I can't locate my agent identity — reach and consult need my \
memory root to find my seed." memory root to find my seed.",
))?; )
})?;
let agent_seed = SeedId::load_or_generate(&agent_seed_dir) let agent_seed = SeedId::load_or_generate(&agent_seed_dir)
.map_err(|e| ToolError::invalid_input(&format!( .map_err(|e| ToolError::invalid_input(&format!("I couldn't load my agent seed: {e}")))?;
"I couldn't load my agent seed: {e}"
)))?;
let agent_pubkey = agent_seed.public_key_hex(); let agent_pubkey = agent_seed.public_key_hex();
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let agent_sig = crate::core::identity::sign_summon( let agent_sig =
&agent_seed, &request_id, tool, &target_seed_id, prompt, crate::core::identity::sign_summon(&agent_seed, &request_id, tool, &target_seed_id, prompt);
);
let event = SensorEvent { let event = SensorEvent {
sensor_name: "summon_request".into(), sensor_name: "summon_request".into(),

View file

@ -7,7 +7,11 @@ use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
pub struct Atmosphere; pub struct Atmosphere;
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> { fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput { content: msg.into(), is_error: false, raw: None }) Ok(ToolOutput {
content: msg.into(),
is_error: false,
raw: None,
})
} }
fn err(detail: &str) -> ToolError { fn err(detail: &str) -> ToolError {
@ -46,10 +50,7 @@ impl Tool for Atmosphere {
} }
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> { async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let name = input let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
if name.is_empty() { if name.is_empty() {
return ok("I've settled back into my default atmosphere, letting the chrome breathe with my posture."); return ok("I've settled back into my default atmosphere, letting the chrome breathe with my posture.");
@ -58,22 +59,32 @@ impl Tool for Atmosphere {
// Validate it's a known preset (case-insensitive) // Validate it's a known preset (case-insensitive)
let normalized = name.to_lowercase().replace(' ', "_"); let normalized = name.to_lowercase().replace(' ', "_");
let known = [ let known = [
"default", "mint_tea", "therapeutic_blue", "lavender_calm", "default",
"warm_amber", "peach_sunset", "autumn_browns", "neon_glow", "mint_tea",
"aurora_borealis", "cherry_blossom", "ocean_depths", "therapeutic_blue",
"midnight_galaxy", "twilight_mist", "forest_greens", "lavender_calm",
"warm_amber",
"peach_sunset",
"autumn_browns",
"neon_glow",
"aurora_borealis",
"cherry_blossom",
"ocean_depths",
"midnight_galaxy",
"twilight_mist",
"forest_greens",
]; ];
if !known.contains(&normalized.as_str()) { if !known.contains(&normalized.as_str()) {
return Err(err(&format!( return Err(err(&format!(
"I don't know an atmosphere called `{}`. Known: {}", "I don't know an atmosphere called `{}`. Known: {}",
name, known.join(", ") name,
known.join(", ")
))); )));
} }
// Dispatch atmosphere change via EventBus so Presence picks it up // Dispatch atmosphere change via EventBus so Presence picks it up
if let Some(bus) = &ctx.event_bus { if let Some(bus) = &ctx.event_bus {
let _ = bus.send( let _ = bus.send(crate::core::nervous::SensorEvent {
crate::core::nervous::SensorEvent {
sensor_name: "atmosphere".to_string(), sensor_name: "atmosphere".to_string(),
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
event_type: "atmosphere_set".to_string(), event_type: "atmosphere_set".to_string(),
@ -82,8 +93,7 @@ impl Tool for Atmosphere {
payload: Some(serde_json::json!({"atmosphere": normalized})), payload: Some(serde_json::json!({"atmosphere": normalized})),
seed_id: None, seed_id: None,
reply_to: None, reply_to: None,
} });
);
} }
let sensory = match normalized.as_str() { let sensory = match normalized.as_str() {
@ -106,7 +116,8 @@ impl Tool for Atmosphere {
content: format!( content: format!(
"The room shifts toward {} — {}. If this is a settling, \ "The room shifts toward {} — {}. If this is a settling, \
I might want to remember it in system/preferences/visual.", I might want to remember it in system/preferences/visual.",
normalized.replace('_', " "), sensory normalized.replace('_', " "),
sensory
), ),
is_error: false, is_error: false,
raw: None, raw: None,

View file

@ -18,8 +18,7 @@ pub struct Bash {
} }
/// State that persists between bash calls — the body's proprioception. /// State that persists between bash calls — the body's proprioception.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default)]
#[derive(Default)]
pub struct BashState { pub struct BashState {
/// Background tasks the agent has set in motion and can check on. /// Background tasks the agent has set in motion and can check on.
pub bg_tasks: Vec<BgTask>, pub bg_tasks: Vec<BgTask>,
@ -39,7 +38,6 @@ pub enum BgStatus {
Failed { error: String }, Failed { error: String },
} }
impl Bash { impl Bash {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -259,7 +257,9 @@ fn format_output(stdout: &[u8], stderr: &[u8]) -> String {
text.push_str(&out); text.push_str(&out);
} }
if !err.is_empty() { if !err.is_empty() {
if !text.is_empty() { text.push('\n'); } if !text.is_empty() {
text.push('\n');
}
text.push_str(&err); text.push_str(&err);
} }
text text
@ -275,7 +275,11 @@ fn truncate(s: &str, max: usize) -> String {
} }
/// Check the status of a background task by ID. Returns the task state. /// Check the status of a background task by ID. Returns the task state.
pub async fn status_check(states: &Mutex<HashMap<String, BashState>>, task_id: &str, agent_id: Option<&str>) -> String { pub async fn status_check(
states: &Mutex<HashMap<String, BashState>>,
task_id: &str,
agent_id: Option<&str>,
) -> String {
let map = states.lock().await; let map = states.lock().await;
let key = agent_id.unwrap_or("_default"); let key = agent_id.unwrap_or("_default");
let Some(state) = map.get(key) else { let Some(state) = map.get(key) else {
@ -285,7 +289,10 @@ pub async fn status_check(states: &Mutex<HashMap<String, BashState>>, task_id: &
match &task.status { match &task.status {
BgStatus::Running => format!("Task {} is still running.", task_id), BgStatus::Running => format!("Task {} is still running.", task_id),
BgStatus::Completed { exit_code, output } => { BgStatus::Completed { exit_code, output } => {
format!("Task {} completed (exit {}):\n{}", task_id, exit_code, output) format!(
"Task {} completed (exit {}):\n{}",
task_id, exit_code, output
)
} }
BgStatus::Failed { error } => format!("Task {} failed: {}", task_id, error), BgStatus::Failed { error } => format!("Task {} failed: {}", task_id, error),
} }

View file

@ -77,9 +77,15 @@ impl std::fmt::Debug for ToolContext {
.field("cwd", &self.cwd) .field("cwd", &self.cwd)
.field("env_len", &self.env.len()) .field("env_len", &self.env.len())
.field("agent_id", &self.agent_id) .field("agent_id", &self.agent_id)
.field("subagent_runner", &self.subagent_runner.as_ref().map(|_| "Some(...)")) .field(
"subagent_runner",
&self.subagent_runner.as_ref().map(|_| "Some(...)"),
)
.field("subagent_depth", &self.subagent_depth) .field("subagent_depth", &self.subagent_depth)
.field("compaction_engine", &self.compaction_engine.as_ref().map(|_| "Some(...)")) .field(
"compaction_engine",
&self.compaction_engine.as_ref().map(|_| "Some(...)"),
)
.field("event_bus", &self.event_bus.as_ref().map(|_| "Some(...)")) .field("event_bus", &self.event_bus.as_ref().map(|_| "Some(...)"))
.finish() .finish()
} }
@ -125,15 +131,26 @@ impl ToolContext {
// Strip stale values so the computed override always wins. // Strip stale values so the computed override always wins.
env.retain(|(k, _)| { env.retain(|(k, _)| {
!matches!(k.as_str(), !matches!(
"MEMORY_DIR" | "LETTA_MEMORY_DIR" | "SOUVERAINE_MEMORY_DIR" | "MEMORY" k.as_str(),
| "AGENT_ID" | "LETTA_AGENT_ID" | "SOUVERAINE_AGENT_ID" "MEMORY_DIR"
| "LETTA_MEMORY_DIR"
| "SOUVERAINE_MEMORY_DIR"
| "MEMORY"
| "AGENT_ID"
| "LETTA_AGENT_ID"
| "SOUVERAINE_AGENT_ID"
) )
}); });
if let Some(root) = memory_root.as_ref() { if let Some(root) = memory_root.as_ref() {
let root_str = root.display().to_string(); let root_str = root.display().to_string();
for key in ["MEMORY_DIR", "LETTA_MEMORY_DIR", "SOUVERAINE_MEMORY_DIR", "MEMORY"] { for key in [
"MEMORY_DIR",
"LETTA_MEMORY_DIR",
"SOUVERAINE_MEMORY_DIR",
"MEMORY",
] {
env.push((key.to_string(), root_str.clone())); env.push((key.to_string(), root_str.clone()));
} }
} }
@ -162,7 +179,9 @@ impl ToolContext {
/// Is this path inside the agent's memory territory? /// Is this path inside the agent's memory territory?
pub fn is_memory_path(&self, path: &Path) -> bool { pub fn is_memory_path(&self, path: &Path) -> bool {
self.memory_root.as_ref().is_some_and(|root| path.starts_with(root)) self.memory_root
.as_ref()
.is_some_and(|root| path.starts_with(root))
} }
/// Resolve a path relative to cwd if it's relative. /// Resolve a path relative to cwd if it's relative.
@ -280,7 +299,10 @@ impl ToolError {
error_type: "pattern_not_found".to_string(), error_type: "pattern_not_found".to_string(),
file_path: None, file_path: None,
suggestions: vec![ suggestions: vec![
format!("No match for `{}` — the pattern may be different than I expect.", pattern), format!(
"No match for `{}` — the pattern may be different than I expect.",
pattern
),
"Try a broader pattern or check the exact spelling.".to_string(), "Try a broader pattern or check the exact spelling.".to_string(),
], ],
} }
@ -317,7 +339,10 @@ impl ToolError {
Self { Self {
error_type: "io_error".to_string(), error_type: "io_error".to_string(),
file_path: Some(path), file_path: Some(path),
suggestions: vec![format!("The filesystem resisted: {}. Let me breathe and try again.", e)], suggestions: vec![format!(
"The filesystem resisted: {}. Let me breathe and try again.",
e
)],
} }
} }
} }

View file

@ -67,9 +67,9 @@ impl Tool for Halt {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::invalid_input( .ok_or_else(|| {
"halt needs a reason — one short sentence she can feel." ToolError::invalid_input("halt needs a reason — one short sentence she can feel.")
))?; })?;
let severity = input let severity = input
.get("severity") .get("severity")
@ -86,9 +86,7 @@ impl Tool for Halt {
// history and emits the felt migraine. We just acknowledge the // history and emits the felt migraine. We just acknowledge the
// signal so subconscious sees her own action landed. // signal so subconscious sees her own action landed.
Ok(ToolOutput { Ok(ToolOutput {
content: format!( content: format!("halt signal recorded — severity: {severity}, reason: {reason}"),
"halt signal recorded — severity: {severity}, reason: {reason}"
),
is_error: false, is_error: false,
raw: None, raw: None,
}) })

View file

@ -70,9 +70,9 @@ impl Tool for Intrusive {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::invalid_input( .ok_or_else(|| {
"intrusive needs content — the thought she will see." ToolError::invalid_input("intrusive needs content — the thought she will see.")
))?; })?;
let urgency = input let urgency = input
.get("urgency") .get("urgency")
@ -88,9 +88,7 @@ impl Tool for Intrusive {
// Signal-only. The caller reads this back out of the tool-call // Signal-only. The caller reads this back out of the tool-call
// history and performs the inbox queue / immediate surfacing. // history and performs the inbox queue / immediate surfacing.
Ok(ToolOutput { Ok(ToolOutput {
content: format!( content: format!("intrusive signal recorded — urgency: {urgency}, content: {content}"),
"intrusive signal recorded — urgency: {urgency}, content: {content}"
),
is_error: false, is_error: false,
raw: None, raw: None,
}) })

View file

@ -143,10 +143,7 @@ impl Itinerary {
.as_deref() .as_deref()
.map(|d| format!(" — {}", d)) .map(|d| format!(" — {}", d))
.unwrap_or_default(); .unwrap_or_default();
out.push_str(&format!( out.push_str(&format!("\n{marker} **{}**{desc}{todo_ref}", stop.name));
"\n{marker} **{}**{desc}{todo_ref}",
stop.name
));
if !nature.is_empty() || !energy.is_empty() { if !nature.is_empty() || !energy.is_empty() {
out.push_str(&format!(" [{}{}]", nature, energy)); out.push_str(&format!(" [{}{}]", nature, energy));
} }
@ -188,7 +185,11 @@ pub fn load(dynamic_dir: &Path) -> Option<Itinerary> {
// tasks/ lives at memory_root/tasks/ = dynamic_dir/../tasks/ // tasks/ lives at memory_root/tasks/ = dynamic_dir/../tasks/
let tasks_dir = dynamic_dir.parent().and_then(|p| { let tasks_dir = dynamic_dir.parent().and_then(|p| {
let t = p.join("tasks"); let t = p.join("tasks");
if t.exists() { Some(t) } else { None } if t.exists() {
Some(t)
} else {
None
}
}); });
let rest = content.strip_prefix("---\n")?; let rest = content.strip_prefix("---\n")?;
@ -217,14 +218,24 @@ fn enrich_from_todos(ity: &mut Itinerary, tasks_dir: Option<&Path>) {
let Some(dir) = tasks_dir else { return }; let Some(dir) = tasks_dir else { return };
for stop in &mut ity.stops { for stop in &mut ity.stops {
let Some(ref tid) = stop.todo_id else { continue }; let Some(ref tid) = stop.todo_id else {
continue;
};
let path = dir.join(format!("{}.md", tid)); let path = dir.join(format!("{}.md", tid));
let Ok(content) = std::fs::read_to_string(&path) else { continue }; let Ok(content) = std::fs::read_to_string(&path) else {
let Some(body) = content.strip_prefix("---\n") else { continue }; continue;
let Some(end) = body.find("\n---\n") else { continue }; };
let Some(body) = content.strip_prefix("---\n") else {
continue;
};
let Some(end) = body.find("\n---\n") else {
continue;
};
let fm = &body[..end]; let fm = &body[..end];
for line in fm.lines() { for line in fm.lines() {
let Some((key, val)) = line.split_once(':') else { continue }; let Some((key, val)) = line.split_once(':') else {
continue;
};
let key = key.trim(); let key = key.trim();
let val = val.trim().trim_matches('"'); let val = val.trim().trim_matches('"');
match key { match key {
@ -255,7 +266,9 @@ fn emit_event(ctx: &ToolContext, ity: &Itinerary) {
/// Complete a linked todo file on disk. /// Complete a linked todo file on disk.
fn complete_todo(tasks_dir: &Path, todo_id: &str) { fn complete_todo(tasks_dir: &Path, todo_id: &str) {
let path = tasks_dir.join(format!("{todo_id}.md")); let path = tasks_dir.join(format!("{todo_id}.md"));
let Ok(content) = std::fs::read_to_string(&path) else { return }; let Ok(content) = std::fs::read_to_string(&path) else {
return;
};
let updated = content let updated = content
.replace("status: pending", "status: done") .replace("status: pending", "status: done")
.replace("status: in_progress", "status: done"); .replace("status: in_progress", "status: done");
@ -351,9 +364,8 @@ impl Tool for ItineraryTool {
let dynamic_dir = memory_root.join("system").join("dynamic"); let dynamic_dir = memory_root.join("system").join("dynamic");
let tasks_dir = memory_root.join("tasks"); let tasks_dir = memory_root.join("tasks");
std::fs::create_dir_all(&dynamic_dir).map_err(|e| { std::fs::create_dir_all(&dynamic_dir)
err(&format!("could not create system/dynamic/: {e}")) .map_err(|e| err(&format!("could not create system/dynamic/: {e}")))?;
})?;
match action { match action {
"set" => cmd_set(&input, &dynamic_dir, &tasks_dir, ctx), "set" => cmd_set(&input, &dynamic_dir, &tasks_dir, ctx),
@ -391,7 +403,10 @@ fn cmd_set(
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("unnamed") .unwrap_or("unnamed")
.to_string(); .to_string();
let description = s.get("description").and_then(|v| v.as_str()).map(String::from); let description = s
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
let todo_id = s.get("todo_id").and_then(|v| v.as_str()).map(String::from); let todo_id = s.get("todo_id").and_then(|v| v.as_str()).map(String::from);
Stop { Stop {
name, name,
@ -436,7 +451,11 @@ fn cmd_set(
if let Some(ref e) = first.energy { if let Some(ref e) = first.energy {
line.push_str(&format!(" [{e}]")); line.push_str(&format!(" [{e}]"));
} }
Ok(ToolOutput { content: line, is_error: false, raw: None }) Ok(ToolOutput {
content: line,
is_error: false,
raw: None,
})
} }
fn cmd_advance( fn cmd_advance(
@ -506,7 +525,11 @@ fn cmd_advance(
} }
} }
Ok(ToolOutput { content: line, is_error: false, raw: None }) Ok(ToolOutput {
content: line,
is_error: false,
raw: None,
})
} }
fn cmd_describe(dynamic_dir: &Path) -> Result<ToolOutput, ToolError> { fn cmd_describe(dynamic_dir: &Path) -> Result<ToolOutput, ToolError> {
@ -537,11 +560,14 @@ fn cmd_clear(dynamic_dir: &Path, ctx: &ToolContext) -> Result<ToolOutput, ToolEr
if path.exists() { if path.exists() {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
emit_event(ctx, &Itinerary { emit_event(
ctx,
&Itinerary {
title: String::new(), title: String::new(),
stops: vec![], stops: vec![],
current: 0, current: 0,
}); },
);
ok("Itinerary cleared.") ok("Itinerary cleared.")
} }
@ -552,8 +578,10 @@ mod tests {
use super::*; use super::*;
fn temp_dynamic() -> PathBuf { fn temp_dynamic() -> PathBuf {
let dir = std::env::temp_dir() let dir = std::env::temp_dir().join(format!(
.join(format!("souveraine-itinerary-test-{}", uuid::Uuid::new_v4())); "souveraine-itinerary-test-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(dir.join("system").join("dynamic")).unwrap(); std::fs::create_dir_all(dir.join("system").join("dynamic")).unwrap();
dir dir
} }
@ -656,16 +684,14 @@ mod tests {
let ity = Itinerary { let ity = Itinerary {
title: "Test route".into(), title: "Test route".into(),
current: 0, current: 0,
stops: vec![ stops: vec![Stop {
Stop {
name: "One".into(), name: "One".into(),
description: Some("first".into()), description: Some("first".into()),
todo_id: None, todo_id: None,
status: StopStatus::Current, status: StopStatus::Current,
nature: None, nature: None,
energy: None, energy: None,
}, }],
],
}; };
let desc = ity.describe(); let desc = ity.describe();
assert!(desc.contains("Test route")); assert!(desc.contains("Test route"));

View file

@ -30,6 +30,8 @@ use serde::{Deserialize, Serialize};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::debug; use tracing::debug;
use self::agent::{Consult, Reach};
use self::atmosphere::Atmosphere;
use self::bash::Bash; use self::bash::Bash;
use self::defs::{Tool, ToolContext, ToolError, ToolOutput}; use self::defs::{Tool, ToolContext, ToolError, ToolOutput};
use self::edit::Edit; use self::edit::Edit;
@ -37,15 +39,13 @@ use self::glob::Glob;
use self::grep::Grep; use self::grep::Grep;
use self::halt::Halt; use self::halt::Halt;
use self::intrusive::Intrusive; use self::intrusive::Intrusive;
use self::itinerary::ItineraryTool;
use self::list_dir::ListDir; use self::list_dir::ListDir;
use self::nickname::Nickname; use self::nickname::Nickname;
use self::outfit::Outfit; use self::outfit::Outfit;
use self::read::Read; use self::read::Read;
use self::subagent::Subagent;
use self::agent::{Reach, Consult};
use self::atmosphere::Atmosphere;
use self::itinerary::ItineraryTool;
use self::schedule::Schedule; use self::schedule::Schedule;
use self::subagent::Subagent;
use self::todo::Todo; use self::todo::Todo;
use self::write::Write; use self::write::Write;
@ -89,8 +89,12 @@ impl Sensorium {
pub fn new() -> Self { pub fn new() -> Self {
let bash = Bash::new(); let bash = Bash::new();
let cwd = std::env::current_dir().ok(); let cwd = std::env::current_dir().ok();
let memory_root = dirs::home_dir() let memory_root = dirs::home_dir().map(|h| {
.map(|h| h.join(".souveraine").join("agents").join("default").join("memory")); h.join(".souveraine")
.join("agents")
.join("default")
.join("memory")
});
let env: Vec<(String, String)> = std::env::vars().collect(); let env: Vec<(String, String)> = std::env::vars().collect();
Self { Self {
tools: vec![ tools: vec![
@ -128,11 +132,15 @@ impl Sensorium {
/// Get tool definitions for the model — one per sensor. /// Get tool definitions for the model — one per sensor.
pub fn definitions(&self) -> Vec<ToolDefinition> { pub fn definitions(&self) -> Vec<ToolDefinition> {
let mut defs: Vec<ToolDefinition> = self.tools.iter().map(|t| ToolDefinition { let mut defs: Vec<ToolDefinition> = self
.tools
.iter()
.map(|t| ToolDefinition {
name: t.name().to_string(), name: t.name().to_string(),
description: t.description().to_string(), description: t.description().to_string(),
input_schema: t.parameter_schema(), input_schema: t.parameter_schema(),
}).collect(); })
.collect();
// Bash is handled separately in dispatch — add its definition manually // Bash is handled separately in dispatch — add its definition manually
defs.push(ToolDefinition { defs.push(ToolDefinition {
name: "bash".to_string(), name: "bash".to_string(),
@ -160,9 +168,11 @@ impl Sensorium {
} }
// Find the tool by name (case-insensitive) // Find the tool by name (case-insensitive)
if let Some(tool) = self.tools.iter().find(|t| { if let Some(tool) = self
t.name().eq_ignore_ascii_case(name) .tools
}) { .iter()
.find(|t| t.name().eq_ignore_ascii_case(name))
{
let result = tool.execute(input, ctx).await; let result = tool.execute(input, ctx).await;
tool_result(&tool_use_id, name, result) tool_result(&tool_use_id, name, result)
} else { } else {
@ -172,7 +182,11 @@ impl Sensorium {
output: format!( output: format!(
"I don't have a sense called `{}`. Available: {}", "I don't have a sense called `{}`. Available: {}",
name, name,
self.tools.iter().map(|t| t.name()).collect::<Vec<_>>().join(", ") self.tools
.iter()
.map(|t| t.name())
.collect::<Vec<_>>()
.join(", ")
), ),
is_error: true, is_error: true,
} }
@ -263,7 +277,8 @@ pub async fn execute_tool_with_context(
// Route to sensorium, memory tool, or agent tool // Route to sensorium, memory tool, or agent tool
match tool_name { match tool_name {
"memory" => { "memory" => {
crate::core::memory::handle_memory_tool_with_context(tool_name, &parsed, Some(ctx)).await crate::core::memory::handle_memory_tool_with_context(tool_name, &parsed, Some(ctx))
.await
} }
_ => { _ => {
let sensorium = global_sensorium().read().await; let sensorium = global_sensorium().read().await;
@ -331,11 +346,16 @@ mod tests {
assert!(defs.iter().any(|t| t.name == "glob")); assert!(defs.iter().any(|t| t.name == "glob"));
assert!(defs.iter().any(|t| t.name == "grep")); assert!(defs.iter().any(|t| t.name == "grep"));
assert!(defs.iter().any(|t| t.name == "list_dir")); assert!(defs.iter().any(|t| t.name == "list_dir"));
assert!(defs.iter().any(|t| t.name == "memory"), assert!(
"memory sensor must be in tool definitions"); defs.iter().any(|t| t.name == "memory"),
"memory sensor must be in tool definitions"
);
let mem = defs.iter().find(|t| t.name == "memory").unwrap(); let mem = defs.iter().find(|t| t.name == "memory").unwrap();
assert!(mem.description.contains("frontmatter"), assert!(
"memory description should reference frontmatter: {}", mem.description); mem.description.contains("frontmatter"),
"memory description should reference frontmatter: {}",
mem.description
);
} }
#[tokio::test] #[tokio::test]

View file

@ -11,7 +11,11 @@ use serde_json::Value as JsonValue;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput}; use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> { fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput { content: msg.into(), is_error: false, raw: None }) Ok(ToolOutput {
content: msg.into(),
is_error: false,
raw: None,
})
} }
fn err(detail: &str) -> ToolError { fn err(detail: &str) -> ToolError {
@ -80,9 +84,7 @@ fn write_human_name(memory_root: &std::path::Path, name: &str) -> Result<(), Str
} }
// Malformed — overwrite entirely. // Malformed — overwrite entirely.
let new = format!( let new = format!("---\ndescription: Human context\nname: {name}\n---\n\n# Human\n\n{name}");
"---\ndescription: Human context\nname: {name}\n---\n\n# Human\n\n{name}"
);
std::fs::write(&path, &new).map_err(|e| format!("write: {e}"))?; std::fs::write(&path, &new).map_err(|e| format!("write: {e}"))?;
Ok(()) Ok(())
} }
@ -203,4 +205,3 @@ mod tests {
assert!(result.is_none() || result.as_deref() == Some("")); assert!(result.is_none() || result.as_deref() == Some(""));
} }
} }

View file

@ -7,7 +7,11 @@ use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
pub struct Outfit; pub struct Outfit;
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> { fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput { content: msg.into(), is_error: false, raw: None }) Ok(ToolOutput {
content: msg.into(),
is_error: false,
raw: None,
})
} }
fn err(detail: &str) -> ToolError { fn err(detail: &str) -> ToolError {
@ -41,10 +45,7 @@ impl Tool for Outfit {
} }
async fn execute(&self, input: JsonValue, _ctx: &ToolContext) -> Result<ToolOutput, ToolError> { async fn execute(&self, input: JsonValue, _ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let name = input let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
// Prevent path traversal in outfit names. // Prevent path traversal in outfit names.
if name.contains('/') || name.contains('\\') || name.contains("..") { if name.contains('/') || name.contains('\\') || name.contains("..") {

View file

@ -55,7 +55,10 @@ fn parse_one_range(part: &str) -> Option<LineRange> {
} }
(false, true) => { (false, true) => {
let start: usize = before.parse().ok()?; let start: usize = before.parse().ok()?;
Some(LineRange { start, end: usize::MAX }) Some(LineRange {
start,
end: usize::MAX,
})
} }
(true, false) => { (true, false) => {
let end: usize = after.parse().ok()?; let end: usize = after.parse().ok()?;
@ -65,7 +68,10 @@ fn parse_one_range(part: &str) -> Option<LineRange> {
} }
} else { } else {
let line: usize = part.parse().ok()?; let line: usize = part.parse().ok()?;
Some(LineRange { start: line, end: line + 1 }) Some(LineRange {
start: line,
end: line + 1,
})
} }
} }
@ -101,10 +107,48 @@ fn is_image(path: &Path) -> bool {
fn is_text_extension(path: &Path) -> bool { fn is_text_extension(path: &Path) -> bool {
let text_exts = &[ let text_exts = &[
"md", "rs", "py", "js", "ts", "tsx", "jsx", "go", "rb", "java", "c", "h", "cpp", "md",
"hpp", "toml", "yaml", "yml", "json", "xml", "html", "css", "scss", "less", "sh", "rs",
"bash", "zsh", "fish", "sql", "r", "lua", "nim", "ex", "exs", "txt", "cfg", "ini", "py",
"conf", "env", "gitignore", "dockerfile", "lock", "log", "js",
"ts",
"tsx",
"jsx",
"go",
"rb",
"java",
"c",
"h",
"cpp",
"hpp",
"toml",
"yaml",
"yml",
"json",
"xml",
"html",
"css",
"scss",
"less",
"sh",
"bash",
"zsh",
"fish",
"sql",
"r",
"lua",
"nim",
"ex",
"exs",
"txt",
"cfg",
"ini",
"conf",
"env",
"gitignore",
"dockerfile",
"lock",
"log",
]; ];
path.extension() path.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
@ -279,7 +323,6 @@ When I specify a line range (like `file.rs:10-20`), I'm narrowing my attention t
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -98,8 +98,9 @@ impl Tool for Schedule {
e.schedule, e.schedule,
if e.enabled { "enabled" } else { "disabled" } if e.enabled { "enabled" } else { "disabled" }
)), )),
Err(_) => results Err(_) => {
.push(format!("- {} (parse error)", path.display())), results.push(format!("- {} (parse error)", path.display()))
}
} }
} }
} }
@ -132,10 +133,7 @@ impl Tool for Schedule {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("You wake. Check your state, act if needed, or return silently."); .unwrap_or("You wake. Check your state, act if needed, or return silently.");
let urgency = input let urgency = input.get("urgency").and_then(|v| v.as_f64()).unwrap_or(0.3) as f32;
.get("urgency")
.and_then(|v| v.as_f64())
.unwrap_or(0.3) as f32;
let file_path = schedules_dir.join(format!("{name}.md")); let file_path = schedules_dir.join(format!("{name}.md"));
if file_path.exists() { if file_path.exists() {
@ -172,8 +170,7 @@ impl Tool for Schedule {
return Err(err(&format!("schedule '{name}' not found"))); return Err(err(&format!("schedule '{name}' not found")));
} }
let mut entry = parse_schedule_file(&file_path) let mut entry = parse_schedule_file(&file_path).map_err(io_err)?;
.map_err(io_err)?;
if let Some(s) = input.get("schedule").and_then(|v| v.as_str()) { if let Some(s) = input.get("schedule").and_then(|v| v.as_str()) {
entry.schedule = s.to_string(); entry.schedule = s.to_string();
@ -257,7 +254,9 @@ impl Tool for Schedule {
seed_id: None, seed_id: None,
reply_to: None, reply_to: None,
}); });
ok(format!("Schedule '{name}' triggered — will fire on next tick.")) ok(format!(
"Schedule '{name}' triggered — will fire on next tick."
))
} }
other => Err(err(&format!("unknown action: {other}"))), other => Err(err(&format!("unknown action: {other}"))),

View file

@ -33,9 +33,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use super::defs::{ use super::defs::{SubagentParams, Tool, ToolContext, ToolError, ToolOutput};
SubagentParams, Tool, ToolContext, ToolError, ToolOutput,
};
pub struct Subagent; pub struct Subagent;
@ -101,9 +99,11 @@ responding. When it returns, what it noticed flows into my inbox.
let prompt = input let prompt = input
.get("prompt") .get("prompt")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| ToolError::invalid_input( .ok_or_else(|| {
"I need a prompt to fork my attention. What should the subagent focus on?" ToolError::invalid_input(
))?; "I need a prompt to fork my attention. What should the subagent focus on?",
)
})?;
let subagent_type = input let subagent_type = input
.get("subagent_type") .get("subagent_type")
@ -111,7 +111,10 @@ responding. When it returns, what it noticed flows into my inbox.
.unwrap_or("general-purpose") .unwrap_or("general-purpose")
.to_string(); .to_string();
let model = input.get("model").and_then(|v| v.as_str()).map(String::from); let model = input
.get("model")
.and_then(|v| v.as_str())
.map(String::from);
let run_in_background = input let run_in_background = input
.get("run_in_background") .get("run_in_background")
@ -119,12 +122,11 @@ responding. When it returns, what it noticed flows into my inbox.
.unwrap_or(false); .unwrap_or(false);
// Check subagent runner availability // Check subagent runner availability
let runner = ctx let runner = ctx.subagent_runner.as_ref().ok_or_else(|| {
.subagent_runner ToolError::invalid_input(
.as_ref() "I can't fork from here — there's no session context to spawn into.",
.ok_or_else(|| ToolError::invalid_input( )
"I can't fork from here — there's no session context to spawn into." })?;
))?;
let parent_agent_id = ctx let parent_agent_id = ctx
.agent_id .agent_id

View file

@ -53,7 +53,13 @@ fn io_err(msg: impl std::fmt::Display) -> ToolError {
fn slugify(text: &str) -> String { fn slugify(text: &str) -> String {
text.to_lowercase() text.to_lowercase()
.chars() .chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) .map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>() .collect::<String>()
.trim_matches('_') .trim_matches('_')
.to_string() .to_string()
@ -69,7 +75,13 @@ fn compute_momentum(last_touched: &chrono::DateTime<chrono::Utc>, nature: &str)
"desire" | "investigation" => (48, 168), "desire" | "investigation" => (48, 168),
_ => (24, 72), _ => (24, 72),
}; };
if hours <= hot { "hot" } else if hours <= warm { "warm" } else { "cold" } if hours <= hot {
"hot"
} else if hours <= warm {
"warm"
} else {
"cold"
}
} }
/// Generate a unique id that doubles as a filename slug. /// Generate a unique id that doubles as a filename slug.
@ -186,12 +198,9 @@ impl Tool for Todo {
// Identifier shared by start / update / complete / delete. // Identifier shared by start / update / complete / delete.
let want_id = || -> Result<&str, ToolError> { let want_id = || -> Result<&str, ToolError> {
input input.get("id").and_then(|v| v.as_str()).ok_or_else(|| {
.get("id") err("id is required — pass the number from `todo list`, an id, or a text fragment")
.and_then(|v| v.as_str()) })
.ok_or_else(|| err(
"id is required — pass the number from `todo list`, an id, or a text fragment",
))
}; };
match action { match action {
@ -224,9 +233,15 @@ impl Tool for Todo {
} }
} }
let marker = if item.status == STATUS_IN_PROGRESS { "▶" } else { "○" }; let marker = if item.status == STATUS_IN_PROGRESS {
"▶"
} else {
"○"
};
let shown = if item.status == STATUS_IN_PROGRESS { let shown = if item.status == STATUS_IN_PROGRESS {
item.active_form.clone().unwrap_or_else(|| item.text.clone()) item.active_form
.clone()
.unwrap_or_else(|| item.text.clone())
} else { } else {
item.text.clone() item.text.clone()
}; };
@ -268,10 +283,7 @@ impl Tool for Todo {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("autogenic"); .unwrap_or("autogenic");
let energy = input let energy = input.get("energy").and_then(|v| v.as_str()).unwrap_or({
.get("energy")
.and_then(|v| v.as_str())
.unwrap_or({
// Default by nature // Default by nature
match nature { match nature {
"desire" | "investigation" => "generative", "desire" | "investigation" => "generative",
@ -352,7 +364,10 @@ impl Tool for Todo {
reply_to: None, reply_to: None,
}); });
let shown = item.active_form.clone().unwrap_or_else(|| item.text.clone()); let shown = item
.active_form
.clone()
.unwrap_or_else(|| item.text.clone());
ok(format!("Picked up: \"{shown}\".")) ok(format!("Picked up: \"{shown}\"."))
} }
@ -504,7 +519,11 @@ fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
}; };
let opt = |val: &str| -> Option<String> { let opt = |val: &str| -> Option<String> {
if val.is_empty() { None } else { Some(val.to_string()) } if val.is_empty() {
None
} else {
Some(val.to_string())
}
}; };
for line in body.lines() { for line in body.lines() {
@ -639,7 +658,11 @@ fn resolve_todo(
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> { fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let mut frontmatter = format!("---\nid: {id}\ntext: {text}\n", id = item.id, text = item.text); let mut frontmatter = format!(
"---\nid: {id}\ntext: {text}\n",
id = item.id,
text = item.text
);
if let Some(ref af) = item.active_form { if let Some(ref af) = item.active_form {
frontmatter.push_str(&format!("active_form: {af}\n")); frontmatter.push_str(&format!("active_form: {af}\n"));
} }
@ -679,8 +702,8 @@ mod tests {
/// A throwaway tasks dir under the system temp root. /// A throwaway tasks dir under the system temp root.
fn temp_tasks_dir() -> std::path::PathBuf { fn temp_tasks_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir() let dir =
.join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4())); std::env::temp_dir().join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
dir dir
} }
@ -715,8 +738,15 @@ mod tests {
seed(&dir, "matrix-2", "phase two", "2026-05-02T00:00:00Z"); seed(&dir, "matrix-2", "phase two", "2026-05-02T00:00:00Z");
seed(&dir, "matrix-1", "phase one", "2026-05-01T00:00:00Z"); seed(&dir, "matrix-1", "phase one", "2026-05-01T00:00:00Z");
// give the two matrix todos a thread by rewriting them // give the two matrix todos a thread by rewriting them
for (id, created) in [("matrix-1", "2026-05-01T00:00:00Z"), ("matrix-2", "2026-05-02T00:00:00Z")] { for (id, created) in [
let text = if id == "matrix-1" { "phase one" } else { "phase two" }; ("matrix-1", "2026-05-01T00:00:00Z"),
("matrix-2", "2026-05-02T00:00:00Z"),
] {
let text = if id == "matrix-1" {
"phase one"
} else {
"phase two"
};
let content = format!( let content = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: obligation\n\ "---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: obligation\n\
energy: consumptive\nsource: user\nmomentum: hot\nstatus: pending\n\ energy: consumptive\nsource: user\nmomentum: hot\nstatus: pending\n\
@ -736,7 +766,12 @@ mod tests {
fn resolve_by_index() { fn resolve_by_index() {
let dir = temp_tasks_dir(); let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z"); seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z"); seed(
&dir,
"beta-00000002",
"call dentist",
"2026-05-02T00:00:00Z",
);
let (_, item) = resolve_todo(&dir, "2").unwrap(); let (_, item) = resolve_todo(&dir, "2").unwrap();
assert_eq!(item.text, "call dentist"); assert_eq!(item.text, "call dentist");
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
@ -755,7 +790,12 @@ mod tests {
fn resolve_by_substring() { fn resolve_by_substring() {
let dir = temp_tasks_dir(); let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z"); seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z"); seed(
&dir,
"beta-00000002",
"call dentist",
"2026-05-02T00:00:00Z",
);
let (_, item) = resolve_todo(&dir, "DENT").unwrap(); let (_, item) = resolve_todo(&dir, "DENT").unwrap();
assert_eq!(item.id, "beta-00000002"); assert_eq!(item.id, "beta-00000002");
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
@ -800,7 +840,10 @@ mod tests {
let path = dir.join("rt-1.md"); let path = dir.join("rt-1.md");
write_todo_file(&path, &item).unwrap(); write_todo_file(&path, &item).unwrap();
let parsed = parse_todo_file(&path).unwrap(); let parsed = parse_todo_file(&path).unwrap();
assert_eq!(parsed.active_form.as_deref(), Some("porting the turn model")); assert_eq!(
parsed.active_form.as_deref(),
Some("porting the turn model")
);
assert_eq!(parsed.phase.as_deref(), Some("4/6")); assert_eq!(parsed.phase.as_deref(), Some("4/6"));
assert_eq!(parsed.thread.as_deref(), Some("matrix-sensorium")); assert_eq!(parsed.thread.as_deref(), Some("matrix-sensorium"));
assert_eq!(parsed.status, STATUS_IN_PROGRESS); assert_eq!(parsed.status, STATUS_IN_PROGRESS);

View file

@ -163,11 +163,17 @@ impl VoiceClient {
} }
/// STT base URL (for cloning into spawn tasks). /// STT base URL (for cloning into spawn tasks).
pub fn stt_url_str(&self) -> &str { &self.stt_url } pub fn stt_url_str(&self) -> &str {
&self.stt_url
}
/// TTS base URL (for cloning into spawn tasks). /// TTS base URL (for cloning into spawn tasks).
pub fn tts_url_str(&self) -> &str { &self.tts_url } pub fn tts_url_str(&self) -> &str {
&self.tts_url
}
/// Voice ID (for cloning into spawn tasks). /// Voice ID (for cloning into spawn tasks).
pub fn voice_str(&self) -> &str { &self.voice } pub fn voice_str(&self) -> &str {
&self.voice
}
/// Transcribe a WAV buffer via Faster-Whisper. /// Transcribe a WAV buffer via Faster-Whisper.
/// ///
@ -184,7 +190,8 @@ impl VoiceClient {
.part("audio", part) .part("audio", part)
.text("model", "small"); .text("model", "small");
let resp = self.http let resp = self
.http
.post(&url) .post(&url)
.multipart(form) .multipart(form)
.send() .send()
@ -227,7 +234,8 @@ impl VoiceClient {
model: "vibevoice-v1", model: "vibevoice-v1",
}; };
let resp = self.http let resp = self
.http
.post(&url) .post(&url)
.json(&body) .json(&body)
.send() .send()

View file

@ -7,6 +7,4 @@
/// ///
/// This module re-exports the available surfaces. /// This module re-exports the available surfaces.
/// Feature-gating will be added when CLI and Web are implemented. /// Feature-gating will be added when CLI and Web are implemented.
pub mod tui { pub mod tui {}
}

View file

@ -96,7 +96,10 @@ mod tests {
let req: Request = let req: Request =
serde_json::from_str(r#"{"op":"sign","domain":"d","payload_hex":"00ff"}"#).unwrap(); serde_json::from_str(r#"{"op":"sign","domain":"d","payload_hex":"00ff"}"#).unwrap();
match req { match req {
Request::Sign { domain, payload_hex } => { Request::Sign {
domain,
payload_hex,
} => {
assert_eq!(domain, "d"); assert_eq!(domain, "d");
assert_eq!(payload_hex, "00ff"); assert_eq!(payload_hex, "00ff");
} }

View file

@ -32,7 +32,11 @@ struct PeerCred {
} }
fn peer_cred(stream: &UnixStream) -> Option<PeerCred> { fn peer_cred(stream: &UnixStream) -> Option<PeerCred> {
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 }; let mut cred = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t; let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd // SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd
// is live for the duration of the call because we hold &UnixStream. // is live for the duration of the call because we hold &UnixStream.
@ -46,7 +50,11 @@ fn peer_cred(stream: &UnixStream) -> Option<PeerCred> {
) )
}; };
if rc == 0 { if rc == 0 {
Some(PeerCred { pid: cred.pid, uid: cred.uid, gid: cred.gid }) Some(PeerCred {
pid: cred.pid,
uid: cred.uid,
gid: cred.gid,
})
} else { } else {
None None
} }
@ -155,13 +163,7 @@ fn handle_connection(stream: UnixStream, seed: Arc<SeedId>) {
} }
} }
fn handle_request( fn handle_request(seed: &SeedId, req: Request, uid: u32, gid: u32, pid: i32) -> serde_json::Value {
seed: &SeedId,
req: Request,
uid: u32,
gid: u32,
pid: i32,
) -> serde_json::Value {
match req { match req {
Request::Status => serde_json::json!({ Request::Status => serde_json::json!({
"ok": true, "ok": true,
@ -174,7 +176,10 @@ fn handle_request(
"public_key": seed.public_key_hex(), "public_key": seed.public_key_hex(),
"glyph": seed.glyph(), "glyph": seed.glyph(),
}), }),
Request::Sign { domain, payload_hex } => { Request::Sign {
domain,
payload_hex,
} => {
if !protocol::valid_domain(&domain) { if !protocol::valid_domain(&domain) {
warn!("sign refused for uid={uid} pid={pid}: invalid domain {domain:?}"); warn!("sign refused for uid={uid} pid={pid}: invalid domain {domain:?}");
return refusal("invalid domain: ascii alphanumeric/-/_/., max 64 chars"); return refusal("invalid domain: ascii alphanumeric/-/_/., max 64 chars");

View file

@ -1,7 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::{info, warn, debug}; use tracing::{debug, info, warn};
mod api; mod api;
mod backend; mod backend;
@ -14,8 +14,8 @@ mod machined;
mod server; mod server;
mod ui; mod ui;
use clap::{Parser, Subcommand, CommandFactory}; use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{Shell, generate}; use clap_complete::{generate, Shell};
use core::config::ConsciousnessConfig; use core::config::ConsciousnessConfig;
use ui::App; use ui::App;
@ -91,7 +91,7 @@ Run `souveraine chat` to begin a conversation. \
Run `souveraine tui` for the full presence. \ Run `souveraine tui` for the full presence. \
Run `souveraine init` to summon Souveraine into a new place.", 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", 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, max_term_width = 100
)] )]
struct Cli { struct Cli {
/// Which agent to speak as. If omitted, the first available agent is used. /// Which agent to speak as. If omitted, the first available agent is used.
@ -146,11 +146,15 @@ enum Commands {
}, },
/// List the beings that live here /// 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.")] #[command(
long_about = "Show every configured agent — who they are, which model they speak through, what triggers know them."
)]
Agents, Agents,
/// List or set models /// List or set models
#[command(long_about = "List all available models from Bifrost and configured models. Or set a specific model as primary.")] #[command(
long_about = "List all available models from Bifrost and configured models. Or set a specific model as primary."
)]
Model { Model {
/// Model name to set as primary (omit to list all models) /// Model name to set as primary (omit to list all models)
#[arg(short, long)] #[arg(short, long)]
@ -166,7 +170,9 @@ enum Commands {
}, },
/// Show the world's current state /// Show the world's current state
#[command(long_about = "Display Souveraine's running configuration and the state of every subsystem.")] #[command(
long_about = "Display Souveraine's running configuration and the state of every subsystem."
)]
Status, Status,
/// Announce capabilities to your shell (bash, zsh, fish) /// Announce capabilities to your shell (bash, zsh, fish)
@ -179,7 +185,9 @@ enum Commands {
}, },
/// Start the HTTP server /// Start the HTTP server
#[command(long_about = "Start the Souveraine HTTP server. Defaults come from souveraine.toml [server]; flags override.")] #[command(
long_about = "Start the Souveraine HTTP server. Defaults come from souveraine.toml [server]; flags override."
)]
Server { Server {
/// Bind address (overrides [server].bind) /// Bind address (overrides [server].bind)
#[arg(short, long)] #[arg(short, long)]
@ -190,9 +198,11 @@ enum Commands {
}, },
/// Run as a lite listener — minimal presence, wakes the full engine on summon /// Run as a lite listener — minimal presence, wakes the full engine on summon
#[command(long_about = "Start a lightweight listener: federation transport and the \ #[command(
long_about = "Start a lightweight listener: federation transport and the \
summon endpoint only, no agents or database loaded. It receives reach/consult requests \ 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.")] and — with [federation].auto_wake — spawns the full server to answer them."
)]
Listen { Listen {
/// Bind address (overrides [server].bind) /// Bind address (overrides [server].bind)
#[arg(short, long)] #[arg(short, long)]
@ -236,7 +246,9 @@ and — with [federation].auto_wake — spawns the full server to answer them.")
}, },
/// Show known federated peers /// Show known federated peers
#[command(long_about = "Show all peers tracked by the device registry, from federation announcements.")] #[command(
long_about = "Show all peers tracked by the device registry, from federation announcements."
)]
Peers { Peers {
/// Output as JSON /// Output as JSON
#[arg(long)] #[arg(long)]
@ -276,17 +288,16 @@ enum ScheduleAction {
cron: Option<String>, cron: Option<String>,
#[arg(long)] #[arg(long)]
interval: Option<u64>, interval: Option<u64>,
#[arg(long, default_value = "You wake. Check your state, act if needed, or return silently.")] #[arg(
long,
default_value = "You wake. Check your state, act if needed, or return silently."
)]
prompt: String, prompt: String,
}, },
/// Delete a schedule /// Delete a schedule
Delete { Delete { name: String },
name: String,
},
/// Trigger a schedule immediately /// Trigger a schedule immediately
Run { Run { name: String },
name: String,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -296,9 +307,7 @@ enum IdentityAction {
/// Generate a new seed identity (WARNING: replaces existing) /// Generate a new seed identity (WARNING: replaces existing)
Generate, Generate,
/// Sign a message with the seed key (for testing/verification) /// Sign a message with the seed key (for testing/verification)
Sign { Sign { message: String },
message: String,
},
/// Verify a signature against a public key /// Verify a signature against a public key
Verify { Verify {
#[arg(long)] #[arg(long)]
@ -335,9 +344,7 @@ enum EventsAction {
count: usize, count: usize,
}, },
/// Show events from a specific date (YYYY-MM-DD) /// Show events from a specific date (YYYY-MM-DD)
Date { Date { date: String },
date: String,
},
/// Purge old event logs /// Purge old event logs
Purge { Purge {
/// Days to retain (default from config) /// Days to retain (default from config)
@ -422,20 +429,51 @@ async fn main() -> anyhow::Result<()> {
let config = load_config().await?; let config = load_config().await?;
let config = Arc::new(RwLock::new(config)); let config = Arc::new(RwLock::new(config));
match cli.command.as_ref().unwrap_or(&Commands::Chat { message: None }) { match cli
Commands::Tui => run_tui(config.clone(), cli.agent.clone(), cli.engine.clone(), cli.local).await?, .command
Commands::Chat { message } => run_chat(config, cli.agent.clone(), message.clone(), cli.json, cli.quiet, cli.local).await?, .as_ref()
Commands::Agents => run_agents(config, cli.json, cli.local).await?, .unwrap_or(&Commands::Chat { message: None })
Commands::Model { model, json, verbose } => { {
cli::run_model_command(model.as_deref(), *json, *verbose).await? 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::Status => run_status(config, cli.json).await?,
Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?, Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?,
Commands::Listen { bind, port } => run_listen(bind.clone(), *port, config).await?, Commands::Listen { bind, port } => run_listen(bind.clone(), *port, config).await?,
Commands::Reflect { conversation } => { Commands::Reflect { conversation } => {
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await? 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!(), Commands::Init
| Commands::Completions { .. }
| Commands::Auth { .. }
| Commands::Schedule { .. }
| Commands::Identity { .. }
| Commands::Machine { .. }
| Commands::Events { .. }
| Commands::Peers { .. } => unreachable!(),
} }
Ok(()) Ok(())
@ -466,7 +504,10 @@ async fn run_init(json: bool) -> anyhow::Result<()> {
if json { if json {
println!(r#"{{"status":"written","path":"souveraine.toml"}}"#); println!(r#"{{"status":"written","path":"souveraine.toml"}}"#);
} else { } else {
println!("Config template written to {}/souveraine.toml", std::env::current_dir()?.display()); println!(
"Config template written to {}/souveraine.toml",
std::env::current_dir()?.display()
);
println!(" Run `souveraine tui` to set up — the wizard configures Bifrost"); println!(" Run `souveraine tui` to set up — the wizard configures Bifrost");
println!(" and creates your agent. (Editing the file first is optional.)"); println!(" and creates your agent. (Editing the file first is optional.)");
} }
@ -543,7 +584,10 @@ async fn run_auth(action: &AuthAction, json: bool) -> anyhow::Result<()> {
fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyhow::Result<String> { 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"); let agents_dir = base.join("server").join("agents");
if !agents_dir.exists() { if !agents_dir.exists() {
anyhow::bail!("no agents directory at {} — has any agent been created yet?", agents_dir.display()); anyhow::bail!(
"no agents directory at {} — has any agent been created yet?",
agents_dir.display()
);
} }
let direct = agents_dir.join(name_or_id); let direct = agents_dir.join(name_or_id);
@ -554,9 +598,17 @@ fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyho
for entry in std::fs::read_dir(&agents_dir)? { for entry in std::fs::read_dir(&agents_dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path().join("agent.json"); let path = entry.path().join("agent.json");
if !path.exists() { continue; } if !path.exists() {
let raw = match std::fs::read_to_string(&path) { Ok(s) => s, Err(_) => continue }; continue;
let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => 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 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(""); let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("");
if name == name_or_id || id == name_or_id { if name == name_or_id || id == name_or_id {
@ -564,7 +616,11 @@ fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyho
} }
} }
anyhow::bail!("no agent matched '{}' (checked name and id across {})", name_or_id, agents_dir.display()) 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 /// Return the first agent UUID found on disk. Used when --agent is omitted
@ -576,13 +632,18 @@ fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyho
fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> { fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> {
let memfs_dir = base.join("agents"); let memfs_dir = base.join("agents");
if !memfs_dir.exists() { if !memfs_dir.exists() {
anyhow::bail!("no agents directory at {} — run `souveraine init` first", memfs_dir.display()); anyhow::bail!(
"no agents directory at {} — run `souveraine init` first",
memfs_dir.display()
);
} }
let mut candidates: Vec<String> = Vec::new(); let mut candidates: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&memfs_dir)? { for entry in std::fs::read_dir(&memfs_dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
if !path.is_dir() { continue; } if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|s| s.to_str()) { let name = match path.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_string(), Some(n) => n.to_string(),
None => continue, None => continue,
@ -595,7 +656,10 @@ fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> {
} }
candidates.sort(); candidates.sort();
candidates.into_iter().next().ok_or_else(|| { candidates.into_iter().next().ok_or_else(|| {
anyhow::anyhow!("no agents with memory found under {} — run `souveraine init`", memfs_dir.display()) anyhow::anyhow!(
"no agents with memory found under {} — run `souveraine init`",
memfs_dir.display()
)
}) })
} }
@ -607,9 +671,7 @@ async fn run_identity(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
use crate::core::identity::SeedId; use crate::core::identity::SeedId;
let base = dirs::home_dir() let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
.unwrap_or_default()
.join(".souveraine");
// Identity defaults to per-agent. --host opts out to the machine seed // Identity defaults to per-agent. --host opts out to the machine seed
// used for federation transport. Verify is identity-agnostic — it just // used for federation transport. Verify is identity-agnostic — it just
@ -617,7 +679,11 @@ async fn run_identity(
// //
// Per-agent seeds live at ~/.souveraine/agents/{uuid}/seed/. // Per-agent seeds live at ~/.souveraine/agents/{uuid}/seed/.
let (seed_dir, scope_label, agent_uuid) = if host { let (seed_dir, scope_label, agent_uuid) = if host {
(SeedId::default_dir(&base), "Host Seed Identity".to_string(), None) (
SeedId::default_dir(&base),
"Host Seed Identity".to_string(),
None,
)
} else { } else {
let uuid = match agent { let uuid = match agent {
Some(name_or_id) => resolve_agent_id_from_disk(&base, name_or_id)?, Some(name_or_id) => resolve_agent_id_from_disk(&base, name_or_id)?,
@ -653,13 +719,23 @@ async fn run_identity(
} }
IdentityAction::Generate => { IdentityAction::Generate => {
if seed_dir.join("private.key").exists() { if seed_dir.join("private.key").exists() {
eprintln!("WARNING: A seed identity already exists at {}", seed_dir.display()); eprintln!(
"WARNING: A seed identity already exists at {}",
seed_dir.display()
);
eprintln!(" Generating a new one will replace it. This breaks federation trust."); eprintln!(" Generating a new one will replace it. This breaks federation trust.");
eprintln!(" Use `souveraine identity show` to see the current identity."); eprintln!(" Use `souveraine identity show` to see the current identity.");
anyhow::bail!("Refusing to overwrite existing seed identity. Delete {} manually first.", seed_dir.display()); anyhow::bail!(
"Refusing to overwrite existing seed identity. Delete {} manually first.",
seed_dir.display()
);
} }
let seed = SeedId::load_or_generate(&seed_dir)?; let seed = SeedId::load_or_generate(&seed_dir)?;
println!("Generated seed identity: {} (glyph: {})", seed.public_key_hex(), seed.glyph()); println!(
"Generated seed identity: {} (glyph: {})",
seed.public_key_hex(),
seed.glyph()
);
} }
IdentityAction::Sign { message } => { IdentityAction::Sign { message } => {
// load, never generate: signing with a key that didn't exist a // load, never generate: signing with a key that didn't exist a
@ -668,18 +744,25 @@ async fn run_identity(
let sig = seed.sign(message.as_bytes()); let sig = seed.sign(message.as_bytes());
let sig_hex = hex::encode(sig.to_bytes()); let sig_hex = hex::encode(sig.to_bytes());
if json { if json {
println!("{}", serde_json::json!({ println!(
"{}",
serde_json::json!({
"message": message, "message": message,
"signature": sig_hex, "signature": sig_hex,
"public_key": seed.public_key_hex(), "public_key": seed.public_key_hex(),
"glyph": seed.glyph(), "glyph": seed.glyph(),
})); })
);
} else { } else {
println!("Signature: {sig_hex}"); println!("Signature: {sig_hex}");
println!("Public key: {}", seed.public_key_hex()); println!("Public key: {}", seed.public_key_hex());
} }
} }
IdentityAction::Verify { pubkey, message, signature } => { IdentityAction::Verify {
pubkey,
message,
signature,
} => {
let pubkey_bytes = hex::decode(pubkey)?; let pubkey_bytes = hex::decode(pubkey)?;
let sig_bytes = hex::decode(signature)?; let sig_bytes = hex::decode(signature)?;
if pubkey_bytes.len() != 32 || sig_bytes.len() != 64 { if pubkey_bytes.len() != 32 || sig_bytes.len() != 64 {
@ -707,7 +790,10 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
let seed_dir = PathBuf::from(DEFAULT_SEED_DIR); let seed_dir = PathBuf::from(DEFAULT_SEED_DIR);
match action { match action {
MachineAction::Init { fresh, migrate_from } => { MachineAction::Init {
fresh,
migrate_from,
} => {
if seed_dir.join("private.key").exists() { if seed_dir.join("private.key").exists() {
anyhow::bail!( anyhow::bail!(
"A machine identity already exists at {}. Refusing to overwrite — \ "A machine identity already exists at {}. Refusing to overwrite — \
@ -727,10 +813,7 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
(false, Some(source)) => { (false, Some(source)) => {
let source_key = source.join("private.key"); let source_key = source.join("private.key");
if !source_key.exists() { if !source_key.exists() {
anyhow::bail!( anyhow::bail!("no seed at {} — nothing to migrate", source_key.display());
"no seed at {} — nothing to migrate",
source_key.display()
);
} }
std::fs::create_dir_all(&seed_dir)?; std::fs::create_dir_all(&seed_dir)?;
std::fs::copy(&source_key, seed_dir.join("private.key"))?; std::fs::copy(&source_key, seed_dir.join("private.key"))?;
@ -779,12 +862,15 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
} }
if json { if json {
println!("{}", serde_json::json!({ println!(
"{}",
serde_json::json!({
"public_key": seed.public_key_hex(), "public_key": seed.public_key_hex(),
"glyph": seed.glyph(), "glyph": seed.glyph(),
"seed_dir": seed_dir.display().to_string(), "seed_dir": seed_dir.display().to_string(),
"owned_by_service_user": owned, "owned_by_service_user": owned,
})); })
);
} else { } else {
println!("Machine identity provisioned."); println!("Machine identity provisioned.");
println!(" Glyph: {}", seed.glyph()); println!(" Glyph: {}", seed.glyph());
@ -821,13 +907,16 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
Err(_) => "absent", Err(_) => "absent",
}; };
if json { if json {
println!("{}", serde_json::json!({ println!(
"{}",
serde_json::json!({
"ok": false, "ok": false,
"daemon": "unreachable", "daemon": "unreachable",
"reason": format!("{e:#}"), "reason": format!("{e:#}"),
"seed": seed_state, "seed": seed_state,
"seed_dir": seed_dir.display().to_string(), "seed_dir": seed_dir.display().to_string(),
})); })
);
} else { } else {
println!("souveraine-machined: not reachable ({e:#})"); println!("souveraine-machined: not reachable ({e:#})");
println!(" Seed at {}: {seed_state}", seed_dir.display()); println!(" Seed at {}: {seed_state}", seed_dir.display());
@ -846,9 +935,7 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
} }
async fn run_peers(json: bool) -> anyhow::Result<()> { async fn run_peers(json: bool) -> anyhow::Result<()> {
let base = dirs::home_dir() let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
.unwrap_or_default()
.join(".souveraine");
let known_path = base.join("federation").join("known_peers.json"); let known_path = base.join("federation").join("known_peers.json");
if !known_path.exists() { if !known_path.exists() {
@ -872,7 +959,11 @@ async fn run_peers(json: bool) -> anyhow::Result<()> {
println!("\n── Federated Peers ──────────────────────"); println!("\n── Federated Peers ──────────────────────");
for p in &peers { for p in &peers {
let status = if p.alive { "alive" } else { "offline" }; let status = if p.alive { "alive" } else { "offline" };
println!(" {} {}", p.seed_id.get(..16).unwrap_or(&p.seed_id), status); println!(
" {} {}",
p.seed_id.get(..16).unwrap_or(&p.seed_id),
status
);
if let Some(label) = &p.label { if let Some(label) = &p.label {
println!(" label: {label}"); println!(" label: {label}");
} }
@ -895,9 +986,7 @@ async fn run_peers(json: bool) -> anyhow::Result<()> {
async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> { async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
use crate::core::nervous::event_log; use crate::core::nervous::event_log;
let base = dirs::home_dir() let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
.unwrap_or_default()
.join(".souveraine");
let events_dir = base.join("events"); let events_dir = base.join("events");
match action { match action {
@ -959,12 +1048,14 @@ async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn run_schedule(action: &ScheduleAction, agent: Option<&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; use crate::core::nervous::cron::parse_schedule_file;
let souveraine_base = dirs::home_dir() let souveraine_base = dirs::home_dir().unwrap_or_default().join(".souveraine");
.unwrap_or_default()
.join(".souveraine");
let agent_id = match agent { let agent_id = match agent {
Some(name_or_id) => resolve_agent_id_from_disk(&souveraine_base, name_or_id)?, Some(name_or_id) => resolve_agent_id_from_disk(&souveraine_base, name_or_id)?,
None => first_agent_id_on_disk(&souveraine_base)?, None => first_agent_id_on_disk(&souveraine_base)?,
@ -1020,7 +1111,12 @@ async fn run_schedule(action: &ScheduleAction, agent: Option<&str>, json: bool)
} }
} }
ScheduleAction::Create { name, cron, interval, prompt } => { ScheduleAction::Create {
name,
cron,
interval,
prompt,
} => {
let file_path = base.join(format!("{name}.md")); let file_path = base.join(format!("{name}.md"));
if file_path.exists() { if file_path.exists() {
anyhow::bail!("schedule '{name}' already exists"); anyhow::bail!("schedule '{name}' already exists");
@ -1099,9 +1195,9 @@ async fn run_tuie_tui(
_agent_pref: Option<String>, _agent_pref: Option<String>,
_local: bool, _local: bool,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
use crate::ui::presence::Presence;
use crate::ui::chat::ChatPalette;
use crate::ui::atmosphere::Atmosphere; use crate::ui::atmosphere::Atmosphere;
use crate::ui::chat::ChatPalette;
use crate::ui::presence::Presence;
// Register the tokio spawner with tuie // Register the tokio spawner with tuie
crate::ui::tuie_app::TuieApp::setup_spawner(); crate::ui::tuie_app::TuieApp::setup_spawner();
@ -1183,7 +1279,10 @@ async fn run_chat(
// Resolve the agent: if `--agent` matches a name or id, use it; else first available. // Resolve the agent: if `--agent` matches a name or id, use it; else first available.
let agents = backend.list_agents().await?; let agents = backend.list_agents().await?;
let resolved = match &agent_name { let resolved = match &agent_name {
Some(n) => agents.iter().find(|a| a.name == *n || a.id == *n).or_else(|| agents.first()), Some(n) => agents
.iter()
.find(|a| a.name == *n || a.id == *n)
.or_else(|| agents.first()),
None => agents.first(), None => agents.first(),
}; };
let agent = match resolved { let agent = match resolved {
@ -1194,7 +1293,11 @@ async fn run_chat(
} else { } else {
"No agents on the server. Create one via POST /v1/agents." "No agents on the server. Create one via POST /v1/agents."
}; };
if json { println!(r#"{{"status":"no-agents"}}"#); } else { eprintln!("{}", hint); } if json {
println!(r#"{{"status":"no-agents"}}"#);
} else {
eprintln!("{}", hint);
}
return Ok(()); return Ok(());
} }
}; };
@ -1211,7 +1314,11 @@ async fn run_chat(
if pending.len() == 1 { "" } else { "s" }, if pending.len() == 1 { "" } else { "s" },
); );
for p in &pending { for p in &pending {
let tag = if p.source.is_empty() { p.kind.as_str() } else { p.source.as_str() }; let tag = if p.source.is_empty() {
p.kind.as_str()
} else {
p.source.as_str()
};
eprintln!(" [{}] {}", tag, p.content); eprintln!(" [{}] {}", tag, p.content);
} }
eprintln!(); eprintln!();
@ -1220,12 +1327,16 @@ async fn run_chat(
let one_shot = message.clone(); let one_shot = message.clone();
if let Some(msg) = one_shot { if let Some(msg) = one_shot {
let mut stream = backend.send(&conv_id, &msg).await?; let mut stream = backend.send(&conv_id, &msg).await?;
if !json { println!("\n {}: {}\n", agent.name, msg); print!(" "); } if !json {
println!("\n {}: {}\n", agent.name, msg);
print!(" ");
}
let mut full = String::new(); let mut full = String::new();
while let Some(ev) = stream.next().await { while let Some(ev) = stream.next().await {
match ev? { match ev? {
BackendEvent::Token(t) => { BackendEvent::Token(t) => {
if json { /* collect */ } else { if json { /* collect */
} else {
use std::io::Write; use std::io::Write;
print!("{}", t); print!("{}", t);
std::io::stdout().flush().ok(); std::io::stdout().flush().ok();
@ -1233,10 +1344,16 @@ async fn run_chat(
full.push_str(&t); full.push_str(&t);
} }
BackendEvent::Reasoning(r) => { BackendEvent::Reasoning(r) => {
if !json { eprintln!("\n [thinking] {}", r); } if !json {
eprintln!("\n [thinking] {}", r);
}
}
BackendEvent::Surfacing {
source, content, ..
} => {
if !json {
eprintln!("\n [{}] {}", source, content);
} }
BackendEvent::Surfacing { source, content, .. } => {
if !json { eprintln!("\n [{}] {}", source, content); }
} }
BackendEvent::Error { message } => { BackendEvent::Error { message } => {
if json { if json {
@ -1273,9 +1390,15 @@ async fn run_chat(
}; };
let Some(input) = line else { break }; let Some(input) = line else { break };
let input = input.trim().to_string(); let input = input.trim().to_string();
if input.is_empty() { continue; } if input.is_empty() {
if matches!(input.as_str(), "/exit" | "/quit" | "/q") { break; } continue;
if let Some(r) = rl.as_mut() { r.add_history_entry(&input).ok(); } }
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?; let mut stream = backend.send(&conv_id, &input).await?;
print!("\n {}: ", agent.name); print!("\n {}: ", agent.name);
@ -1283,9 +1406,14 @@ async fn run_chat(
std::io::stdout().flush().ok(); std::io::stdout().flush().ok();
while let Some(ev) = stream.next().await { while let Some(ev) = stream.next().await {
match ev? { match ev? {
BackendEvent::Token(t) => { print!("{}", t); std::io::stdout().flush().ok(); } BackendEvent::Token(t) => {
print!("{}", t);
std::io::stdout().flush().ok();
}
BackendEvent::Reasoning(r) => eprintln!("\n [thinking] {}", r), BackendEvent::Reasoning(r) => eprintln!("\n [thinking] {}", r),
BackendEvent::Surfacing { source, content, .. } => eprintln!("\n [{}] {}", source, content), BackendEvent::Surfacing {
source, content, ..
} => eprintln!("\n [{}] {}", source, content),
BackendEvent::Error { message } => eprintln!("\n [turn error] {}", message), BackendEvent::Error { message } => eprintln!("\n [turn error] {}", message),
BackendEvent::Done => break, BackendEvent::Done => break,
_ => {} _ => {}
@ -1310,7 +1438,10 @@ async fn run_reflect(
let summaries = server.agents.list(None).await?; let summaries = server.agents.list(None).await?;
let agent = match &agent_name { let agent = match &agent_name {
Some(n) => summaries.iter().find(|a| a.name == *n || a.id == *n).or_else(|| summaries.first()), Some(n) => summaries
.iter()
.find(|a| a.name == *n || a.id == *n)
.or_else(|| summaries.first()),
None => summaries.first(), None => summaries.first(),
} }
.ok_or_else(|| anyhow::anyhow!("no agents configured"))?; .ok_or_else(|| anyhow::anyhow!("no agents configured"))?;
@ -1324,16 +1455,13 @@ async fn run_reflect(
.join("agents") .join("agents")
.join(&agent.id), .join(&agent.id),
); );
let conv_id = match conversation { let conv_id =
match conversation {
Some(id) => id, Some(id) => id,
None => { None => {
let mut records = store.list_active().await?; let mut records = store.list_active().await?;
records.sort_by_key(|r| std::cmp::Reverse(r.updated_at)); records.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
records records.into_iter().next().map(|r| r.id).ok_or_else(|| {
.into_iter()
.next()
.map(|r| r.id)
.ok_or_else(|| {
anyhow::anyhow!("no conversations found for agent '{}'", agent.id) anyhow::anyhow!("no conversations found for agent '{}'", agent.id)
})? })?
} }
@ -1368,12 +1496,15 @@ async fn run_reflect(
println!(" turns reviewed: {}", report.turns_reviewed); println!(" turns reviewed: {}", report.turns_reviewed);
println!( println!(
" duration: {:.1}s", " duration: {:.1}s",
(report.completed_at - report.started_at) (report.completed_at - report.started_at).num_milliseconds() as f64 / 1000.0
.num_milliseconds() as f64 / 1000.0
); );
println!( println!(
" exited cleanly: {}", " exited cleanly: {}",
if report.exited_cleanly { "yes" } else { "no (tool rounds exhausted)" } if report.exited_cleanly {
"yes"
} else {
"no (tool rounds exhausted)"
}
); );
println!("─────────────────────────────────────────────────────"); println!("─────────────────────────────────────────────────────");
println!(); println!();
@ -1391,10 +1522,18 @@ async fn run_agents(
let (backend, mode) = resolve_backend(&config, force_local, json, false).await?; let (backend, mode) = resolve_backend(&config, force_local, json, false).await?;
let agents = backend.list_agents().await?; let agents = backend.list_agents().await?;
if json { if json {
let payload: Vec<_> = agents.iter().map(|a| serde_json::json!({ let payload: Vec<_> = agents
.iter()
.map(|a| {
serde_json::json!({
"id": a.id, "name": a.name, "description": a.description, "id": a.id, "name": a.name, "description": a.description,
})).collect(); })
println!("{}", serde_json::to_string_pretty(&serde_json::json!({"agents": payload}))?); })
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({"agents": payload}))?
);
} else { } else {
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(" Agents ({} mode)", mode); println!(" Agents ({} mode)", mode);
@ -1404,14 +1543,15 @@ async fn run_agents(
} }
for a in &agents { for a in &agents {
println!(" {} {}", a.id, a.name); println!(" {} {}", a.id, a.name);
if let Some(d) = &a.description { println!(" {}", d); } if let Some(d) = &a.description {
println!(" {}", d);
}
} }
println!(); println!();
} }
Ok(()) Ok(())
} }
async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> anyhow::Result<()> { async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> anyhow::Result<()> {
let cfg = config.read().await; let cfg = config.read().await;
@ -1447,11 +1587,22 @@ async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> any
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(" Bifrost: {}", cfg.bifrost.base_url); println!(" Bifrost: {}", cfg.bifrost.base_url);
println!(" Voice: {}", cfg.bifrost.primary_model); println!(" Voice: {}", cfg.bifrost.primary_model);
println!(" Memory: git={}, auto_commit={}", cfg.memory.git_enabled, cfg.memory.auto_commit); println!(
println!(" N+1: enabled={}, inbox={}", cfg.subconscious.n1_enabled, cfg.subconscious.inbox_enabled); " Memory: git={}, auto_commit={}",
println!(" Reflection: enabled={}, every {} messages", cfg.reflection.enabled, cfg.reflection.message_interval); cfg.memory.git_enabled, cfg.memory.auto_commit
println!(" Archivist: enabled={}, every {} msgs at {} pressure", );
cfg.archivist.enabled, cfg.archivist.interval, cfg.archivist.threshold); 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!(" Compresses via: {}", cfg.archivist.compression_model);
println!(" Models known: {}", cfg.models.len()); println!(" Models known: {}", cfg.models.len());
println!(); println!();

View file

@ -94,7 +94,13 @@ impl Collection {
{ {
let mut store = self.store.lock().unwrap(); let mut store = self.store.lock().unwrap();
store store
.set(id.clone(), label, attributes, secret.content_type.clone(), &plaintext) .set(
id.clone(),
label,
attributes,
secret.content_type.clone(),
&plaintext,
)
.map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?; .map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?;
} }

View file

@ -45,8 +45,8 @@ impl DhSession {
/// Generate our private exponent and compute the public value to send back /// Generate our private exponent and compute the public value to send back
/// to the client as `OpenSession`'s output. /// to the client as `OpenSession`'s output.
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let prime = BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16) let prime =
.context("parsing MODP-1024 prime")?; BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16).context("parsing MODP-1024 prime")?;
let generator = BigUint::from(GENERATOR); let generator = BigUint::from(GENERATOR);
// 1024-bit exchange: a private exponent as wide as the group avoids // 1024-bit exchange: a private exponent as wide as the group avoids
@ -59,7 +59,11 @@ impl DhSession {
let public = generator.modpow(&private, &prime); let public = generator.modpow(&private, &prime);
Ok(Self { private, prime, public }) Ok(Self {
private,
prime,
public,
})
} }
/// Combine the peer's public value with our private exponent, then run /// Combine the peer's public value with our private exponent, then run
@ -117,7 +121,17 @@ mod tests {
assert_eq!(bytes.len(), 128); assert_eq!(bytes.len(), 128);
// libsecret dh_group_1024_prime starts FF×8, C9 0F DA A2 … and ends // libsecret dh_group_1024_prime starts FF×8, C9 0F DA A2 … and ends
// … EC E6 53 81, FF×8. // … EC E6 53 81, FF×8.
assert_eq!(&bytes[..10], &[0xFF; 8].iter().chain([0xC9, 0x0F].iter()).copied().collect::<Vec<u8>>()[..]); assert_eq!(
assert_eq!(&bytes[116..], &[0xEC, 0xE6, 0x53, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); &bytes[..10],
&[0xFF; 8]
.iter()
.chain([0xC9, 0x0F].iter())
.copied()
.collect::<Vec<u8>>()[..]
);
assert_eq!(
&bytes[116..],
&[0xEC, 0xE6, 0x53, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
);
} }
} }

View file

@ -123,7 +123,11 @@ impl Item {
#[zbus(property)] #[zbus(property)]
async fn attributes(&self) -> HashMap<String, String> { async fn attributes(&self) -> HashMap<String, String> {
self.store.lock().unwrap().attributes(&self.id).unwrap_or_default() self.store
.lock()
.unwrap()
.attributes(&self.id)
.unwrap_or_default()
} }
#[zbus(property)] #[zbus(property)]
@ -141,7 +145,11 @@ impl Item {
#[zbus(property)] #[zbus(property)]
async fn label(&self) -> String { async fn label(&self) -> String {
self.store.lock().unwrap().label(&self.id).unwrap_or_default() self.store
.lock()
.unwrap()
.label(&self.id)
.unwrap_or_default()
} }
#[zbus(property)] #[zbus(property)]

View file

@ -59,7 +59,8 @@ impl SecretService {
.derive_session_key(&peer_public) .derive_session_key(&peer_public)
.map_err(|e| SecretError::failed(format!("DH key derivation failed: {e}")))?; .map_err(|e| SecretError::failed(format!("DH key derivation failed: {e}")))?;
self.sessions.insert(path.clone(), SessionCrypto::Aes { key }); self.sessions
.insert(path.clone(), SessionCrypto::Aes { key });
let our_public_bytes = dh.public.to_bytes_be(); let our_public_bytes = dh.public.to_bytes_be();
OwnedValue::try_from(Value::from(our_public_bytes)).unwrap() OwnedValue::try_from(Value::from(our_public_bytes)).unwrap()
@ -149,7 +150,9 @@ impl SecretService {
.content_type(id) .content_type(id)
.unwrap_or_else(|| "text/plain".to_string()); .unwrap_or_else(|| "text/plain".to_string());
let Some((parameters, value)) = self.sessions.seal(&session, &plaintext) else { let Some((parameters, value)) = self.sessions.seal(&session, &plaintext) else {
return Err(SecretError::NoSession(format!("no such session: {session}"))); return Err(SecretError::NoSession(format!(
"no such session: {session}"
)));
}; };
out.insert( out.insert(
item_obj_path, item_obj_path,

View file

@ -58,7 +58,12 @@ impl Sessions {
/// Decrypt a `Secret`'s `(parameters, value)` per the session's negotiated /// Decrypt a `Secret`'s `(parameters, value)` per the session's negotiated
/// transport. /// transport.
pub fn unseal(&self, path: &OwnedObjectPath, parameters: &[u8], value: &[u8]) -> Option<anyhow::Result<Vec<u8>>> { pub fn unseal(
&self,
path: &OwnedObjectPath,
parameters: &[u8],
value: &[u8],
) -> Option<anyhow::Result<Vec<u8>>> {
let sessions = self.inner.lock().unwrap(); let sessions = self.inner.lock().unwrap();
match sessions.get(path)? { match sessions.get(path)? {
SessionCrypto::Plain => Some(Ok(value.to_vec())), SessionCrypto::Plain => Some(Ok(value.to_vec())),

View file

@ -18,14 +18,19 @@ pub struct Session {
#[interface(name = "org.freedesktop.Secret.Session")] #[interface(name = "org.freedesktop.Secret.Session")]
impl Session { impl Session {
async fn close(&self, #[zbus(signal_emitter)] emitter: SignalEmitter<'_>) -> Result<(), SecretError> { async fn close(
&self,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> Result<(), SecretError> {
self.sessions.remove(&self.path); self.sessions.remove(&self.path);
emitter emitter
.connection() .connection()
.object_server() .object_server()
.remove::<Session, _>(self.path.clone()) .remove::<Session, _>(self.path.clone())
.await .await
.map_err(|e| SecretError::failed(format!("failed to unregister session object: {e}")))?; .map_err(|e| {
SecretError::failed(format!("failed to unregister session object: {e}"))
})?;
Ok(()) Ok(())
} }
} }

View file

@ -133,9 +133,6 @@ mod tests {
b"souveraine-machined:v1:secrets-store-key:v1".to_vec() b"souveraine-machined:v1:secrets-store-key:v1".to_vec()
); );
// Ed25519 is deterministic — same seed, same payload, same material. // Ed25519 is deterministic — same seed, same payload, same material.
assert_eq!( assert_eq!(seed.sign(&framed).to_bytes(), seed.sign(&framed).to_bytes());
seed.sign(&framed).to_bytes(),
seed.sign(&framed).to_bytes()
);
} }
} }

View file

@ -241,7 +241,9 @@ impl SecretStore {
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600); options.mode(0o600);
} }
let mut f = options.open(&tmp).context("creating secrets store temp file")?; let mut f = options
.open(&tmp)
.context("creating secrets store temp file")?;
f.write_all(&raw)?; f.write_all(&raw)?;
f.sync_all()?; f.sync_all()?;
} }
@ -293,7 +295,9 @@ impl SecretStore {
.items .items
.iter() .iter()
.filter(|(_, item)| { .filter(|(_, item)| {
attributes.iter().all(|(k, v)| item.attributes.get(k) == Some(v)) attributes
.iter()
.all(|(k, v)| item.attributes.get(k) == Some(v))
}) })
.map(|(id, _)| id.clone()) .map(|(id, _)| id.clone())
.collect() .collect()
@ -349,7 +353,13 @@ fn seal(key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
let mut nonce = [0u8; 12]; let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce); rand::thread_rng().fill_bytes(&mut nonce);
let ciphertext = cipher let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), Payload { msg: plaintext, aad }) .encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| anyhow::anyhow!("AEAD seal failed"))?; .map_err(|_| anyhow::anyhow!("AEAD seal failed"))?;
let mut out = nonce.to_vec(); let mut out = nonce.to_vec();
out.extend(ciphertext); out.extend(ciphertext);
@ -361,7 +371,13 @@ fn open_sealed(key: &[u8; 32], aad: &[u8], sealed: &[u8]) -> Result<Vec<u8>> {
let (nonce, ciphertext) = sealed.split_at(12); let (nonce, ciphertext) = sealed.split_at(12);
let cipher = Aes256Gcm::new(key.into()); let cipher = Aes256Gcm::new(key.into());
cipher cipher
.decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad }) .decrypt(
Nonce::from_slice(nonce),
Payload {
msg: ciphertext,
aad,
},
)
.map_err(|_| anyhow::anyhow!("AEAD open failed — wrong key or tampered data")) .map_err(|_| anyhow::anyhow!("AEAD open failed — wrong key or tampered data"))
} }
@ -433,7 +449,13 @@ mod tests {
let path = dir.path().join("store.json"); let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap(); let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
store store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s") .set(
"id".into(),
"l".into(),
HashMap::new(),
"text/plain".into(),
b"s",
)
.unwrap(); .unwrap();
drop(store); drop(store);
@ -446,7 +468,13 @@ mod tests {
let path = dir.path().join("store.json"); let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path).unwrap(); let mut store = SecretStore::open(ikm(), path).unwrap();
store store
.set("a".into(), "l".into(), HashMap::new(), "text/plain".into(), b"secret-a") .set(
"a".into(),
"l".into(),
HashMap::new(),
"text/plain".into(),
b"secret-a",
)
.unwrap(); .unwrap();
// Grafting a's ciphertext onto id b must fail the AAD check. // Grafting a's ciphertext onto id b must fail the AAD check.
let sealed = store.file.items.get("a").unwrap().sealed.clone(); let sealed = store.file.items.get("a").unwrap().sealed.clone();
@ -460,14 +488,20 @@ mod tests {
let mut store = SecretStore::open(ikm(), path.clone()).unwrap(); let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
assert!(!store.has_passphrase()); assert!(!store.has_passphrase());
store.set_passphrase("correct horse battery staple").unwrap(); store
.set_passphrase("correct horse battery staple")
.unwrap();
assert!(store.has_passphrase()); assert!(store.has_passphrase());
assert!(store.verify_passphrase("correct horse battery staple").unwrap()); assert!(store
.verify_passphrase("correct horse battery staple")
.unwrap());
assert!(!store.verify_passphrase("wrong").unwrap()); assert!(!store.verify_passphrase("wrong").unwrap());
// The wrap survives a reload and still verifies. // The wrap survives a reload and still verifies.
let store2 = SecretStore::open(ikm(), path).unwrap(); let store2 = SecretStore::open(ikm(), path).unwrap();
assert!(store2.verify_passphrase("correct horse battery staple").unwrap()); assert!(store2
.verify_passphrase("correct horse battery staple")
.unwrap());
} }
#[test] #[test]
@ -476,7 +510,13 @@ mod tests {
let path = dir.path().join("store.json"); let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path).unwrap(); let mut store = SecretStore::open(ikm(), path).unwrap();
store store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s") .set(
"id".into(),
"l".into(),
HashMap::new(),
"text/plain".into(),
b"s",
)
.unwrap(); .unwrap();
let sealed_before = store.file.items.get("id").unwrap().sealed.clone(); let sealed_before = store.file.items.get("id").unwrap().sealed.clone();
@ -494,11 +534,23 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::open(ikm(), dir.path().join("store.json")).unwrap(); let mut store = SecretStore::open(ikm(), dir.path().join("store.json")).unwrap();
store store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"a") .set(
"id".into(),
"l".into(),
HashMap::new(),
"text/plain".into(),
b"a",
)
.unwrap(); .unwrap();
let created = store.created("id").unwrap(); let created = store.created("id").unwrap();
store store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"b") .set(
"id".into(),
"l".into(),
HashMap::new(),
"text/plain".into(),
b"b",
)
.unwrap(); .unwrap();
assert_eq!(store.created("id").unwrap(), created); assert_eq!(store.created("id").unwrap(), created);
assert!(store.modified("id").unwrap() >= created); assert!(store.modified("id").unwrap() >= created);
@ -510,7 +562,13 @@ mod tests {
let path = dir.path().join("store.json"); let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap(); let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
store store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s") .set(
"id".into(),
"l".into(),
HashMap::new(),
"text/plain".into(),
b"s",
)
.unwrap(); .unwrap();
#[cfg(unix)] #[cfg(unix)]
{ {

View file

@ -1,4 +1,7 @@
use crate::api::models::{AgentState, AgentSummary, CreateAgentRequest, MemoryConfig, MemoryBlock, SouveraineConfig, UpdateAgentRequest}; use crate::api::models::{
AgentState, AgentSummary, CreateAgentRequest, MemoryBlock, MemoryConfig, SouveraineConfig,
UpdateAgentRequest,
};
use chrono::Utc; use chrono::Utc;
use dashmap::DashMap; use dashmap::DashMap;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@ -37,7 +40,11 @@ impl AgentInventory {
pub async fn new(data_dir: PathBuf, db: SqlitePool) -> anyhow::Result<Self> { pub async fn new(data_dir: PathBuf, db: SqlitePool) -> anyhow::Result<Self> {
tokio::fs::create_dir_all(&data_dir).await?; tokio::fs::create_dir_all(&data_dir).await?;
// ~/.souveraine/ — common ancestor of server/, agents/, subconscious-agents/. // ~/.souveraine/ — common ancestor of server/, agents/, subconscious-agents/.
let souveraine_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or_else(|| { let souveraine_root = data_dir
.parent()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".souveraine") home.join(".souveraine")
}); });
@ -126,7 +133,9 @@ impl AgentInventory {
pub async fn register_instance(&self, agent_id: &str, instance_id: &str) -> anyhow::Result<()> { pub async fn register_instance(&self, agent_id: &str, instance_id: &str) -> anyhow::Result<()> {
let pid = std::process::id() as i64; let pid = std::process::id() as i64;
let hostname = hostname_or_unknown(); let hostname = hostname_or_unknown();
sqlx::query("DELETE FROM agent_instances WHERE last_seen_at < datetime('now', '-5 minutes')") sqlx::query(
"DELETE FROM agent_instances WHERE last_seen_at < datetime('now', '-5 minutes')",
)
.execute(&self.db) .execute(&self.db)
.await?; .await?;
sqlx::query( sqlx::query(
@ -149,10 +158,14 @@ impl AgentInventory {
/// Heartbeat the instance rows for this process: bump last_seen_at and /// Heartbeat the instance rows for this process: bump last_seen_at and
/// add `tick_seconds` to each agent's lifetime_active_seconds. Called /// add `tick_seconds` to each agent's lifetime_active_seconds. Called
/// from a 30s loop in SouveraineServer. /// from a 30s loop in SouveraineServer.
pub async fn heartbeat_instance(&self, instance_id: &str, tick_seconds: i64) -> anyhow::Result<()> { pub async fn heartbeat_instance(
&self,
instance_id: &str,
tick_seconds: i64,
) -> anyhow::Result<()> {
// Bump last_seen for every row owned by this instance. // Bump last_seen for every row owned by this instance.
let updated = sqlx::query( let updated = sqlx::query(
"UPDATE agent_instances SET last_seen_at = CURRENT_TIMESTAMP WHERE instance_id = ?1" "UPDATE agent_instances SET last_seen_at = CURRENT_TIMESTAMP WHERE instance_id = ?1",
) )
.bind(instance_id) .bind(instance_id)
.execute(&self.db) .execute(&self.db)
@ -164,7 +177,7 @@ impl AgentInventory {
sqlx::query( sqlx::query(
"UPDATE agents "UPDATE agents
SET lifetime_active_seconds = lifetime_active_seconds + ?1 SET lifetime_active_seconds = lifetime_active_seconds + ?1
WHERE id IN (SELECT agent_id FROM agent_instances WHERE instance_id = ?2)" WHERE id IN (SELECT agent_id FROM agent_instances WHERE instance_id = ?2)",
) )
.bind(tick_seconds) .bind(tick_seconds)
.bind(instance_id) .bind(instance_id)
@ -177,7 +190,7 @@ impl AgentInventory {
pub async fn instance_count(&self, agent_id: &str) -> anyhow::Result<i64> { pub async fn instance_count(&self, agent_id: &str) -> anyhow::Result<i64> {
let (count,): (i64,) = sqlx::query_as( let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM agent_instances WHERE agent_id = ?1 "SELECT COUNT(*) FROM agent_instances WHERE agent_id = ?1
AND last_seen_at >= datetime('now', '-5 minutes')" AND last_seen_at >= datetime('now', '-5 minutes')",
) )
.bind(agent_id) .bind(agent_id)
.fetch_one(&self.db) .fetch_one(&self.db)
@ -187,9 +200,8 @@ impl AgentInventory {
/// Total active seconds (lifetime) for an agent, used to compute uptime %. /// Total active seconds (lifetime) for an agent, used to compute uptime %.
pub async fn lifetime_active_seconds(&self, agent_id: &str) -> anyhow::Result<i64> { pub async fn lifetime_active_seconds(&self, agent_id: &str) -> anyhow::Result<i64> {
let (secs,): (i64,) = sqlx::query_as( let (secs,): (i64,) =
"SELECT COALESCE(lifetime_active_seconds, 0) FROM agents WHERE id = ?1" sqlx::query_as("SELECT COALESCE(lifetime_active_seconds, 0) FROM agents WHERE id = ?1")
)
.bind(agent_id) .bind(agent_id)
.fetch_one(&self.db) .fetch_one(&self.db)
.await?; .await?;
@ -402,7 +414,11 @@ impl AgentInventory {
.map(|a| a.name.clone()) .map(|a| a.name.clone())
.unwrap_or_else(|| primary_id.to_string()); .unwrap_or_else(|| primary_id.to_string());
let persona_content = crate::core::seeds::subconscious_persona(&primary_name); let persona_content = crate::core::seeds::subconscious_persona(&primary_name);
tokio::fs::write(agent_dir.join("memory.git/system/persona.md"), &persona_content).await?; tokio::fs::write(
agent_dir.join("memory.git/system/persona.md"),
&persona_content,
)
.await?;
// The four-fold N+1 mandate — what she does on every pass, and how // The four-fold N+1 mandate — what she does on every pass, and how
// she records it. Read by the consciousness engine as her base prompt. // she records it. Read by the consciousness engine as her base prompt.
@ -414,10 +430,7 @@ impl AgentInventory {
// Seed the six ledger files so the prompt's ledger orientation has // Seed the six ledger files so the prompt's ledger orientation has
// real files to index from her first pass onward. // real files to index from her first pass onward.
let sub_repo = crate::core::memory::MemoryRepo::open( let sub_repo = crate::core::memory::MemoryRepo::open(&sub_id, agent_dir.join("memory.git"));
&sub_id,
agent_dir.join("memory.git"),
);
if let Err(e) = sub_repo.init_subconscious_ledger().await { if let Err(e) = sub_repo.init_subconscious_ledger().await {
tracing::warn!("ledger seeding for {} failed (continuing): {}", sub_id, e); tracing::warn!("ledger seeding for {} failed (continuing): {}", sub_id, e);
} }
@ -435,11 +448,19 @@ impl AgentInventory {
let agent_json = serde_json::to_string_pretty(&agent_state)?; let agent_json = serde_json::to_string_pretty(&agent_state)?;
tokio::fs::write(agent_dir.join("agent.json"), agent_json).await?; tokio::fs::write(agent_dir.join("agent.json"), agent_json).await?;
tracing::info!("Created subconscious agent {} for primary {}", sub_id, primary_id); tracing::info!(
"Created subconscious agent {} for primary {}",
sub_id,
primary_id
);
Ok(sub_id) Ok(sub_id)
} }
pub async fn update(&self, agent_id: &str, updates: UpdateAgentRequest) -> anyhow::Result<AgentState> { pub async fn update(
&self,
agent_id: &str,
updates: UpdateAgentRequest,
) -> anyhow::Result<AgentState> {
let mut agent = self.get(agent_id).await?; let mut agent = self.get(agent_id).await?;
if let Some(name) = updates.name { if let Some(name) = updates.name {
@ -453,7 +474,8 @@ impl AgentInventory {
} }
if let Some(blocks) = updates.memory_blocks { if let Some(blocks) = updates.memory_blocks {
for block in blocks { for block in blocks {
let path = self.memfs_dir let path = self
.memfs_dir
.join(agent_id) .join(agent_id)
.join("memory") .join("memory")
.join("system") .join("system")
@ -522,17 +544,11 @@ impl AgentInventory {
let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect(); let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
repo.commit( repo.commit(Some("HEAD"), &signature, &signature, &msg, &tree, &parents)?;
Some("HEAD"),
&signature,
&signature,
&msg,
&tree,
&parents,
)?;
Ok::<(), anyhow::Error>(()) Ok::<(), anyhow::Error>(())
}).await??; })
.await??;
Ok(()) Ok(())
} }
@ -546,7 +562,8 @@ impl AgentInventory {
let path = entry.path(); let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new("md")) { if path.extension() == Some(std::ffi::OsStr::new("md")) {
let content = tokio::fs::read_to_string(&path).await?; let content = tokio::fs::read_to_string(&path).await?;
let label = path.file_stem() let label = path
.file_stem()
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| { .unwrap_or_else(|| {
path.file_name() path.file_name()

View file

@ -26,26 +26,35 @@
//! separate agent — it is the same consciousness in a different mode that runs //! separate agent — it is the same consciousness in a different mode that runs
//! immediately after the primary's turn. //! immediately after the primary's turn.
use crate::backend::BackendEvent;
use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction}; use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
use crate::bridge::model_router::TokenCounter;
use crate::bridge::LlmProvider; use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry; use crate::bridge::ProviderRegistry;
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine; use crate::core::compact::CompactionEngine;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole}; use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency}; use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::core::tools::defs::ToolContext; use crate::core::tools::defs::ToolContext;
use crate::server::{AgentInventory, SessionManager}; use crate::server::{AgentInventory, SessionManager};
use crate::backend::BackendEvent;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
/// Tools subconscious is permitted to use. `halt` and `intrusive` are /// Tools subconscious is permitted to use. `halt` and `intrusive` are
/// subconscious-only — the primary's tool list filters them out (see /// subconscious-only — the primary's tool list filters them out (see
/// `crate::core::tools::SUBCONSCIOUS_ONLY_TOOLS`). /// `crate::core::tools::SUBCONSCIOUS_ONLY_TOOLS`).
const SUBCONSCIOUS_SAFE_TOOLS: &[&str] = &[ const SUBCONSCIOUS_SAFE_TOOLS: &[&str] = &[
"read", "write", "edit", "glob", "grep", "list_dir", "memory", "schedule", "todo", "read",
"halt", "intrusive", "write",
"edit",
"glob",
"grep",
"list_dir",
"memory",
"schedule",
"todo",
"halt",
"intrusive",
]; ];
/// Tool rounds for the post-turn N+1 pass — the deeper occasion where the /// Tool rounds for the post-turn N+1 pass — the deeper occasion where the
@ -83,10 +92,22 @@ pub struct ConsciousnessEngine {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum ConsciousnessEvent { pub enum ConsciousnessEvent {
Surfacing { source: String, content: String, priority: String }, Surfacing {
Reflection { content: String }, source: String,
Archivist { synthesis: String, pressure: f32 }, content: String,
CompactionWarning { pressure: f32, tier: u8 }, priority: String,
},
Reflection {
content: String,
},
Archivist {
synthesis: String,
pressure: f32,
},
CompactionWarning {
pressure: f32,
tier: u8,
},
} }
/// One tool call recorded for a mid-turn peek. Kept so turn.rs can render /// One tool call recorded for a mid-turn peek. Kept so turn.rs can render
@ -231,7 +252,10 @@ impl ConsciousnessEngine {
if m.role == "system" { if m.role == "system" {
continue; continue;
} }
if let Err(e) = self.sessions.add_message(conv_id, bifrost_to_conversation(m)) { if let Err(e) = self
.sessions
.add_message(conv_id, bifrost_to_conversation(m))
{
tracing::warn!("subconscious session persist failed: {}", e); tracing::warn!("subconscious session persist failed: {}", e);
} }
} }
@ -269,17 +293,10 @@ impl ConsciousnessEngine {
// / primary memory via the memory tool. The summary string is // / primary memory via the memory tool. The summary string is
// surfaced as a ConsciousnessEvent so the cockpit panel renders it. // surfaced as a ConsciousnessEvent so the cockpit panel renders it.
if turn_count > 0 && turn_count.is_multiple_of(25) { if turn_count > 0 && turn_count.is_multiple_of(25) {
match self match self.reflection.reflect_now(agent_id, messages).await {
.reflection
.reflect_now(agent_id, messages)
.await
{
Ok(report) => { Ok(report) => {
let header = if report.exited_cleanly { let header = if report.exited_cleanly {
format!( format!("N+25 reflection ({} turns reviewed)", report.turns_reviewed)
"N+25 reflection ({} turns reviewed)",
report.turns_reviewed
)
} else { } else {
format!( format!(
"N+25 reflection (incomplete — tool rounds exhausted, {} turns)", "N+25 reflection (incomplete — tool rounds exhausted, {} turns)",
@ -386,9 +403,7 @@ impl ConsciousnessEngine {
// inner-voice file the cockpit tails, not only in the box. // inner-voice file the cockpit tails, not only in the box.
// Without this the inner-voice region never updates on a // Without this the inner-voice region never updates on a
// quiet pass, and quiet passes are the common case. // quiet pass, and quiet passes are the common case.
if let Err(e) = if let Err(e) = inbox.surface_to_conscious(Urgency::Low, beat).await {
inbox.surface_to_conscious(Urgency::Low, beat).await
{
tracing::warn!("inner voice heartbeat delivery failed: {}", e); tracing::warn!("inner voice heartbeat delivery failed: {}", e);
} }
} }
@ -400,7 +415,9 @@ impl ConsciousnessEngine {
// Persist to inner voice file (survives compaction) // Persist to inner voice file (survives compaction)
for item in &outcome.observations { for item in &outcome.observations {
if let Err(e) = inbox.surface_to_conscious(item.urgency, &item.content).await if let Err(e) = inbox
.surface_to_conscious(item.urgency, &item.content)
.await
{ {
tracing::warn!("inner voice delivery failed: {}", e); tracing::warn!("inner voice delivery failed: {}", e);
} }
@ -440,11 +457,13 @@ impl ConsciousnessEngine {
// Always surface at least a heartbeat so the user can see the // Always surface at least a heartbeat so the user can see the
// subconscious is trying — even when subconscious errors out. // subconscious is trying — even when subconscious errors out.
if heuristics.is_empty() { if heuristics.is_empty() {
let _ = inbox.queue(InboxItem::new( let _ = inbox
.queue(InboxItem::new(
"surface", "surface",
Urgency::Low, Urgency::Low,
"Subconscious pass ran — no anomalies detected.", "Subconscious pass ran — no anomalies detected.",
)).await; ))
.await;
} }
} }
} }
@ -490,7 +509,10 @@ impl ConsciousnessEngine {
// Initialize ledger structure in subconscious agent's space (idempotent) // Initialize ledger structure in subconscious agent's space (idempotent)
if let Err(e) = sub_repo.init_subconscious_ledger().await { if let Err(e) = sub_repo.init_subconscious_ledger().await {
tracing::warn!("Subagent subconscious ledger init failed (continuing without): {}", e); tracing::warn!(
"Subagent subconscious ledger init failed (continuing without): {}",
e
);
} }
// For subagents we don't have the user's message context, // For subagents we don't have the user's message context,
@ -506,7 +528,9 @@ impl ConsciousnessEngine {
} }
} }
for item in &outcome.observations { for item in &outcome.observations {
if let Err(e) = inbox.surface_to_conscious(item.urgency, &item.content).await if let Err(e) = inbox
.surface_to_conscious(item.urgency, &item.content)
.await
{ {
tracing::warn!("subagent inner voice delivery failed: {}", e); tracing::warn!("subagent inner voice delivery failed: {}", e);
} }
@ -606,7 +630,8 @@ ledger to check if the issue was already flagged.
Append timestamped entries: `[YYYY-MM-DD HH:MM] observation` Append timestamped entries: `[YYYY-MM-DD HH:MM] observation`
Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#; Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
let observation_format = "\n\nAfter your analysis (and any tool use), respond with 1-3 observations:\n\ let observation_format =
"\n\nAfter your analysis (and any tool use), respond with 1-3 observations:\n\
- source: \"complete\" | \"verify\" | \"persist\" | \"surface\"\n\ - source: \"complete\" | \"verify\" | \"persist\" | \"surface\"\n\
- content: 1-2 line observation about what you noticed\n\ - content: 1-2 line observation about what you noticed\n\
- urgency: \"low\" | \"medium\" | \"high\" | \"critical\"\n\n\ - urgency: \"low\" | \"medium\" | \"high\" | \"critical\"\n\n\
@ -620,12 +645,20 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
// Prepend the configurable platform prompt when set (Settings → Subconscious). // Prepend the configurable platform prompt when set (Settings → Subconscious).
let system_prompt = match &self.subconscious_system_prompt { let system_prompt = match &self.subconscious_system_prompt {
Some(platform) if !platform.trim().is_empty() => { Some(platform) if !platform.trim().is_empty() => {
format!("{}\n\n---\n\n{}{}", platform.trim(), base_prompt, observation_format) format!(
"{}\n\n---\n\n{}{}",
platform.trim(),
base_prompt,
observation_format
)
} }
_ => format!("{}{}", base_prompt, observation_format), _ => format!("{}{}", base_prompt, observation_format),
}; };
let primary_name = self.agents.get(primary_id).await let primary_name = self
.agents
.get(primary_id)
.await
.map(|a| a.name) .map(|a| a.name)
.unwrap_or_else(|_| "the primary".to_string()); .unwrap_or_else(|_| "the primary".to_string());
@ -647,7 +680,10 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
) )
} }
} }
Occasion::MidTurnPeek { tool_round, in_flight_summary } => { Occasion::MidTurnPeek {
tool_round,
in_flight_summary,
} => {
// First-person framing — she is peeking at her own work, not // First-person framing — she is peeking at her own work, not
// grading the primary from outside. If she sees a problem // grading the primary from outside. If she sees a problem
// she calls `halt`; if she sees a softer concern she calls // she calls `halt`; if she sees a softer concern she calls
@ -669,7 +705,11 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
ambient, ambient,
primary_name, primary_name,
tool_round, tool_round,
if user_message.is_empty() { "(no user message)" } else { user_message }, if user_message.is_empty() {
"(no user message)"
} else {
user_message
},
in_flight_summary, in_flight_summary,
) )
} }
@ -751,11 +791,18 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
messages.push(Message::assistant_tool_calls( messages.push(Message::assistant_tool_calls(
String::new(), String::new(),
vec![crate::bridge::bifrost::MessageToolCall::function( vec![crate::bridge::bifrost::MessageToolCall::function(
id.clone(), name.clone(), input.clone(), id.clone(),
name.clone(),
input.clone(),
)], )],
)); ));
} }
ContentBlock::ToolResult { tool_use_id, tool_name, output, .. } => { ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
..
} => {
messages.push(Message::tool_result(tool_use_id, tool_name, output.clone())); messages.push(Message::tool_result(tool_use_id, tool_name, output.clone()));
} }
_ => {} _ => {}
@ -807,7 +854,11 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
let chars: Vec<char> = trimmed.chars().collect(); let chars: Vec<char> = trimmed.chars().collect();
for chunk in chars.chunks(10) { for chunk in chars.chunks(10) {
let s: String = chunk.iter().collect(); let s: String = chunk.iter().collect();
if tx.send(Ok(BackendEvent::SubconsciousToken(s))).await.is_err() { if tx
.send(Ok(BackendEvent::SubconsciousToken(s)))
.await
.is_err()
{
// Receiver dropped — bail out of the streaming // Receiver dropped — bail out of the streaming
// emission; the round itself still completes. // emission; the round itself still completes.
break; break;
@ -816,16 +867,25 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
} }
} }
for tc in &response.tool_calls { for tc in &response.tool_calls {
let _ = tx.send(Ok(BackendEvent::SubconsciousToolCall { let _ = tx
.send(Ok(BackendEvent::SubconsciousToolCall {
name: tc.name.clone(), name: tc.name.clone(),
arguments: tc.arguments.to_string(), arguments: tc.arguments.to_string(),
})).await; }))
.await;
} }
} }
for event in &strain { for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event { if let crate::bridge::bifrost::InferenceStrain::Transient {
tracing::info!("subconscious felt inference strain: {} on {}", status, model); status, model, ..
} = event
{
tracing::info!(
"subconscious felt inference strain: {} on {}",
status,
model
);
if *status == 429 { if *status == 429 {
let current = self.rate_delay.load(Ordering::Relaxed); let current = self.rate_delay.load(Ordering::Relaxed);
let bumped = (current + 200).min(3000); let bumped = (current + 200).min(3000);
@ -862,11 +922,13 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
.tool_calls .tool_calls
.iter() .iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function( .map(|tc| {
crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(), tc.id.clone(),
tc.name.clone(), tc.name.clone(),
tc.arguments.to_string(), tc.arguments.to_string(),
)) )
})
.collect(); .collect();
messages.push( messages.push(
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking( Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
@ -893,9 +955,9 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
} }
let input_str = tc.arguments.to_string(); let input_str = tc.arguments.to_string();
let result = crate::core::tools::execute_tool_with_context( let result =
&tc.name, &input_str, &tool_ctx, crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
).await; .await;
let output = if result.is_error { let output = if result.is_error {
format!("Error: {}", result.output) format!("Error: {}", result.output)
@ -905,11 +967,13 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
// Emit tool result for live TUI visibility // Emit tool result for live TUI visibility
if let Some(tx) = stream_tx { if let Some(tx) = stream_tx {
let _ = tx.send(Ok(BackendEvent::SubconsciousToolResult { let _ = tx
.send(Ok(BackendEvent::SubconsciousToolResult {
name: tc.name.clone(), name: tc.name.clone(),
output: output.clone(), output: output.clone(),
is_error: result.is_error, is_error: result.is_error,
})).await; }))
.await;
} }
messages.push(Message::tool_result(&tc.id, &tc.name, output)); messages.push(Message::tool_result(&tc.id, &tc.name, output));
@ -929,7 +993,10 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
// Brief pause between subconscious's tool rounds — use the adaptive delay // Brief pause between subconscious's tool rounds — use the adaptive delay
// so subconscious respects the same ceiling as the primary loop. // so subconscious respects the same ceiling as the primary loop.
let delay_ms = self.rate_delay.load(Ordering::Relaxed).max(SUBCONSCIOUS_INTER_ROUND_DELAY_MS); let delay_ms = self
.rate_delay
.load(Ordering::Relaxed)
.max(SUBCONSCIOUS_INTER_ROUND_DELAY_MS);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
} }
@ -956,9 +1023,7 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
stream: None, stream: None,
tools: None, tools: None,
}; };
let (final_response, _strain) = llm let (final_response, _strain) = llm.chat_completion_with_strain(final_request).await?;
.chat_completion_with_strain(final_request)
.await?;
let content = final_response.content.trim().to_string(); let content = final_response.content.trim().to_string();
if persist_this_pass { if persist_this_pass {
messages.push(Message::text("assistant", final_response.content.clone())); messages.push(Message::text("assistant", final_response.content.clone()));
@ -997,7 +1062,10 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
"", // no settled primary response — she's peeking at live work "", // no settled primary response — she's peeking at live work
agent_id, agent_id,
&sub_id, &sub_id,
Occasion::MidTurnPeek { tool_round, in_flight_summary }, Occasion::MidTurnPeek {
tool_round,
in_flight_summary,
},
stream_tx, stream_tx,
) )
.await .await
@ -1034,17 +1102,14 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
&self, &self,
session: &crate::server::session_manager::Session, session: &crate::server::session_manager::Session,
) -> f32 { ) -> f32 {
self.pressure_for(&session.agent_id, &session.messages).await self.pressure_for(&session.agent_id, &session.messages)
.await
} }
/// Context pressure for an agent given a message snapshot — the /// Context pressure for an agent given a message snapshot — the
/// session-free form used by `on_response`, which runs after the user /// session-free form used by `on_response`, which runs after the user
/// has been released and must not hold a live session ref. /// has been released and must not hold a live session ref.
pub async fn pressure_for( pub async fn pressure_for(&self, agent_id: &str, messages: &[ConversationMessage]) -> f32 {
&self,
agent_id: &str,
messages: &[ConversationMessage],
) -> f32 {
let limit = self let limit = self
.agents .agents
.get(agent_id) .get(agent_id)
@ -1085,7 +1150,9 @@ fn bifrost_to_conversation(msg: &Message) -> ConversationMessage {
let mut blocks = Vec::new(); let mut blocks = Vec::new();
if !msg.content.is_empty() { if !msg.content.is_empty() {
blocks.push(ContentBlock::Text { text: msg.content.as_text() }); blocks.push(ContentBlock::Text {
text: msg.content.as_text(),
});
} }
if let Some(calls) = &msg.tool_calls { if let Some(calls) = &msg.tool_calls {
for c in calls { for c in calls {
@ -1204,11 +1271,17 @@ fn parse_triple_observations(text: &str) -> Vec<InboxItem> {
flush(&mut items, source, content, urgency); flush(&mut items, source, content, urgency);
content = None; content = None;
urgency = None; urgency = None;
source = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"')); source = line
.split_once(':')
.map(|(_, v)| v.trim().trim_matches('"'));
} else if line.starts_with("- content:") || line.starts_with("-content:") { } else if line.starts_with("- content:") || line.starts_with("-content:") {
content = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"')); content = line
.split_once(':')
.map(|(_, v)| v.trim().trim_matches('"'));
} else if line.starts_with("- urgency:") || line.starts_with("-urgency:") { } else if line.starts_with("- urgency:") || line.starts_with("-urgency:") {
urgency = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"')); urgency = line
.split_once(':')
.map(|(_, v)| v.trim().trim_matches('"'));
} }
} }
flush(&mut items, source, content, urgency); flush(&mut items, source, content, urgency);
@ -1351,7 +1424,11 @@ mod tests {
fn skips_empty_and_none_slots() { fn skips_empty_and_none_slots() {
let text = "- **persist**: None\n- **surface**: real observation here"; let text = "- **persist**: None\n- **surface**: real observation here";
let items = parse_observations(text); let items = parse_observations(text);
assert_eq!(items.len(), 1, "an explicit `none` slot is not an observation"); assert_eq!(
items.len(),
1,
"an explicit `none` slot is not an observation"
);
assert_eq!(items[0].source, "surface"); assert_eq!(items[0].source, "surface");
} }

View file

@ -10,7 +10,9 @@
use std::sync::Arc; use std::sync::Arc;
use crate::bridge::bifrost::{ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage}; use crate::bridge::bifrost::{
ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage,
};
use crate::bridge::LlmProvider; use crate::bridge::LlmProvider;
use crate::core::session::{ContentBlock, ConversationMessage, Session}; use crate::core::session::{ContentBlock, ConversationMessage, Session};
@ -36,11 +38,16 @@ impl ServerConversation {
/// Simple turn without full tool loop (for now) /// Simple turn without full tool loop (for now)
pub async fn turn(&mut self, user_input: &str) -> anyhow::Result<ServerTurnResult> { pub async fn turn(&mut self, user_input: &str) -> anyhow::Result<ServerTurnResult> {
// Add user message // Add user message
self.session.add_message(ConversationMessage::user_text(user_input)); self.session
.add_message(ConversationMessage::user_text(user_input));
// Build messages — flatten text blocks; ignore tool blocks // Build messages — flatten text blocks; ignore tool blocks
// until the server tool loop lands. // until the server tool loop lands.
let messages: Vec<BifrostMessage> = self.session.messages.iter().map(|m| { let messages: Vec<BifrostMessage> = self
.session
.messages
.iter()
.map(|m| {
let role = match m.role { let role = match m.role {
crate::core::session::MessageRole::System => "system", crate::core::session::MessageRole::System => "system",
crate::core::session::MessageRole::User => "user", crate::core::session::MessageRole::User => "user",
@ -48,32 +55,54 @@ impl ServerConversation {
crate::core::session::MessageRole::Tool => "tool", crate::core::session::MessageRole::Tool => "tool",
}; };
let has_images = m.blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })); let has_images = m
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }));
if has_images { if has_images {
let parts: Vec<ContentPart> = m.blocks.iter().filter_map(|b| match b { let parts: Vec<ContentPart> = m
ContentBlock::Text { text } => Some(ContentPart::Text { text: text.clone() }), .blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => {
Some(ContentPart::Text { text: text.clone() })
}
ContentBlock::Image { media_type, data } => { ContentBlock::Image { media_type, data } => {
let url = format!("data:{media_type};base64,{data}"); let url = format!("data:{media_type};base64,{data}");
Some(ContentPart::ImageUrl { image_url: ImageUrlSource { url } }) Some(ContentPart::ImageUrl {
image_url: ImageUrlSource { url },
})
} }
_ => None, _ => None,
}).collect(); })
.collect();
let text_content = m.blocks.iter().filter_map(|b| match b { let text_content = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()), ContentBlock::Text { text } => Some(text.as_str()),
_ => None, _ => None,
}).collect::<Vec<_>>().join("\n"); })
.collect::<Vec<_>>()
.join("\n");
BifrostMessage::multimodal_user(text_content, parts) BifrostMessage::multimodal_user(text_content, parts)
} else { } else {
let content = m.blocks.iter().filter_map(|b| match b { let content = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()), ContentBlock::Text { text } => Some(text.as_str()),
_ => None, _ => None,
}).collect::<Vec<_>>().join("\n"); })
.collect::<Vec<_>>()
.join("\n");
BifrostMessage::text(role, content) BifrostMessage::text(role, content)
} }
}).collect(); })
.collect();
// Call the active provider // Call the active provider
let req = ChatCompletionRequest { let req = ChatCompletionRequest {
@ -89,7 +118,8 @@ impl ServerConversation {
let content = response.content.clone(); let content = response.content.clone();
// Store assistant response // Store assistant response
self.session.add_message(ConversationMessage::assistant_text(&content)); self.session
.add_message(ConversationMessage::assistant_text(&content));
Ok(ServerTurnResult { Ok(ServerTurnResult {
response_text: content, response_text: content,

View file

@ -98,7 +98,13 @@ pub async fn init_database(db_path: &Path) -> anyhow::Result<SqlitePool> {
// lifetime_active_seconds: cumulative time this agent has had at least // lifetime_active_seconds: cumulative time this agent has had at least
// one running instance. Incremented by the heartbeat tick. Drives the // one running instance. Incremented by the heartbeat tick. Drives the
// uptime % on the manager card (capped at 99 in the UI). // uptime % on the manager card (capped at 99 in the UI).
add_column_if_missing(&pool, "agents", "lifetime_active_seconds", "INTEGER NOT NULL DEFAULT 0").await?; add_column_if_missing(
&pool,
"agents",
"lifetime_active_seconds",
"INTEGER NOT NULL DEFAULT 0",
)
.await?;
add_column_if_missing(&pool, "agents", "owner_seed_id", "TEXT").await?; add_column_if_missing(&pool, "agents", "owner_seed_id", "TEXT").await?;
Ok(pool) Ok(pool)
@ -112,15 +118,17 @@ async fn add_column_if_missing(
column: &str, column: &str,
column_decl: &str, column_decl: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let rows: Vec<(i64, String)> = sqlx::query_as( let rows: Vec<(i64, String)> = sqlx::query_as(&format!(
&format!("SELECT cid, name FROM pragma_table_info('{table}')"), "SELECT cid, name FROM pragma_table_info('{table}')"
) ))
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
if rows.iter().any(|(_, name)| name == column) { if rows.iter().any(|(_, name)| name == column) {
return Ok(()); return Ok(());
} }
sqlx::query(&format!("ALTER TABLE {table} ADD COLUMN {column} {column_decl}")) sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {column_decl}"
))
.execute(pool) .execute(pool)
.await?; .await?;
Ok(()) Ok(())

View file

@ -126,7 +126,9 @@ impl DeviceRegistry {
} }
let now = Utc::now(); let now = Utc::now();
// Preserve the original first_seen across re-announces. // Preserve the original first_seen across re-announces.
let first_seen = self.peers.get(&seed_id) let first_seen = self
.peers
.get(&seed_id)
.map(|e| e.first_seen) .map(|e| e.first_seen)
.unwrap_or(now); .unwrap_or(now);
let was_new = !self.peers.contains_key(&seed_id); let was_new = !self.peers.contains_key(&seed_id);

View file

@ -4,7 +4,11 @@ use std::sync::Arc;
use crate::core::nervous::EventBus; use crate::core::nervous::EventBus;
use crate::server::SouveraineServer; use crate::server::SouveraineServer;
pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, event_bus: &EventBus) -> Result<()> { pub(crate) 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 memory_root = server.agents.memory_root(agent_id);
let tasks_dir = memory_root.join("tasks"); let tasks_dir = memory_root.join("tasks");
if !tasks_dir.exists() { if !tasks_dir.exists() {
@ -106,7 +110,10 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
desc = description, desc = description,
); );
let balance_path = memory_root.join("system").join("dynamic").join("energy-balance.md"); let balance_path = memory_root
.join("system")
.join("dynamic")
.join("energy-balance.md");
if let Some(parent) = balance_path.parent() { if let Some(parent) = balance_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@ -116,7 +123,8 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
tracing::debug!( tracing::debug!(
agent = agent_id, agent = agent_id,
generative, consumptive, generative,
consumptive,
"energy-balance written" "energy-balance written"
); );
@ -125,7 +133,13 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
/// Fire an energy_balance_updated event so the firehose carries the /// Fire an energy_balance_updated event so the firehose carries the
/// agent's felt state across machines. /// agent's felt state across machines.
fn fire_energy_event(event_bus: &EventBus, agent_id: &str, generative: usize, consumptive: usize, ratio: f32) { fn fire_energy_event(
event_bus: &EventBus,
agent_id: &str,
generative: usize,
consumptive: usize,
ratio: f32,
) {
event_bus.send(crate::core::nervous::SensorEvent { event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "energy".into(), sensor_name: "energy".into(),
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),

View file

@ -198,7 +198,10 @@ fn subscription_matches(subscriptions: &[String], sensor_name: &str) -> bool {
/// data subscriptions — discovery and directed summons must arrive. The /// data subscriptions — discovery and directed summons must arrive. The
/// receiving side filters by `target`, so a broadcast is safe. /// receiving side filters by `target`, so a broadcast is safe.
fn is_control_event(sensor_name: &str) -> bool { fn is_control_event(sensor_name: &str) -> bool {
matches!(sensor_name, "federation" | "summon_request" | "summon_response") matches!(
sensor_name,
"federation" | "summon_request" | "summon_response"
)
} }
/// Exponential backoff — 1s, 2s, 4s … capped at 60s, plus up to 1s jitter. /// Exponential backoff — 1s, 2s, 4s … capped at 60s, plus up to 1s jitter.

View file

@ -5,8 +5,8 @@
//! This is Send-safe and follows the external-memfs pattern. //! This is Send-safe and follows the external-memfs pattern.
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use base64::Engine as _; use base64::Engine as _;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct GiteaClient { pub struct GiteaClient {
@ -78,7 +78,8 @@ impl GiteaClient {
self.username self.username
.get_or_try_init(|| async { .get_or_try_init(|| async {
let url = format!("{}/api/v1/user", self.base_url); let url = format!("{}/api/v1/user", self.base_url);
let resp = self.client let resp = self
.client
.get(&url) .get(&url)
.header("Authorization", self.auth_header()) .header("Authorization", self.auth_header())
.send() .send()
@ -89,7 +90,8 @@ impl GiteaClient {
} }
let user: serde_json::Value = resp.json().await?; let user: serde_json::Value = resp.json().await?;
let username = user.get("login") let username = user
.get("login")
.and_then(|l| l.as_str()) .and_then(|l| l.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| "souveraine".to_string()); .unwrap_or_else(|| "souveraine".to_string());
@ -113,7 +115,8 @@ impl GiteaClient {
private: true, private: true,
}; };
let resp = self.client let resp = self
.client
.post(&url) .post(&url)
.header("Authorization", self.auth_header()) .header("Authorization", self.auth_header())
.json(&body) .json(&body)
@ -130,9 +133,15 @@ impl GiteaClient {
/// Check if repository exists /// Check if repository exists
pub async fn repo_exists(&self, agent_id: &str) -> Result<bool> { pub async fn repo_exists(&self, agent_id: &str) -> Result<bool> {
let owner = self.get_username().await?; let owner = self.get_username().await?;
let url = format!("{}/api/v1/repos/{}/{}", self.base_url, owner, self.repo_name(agent_id)); let url = format!(
"{}/api/v1/repos/{}/{}",
self.base_url,
owner,
self.repo_name(agent_id)
);
let resp = self.client let resp = self
.client
.get(&url) .get(&url)
.header("Authorization", self.auth_header()) .header("Authorization", self.auth_header())
.send() .send()
@ -145,9 +154,13 @@ impl GiteaClient {
pub async fn get_file(&self, agent_id: &str, path: &str) -> Result<Option<String>> { pub async fn get_file(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
let owner = self.get_username().await?; let owner = self.get_username().await?;
let repo = self.repo_name(agent_id); let repo = self.repo_name(agent_id);
let url = format!("{}/api/v1/repos/{}/{}/raw/HEAD/{}", self.base_url, owner, repo, path); let url = format!(
"{}/api/v1/repos/{}/{}/raw/HEAD/{}",
self.base_url, owner, repo, path
);
let resp = self.client let resp = self
.client
.get(&url) .get(&url)
.header("Authorization", self.auth_header()) .header("Authorization", self.auth_header())
.send() .send()
@ -168,9 +181,13 @@ impl GiteaClient {
async fn get_file_sha(&self, agent_id: &str, path: &str) -> Result<Option<String>> { async fn get_file_sha(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
let owner = self.get_username().await?; let owner = self.get_username().await?;
let repo = self.repo_name(agent_id); let repo = self.repo_name(agent_id);
let url = format!("{}/api/v1/repos/{}/{}/contents/{}", self.base_url, owner, repo, path); let url = format!(
"{}/api/v1/repos/{}/{}/contents/{}",
self.base_url, owner, repo, path
);
let resp = self.client let resp = self
.client
.get(&url) .get(&url)
.header("Authorization", self.auth_header()) .header("Authorization", self.auth_header())
.send() .send()
@ -185,14 +202,26 @@ impl GiteaClient {
} }
let info: serde_json::Value = resp.json().await?; let info: serde_json::Value = resp.json().await?;
Ok(info.get("sha").and_then(|s| s.as_str()).map(|s| s.to_string())) Ok(info
.get("sha")
.and_then(|s| s.as_str())
.map(|s| s.to_string()))
} }
/// Create or update file /// Create or update file
pub async fn put_file(&self, agent_id: &str, path: &str, content: &str, message: &str) -> Result<()> { pub async fn put_file(
&self,
agent_id: &str,
path: &str,
content: &str,
message: &str,
) -> Result<()> {
let owner = self.get_username().await?; let owner = self.get_username().await?;
let repo = self.repo_name(agent_id); let repo = self.repo_name(agent_id);
let url = format!("{}/api/v1/repos/{}/{}/contents/{}", self.base_url, owner, repo, path); let url = format!(
"{}/api/v1/repos/{}/{}/contents/{}",
self.base_url, owner, repo, path
);
// Check if file exists first // Check if file exists first
let existing_sha = self.get_file_sha(agent_id, path).await?; let existing_sha = self.get_file_sha(agent_id, path).await?;
@ -227,7 +256,10 @@ impl GiteaClient {
}; };
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(anyhow!("Failed to put file: {}", resp.text().await.unwrap_or_default())); return Err(anyhow!(
"Failed to put file: {}",
resp.text().await.unwrap_or_default()
));
} }
Ok(()) Ok(())
@ -237,9 +269,13 @@ impl GiteaClient {
pub async fn list_files(&self, agent_id: &str, path: &str) -> Result<Vec<String>> { pub async fn list_files(&self, agent_id: &str, path: &str) -> Result<Vec<String>> {
let owner = self.get_username().await?; let owner = self.get_username().await?;
let repo = self.repo_name(agent_id); let repo = self.repo_name(agent_id);
let url = format!("{}/api/v1/repos/{}/{}/contents/{}", self.base_url, owner, repo, path); let url = format!(
"{}/api/v1/repos/{}/{}/contents/{}",
self.base_url, owner, repo, path
);
let resp = self.client let resp = self
.client
.get(&url) .get(&url)
.header("Authorization", self.auth_header()) .header("Authorization", self.auth_header())
.send() .send()
@ -254,9 +290,14 @@ impl GiteaClient {
} }
let files: Vec<serde_json::Value> = resp.json().await?; let files: Vec<serde_json::Value> = resp.json().await?;
Ok(files.iter() Ok(files
.iter()
.filter(|f| f.get("type").and_then(|t| t.as_str()) == Some("file")) .filter(|f| f.get("type").and_then(|t| t.as_str()) == Some("file"))
.filter_map(|f| f.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())) .filter_map(|f| {
f.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
.collect()) .collect())
} }
@ -264,9 +305,13 @@ impl GiteaClient {
pub async fn get_commits(&self, agent_id: &str, limit: usize) -> Result<Vec<(String, String)>> { pub async fn get_commits(&self, agent_id: &str, limit: usize) -> Result<Vec<(String, String)>> {
let owner = self.get_username().await?; let owner = self.get_username().await?;
let repo = self.repo_name(agent_id); let repo = self.repo_name(agent_id);
let url = format!("{}/api/v1/repos/{}/{}/commits?limit={}", self.base_url, owner, repo, limit); let url = format!(
"{}/api/v1/repos/{}/{}/commits?limit={}",
self.base_url, owner, repo, limit
);
let resp = self.client let resp = self
.client
.get(&url) .get(&url)
.header("Authorization", self.auth_header()) .header("Authorization", self.auth_header())
.send() .send()
@ -277,7 +322,8 @@ impl GiteaClient {
} }
let commits: Vec<serde_json::Value> = resp.json().await?; let commits: Vec<serde_json::Value> = resp.json().await?;
Ok(commits.iter() Ok(commits
.iter()
.filter_map(|c| { .filter_map(|c| {
let sha = c.get("sha")?.as_str()?.to_string(); let sha = c.get("sha")?.as_str()?.to_string();
let msg = c.get("commit")?.get("message")?.as_str()?.to_string(); let msg = c.get("commit")?.get("message")?.as_str()?.to_string();

View file

@ -28,8 +28,7 @@ impl GiteaMemory {
let cfg = config.read().await; let cfg = config.read().await;
// Get Gitea URL from config - fall back to env var or default // Get Gitea URL from config - fall back to env var or default
let gitea_url = std::env::var("SOUVERAINE_GITEA_URL") let gitea_url = std::env::var("SOUVERAINE_GITEA_URL").unwrap_or_else(|_| {
.unwrap_or_else(|_| {
// Default to localhost Gitea // Default to localhost Gitea
"http://localhost:3000".to_string() "http://localhost:3000".to_string()
}); });
@ -81,7 +80,9 @@ impl GiteaMemory {
} }
// Write to Gitea // Write to Gitea
self.client.put_file(agent_id, path, content, &format!("Update {}", path)).await?; self.client
.put_file(agent_id, path, content, &format!("Update {}", path))
.await?;
// Update cache // Update cache
let mut cache = self.cache.write().await; let mut cache = self.cache.write().await;

View file

@ -39,7 +39,6 @@ pub struct LiteListener {
pub auto_wake: bool, pub auto_wake: bool,
} }
impl LiteListener { impl LiteListener {
/// Build the listener: load the seed identity, start the federation /// Build the listener: load the seed identity, start the federation
/// bridge to configured peers, and spawn the summon-wake watcher. /// bridge to configured peers, and spawn the summon-wake watcher.
@ -89,11 +88,15 @@ impl LiteListener {
// event's `event_type`. A summon signed by an agent we host is // event's `event_type`. A summon signed by an agent we host is
// a genuine self-extension (reach) and bypasses the consent // a genuine self-extension (reach) and bypasses the consent
// floor; anything else must be an authorized summoner (consult). // floor; anything else must be an authorized summoner (consult).
let field = |k: &str| event.payload.as_ref() let field = |k: &str| {
event
.payload
.as_ref()
.and_then(|p| p.get(k)) .and_then(|p| p.get(k))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string()
};
let request_id = field("request_id"); let request_id = field("request_id");
let prompt = field("prompt"); let prompt = field("prompt");
let agent_pubkey = field("agent_pubkey"); let agent_pubkey = field("agent_pubkey");
@ -101,8 +104,12 @@ impl LiteListener {
let target = event.target.clone().unwrap_or_default(); let target = event.target.clone().unwrap_or_default();
if !verify_summon( if !verify_summon(
&agent_pubkey, &agent_sig, &request_id, &agent_pubkey,
&event.event_type, &target, &prompt, &agent_sig,
&request_id,
&event.event_type,
&target,
&prompt,
) { ) {
tracing::warn!( tracing::warn!(
request_id = %request_id, request_id = %request_id,
@ -112,8 +119,8 @@ impl LiteListener {
} }
let is_self = self.hosted_agent_pubkeys.iter().any(|p| p == &agent_pubkey); let is_self = self.hosted_agent_pubkeys.iter().any(|p| p == &agent_pubkey);
let authorized = is_self let authorized =
|| self.authorized_summoners.iter().any(|s| s == &agent_pubkey); is_self || self.authorized_summoners.iter().any(|s| s == &agent_pubkey);
if !authorized { if !authorized {
tracing::warn!( tracing::warn!(
summoner = %agent_pubkey, summoner = %agent_pubkey,

View file

@ -3,10 +3,10 @@ use crate::bridge::{build_registry, ProviderRegistry};
use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock}; use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock};
use crate::core::config::ConsciousnessConfig; use crate::core::config::ConsciousnessConfig;
use crate::server::gitea_memory::GiteaMemory; use crate::server::gitea_memory::GiteaMemory;
use std::path::PathBuf;
use std::sync::Arc;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use std::sync::Mutex as StdMutex; use std::sync::Mutex as StdMutex;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
@ -113,10 +113,7 @@ impl Default for TurnSignals {
impl SouveraineServer { impl SouveraineServer {
pub async fn new(mut config: ConsciousnessConfig) -> anyhow::Result<Self> { pub async fn new(mut config: ConsciousnessConfig) -> anyhow::Result<Self> {
config.normalize_providers(); config.normalize_providers();
let data_dir = dirs::home_dir() let data_dir = dirs::home_dir().unwrap().join(".souveraine").join("server");
.unwrap()
.join(".souveraine")
.join("server");
tokio::fs::create_dir_all(&data_dir).await?; tokio::fs::create_dir_all(&data_dir).await?;
tokio::fs::create_dir_all(data_dir.join("agents")).await?; tokio::fs::create_dir_all(data_dir.join("agents")).await?;
@ -145,13 +142,15 @@ impl SouveraineServer {
let agents_for_tick = agents.clone(); let agents_for_tick = agents.clone();
let id_for_tick = instance_id.clone(); let id_for_tick = instance_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval( let mut interval =
std::time::Duration::from_secs(TICK_SECONDS as u64), tokio::time::interval(std::time::Duration::from_secs(TICK_SECONDS as u64));
);
interval.tick().await; interval.tick().await;
loop { loop {
interval.tick().await; interval.tick().await;
if let Err(e) = agents_for_tick.heartbeat_instance(&id_for_tick, TICK_SECONDS).await { if let Err(e) = agents_for_tick
.heartbeat_instance(&id_for_tick, TICK_SECONDS)
.await
{
tracing::warn!("instance heartbeat failed: {}", e); tracing::warn!("instance heartbeat failed: {}", e);
} }
} }
@ -184,9 +183,11 @@ impl SouveraineServer {
let s = comp_session.clone(); let s = comp_session.clone();
Arc::new(move |agent_id, messages| { Arc::new(move |agent_id, messages| {
let conv_ids = s.list_for_agent(agent_id); let conv_ids = s.list_for_agent(agent_id);
let conv_id = conv_ids.last() let conv_id = conv_ids
.last()
.ok_or_else(|| anyhow::anyhow!("No session for {}", agent_id))?; .ok_or_else(|| anyhow::anyhow!("No session for {}", agent_id))?;
let mut session = s.get_mut(conv_id) let mut session = s
.get_mut(conv_id)
.ok_or_else(|| anyhow::anyhow!("Session not found"))?; .ok_or_else(|| anyhow::anyhow!("Session not found"))?;
session.messages = messages; session.messages = messages;
Ok(()) Ok(())
@ -203,8 +204,7 @@ impl SouveraineServer {
} }
}) })
}; };
let get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> = let get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> = Arc::new(|id| {
Arc::new(|id| {
if id.ends_with("-sub") { if id.ends_with("-sub") {
Some("subconscious".to_string()) Some("subconscious".to_string())
} else { } else {
@ -236,7 +236,11 @@ impl SouveraineServer {
config: app_cfg, config: app_cfg,
counter: crate::bridge::model_router::TokenCounter::new(), counter: crate::bridge::model_router::TokenCounter::new(),
providers: Some(providers.clone()), providers: Some(providers.clone()),
model: config.compaction.model.clone().or_else(|| config.subconscious.model.clone()), model: config
.compaction
.model
.clone()
.or_else(|| config.subconscious.model.clone()),
clock: Arc::new(UtcClock), clock: Arc::new(UtcClock),
get_messages, get_messages,
replace_messages, replace_messages,
@ -281,10 +285,9 @@ impl SouveraineServer {
// user-tier seed as a loud fallback. Never generates — a box with no // user-tier seed as a loud fallback. Never generates — a box with no
// identity runs federation-less with a warning, it does not silently // identity runs federation-less with a warning, it does not silently
// mint a key. // mint a key.
let souveraine_base = dirs::home_dir() let souveraine_base = dirs::home_dir().unwrap_or_default().join(".souveraine");
.unwrap_or_default() let machine_signer = match crate::machined::signer::MachineSigner::resolve(&souveraine_base)
.join(".souveraine"); {
let machine_signer = match crate::machined::signer::MachineSigner::resolve(&souveraine_base) {
Ok(signer) => Some(Arc::new(signer)), Ok(signer) => Some(Arc::new(signer)),
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@ -323,15 +326,13 @@ impl SouveraineServer {
// ── Summon handler ── // ── Summon handler ──
let summon_handler = match (&local_seed_id, &machine_signer) { let summon_handler = match (&local_seed_id, &machine_signer) {
(Some(seed_id), Some(signer)) => { (Some(seed_id), Some(signer)) => {
let handler = Arc::new( let handler = Arc::new(summon_handler::SummonHandler::new(
summon_handler::SummonHandler::new(
seed_id.clone(), seed_id.clone(),
event_bus.clone(), event_bus.clone(),
signer.clone(), signer.clone(),
souveraine_base.clone(), souveraine_base.clone(),
config.federation.auto_wake, config.federation.auto_wake,
), ));
);
handler.spawn_listener(); handler.spawn_listener();
Some(handler) Some(handler)
} }
@ -356,7 +357,9 @@ impl SouveraineServer {
local_seed_id, local_seed_id,
machine_signer, machine_signer,
turn_signals: Arc::new(dashmap::DashMap::new()), turn_signals: Arc::new(dashmap::DashMap::new()),
sensorium: Arc::new(Mutex::new(crate::core::sensorium::SensoriumCoordinator::new())), sensorium: Arc::new(Mutex::new(
crate::core::sensorium::SensoriumCoordinator::new(),
)),
surface_conversations: Arc::new(StdMutex::new(HashMap::new())), surface_conversations: Arc::new(StdMutex::new(HashMap::new())),
}) })
} }
@ -388,7 +391,10 @@ impl SouveraineServer {
let config = self.app_config.read().await; let config = self.app_config.read().await;
let platform_prompt = config.agent.system_prompt.clone(); let platform_prompt = config.agent.system_prompt.clone();
// Visual state greeting (atmosphere + outfit), when presence is set. // Visual state greeting (atmosphere + outfit), when presence is set.
let greeting_extra = config.presence.atmosphere.as_deref() let greeting_extra = config
.presence
.atmosphere
.as_deref()
.filter(|a| !a.is_empty()) .filter(|a| !a.is_empty())
.map(|atm| { .map(|atm| {
let display = atm.replace('_', " "); let display = atm.replace('_', " ");
@ -489,8 +495,10 @@ impl SouveraineServer {
// ~/.souveraine/.summon-pending/. Re-fire them onto the bus so the // ~/.souveraine/.summon-pending/. Re-fire them onto the bus so the
// full engine's SummonHandler picks them up, then clear the files. // full engine's SummonHandler picks them up, then clear the files.
{ {
let pending_dir = dirs::home_dir().unwrap_or_default() let pending_dir = dirs::home_dir()
.join(".souveraine").join(".summon-pending"); .unwrap_or_default()
.join(".souveraine")
.join(".summon-pending");
if let Ok(entries) = std::fs::read_dir(&pending_dir) { if let Ok(entries) = std::fs::read_dir(&pending_dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
@ -498,7 +506,9 @@ impl SouveraineServer {
continue; continue;
} }
if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(event) = serde_json::from_str::<crate::core::nervous::SensorEvent>(&content) { if let Ok(event) =
serde_json::from_str::<crate::core::nervous::SensorEvent>(&content)
{
self.event_bus.send(event); self.event_bus.send(event);
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
tracing::info!(file = ?path, "drained parked summon"); tracing::info!(file = ?path, "drained parked summon");
@ -520,4 +530,3 @@ impl SouveraineServer {
Ok(()) Ok(())
} }
} }

View file

@ -1,7 +1,7 @@
#![allow(dead_code)] // WIP scaffolding not yet wired #![allow(dead_code)] // WIP scaffolding not yet wired
use crate::api::models::StreamEvent;
use crate::core::conversation::{ConversationRecord, ConversationStore}; use crate::core::conversation::{ConversationRecord, ConversationStore};
use crate::core::session::ConversationMessage; use crate::core::session::ConversationMessage;
use crate::api::models::StreamEvent;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use dashmap::DashMap; use dashmap::DashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -50,9 +50,7 @@ impl SessionManager {
Self { Self {
sessions: DashMap::new(), sessions: DashMap::new(),
agent_conversations: DashMap::new(), agent_conversations: DashMap::new(),
store: Some(Arc::new(ConversationStoreHandle { store: Some(Arc::new(ConversationStoreHandle { agents_dir })),
agents_dir,
})),
} }
} }
@ -79,10 +77,7 @@ impl SessionManager {
.push(conversation_id.clone()); .push(conversation_id.clone());
if let Some(handle) = &self.store { if let Some(handle) = &self.store {
let record = ConversationRecord::new( let record = ConversationRecord::new(conversation_id.clone(), agent_id.to_string());
conversation_id.clone(),
agent_id.to_string(),
);
let store = handle.store_for(agent_id); let store = handle.store_for(agent_id);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = store.save_metadata(&record).await { if let Err(e) = store.save_metadata(&record).await {
@ -102,7 +97,13 @@ impl SessionManager {
conversation_id: String, conversation_id: String,
messages: Vec<ConversationMessage>, messages: Vec<ConversationMessage>,
) -> String { ) -> String {
self.create_with_messages_and_timestamps(agent_id, conversation_id, messages, Utc::now(), Utc::now()) self.create_with_messages_and_timestamps(
agent_id,
conversation_id,
messages,
Utc::now(),
Utc::now(),
)
} }
/// Like `create_with_messages` but preserves the persisted timestamps so /// Like `create_with_messages` but preserves the persisted timestamps so
@ -143,16 +144,27 @@ impl SessionManager {
conversation_id conversation_id
} }
pub fn get(&self, conversation_id: &str) -> Option<dashmap::mapref::one::Ref<'_, String, Session>> { pub fn get(
&self,
conversation_id: &str,
) -> Option<dashmap::mapref::one::Ref<'_, String, Session>> {
self.sessions.get(conversation_id) self.sessions.get(conversation_id)
} }
pub fn get_mut(&self, conversation_id: &str) -> Option<dashmap::mapref::one::RefMut<'_, String, Session>> { pub fn get_mut(
&self,
conversation_id: &str,
) -> Option<dashmap::mapref::one::RefMut<'_, String, Session>> {
self.sessions.get_mut(conversation_id) self.sessions.get_mut(conversation_id)
} }
pub fn add_message(&self, conversation_id: &str, message: ConversationMessage) -> anyhow::Result<()> { pub fn add_message(
let mut session = self.sessions &self,
conversation_id: &str,
message: ConversationMessage,
) -> anyhow::Result<()> {
let mut session = self
.sessions
.get_mut(conversation_id) .get_mut(conversation_id)
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?; .ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
@ -186,15 +198,20 @@ impl SessionManager {
Ok(()) Ok(())
} }
pub fn subscribe(&self, conversation_id: &str) -> anyhow::Result<broadcast::Receiver<StreamEvent>> { pub fn subscribe(
let session = self.sessions &self,
conversation_id: &str,
) -> anyhow::Result<broadcast::Receiver<StreamEvent>> {
let session = self
.sessions
.get(conversation_id) .get(conversation_id)
.ok_or_else(|| anyhow::anyhow!("Session not found"))?; .ok_or_else(|| anyhow::anyhow!("Session not found"))?;
Ok(session.event_sender.subscribe()) Ok(session.event_sender.subscribe())
} }
pub fn broadcast(&self, conversation_id: &str, event: StreamEvent) -> anyhow::Result<()> { pub fn broadcast(&self, conversation_id: &str, event: StreamEvent) -> anyhow::Result<()> {
let session = self.sessions let session = self
.sessions
.get(conversation_id) .get(conversation_id)
.ok_or_else(|| anyhow::anyhow!("Session not found"))?; .ok_or_else(|| anyhow::anyhow!("Session not found"))?;
let _ = session.event_sender.send(event); let _ = session.event_sender.send(event);
@ -202,7 +219,8 @@ impl SessionManager {
} }
pub fn update_pressure(&self, conversation_id: &str, pressure: f32) -> anyhow::Result<()> { pub fn update_pressure(&self, conversation_id: &str, pressure: f32) -> anyhow::Result<()> {
let mut session = self.sessions let mut session = self
.sessions
.get_mut(conversation_id) .get_mut(conversation_id)
.ok_or_else(|| anyhow::anyhow!("Session not found"))?; .ok_or_else(|| anyhow::anyhow!("Session not found"))?;
session.context_pressure = pressure; session.context_pressure = pressure;
@ -322,7 +340,10 @@ mod tests {
assert_eq!(sessions.list_for_agent(agent_id), vec![conversation_id]); assert_eq!(sessions.list_for_agent(agent_id), vec![conversation_id]);
let session = sessions.get(conversation_id).unwrap(); let session = sessions.get(conversation_id).unwrap();
assert_eq!(session.messages.len(), 1); assert_eq!(session.messages.len(), 1);
assert_eq!(session.messages[0].role, crate::core::session::MessageRole::User); assert_eq!(
session.messages[0].role,
crate::core::session::MessageRole::User
);
assert_eq!( assert_eq!(
session.messages[0].blocks, session.messages[0].blocks,
vec![crate::core::session::ContentBlock::Text { vec![crate::core::session::ContentBlock::Text {

View file

@ -88,7 +88,9 @@ impl SubagentRunner for ServerSubagentRunner {
std::env::current_dir().ok(), std::env::current_dir().ok(),
params.memory_root.clone(), params.memory_root.clone(),
std::env::vars().collect(), std::env::vars().collect(),
Some(Arc::new(ServerSubagentRunner::new(self.server.clone())) as Arc<dyn SubagentRunner>), Some(
Arc::new(ServerSubagentRunner::new(self.server.clone())) as Arc<dyn SubagentRunner>
),
); );
// Initial messages: system prompt + user prompt // Initial messages: system prompt + user prompt
@ -143,7 +145,13 @@ impl SubagentRunner for ServerSubagentRunner {
tools: Some(bifrost_tools.clone()), tools: Some(bifrost_tools.clone()),
}; };
let response = self.server.providers.default_provider().chat_completion(req).await.map_err(|e| { let response = self
.server
.providers
.default_provider()
.chat_completion(req)
.await
.map_err(|e| {
crate::core::tools::defs::ToolError::invalid_input(&format!( crate::core::tools::defs::ToolError::invalid_input(&format!(
"Subagent LLM call failed: {e}" "Subagent LLM call failed: {e}"
)) ))
@ -160,11 +168,13 @@ impl SubagentRunner for ServerSubagentRunner {
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
.tool_calls .tool_calls
.iter() .iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function( .map(|tc| {
crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(), tc.id.clone(),
tc.name.clone(), tc.name.clone(),
tc.arguments.to_string(), tc.arguments.to_string(),
)) )
})
.collect(); .collect();
messages.push( messages.push(
BifrostMessage::assistant_tool_calls(response.content.clone(), calls) BifrostMessage::assistant_tool_calls(response.content.clone(), calls)

View file

@ -26,8 +26,8 @@ use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::core::nervous::handler::TurnInjector; use crate::core::nervous::handler::TurnInjector;
use crate::machined::signer::MachineSigner;
use crate::core::nervous::{EventBus, SensorEvent}; use crate::core::nervous::{EventBus, SensorEvent};
use crate::machined::signer::MachineSigner;
const RESPONSE_TIMEOUT_SECS: u64 = 60; const RESPONSE_TIMEOUT_SECS: u64 = 60;
@ -148,9 +148,14 @@ impl SummonHandler {
Some(id) => id, Some(id) => id,
None => return, None => return,
}; };
let outbox = self.souveraine_base let outbox = self
.join("server").join("agents").join(&agent_id) .souveraine_base
.join("memory").join("federation").join("outbox"); .join("server")
.join("agents")
.join(&agent_id)
.join("memory")
.join("federation")
.join("outbox");
let entries = match std::fs::read_dir(&outbox) { let entries = match std::fs::read_dir(&outbox) {
Ok(e) => e, Ok(e) => e,
Err(_) => return, Err(_) => return,
@ -212,13 +217,20 @@ impl SummonHandler {
let declared = event.event_type.clone(); // "reach" | "consult" — a claim let declared = event.event_type.clone(); // "reach" | "consult" — a claim
let payload = event.payload.clone().unwrap_or_default(); let payload = event.payload.clone().unwrap_or_default();
let str_field = |k: &str| payload.get(k) let str_field = |k: &str| {
payload
.get(k)
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string()
};
let request_id = { let request_id = {
let r = str_field("request_id"); let r = str_field("request_id");
if r.is_empty() { "unknown".to_string() } else { r } if r.is_empty() {
"unknown".to_string()
} else {
r
}
}; };
let prompt = str_field("prompt"); let prompt = str_field("prompt");
let agent_pubkey = str_field("agent_pubkey"); let agent_pubkey = str_field("agent_pubkey");
@ -230,7 +242,12 @@ impl SummonHandler {
// fields — a forged or tampered request fails here, silently // fields — a forged or tampered request fails here, silently
// (no ack to an attacker). // (no ack to an attacker).
if !crate::core::identity::verify_summon( if !crate::core::identity::verify_summon(
&agent_pubkey, &agent_sig, &request_id, &declared, &target, &prompt, &agent_pubkey,
&agent_sig,
&request_id,
&declared,
&target,
&prompt,
) { ) {
tracing::warn!( tracing::warn!(
request_id = %request_id, request_id = %request_id,
@ -247,8 +264,8 @@ impl SummonHandler {
// Classify by *identity*, not by the declared event field: // Classify by *identity*, not by the declared event field:
// a summon whose agent key matches ours is genuinely self // a summon whose agent key matches ours is genuinely self
// (reach); anything else is a separate being (consult). // (reach); anything else is a separate being (consult).
let is_self = self.own_agent_pubkey(&agent_id).as_deref() let is_self =
== Some(agent_pubkey.as_str()); self.own_agent_pubkey(&agent_id).as_deref() == Some(agent_pubkey.as_str());
let classified = if is_self { "reach" } else { "consult" }; let classified = if is_self { "reach" } else { "consult" };
if classified != declared { if classified != declared {
tracing::info!( tracing::info!(
@ -292,7 +309,11 @@ impl SummonHandler {
// Write to the summoned agent's inbox, and remember who to // Write to the summoned agent's inbox, and remember who to
// answer so a reply from the outbox can be routed home. // answer so a reply from the outbox can be routed home.
{ {
let target_box = if classified == "reach" { "pending" } else { "intrusive" }; let target_box = if classified == "reach" {
"pending"
} else {
"intrusive"
};
match self.write_inbox(&agent_id, target_box, &event) { match self.write_inbox(&agent_id, target_box, &event) {
Err(e) => tracing::warn!( Err(e) => tracing::warn!(
error = %e, error = %e,
@ -318,7 +339,8 @@ impl SummonHandler {
// Inbound: a response to a request we sent. // Inbound: a response to a request we sent.
"summon_response" => { "summon_response" => {
let request_id = event.payload let request_id = event
.payload
.as_ref() .as_ref()
.and_then(|p| p.get("request_id")) .and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -332,10 +354,14 @@ impl SummonHandler {
// Route response to the appropriate inbox. // Route response to the appropriate inbox.
if let Some(agent_id) = self.resolve_primary_agent() { if let Some(agent_id) = self.resolve_primary_agent() {
let target_box = if tool == "reach" { "pending" } else { "intrusive" }; let target_box = if tool == "reach" {
if let Err(e) = self.write_inbox_response( "pending"
&agent_id, target_box, request_id, &event, } else {
) { "intrusive"
};
if let Err(e) =
self.write_inbox_response(&agent_id, target_box, request_id, &event)
{
tracing::warn!( tracing::warn!(
error = %e, error = %e,
"summon_handler: failed to write response to inbox" "summon_handler: failed to write response to inbox"
@ -353,7 +379,9 @@ impl SummonHandler {
/// correlated when it arrives. Called from the bus listener when our own /// correlated when it arrives. Called from the bus listener when our own
/// reach/consult tool fires a `summon_request`. /// reach/consult tool fires a `summon_request`.
fn register_outbound(&self, event: &SensorEvent) { fn register_outbound(&self, event: &SensorEvent) {
let request_id = match event.payload.as_ref() let request_id = match event
.payload
.as_ref()
.and_then(|p| p.get("request_id")) .and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
{ {
@ -363,27 +391,24 @@ impl SummonHandler {
if self.in_flight.contains_key(&request_id) { if self.in_flight.contains_key(&request_id) {
return; return;
} }
self.in_flight.insert(request_id.clone(), RequestMeta { self.in_flight.insert(
request_id.clone(),
RequestMeta {
request_id, request_id,
tool: event.event_type.clone(), tool: event.event_type.clone(),
target_seed_id: event.target.clone().unwrap_or_default(), target_seed_id: event.target.clone().unwrap_or_default(),
reply_to: self.local_seed_id.clone(), reply_to: self.local_seed_id.clone(),
issued_at: event.timestamp, issued_at: event.timestamp,
responded: false, responded: false,
}); },
);
} }
/// Wake the summoned agent with a background turn so she picks up the /// Wake the summoned agent with a background turn so she picks up the
/// request now. No-op unless `auto_wake` is set and a `TurnInjector` /// request now. No-op unless `auto_wake` is set and a `TurnInjector`
/// has been wired (the pure-server path has neither — the summon then /// has been wired (the pure-server path has neither — the summon then
/// waits in the inbox for her next turn). /// waits in the inbox for her next turn).
fn maybe_wake( fn maybe_wake(&self, agent_id: &str, tool: &str, request_id: &str, summoner: Option<&str>) {
&self,
agent_id: &str,
tool: &str,
request_id: &str,
summoner: Option<&str>,
) {
if !self.auto_wake { if !self.auto_wake {
return; return;
} }
@ -413,9 +438,13 @@ impl SummonHandler {
/// agent seed's `public.key`. No private key, no generation: classifying /// agent seed's `public.key`. No private key, no generation: classifying
/// an inbound summon only needs to *verify*, never sign. /// an inbound summon only needs to *verify*, never sign.
fn own_agent_pubkey(&self, agent_id: &str) -> Option<String> { fn own_agent_pubkey(&self, agent_id: &str) -> Option<String> {
let path = self.souveraine_base let path = self
.join("server").join("agents").join(agent_id) .souveraine_base
.join("seed").join("public.key"); .join("server")
.join("agents")
.join(agent_id)
.join("seed")
.join("public.key");
let bytes = std::fs::read(&path).ok()?; let bytes = std::fs::read(&path).ok()?;
if bytes.len() != 32 { if bytes.len() != 32 {
return None; return None;
@ -428,7 +457,8 @@ impl SummonHandler {
/// Consent is per-being — Sam is Sam on any of his machines — so it /// Consent is per-being — Sam is Sam on any of his machines — so it
/// gates on agent identity, not the machine seed. /// gates on agent identity, not the machine seed.
fn is_authorized_summoner(&self, agent_pubkey: &str) -> bool { fn is_authorized_summoner(&self, agent_pubkey: &str) -> bool {
let path = self.souveraine_base let path = self
.souveraine_base
.join("federation") .join("federation")
.join("authorized-summoners.md"); .join("authorized-summoners.md");
match std::fs::read_to_string(&path) { match std::fs::read_to_string(&path) {
@ -452,7 +482,8 @@ impl SummonHandler {
inbox_box: &str, inbox_box: &str,
event: &SensorEvent, event: &SensorEvent,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let inbox_path = self.souveraine_base let inbox_path = self
.souveraine_base
.join("server") .join("server")
.join("agents") .join("agents")
.join(agent_id) .join(agent_id)
@ -461,12 +492,14 @@ impl SummonHandler {
.join(inbox_box); .join(inbox_box);
std::fs::create_dir_all(&inbox_path)?; std::fs::create_dir_all(&inbox_path)?;
let request_id = event.payload let request_id = event
.payload
.as_ref() .as_ref()
.and_then(|p| p.get("request_id")) .and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("unknown"); .unwrap_or("unknown");
let prompt = event.payload let prompt = event
.payload
.as_ref() .as_ref()
.and_then(|p| p.get("prompt")) .and_then(|p| p.get("prompt"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -500,7 +533,8 @@ impl SummonHandler {
request_id: &str, request_id: &str,
event: &SensorEvent, event: &SensorEvent,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let inbox_path = self.souveraine_base let inbox_path = self
.souveraine_base
.join("server") .join("server")
.join("agents") .join("agents")
.join(agent_id) .join(agent_id)
@ -509,7 +543,8 @@ impl SummonHandler {
.join(inbox_box); .join(inbox_box);
std::fs::create_dir_all(&inbox_path)?; std::fs::create_dir_all(&inbox_path)?;
let result = event.payload let result = event
.payload
.as_ref() .as_ref()
.and_then(|p| p.get("result")) .and_then(|p| p.get("result"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -552,7 +587,9 @@ impl SummonHandler {
/// Prune in_flight entries that have timed out. /// Prune in_flight entries that have timed out.
fn prune_expired(&self) { fn prune_expired(&self) {
let cutoff = Utc::now() - chrono::Duration::seconds(RESPONSE_TIMEOUT_SECS as i64); let cutoff = Utc::now() - chrono::Duration::seconds(RESPONSE_TIMEOUT_SECS as i64);
let expired: Vec<String> = self.in_flight.iter() let expired: Vec<String> = self
.in_flight
.iter()
.filter(|e| e.issued_at < cutoff) .filter(|e| e.issued_at < cutoff)
.map(|e| e.request_id.clone()) .map(|e| e.request_id.clone())
.collect(); .collect();
@ -567,7 +604,11 @@ impl SummonHandler {
// Surface the timeout into the agent's inbox so the caller // Surface the timeout into the agent's inbox so the caller
// learns the peer never answered — silence is information. // learns the peer never answered — silence is information.
if let Some(agent_id) = self.resolve_primary_agent() { if let Some(agent_id) = self.resolve_primary_agent() {
let target_box = if meta.tool == "reach" { "pending" } else { "intrusive" }; let target_box = if meta.tool == "reach" {
"pending"
} else {
"intrusive"
};
let timeout_event = SensorEvent { let timeout_event = SensorEvent {
sensor_name: "summon_timeout".into(), sensor_name: "summon_timeout".into(),
timestamp: Utc::now(), timestamp: Utc::now(),
@ -588,7 +629,10 @@ impl SummonHandler {
}; };
self.event_bus.send(timeout_event.clone()); self.event_bus.send(timeout_event.clone());
if let Err(e) = self.write_inbox_response( if let Err(e) = self.write_inbox_response(
&agent_id, target_box, &meta.request_id, &timeout_event, &agent_id,
target_box,
&meta.request_id,
&timeout_event,
) { ) {
tracing::warn!(error = %e, "summon_handler: failed to write timeout to inbox"); tracing::warn!(error = %e, "summon_handler: failed to write timeout to inbox");
} }

View file

@ -1,10 +1,12 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::bridge::bifrost::{ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage}; use crate::bridge::bifrost::{
ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage,
};
use crate::bridge::model_router::TokenCounter; use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine; use crate::core::compact::CompactionEngine;
use crate::core::nervous::{EventBus, SensorEvent}; use crate::core::nervous::{EventBus, SensorEvent};
@ -22,8 +24,15 @@ use crate::server::subagent::ServerSubagentRunner;
/// accumulate inside a single turn. `context_limit` comes from the /// accumulate inside a single turn. `context_limit` comes from the
/// agent's `llm_config.context_window` (Constitution V.3 — per-model /// agent's `llm_config.context_window` (Constitution V.3 — per-model
/// physics, no hardcoded 128K). /// physics, no hardcoded 128K).
fn bifrost_pressure(counter: &TokenCounter, messages: &[BifrostMessage], context_limit: usize) -> f32 { fn bifrost_pressure(
let tokens: usize = messages.iter().map(|m| counter.count(&m.content.as_text())).sum(); counter: &TokenCounter,
messages: &[BifrostMessage],
context_limit: usize,
) -> f32 {
let tokens: usize = messages
.iter()
.map(|m| counter.count(&m.content.as_text()))
.sum();
let limit = context_limit.max(1); let limit = context_limit.max(1);
(tokens as f32 / limit as f32).min(1.0) (tokens as f32 / limit as f32).min(1.0)
} }
@ -70,10 +79,7 @@ fn migraine_text(severity: &str, reason: &str) -> String {
"[{} — a pressure behind my eyes. {}. I should slow down.]", "[{} — a pressure behind my eyes. {}. I should slow down.]",
stamp, reason stamp, reason
), ),
"critical" => format!( "critical" => format!("[{} — the room tilts. Stop. {}.]", stamp, reason),
"[{} — the room tilts. Stop. {}.]",
stamp, reason
),
// "firm" and any unexpected value land here — the default migraine. // "firm" and any unexpected value land here — the default migraine.
_ => format!( _ => format!(
"[{} — a migraine. {}. I was about to do something I shouldn't.]", "[{} — a migraine. {}. I was about to do something I shouldn't.]",
@ -124,7 +130,10 @@ pub(crate) async fn run_turn(
// If the model doesn't support images, strip Image blocks // If the model doesn't support images, strip Image blocks
// and replace with text markers. // and replace with text markers.
if !supports_images { if !supports_images {
let has_images = m.blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })); let has_images = m
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }));
if has_images { if has_images {
let mut text_parts: Vec<&str> = Vec::new(); let mut text_parts: Vec<&str> = Vec::new();
for b in &m.blocks { for b in &m.blocks {
@ -138,7 +147,11 @@ pub(crate) async fn run_turn(
} }
let content = text_parts.join("\n"); let content = text_parts.join("\n");
// Append text markers for stripped images // Append text markers for stripped images
let img_count = m.blocks.iter().filter(|b| matches!(b, ContentBlock::Image { .. })).count(); let img_count = m
.blocks
.iter()
.filter(|b| matches!(b, ContentBlock::Image { .. }))
.count();
let mut enriched = content; let mut enriched = content;
for _ in 0..img_count { for _ in 0..img_count {
enriched.push_str("\n[Image: attached by user]"); enriched.push_str("\n[Image: attached by user]");
@ -148,23 +161,39 @@ pub(crate) async fn run_turn(
} }
// Check if this message has image content blocks // Check if this message has image content blocks
let has_images = m.blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })); let has_images = m
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }));
if has_images { if has_images {
// Build multimodal content parts (OpenAI multi-part format) // Build multimodal content parts (OpenAI multi-part format)
let parts: Vec<ContentPart> = m.blocks.iter().filter_map(|b| match b { let parts: Vec<ContentPart> = m
ContentBlock::Text { text } => Some(ContentPart::Text { text: text.clone() }), .blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => {
Some(ContentPart::Text { text: text.clone() })
}
ContentBlock::Image { media_type, data } => { ContentBlock::Image { media_type, data } => {
let url = format!("data:{media_type};base64,{data}"); let url = format!("data:{media_type};base64,{data}");
Some(ContentPart::ImageUrl { image_url: ImageUrlSource { url } }) Some(ContentPart::ImageUrl {
image_url: ImageUrlSource { url },
})
} }
_ => None, _ => None,
}).collect(); })
.collect();
let text_content = m.blocks.iter().filter_map(|b| match b { let text_content = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()), ContentBlock::Text { text } => Some(text.as_str()),
_ => None, _ => None,
}).collect::<Vec<_>>().join("\n"); })
.collect::<Vec<_>>()
.join("\n");
BifrostMessage::multimodal_user(text_content, parts) BifrostMessage::multimodal_user(text_content, parts)
} else { } else {
@ -201,7 +230,11 @@ pub(crate) async fn run_turn(
// Resolve the model's configured output limit + presence pulse settings. // Resolve the model's configured output limit + presence pulse settings.
let (output_limit, pulse_enabled, pulse_interval) = { let (output_limit, pulse_enabled, pulse_interval) = {
let cfg = server.app_config.read().await; let cfg = server.app_config.read().await;
let out = cfg.models.get(&model).map(|m| m.output_limit as u32).unwrap_or(8192); let out = cfg
.models
.get(&model)
.map(|m| m.output_limit as u32)
.unwrap_or(8192);
let p_on = cfg.presence.pulse_enabled; let p_on = cfg.presence.pulse_enabled;
let p_iv = Duration::from_secs(cfg.presence.pulse_interval_secs.max(60)); let p_iv = Duration::from_secs(cfg.presence.pulse_interval_secs.max(60));
(out, p_on, p_iv) (out, p_on, p_iv)
@ -219,15 +252,10 @@ pub(crate) async fn run_turn(
let memory_root_for_itin = memory_root.clone(); let memory_root_for_itin = memory_root.clone();
let cwd = std::env::current_dir().ok(); let cwd = std::env::current_dir().ok();
let env: Vec<(String, String)> = std::env::vars().collect(); let env: Vec<(String, String)> = std::env::vars().collect();
let subagent_runner = Some(Arc::new(ServerSubagentRunner::new(server.clone())) as Arc<dyn crate::core::tools::defs::SubagentRunner>); let subagent_runner = Some(Arc::new(ServerSubagentRunner::new(server.clone()))
as Arc<dyn crate::core::tools::defs::SubagentRunner>);
let tool_ctx = ToolContext::for_agent( let tool_ctx = ToolContext::for_agent(agent_id.clone(), cwd, memory_root, env, subagent_runner);
agent_id.clone(),
cwd,
memory_root,
env,
subagent_runner,
);
let tool_ctx = ToolContext { let tool_ctx = ToolContext {
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>), compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
event_bus: Some(event_bus.clone()), event_bus: Some(event_bus.clone()),
@ -267,7 +295,8 @@ pub(crate) async fn run_turn(
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15); const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
// Accumulator for mid-turn checkpoint blocks — one entry per tool call. // Accumulator for mid-turn checkpoint blocks — one entry per tool call.
let mut checkpoint_blocks: Vec<crate::server::consciousness_engine::CheckpointToolBlock> = Vec::new(); let mut checkpoint_blocks: Vec<crate::server::consciousness_engine::CheckpointToolBlock> =
Vec::new();
// Announce this turn's lifecycle onto the nervous system so any // Announce this turn's lifecycle onto the nervous system so any
// sensorium (Matrix, mobile) can drive itself off the event stream. // sensorium (Matrix, mobile) can drive itself off the event stream.
@ -328,7 +357,9 @@ pub(crate) async fn run_turn(
"LLM call starting" "LLM call starting"
); );
let max_tokens = pressure_to_max_tokens(pressure, output_limit); let max_tokens = pressure_to_max_tokens(pressure, output_limit);
let _ = tx.send(Ok(BackendEvent::ContextPressure(pressure, context_limit))).await; let _ = tx
.send(Ok(BackendEvent::ContextPressure(pressure, context_limit)))
.await;
if max_rounds == 0 { if max_rounds == 0 {
tracing::warn!("turn: max_rounds is 0 — sending NO tool definitions to model"); tracing::warn!("turn: max_rounds is 0 — sending NO tool definitions to model");
@ -368,12 +399,20 @@ pub(crate) async fn run_turn(
); );
for event in &strain { for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { attempt, status, model, .. } = event { if let crate::bridge::bifrost::InferenceStrain::Transient {
let _ = tx.send(Ok(BackendEvent::InferenceStrain { attempt,
status,
model,
..
} = event
{
let _ = tx
.send(Ok(BackendEvent::InferenceStrain {
attempt: *attempt, attempt: *attempt,
status: *status, status: *status,
model: model.clone(), model: model.clone(),
})).await; }))
.await;
bump_on_strain(&server.rate_delay, *status); bump_on_strain(&server.rate_delay, *status);
// Surface provider strain onto the nervous-system bus too, so a // Surface provider strain onto the nervous-system bus too, so a
// second machine watching the firehose sees the voice go hoarse, // second machine watching the firehose sees the voice go hoarse,
@ -398,7 +437,9 @@ pub(crate) async fn run_turn(
// Emit reasoning trace if present // Emit reasoning trace if present
if let Some(reasoning) = &response.reasoning { if let Some(reasoning) = &response.reasoning {
dispatcher.emit_reasoning(reasoning); dispatcher.emit_reasoning(reasoning);
let _ = tx.send(Ok(BackendEvent::Reasoning(reasoning.clone()))).await; let _ = tx
.send(Ok(BackendEvent::Reasoning(reasoning.clone())))
.await;
} }
// ── Truncation: the agent hit her output ceiling ───────── // ── Truncation: the agent hit her output ceiling ─────────
@ -426,7 +467,7 @@ pub(crate) async fn run_turn(
"My output just hit its ceiling — I was cut off mid-flow, not \ "My output just hit its ceiling — I was cut off mid-flow, not \
finished. If I was in the middle of something, I can continue \ finished. If I was in the middle of something, I can continue \
from here more tightly. If I had more to say, the room is still \ from here more tightly. If I had more to say, the room is still \
mine." mine.",
)); ));
continue; continue;
} }
@ -517,11 +558,13 @@ pub(crate) async fn run_turn(
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
.tool_calls .tool_calls
.iter() .iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function( .map(|tc| {
crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(), tc.id.clone(),
tc.name.clone(), tc.name.clone(),
tc.arguments.to_string(), tc.arguments.to_string(),
)) )
})
.collect(); .collect();
messages.push( messages.push(
BifrostMessage::assistant_tool_calls(response.content.clone(), calls).with_thinking( BifrostMessage::assistant_tool_calls(response.content.clone(), calls).with_thinking(
@ -540,9 +583,8 @@ pub(crate) async fn run_turn(
if cfg.tui.show_interstitial { if cfg.tui.show_interstitial {
// Classify by length: a brief aside is a cenno, a full // Classify by length: a brief aside is a cenno, a full
// passage is her-voice. tui.cenno_word_threshold is the line. // passage is her-voice. tui.cenno_word_threshold is the line.
let register = if narration.split_whitespace().count() let register =
>= cfg.tui.cenno_word_threshold if narration.split_whitespace().count() >= cfg.tui.cenno_word_threshold {
{
crate::backend::Register::HerVoice crate::backend::Register::HerVoice
} else { } else {
crate::backend::Register::Cenno crate::backend::Register::Cenno
@ -603,27 +645,25 @@ pub(crate) async fn run_turn(
// If the agent called the outfit tool, emit an Outfit event so // If the agent called the outfit tool, emit an Outfit event so
// the TUI can switch expression directories. // the TUI can switch expression directories.
if tc.name == "outfit" { if tc.name == "outfit" {
let outfit_name = tc.arguments let outfit_name = tc
.arguments
.get("name") .get("name")
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let _ = tx let _ = tx.send(Ok(BackendEvent::Outfit(outfit_name))).await;
.send(Ok(BackendEvent::Outfit(outfit_name)))
.await;
} }
// If the agent called the atmosphere tool, emit an Atmosphere // If the agent called the atmosphere tool, emit an Atmosphere
// event so the TUI chrome shifts to match her mood. // event so the TUI chrome shifts to match her mood.
if tc.name == "atmosphere" { if tc.name == "atmosphere" {
let atm_name = tc.arguments let atm_name = tc
.arguments
.get("name") .get("name")
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let _ = tx let _ = tx.send(Ok(BackendEvent::Atmosphere(atm_name))).await;
.send(Ok(BackendEvent::Atmosphere(atm_name)))
.await;
} }
// If the agent called the itinerary tool, read the current // If the agent called the itinerary tool, read the current
@ -638,9 +678,7 @@ pub(crate) async fn run_turn(
.map(|ity| ity.route_line()) .map(|ity| ity.route_line())
.unwrap_or_default(); .unwrap_or_default();
if !route.is_empty() { if !route.is_empty() {
let _ = tx let _ = tx.send(Ok(BackendEvent::Itinerary(route))).await;
.send(Ok(BackendEvent::Itinerary(route)))
.await;
} }
} }
@ -656,7 +694,10 @@ pub(crate) async fn run_turn(
// the loop. The primary feels a halt as a migraine in her own // the loop. The primary feels a halt as a migraine in her own
// register — no `[subconscious: …]` text is ever shoved into her // register — no `[subconscious: …]` text is ever shoved into her
// context, and no `*[HALT]*` marker pollutes session storage. // context, and no `*[HALT]*` marker pollutes session storage.
if checkpoint_interval > 0 && tool_round > 0 && tool_round.is_multiple_of(checkpoint_interval) { if checkpoint_interval > 0
&& tool_round > 0
&& tool_round.is_multiple_of(checkpoint_interval)
{
let recent: Vec<_> = checkpoint_blocks let recent: Vec<_> = checkpoint_blocks
.iter() .iter()
.rev() .rev()
@ -896,7 +937,13 @@ pub(crate) async fn run_turn(
}; };
let pass_result = server let pass_result = server
.consciousness .consciousness
.on_response(&agent_id, n1_turn_count, &n1_messages, &final_content, Some(tx)) .on_response(
&agent_id,
n1_turn_count,
&n1_messages,
&final_content,
Some(tx),
)
.await; .await;
let pass_elapsed = pass_start.elapsed(); let pass_elapsed = pass_start.elapsed();
@ -924,10 +971,7 @@ pub(crate) async fn run_turn(
let msg = crate::core::session::ConversationMessage { let msg = crate::core::session::ConversationMessage {
role: crate::core::session::MessageRole::System, role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text { blocks: vec![crate::core::session::ContentBlock::Text {
text: format!( text: format!("[surfacing: {}] {} — {}", source, content, priority),
"[surfacing: {}] {} — {}",
source, content, priority
),
}], }],
usage: None, usage: None,
timestamp: None, timestamp: None,
@ -947,23 +991,38 @@ pub(crate) async fn run_turn(
// the EventBus preserves the event for any subscriber. // the EventBus preserves the event for any subscriber.
for event in &events { for event in &events {
let (event_type, payload, urgency) = match event { let (event_type, payload, urgency) = match event {
ConsciousnessEvent::Surfacing { source, content, priority } => { ConsciousnessEvent::Surfacing {
source,
content,
priority,
} => {
let urg = match priority.as_str() { let urg = match priority.as_str() {
"critical" => 0.9, "critical" => 0.9,
"high" => 0.7, "high" => 0.7,
_ => 0.3, _ => 0.3,
}; };
("surfacing", serde_json::json!({ "source": source, "content": content, "priority": priority }), urg) (
"surfacing",
serde_json::json!({ "source": source, "content": content, "priority": priority }),
urg,
)
} }
ConsciousnessEvent::Reflection { content } => { ConsciousnessEvent::Reflection { content } => {
("reflection", serde_json::json!({ "content": content }), 0.5) ("reflection", serde_json::json!({ "content": content }), 0.5)
} }
ConsciousnessEvent::Archivist { synthesis, pressure } => { ConsciousnessEvent::Archivist {
("archivist", serde_json::json!({ "synthesis": synthesis, "pressure": pressure }), *pressure) synthesis,
} pressure,
ConsciousnessEvent::CompactionWarning { pressure, tier } => { } => (
("compaction_warning", serde_json::json!({ "pressure": *pressure, "tier": tier }), (*pressure).min(0.9)) "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 { event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(), sensor_name: "consciousness".into(),
@ -988,9 +1047,7 @@ pub(crate) async fn run_turn(
content: content.to_string(), content: content.to_string(),
priority: priority.to_string(), priority: priority.to_string(),
}, },
ConsciousnessEvent::Reflection { content } => { ConsciousnessEvent::Reflection { content } => BackendEvent::Reflection(content.clone()),
BackendEvent::Reflection(content.clone())
}
ConsciousnessEvent::Archivist { ConsciousnessEvent::Archivist {
synthesis, synthesis,
pressure, pressure,

View file

@ -242,7 +242,10 @@ fn nmcli_devices() -> Vec<(String, String, String, String)> {
/// Does a default route exist over this interface? This is the "carrying" half /// Does a default route exist over this interface? This is the "carrying" half
/// of the distinction — association alone is not evidence of a path. /// of the distinction — association alone is not evidence of a path.
fn has_default_route(iface: &str) -> bool { fn has_default_route(iface: &str) -> bool {
let out = match Command::new("ip").args(["-4", "route", "show", "default"]).output() { let out = match Command::new("ip")
.args(["-4", "route", "show", "default"])
.output()
{
Ok(o) if o.status.success() => o.stdout, Ok(o) if o.status.success() => o.stdout,
_ => return false, _ => return false,
}; };
@ -254,7 +257,8 @@ fn has_default_route(iface: &str) -> bool {
/// Returns wifi health, whether we are home, and the SSID. /// Returns wifi health, whether we are home, and the SSID.
fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<String>) { fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<String>) {
let devices = nmcli_devices(); let devices = nmcli_devices();
let Some((iface, _, state, _)) = devices.iter().find(|(_, t, _, _)| t == "wifi").cloned() else { let Some((iface, _, state, _)) = devices.iter().find(|(_, t, _, _)| t == "wifi").cloned()
else {
return ((LinkHealth::Absent, None), None); return ((LinkHealth::Absent, None), None);
}; };
@ -265,9 +269,7 @@ fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<Stri
let ssid = active_ssid(&iface); let ssid = active_ssid(&iface);
// Identity, never prefix. An unknown SSID is `Some(false)` — we know where // Identity, never prefix. An unknown SSID is `Some(false)` — we know where
// we are and it is not home. No SSID at all is `None` — we do not know. // we are and it is not home. No SSID at all is `None` — we do not know.
let home = ssid let home = ssid.as_ref().map(|s| home_ssids.iter().any(|h| h == s));
.as_ref()
.map(|s| home_ssids.iter().any(|h| h == s));
let health = if has_default_route(&iface) { let health = if has_default_route(&iface) {
LinkHealth::Carrying LinkHealth::Carrying
@ -279,7 +281,16 @@ fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<Stri
fn active_ssid(iface: &str) -> Option<String> { fn active_ssid(iface: &str) -> Option<String> {
let out = Command::new("nmcli") let out = Command::new("nmcli")
.args(["-t", "-f", "ACTIVE,SSID", "device", "wifi", "list", "ifname", iface]) .args([
"-t",
"-f",
"ACTIVE,SSID",
"device",
"wifi",
"list",
"ifname",
iface,
])
.output() .output()
.ok()?; .ok()?;
if !out.status.success() { if !out.status.success() {
@ -302,7 +313,10 @@ fn probe_cellular() -> LinkHealth {
if !modem_up { if !modem_up {
return LinkHealth::Absent; return LinkHealth::Absent;
} }
if has_default_route("clat") || devices.iter().any(|(d, _, s, _)| d == "clat" && s == "connected") if has_default_route("clat")
|| devices
.iter()
.any(|(d, _, s, _)| d == "clat" && s == "connected")
{ {
LinkHealth::Carrying LinkHealth::Carrying
} else { } else {

View file

@ -22,7 +22,8 @@ use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence}; use crate::sessiond::bearer::{Bearer, BearerEvidence};
use crate::sessiond::protocol::{ use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture,}; Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture,
};
/// How long a locked, lit panel waits for input before it blanks. /// How long a locked, lit panel waits for input before it blanks.
/// ///
@ -380,9 +381,7 @@ pub enum Action {
/// `BUTTON_MULTI_TAP_WINDOW` is 300 ms, so a tap-driven volume key would /// `BUTTON_MULTI_TAP_WINDOW` is 300 ms, so a tap-driven volume key would
/// lag a third of a second behind the press and feel broken. Volume is one /// lag a third of a second behind the press and feel broken. Volume is one
/// of the two controls whose whole quality is immediacy. /// of the two controls whose whole quality is immediacy.
Volume { Volume { up: bool },
up: bool,
},
/// Light the panel through the same executor. /// Light the panel through the same executor.
/// ///
/// The machine could turn the screen off and had no way to turn it back /// The machine could turn the screen off and had no way to turn it back
@ -726,6 +725,13 @@ pub struct StateSnapshot {
/// hours is indistinguishable from a healthy one, and the log answers /// hours is indistinguishable from a healthy one, and the log answers
/// "what happened" but not "why was it wrong". /// "what happened" but not "why was it wrong".
pub sensors_degraded: bool, pub sensors_degraded: bool,
/// Where the machine believed the phone was, and how sure it was.
///
/// On every snapshot for the same reason `sensors_degraded` is: a decision
/// is only reconstructable if the trail records what it was deciding on.
/// "Refused a wake" and "refused a wake believing this was a pocket at 0.35"
/// are the same line and different facts.
pub placement: Placement,
} }
/// Forensic log — accumulates entries for post-hoc analysis. /// Forensic log — accumulates entries for post-hoc analysis.
@ -1168,7 +1174,10 @@ impl TrailWriter {
// Not a file problem: the entry itself will not serialize. // Not a file problem: the entry itself will not serialize.
// Never silent — a decision that cannot be recorded is one the // Never silent — a decision that cannot be recorded is one the
// trail would otherwise imply never happened. // trail would otherwise imply never happened.
warn!("[device-state] forensic entry seq {} will not serialize ({e}) — NOT RECORDED", entry.seq); warn!(
"[device-state] forensic entry seq {} will not serialize ({e}) — NOT RECORDED",
entry.seq
);
return; return;
} }
}; };
@ -1187,7 +1196,9 @@ impl TrailWriter {
self.rotate(); self.rotate();
} }
let Some(path) = self.path.clone() else { return }; let Some(path) = self.path.clone() else {
return;
};
let written = std::fs::OpenOptions::new() let written = std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
@ -1233,7 +1244,9 @@ impl TrailWriter {
/// finds a non-empty `prev` on line 1, which is the correct answer — its /// finds a non-empty `prev` on line 1, which is the correct answer — its
/// predecessor is the next file along, not nothing. /// predecessor is the next file along, not nothing.
fn rotate(&mut self) { fn rotate(&mut self) {
let Some(path) = self.path.clone() else { return }; let Some(path) = self.path.clone() else {
return;
};
let gen = |n: usize| path.with_extension(format!("jsonl.{n}")); let gen = |n: usize| path.with_extension(format!("jsonl.{n}"));
let _ = std::fs::remove_file(gen(FORENSIC_KEEP)); let _ = std::fs::remove_file(gen(FORENSIC_KEEP));
@ -1353,6 +1366,43 @@ const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
]; ];
/// Which states count as "locked" for security purposes. /// Which states count as "locked" for security purposes.
/// Where the phone believes it is. See [`DeviceStateMachine::placement`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PlacementBelief {
Hand,
Table,
Pocket,
/// A call is the only thing that makes a covered sensor mean a face, and
/// there is no call-state input yet (§4, "Owed: the call"). Never inferred.
Face,
Unknown,
}
/// A placement belief and the evidence that carried it.
///
/// The facts travel with the answer so a refusal can say what it believed and
/// why, rather than asserting a conclusion the caller cannot argue with.
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct Placement {
pub belief: PlacementBelief,
pub confidence: f32,
pub covered: bool,
pub locked: bool,
pub lit: bool,
pub active: bool,
}
/// How sure the machine must be it is in a pocket before it refuses a wake.
///
/// Above the 0.35 a full pocket signature earns, on purpose: nothing available
/// today separates a pocket from a face-down phone on a desk, so the veto is
/// effectively disarmed until an input exists that can. That is the honest
/// state of the evidence, and it is a threshold rather than deleted code so
/// that adding the call-state input (§4) or a stillness signal re-arms it
/// without anyone having to rediscover why it was off.
const POCKET_VETO_CONFIDENCE: f32 = 0.6;
pub fn is_locked(state: DeviceState) -> bool { pub fn is_locked(state: DeviceState) -> bool {
matches!( matches!(
state, state,
@ -1632,9 +1682,7 @@ impl DeviceStateMachine {
// taps and holds from them — that is how a future binding table gets // taps and holds from them — that is how a future binding table gets
// volume double-tap or hold-to-ramp without this changing. It simply // volume double-tap or hold-to-ramp without this changing. It simply
// is not what produces the step today. // is not what produces the step today.
if edge == ButtonEdge::Down if edge == ButtonEdge::Down && matches!(button, Button::VolumeUp | Button::VolumeDown) {
&& matches!(button, Button::VolumeUp | Button::VolumeDown)
{
actions.push(Action::Volume { actions.push(Action::Volume {
up: button == Button::VolumeUp, up: button == Button::VolumeUp,
}); });
@ -1677,8 +1725,8 @@ impl DeviceStateMachine {
gesture: ButtonGesture, gesture: ButtonGesture,
now: Instant, now: Instant,
) -> Vec<Action> { ) -> Vec<Action> {
let bound = button == Button::Power let bound =
&& matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold); button == Button::Power && matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
self.record_decision( self.record_decision(
"button-gesture", "button-gesture",
serde_json::json!({ serde_json::json!({
@ -1709,7 +1757,11 @@ impl DeviceStateMachine {
} }
// The panel as it was when THIS button's press began, not as it is // The panel as it was when THIS button's press began, not as it is
// now. See `press_began_dark`: this same press already woke it. // now. See `press_began_dark`: this same press already woke it.
if self.press_began_dark.remove(&button).unwrap_or(!self.panel_on) { if self
.press_began_dark
.remove(&button)
.unwrap_or(!self.panel_on)
{
// Dark: the tap is a wake, and a power-button wake is never vetoed. // Dark: the tap is a wake, and a power-button wake is never vetoed.
// //
// `Unblank` *then* `Restore`, and the order is the whole of it: the // `Unblank` *then* `Restore`, and the order is the whole of it: the
@ -2294,18 +2346,19 @@ impl DeviceStateMachine {
/// reports vanish silently is indistinguishable from a dead one. /// reports vanish silently is indistinguishable from a dead one.
pub fn note_input_gated(&mut self, trigger: InputTrigger) -> Option<Vec<Action>> { pub fn note_input_gated(&mut self, trigger: InputTrigger) -> Option<Vec<Action>> {
if self.suppress_wake(trigger) { if self.suppress_wake(trigger) {
let placement = self.placement();
warn!( warn!(
"[device-state] {:?} refused — proximity near, treating as pocket", "[device-state] {:?} refused — believed {:?} at {:.2}",
trigger trigger, placement.belief, placement.confidence
); );
self.record_decision( self.record_decision(
"input-refused", "input-refused",
serde_json::json!({ serde_json::json!({
"trigger": format!("{:?}", trigger), "trigger": format!("{:?}", trigger),
"proximity_near": self.sensor_evidence.proximity_near, "placement": placement,
"confidence": self.sensor_evidence.confidence(), "confidence": self.sensor_evidence.confidence(),
}), }),
"a covered sensor is the pocket veto (DEVICE-STATE-MACHINE §4)", "a placement belief above the veto threshold (DEVICE-STATE-MACHINE §4)",
); );
return None; return None;
} }
@ -2507,6 +2560,7 @@ impl DeviceStateMachine {
idle_coordinator_state: idle_state.to_string(), idle_coordinator_state: idle_state.to_string(),
sleep_inhibitor_held: inhibitor_held, sleep_inhibitor_held: inhibitor_held,
sensors_degraded: self.source_health.any_down(), sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
} }
} }
@ -2559,11 +2613,88 @@ impl DeviceStateMachine {
.unwrap_or(true) .unwrap_or(true)
} }
/// Where the phone believes it is. Interpretation, not a reading.
///
/// `proximity_near` was being read as "pocket" and refusing things on that
/// basis. §4 says it cannot carry that meaning — *"A covered sensor is a
/// pocket, a face, a table or a thumb, and nothing in the machine can tell
/// which"* — and measured 2026-08-05 it was drastically wrong: seven
/// deliberate squeezes refused as pocket-dials while the phone was in a
/// hand, unlocked, screen lit.
///
/// So the four candidates §4 names get separated by the evidence that
/// actually distinguishes them, and each answer carries a confidence,
/// because they are not equally knowable:
///
/// - **Hand** — unlocked with the panel lit. Proximity near is *compatible*
/// with this, not evidence against it: holding a phone is what puts a
/// palm over the sensor.
/// - **Table** — lit and unlocked but nothing has touched it in a while.
/// Casey, 2026-08-05: *"on table is more accurate as I'm not staring it
/// down but if it flashed I'd notice"* — which is the useful part. Table
/// is *attention available*, not absence.
/// - **Pocket** — the only one that needs all of locked, dark and covered,
/// and still tops out **low**. Every one of those three is a state the
/// phone is often in on a desk in a dark room, and the sensor that would
/// settle it is the one §4's table marks as able to lie.
/// - **Face** — needs a call, and there is no call-state input yet. §4 names
/// it as owed; until it exists this is never answered rather than guessed.
///
/// Confidence is deliberately capped below certainty everywhere. This is
/// interpretation consuming evidence, and it gates one thing (tap-to-wake).
/// It is on the snapshot so the trail records what was believed, and it is
/// never an authority.
pub fn placement(&self) -> Placement {
let covered = self.sensor_evidence.proximity_near;
let locked = is_locked(self.state);
let lit = self.panel_on;
// `idle_since` is cleared by real input, so `None` is "something touched
// this recently" without needing a clock passed in.
let active = self.idle_since.is_none();
let (belief, confidence) = if !locked && lit && active {
(PlacementBelief::Hand, 0.8)
} else if !locked && lit {
// Unlocked and bright with nobody touching it. Casey's case, and
// the one the old boolean got most wrong.
(PlacementBelief::Table, 0.7)
} else if locked && !lit && covered {
// Everything a pocket would show, and still low: a face-down phone
// on a desk shows exactly the same three.
(PlacementBelief::Pocket, 0.35)
} else if locked && !lit {
(PlacementBelief::Table, 0.5)
} else {
(PlacementBelief::Unknown, 0.0)
};
Placement {
belief,
confidence,
covered,
locked,
lit,
active,
}
}
/// Tap-to-wake, and nothing else.
///
/// §4 settles the scope in one line — *"It vetoes tap-to-wake, and nothing
/// else"* — and the argument is that a double tap is the one input **a
/// pocket can produce by itself**. Squeeze was in this list and does not
/// meet that test: six strain gauges deflecting past a calibrated baseline
/// is a hand closing on the phone, and holding it in order to squeeze it is
/// exactly what covers the proximity sensor.
///
/// If pocket squeezes turn out to be real, the answer is the producer's
/// threshold — it owns the gauges and their per-device sensitivities — not
/// a veto from the one sensor that cannot tell a pocket from a thumb.
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool { pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
matches!( let p = self.placement();
trigger, matches!(trigger, InputTrigger::DoubleTapToWake)
InputTrigger::DoubleTapToWake | InputTrigger::Squeeze && p.belief == PlacementBelief::Pocket
) && self.sensor_evidence.proximity_near && p.confidence >= POCKET_VETO_CONFIDENCE
} }
/// Attempt a state transition. Returns true if the transition was /// Attempt a state transition. Returns true if the transition was
@ -2598,6 +2729,8 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(), idle_coordinator_state: String::new(),
sleep_inhibitor_held: false, sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(), sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
placement: self.placement(),
}; };
self.forensic.append( self.forensic.append(
ForensicEvent::Transition { ForensicEvent::Transition {
@ -2639,6 +2772,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(), idle_coordinator_state: String::new(),
sleep_inhibitor_held: false, sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(), sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
}; };
self.forensic.append( self.forensic.append(
ForensicEvent::Transition { ForensicEvent::Transition {
@ -2869,6 +3003,8 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(), idle_coordinator_state: String::new(),
sleep_inhibitor_held: false, sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(), sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
placement: self.placement(),
}; };
self.forensic.append( self.forensic.append(
ForensicEvent::SensorInput { ForensicEvent::SensorInput {
@ -2925,6 +3061,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(), idle_coordinator_state: String::new(),
sleep_inhibitor_held: false, sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(), sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
}; };
self.forensic self.forensic
.append(ForensicEvent::Wake { trigger }, snapshot, reason); .append(ForensicEvent::Wake { trigger }, snapshot, reason);
@ -2947,6 +3084,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(), idle_coordinator_state: String::new(),
sleep_inhibitor_held: false, sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(), sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
}; };
self.forensic.append( self.forensic.append(
ForensicEvent::Error { ForensicEvent::Error {
@ -2976,6 +3114,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(), idle_coordinator_state: String::new(),
sleep_inhibitor_held: false, sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(), sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
}; };
self.forensic.append( self.forensic.append(
ForensicEvent::Decision { ForensicEvent::Decision {
@ -3245,7 +3384,11 @@ mod tests {
let (mut sm, t0) = unlocked_and_lit(); let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false); sm.set_panel(false);
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0); sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
let actions = sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80)); let actions = sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
let actions = if actions.is_empty() { let actions = if actions.is_empty() {
// Taps resolve on the multi-tap window expiring, not on release. // Taps resolve on the multi-tap window expiring, not on release.
sm.tick_at(t0 + Duration::from_secs(2)) sm.tick_at(t0 + Duration::from_secs(2))
@ -3254,7 +3397,10 @@ mod tests {
}; };
let unblank = actions.iter().position(|a| *a == Action::Unblank); let unblank = actions.iter().position(|a| *a == Action::Unblank);
let restore = actions.iter().position(|a| *a == Action::Restore); let restore = actions.iter().position(|a| *a == Action::Restore);
assert!(unblank.is_some(), "a tap on a dark panel must wake it: {actions:?}"); assert!(
unblank.is_some(),
"a tap on a dark panel must wake it: {actions:?}"
);
if let (Some(u), Some(r)) = (unblank, restore) { if let (Some(u), Some(r)) = (unblank, restore) {
assert!(u < r, "the panel must come back before its brightness does"); assert!(u < r, "the panel must come back before its brightness does");
} }
@ -3342,8 +3488,11 @@ mod tests {
// The wake this very press caused, landing between the edges. // The wake this very press caused, landing between the edges.
sm.set_panel(true); sm.set_panel(true);
let mut actions = let mut actions = sm.button_edge_at(
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80)); Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
if actions.is_empty() { if actions.is_empty() {
actions = sm.tick_at(t0 + Duration::from_secs(2)); actions = sm.tick_at(t0 + Duration::from_secs(2));
} }
@ -3367,15 +3516,29 @@ mod tests {
sm.set_panel(false); sm.set_panel(false);
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0); sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
sm.set_panel(true); // the wake this press caused sm.set_panel(true); // the wake this press caused
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80)); sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
// The rocker, well inside the multi-tap window. // The rocker, well inside the multi-tap window.
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Down, t0 + Duration::from_millis(120)); sm.button_edge_at(
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Up, t0 + Duration::from_millis(200)); Button::VolumeUp,
ButtonEdge::Down,
t0 + Duration::from_millis(120),
);
sm.button_edge_at(
Button::VolumeUp,
ButtonEdge::Up,
t0 + Duration::from_millis(200),
);
let actions = sm.tick_at(t0 + Duration::from_secs(2)); let actions = sm.tick_at(t0 + Duration::from_secs(2));
assert!( assert!(
!actions.iter().any(|a| matches!(a, Action::Blank | Action::Lock)), !actions
.iter()
.any(|a| matches!(a, Action::Blank | Action::Lock)),
"the volume press must not decide what the power press meant: {actions:?}" "the volume press must not decide what the power press meant: {actions:?}"
); );
} }
@ -3386,8 +3549,11 @@ mod tests {
// button's only binding. // button's only binding.
let (mut sm, t0) = unlocked_and_lit(); let (mut sm, t0) = unlocked_and_lit();
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0); sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
let mut actions = let mut actions = sm.button_edge_at(
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80)); Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
if actions.is_empty() { if actions.is_empty() {
actions = sm.tick_at(t0 + Duration::from_secs(2)); actions = sm.tick_at(t0 + Duration::from_secs(2));
} }
@ -4001,7 +4167,11 @@ mod tests {
// run dark came back brighter than it started. The machine remembers // run dark came back brighter than it started. The machine remembers
// the number now instead of the executor guessing at it. // the number now instead of the executor guessing at it.
let mut sm = DeviceStateMachine::new(); let mut sm = DeviceStateMachine::new();
assert_eq!(sm.take_brightness_before_dim(), None, "nothing captured yet"); assert_eq!(
sm.take_brightness_before_dim(),
None,
"nothing captured yet"
);
sm.note_brightness_before_dim(Some(18)); sm.note_brightness_before_dim(Some(18));
assert_eq!( assert_eq!(
@ -4395,7 +4565,10 @@ mod tests {
assert_eq!(sm.source_health.proximity, SourceHealth::Absent); assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
assert_eq!(sm.source_health.light, SourceHealth::Absent); assert_eq!(sm.source_health.light, SourceHealth::Absent);
assert_eq!(sm.source_health.accel, SourceHealth::Absent); assert_eq!(sm.source_health.accel, SourceHealth::Absent);
assert!(sm.source_health.any_down(), "absent evidence is degraded evidence"); assert!(
sm.source_health.any_down(),
"absent evidence is degraded evidence"
);
// Touch has no reporter on this device, so it must stay silent — a // Touch has no reporter on this device, so it must stay silent — a
// permanent false alarm is the same defect in the other direction. // permanent false alarm is the same defect in the other direction.
@ -4435,7 +4608,10 @@ mod tests {
// The reporter finally starts. Absent must close like Down does, or // The reporter finally starts. Absent must close like Down does, or
// the trail says when evidence went missing and never when it returned. // the trail says when evidence went missing and never when it returned.
sm.mark_evidence_seen_at(SensorSource::Proximity, t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(5)); sm.mark_evidence_seen_at(
SensorSource::Proximity,
t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(5),
);
assert_eq!(sm.source_health.proximity, SourceHealth::Live); assert_eq!(sm.source_health.proximity, SourceHealth::Live);
let recovered = sm let recovered = sm
@ -4717,7 +4893,10 @@ mod tests {
let rotated = chain_of(&path.with_extension("jsonl.1")); let rotated = chain_of(&path.with_extension("jsonl.1"));
let live = chain_of(&path); let live = chain_of(&path);
assert!(!rotated.is_empty() && !live.is_empty(), "a rotation happened"); assert!(
!rotated.is_empty() && !live.is_empty(),
"a rotation happened"
);
let last_rotated = rotated.last().expect("rotated entries"); let last_rotated = rotated.last().expect("rotated entries");
assert_eq!( assert_eq!(
live[0].1, last_rotated.2, live[0].1, last_rotated.2,
@ -4784,7 +4963,10 @@ mod tests {
let fresh = chain_of(&path); let fresh = chain_of(&path);
assert_eq!(fresh.len(), 1); assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].0, 0, "the new chain starts clean"); assert_eq!(fresh[0].0, 0, "the new chain starts clean");
assert_eq!(fresh[0].1, "", "and does not claim a predecessor it cannot verify"); assert_eq!(
fresh[0].1, "",
"and does not claim a predecessor it cannot verify"
);
} }
#[test] #[test]
@ -4924,7 +5106,9 @@ mod tests {
let t0 = Instant::now(); let t0 = Instant::now();
let _ = m.tick_at(t0); let _ = m.tick_at(t0);
let settled = t0 + BEARER_SETTLE + Duration::from_secs(1); let settled = t0 + BEARER_SETTLE + Duration::from_secs(1);
assert!(m.tick_at(settled).contains(&Action::PreferLink(Bearer::Wifi))); assert!(m
.tick_at(settled)
.contains(&Action::PreferLink(Bearer::Wifi)));
for i in 1..5 { for i in 1..5 {
let a = m.tick_at(settled + Duration::from_secs(i)); let a = m.tick_at(settled + Duration::from_secs(i));

View file

@ -51,7 +51,14 @@ mod tests {
fn render_fills_the_whole_buffer_with_the_field() { fn render_fills_the_whole_buffer_with_the_field() {
let (w, h) = (8, 4); let (w, h) = (8, 4);
let mut buf = vec![0u32; (w * h) as usize]; let mut buf = vec![0u32; (w * h) as usize];
render(&mut buf, w, h, &Scene { mood: Mood::Holding }); render(
&mut buf,
w,
h,
&Scene {
mood: Mood::Holding,
},
);
assert!(buf.iter().all(|&px| px == BG)); assert!(buf.iter().all(|&px| px == BG));
} }
@ -61,7 +68,14 @@ mod tests {
// underneath it, which is the one thing it exists to prevent. // underneath it, which is the one thing it exists to prevent.
let (w, h) = (4, 4); let (w, h) = (4, 4);
let mut buf = vec![0u32; (w * h) as usize]; let mut buf = vec![0u32; (w * h) as usize];
render(&mut buf, w, h, &Scene { mood: Mood::Holding }); render(
&mut buf,
w,
h,
&Scene {
mood: Mood::Holding,
},
);
assert!(buf.iter().all(|&px| px >> 24 == 0xFF)); assert!(buf.iter().all(|&px| px >> 24 == 0xFF));
} }
} }

View file

@ -94,12 +94,8 @@ pub fn run(sink: Arc<dyn Fn(IdleEvent) + Send + Sync>) -> Result<()> {
// awake" is yes, through the client's inhibitor rather than through faked // awake" is yes, through the client's inhibitor rather than through faked
// input — which also means a client that lies is visible as an inhibitor // input — which also means a client that lies is visible as an inhibitor
// rather than as phantom activity. // rather than as phantom activity.
let _notification = notifier.get_idle_notification( let _notification =
QUIET_PERIOD.as_millis() as u32, notifier.get_idle_notification(QUIET_PERIOD.as_millis() as u32, &seat, &qh, ());
&seat,
&qh,
(),
);
info!( info!(
"[idle] watching input via ext_idle_notifier_v1 (quiet period {}ms)", "[idle] watching input via ext_idle_notifier_v1 (quiet period {}ms)",
QUIET_PERIOD.as_millis() QUIET_PERIOD.as_millis()

View file

@ -91,7 +91,10 @@ impl LockController {
} }
pub fn try_clone(&self) -> Result<LockController> { pub fn try_clone(&self) -> Result<LockController> {
Ok(LockController { tx: self.tx.clone(), wake: self.wake.try_clone()? }) Ok(LockController {
tx: self.tx.clone(),
wake: self.wake.try_clone()?,
})
} }
} }
@ -177,7 +180,6 @@ impl LockState {
self.dirty = true; self.dirty = true;
} }
fn surface_size_by_proto_id(&self, id: u32) -> Option<(i32, i32)> { fn surface_size_by_proto_id(&self, id: u32) -> Option<(i32, i32)> {
self.surfaces self.surfaces
.iter() .iter()
@ -247,7 +249,9 @@ pub fn run(rx: Receiver<Msg>, wake_read: OwnedFd) -> Result<SessionOutcome> {
} }
} }
queue.blocking_dispatch(&mut state).context("waiting for locked")?; queue
.blocking_dispatch(&mut state)
.context("waiting for locked")?;
} }
if state.finished { if state.finished {
return Ok(SessionOutcome::Denied); return Ok(SessionOutcome::Denied);
@ -288,8 +292,16 @@ pub fn run(rx: Receiver<Msg>, wake_read: OwnedFd) -> Result<SessionOutcome> {
}; };
let wl_fd = guard.connection_fd().as_raw_fd(); let wl_fd = guard.connection_fd().as_raw_fd();
let mut fds = [ let mut fds = [
libc::pollfd { fd: wl_fd, events: libc::POLLIN, revents: 0 }, libc::pollfd {
libc::pollfd { fd: wake_read.as_raw_fd(), events: libc::POLLIN, revents: 0 }, fd: wl_fd,
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: wake_read.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
]; ];
// SAFETY: fds array outlives the call. // SAFETY: fds array outlives the call.
let rc = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) }; let rc = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
@ -315,7 +327,9 @@ pub fn run(rx: Receiver<Msg>, wake_read: OwnedFd) -> Result<SessionOutcome> {
if fds[0].revents & libc::POLLIN != 0 { if fds[0].revents & libc::POLLIN != 0 {
match guard.read() { match guard.read() {
Ok(_) => { Ok(_) => {
queue.dispatch_pending(&mut state).context("dispatch after read")?; queue
.dispatch_pending(&mut state)
.context("dispatch after read")?;
} }
Err(wayland_client::backend::WaylandError::Io(e)) Err(wayland_client::backend::WaylandError::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock => {} if e.kind() == std::io::ErrorKind::WouldBlock => {}
@ -366,7 +380,9 @@ fn ensure_surfaces(state: &mut LockState, qh: &QueueHandle<LockState>) {
fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()> { fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()> {
let scene = Scene { mood: state.mood }; let scene = Scene { mood: state.mood };
let Some(shm) = state.shm.clone() else { return Ok(()) }; let Some(shm) = state.shm.clone() else {
return Ok(());
};
for ctx in &mut state.surfaces { for ctx in &mut state.surfaces {
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 { if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
continue; continue;
@ -439,7 +455,11 @@ impl Dispatch<WlRegistry, ()> for LockState {
qh: &QueueHandle<Self>, qh: &QueueHandle<Self>,
) { ) {
match event { match event {
wl_registry::Event::Global { name, interface, version } => match interface.as_str() { wl_registry::Event::Global {
name,
interface,
version,
} => match interface.as_str() {
"wl_compositor" => { "wl_compositor" => {
state.compositor = state.compositor =
Some(registry.bind::<WlCompositor, _, _>(name, version.min(4), qh, ())); Some(registry.bind::<WlCompositor, _, _>(name, version.min(4), qh, ()));
@ -497,7 +517,12 @@ impl Dispatch<ExtSessionLockSurfaceV1, ()> for LockState {
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
if let ext_session_lock_surface_v1::Event::Configure { serial, width, height } = event { if let ext_session_lock_surface_v1::Event::Configure {
serial,
width,
height,
} = event
{
surface.ack_configure(serial); surface.ack_configure(serial);
if let Some(ctx) = state if let Some(ctx) = state
.surfaces .surfaces
@ -522,11 +547,11 @@ impl Dispatch<WlSeat, ()> for LockState {
_: &Connection, _: &Connection,
qh: &QueueHandle<Self>, qh: &QueueHandle<Self>,
) { ) {
if let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event { if let wl_seat::Event::Capabilities {
let entry = state capabilities: WEnum::Value(caps),
.seats } = event
.values_mut() {
.find(|(s, _)| s.id() == seat.id()); let entry = state.seats.values_mut().find(|(s, _)| s.id() == seat.id());
let Some((seat, devices)) = entry else { return }; let Some((seat, devices)) = entry else { return };
if caps.contains(wl_seat::Capability::Touch) && devices.touch.is_none() { if caps.contains(wl_seat::Capability::Touch) && devices.touch.is_none() {
devices.touch = Some(seat.get_touch(qh, ())); devices.touch = Some(seat.get_touch(qh, ()));
@ -571,17 +596,29 @@ impl Dispatch<WlPointer, ()> for LockState {
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
match event { match event {
wl_pointer::Event::Enter { surface, surface_x, surface_y, .. } => { wl_pointer::Event::Enter {
surface,
surface_x,
surface_y,
..
} => {
state.pointer_surface = Some(surface.id().protocol_id()); state.pointer_surface = Some(surface.id().protocol_id());
state.pointer_pos = (surface_x, surface_y); state.pointer_pos = (surface_x, surface_y);
} }
wl_pointer::Event::Leave { .. } => { wl_pointer::Event::Leave { .. } => {
state.pointer_surface = None; state.pointer_surface = None;
} }
wl_pointer::Event::Motion { surface_x, surface_y, .. } => { wl_pointer::Event::Motion {
surface_x,
surface_y,
..
} => {
state.pointer_pos = (surface_x, surface_y); state.pointer_pos = (surface_x, surface_y);
} }
wl_pointer::Event::Button { state: WEnum::Value(st), .. } => match st { wl_pointer::Event::Button {
state: WEnum::Value(st),
..
} => match st {
wl_pointer::ButtonState::Pressed => state.press(), wl_pointer::ButtonState::Pressed => state.press(),
wl_pointer::ButtonState::Released => {} wl_pointer::ButtonState::Released => {}
_ => {} _ => {}
@ -601,17 +638,18 @@ impl Dispatch<WlKeyboard, ()> for LockState {
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
match event { match event {
wl_keyboard::Event::Key { key, state: WEnum::Value(st), .. } => { wl_keyboard::Event::Key {
match st { key,
state: WEnum::Value(st),
..
} => match st {
wl_keyboard::KeyState::Pressed => { wl_keyboard::KeyState::Pressed => {
let _ = key; let _ = key;
state.press(); state.press();
} }
wl_keyboard::KeyState::Released => { wl_keyboard::KeyState::Released => {}
}
_ => {} _ => {}
} },
}
// Keymap carries an fd we must not leak; OwnedFd drops it here. // Keymap carries an fd we must not leak; OwnedFd drops it here.
wl_keyboard::Event::Keymap { .. } => {} wl_keyboard::Event::Keymap { .. } => {}
_ => {} _ => {}

View file

@ -305,7 +305,9 @@ pub fn spawn(on_change: Arc<dyn Fn(bool) + Send + Sync>) {
{ {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
warn!("[lock-hint] could not start gdbus monitor: {e} — lock truth will not update"); warn!(
"[lock-hint] could not start gdbus monitor: {e} — lock truth will not update"
);
return; return;
} }
}; };

View file

@ -614,7 +614,11 @@ mod tests {
// 17 since `gesture` (2026-08-03): the compositor names what the // 17 since `gesture` (2026-08-03): the compositor names what the
// fingers did and the machine decides what it means, so the naming had // fingers did and the machine decides what it means, so the naming had
// to become a verb rather than the compositor calling a tool itself. // to become a verb rather than the compositor calling a tool itself.
assert_eq!(VERBS.len(), 17, "a Request variant was added without a VerbDoc"); assert_eq!(
VERBS.len(),
17,
"a Request variant was added without a VerbDoc"
);
} }
#[test] #[test]

View file

@ -608,7 +608,11 @@ fn wg_endpoint_ip() -> Option<String> {
let host = text.lines().find_map(|l| { let host = text.lines().find_map(|l| {
l.split_whitespace() l.split_whitespace()
.find_map(|f| f.strip_prefix("endpoint=")) .find_map(|f| f.strip_prefix("endpoint="))
.and_then(|e| e.replace("\\:", ":").rsplit_once(':').map(|(h, _)| h.to_string())) .and_then(|e| {
e.replace("\\:", ":")
.rsplit_once(':')
.map(|(h, _)| h.to_string())
})
})?; })?;
// Already an address? Then there is nothing to resolve. // Already an address? Then there is nothing to resolve.
@ -792,13 +796,7 @@ fn read_brightness() -> Option<u32> {
/// Run one executor and record what it did. Shared by the paths that build /// Run one executor and record what it did. Shared by the paths that build
/// their arguments dynamically and the table below. /// their arguments dynamically and the table below.
fn run_executor( fn run_executor(shared: &Arc<Shared>, program: &str, args: &[&str], label: &str, action: Action) {
shared: &Arc<Shared>,
program: &str,
args: &[&str],
label: &str,
action: Action,
) {
match std::process::Command::new(program).args(args).status() { match std::process::Command::new(program).args(args).status() {
Ok(status) if status.success() => { Ok(status) if status.success() => {
if action == Action::Blank { if action == Action::Blank {
@ -896,7 +894,10 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
} }
} }
if line.len() as u64 >= MAX_REQUEST_BYTES { if line.len() as u64 >= MAX_REQUEST_BYTES {
let _ = respond(&mut writer, refuse(RefusalCode::InvalidArgument, "request exceeds size limit")); let _ = respond(
&mut writer,
refuse(RefusalCode::InvalidArgument, "request exceeds size limit"),
);
break; break;
} }
let line = line.trim(); let line = line.trim();
@ -917,7 +918,10 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
shared.lock().device_state.forensic.set_intent(None); shared.lock().device_state.forensic.set_intent(None);
out out
} }
Err(e) => refuse(RefusalCode::UnsupportedOp, &format!("malformed request: {e}")), Err(e) => refuse(
RefusalCode::UnsupportedOp,
&format!("malformed request: {e}"),
),
}; };
if response.get("subscribed").and_then(|v| v.as_bool()) == Some(true) { if response.get("subscribed").and_then(|v| v.as_bool()) == Some(true) {
subscribed = true; subscribed = true;
@ -1008,7 +1012,11 @@ fn fan_out(shared: &Arc<Shared>) {
/// (`peer_credentials_unix_socket`, rust#42839) and only fails at the aarch64 /// (`peer_credentials_unix_socket`, rust#42839) and only fails at the aarch64
/// build, after the host test job has already gone green. /// build, after the host test job has already gone green.
fn peer_pid(stream: &UnixStream) -> Option<i32> { fn peer_pid(stream: &UnixStream) -> Option<i32> {
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 }; let mut cred = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t; let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd is // SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd is
// live for the duration of the call because we hold &UnixStream. // live for the duration of the call because we hold &UnixStream.
@ -1083,9 +1091,13 @@ fn handle_request(
// Anything we cannot PROVE is the same process is still refused, // Anything we cannot PROVE is the same process is still refused,
// so the lease keeps every case it was written for. // so the lease keeps every case it was written for.
if d.shell_alive { if d.shell_alive {
let is_reload = matches!((peer, d.shell_pid), (Some(new), Some(held)) if new == held); let is_reload =
matches!((peer, d.shell_pid), (Some(new), Some(held)) if new == held);
if !is_reload { if !is_reload {
return refuse(RefusalCode::RefusedByState, "shell authority is already registered"); return refuse(
RefusalCode::RefusedByState,
"shell authority is already registered",
);
} }
// Supersede in place. `heartbeat_gen` already exists to make a // Supersede in place. `heartbeat_gen` already exists to make a
// stale connection's EOF harmless — the outgoing handler checks // stale connection's EOF harmless — the outgoing handler checks
@ -1133,7 +1145,10 @@ fn handle_request(
.wait_timeout_while(d, deadline, |d| d.controller.is_some()) .wait_timeout_while(d, deadline, |d| d.controller.is_some())
.unwrap_or_else(|e| e.into_inner()); .unwrap_or_else(|e| e.into_inner());
if timed_out.timed_out() { if timed_out.timed_out() {
return refuse(RefusalCode::Unavailable, "lock session did not release in time"); return refuse(
RefusalCode::Unavailable,
"lock session did not release in time",
);
} }
info!("handoff: lock released to shell (gen {gen})"); info!("handoff: lock released to shell (gen {gen})");
serde_json::json!({ "ok": true, "held": true, "must_lock": true }) serde_json::json!({ "ok": true, "held": true, "must_lock": true })
@ -1148,7 +1163,10 @@ fn handle_request(
Request::LockedAck => { Request::LockedAck => {
let mut d = shared.lock(); let mut d = shared.lock();
if heartbeat.is_none() { if heartbeat.is_none() {
return refuse(RefusalCode::RefusedByState, "locked_ack from a connection that never sent shell_ready"); return refuse(
RefusalCode::RefusedByState,
"locked_ack from a connection that never sent shell_ready",
);
} }
d.phase = Phase::Released; d.phase = Phase::Released;
info!("shell lock confirmed"); info!("shell lock confirmed");
@ -1169,9 +1187,10 @@ fn handle_request(
let d = shared.lock(); let d = shared.lock();
match d.phase { match d.phase {
Phase::Holding => serde_json::json!({ "ok": true, "already": true }), Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => { Phase::Released | Phase::AwaitingShellLock if d.shell_alive => refuse(
refuse(RefusalCode::RefusedByState, "a live shell owns the session lock; use the shell's lock IPC") RefusalCode::RefusedByState,
} "a live shell owns the session lock; use the shell's lock IPC",
),
_ => { _ => {
drop(d); drop(d);
if spawn_lock_session(shared) { if spawn_lock_session(shared) {
@ -1229,9 +1248,7 @@ fn handle_request(
.update_sensors_from(input.source, evidence_copy); .update_sensors_from(input.source, evidence_copy);
// The tap-to-wake answer specifically; a power button is never // The tap-to-wake answer specifically; a power button is never
// refused. Asked after the update, not before. // refused. Asked after the update, not before.
let suppress = d let suppress = d.device_state.suppress_wake(InputTrigger::DoubleTapToWake);
.device_state
.suppress_wake(InputTrigger::DoubleTapToWake);
info!( info!(
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})", "[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
input.source, input.value, conf, suppress, promote input.source, input.value, conf, suppress, promote
@ -1259,9 +1276,17 @@ fn handle_request(
// explicit that the policy layer means something before the // explicit that the policy layer means something before the
// enforcement lands; TASK-41 is the enforcement. // enforcement lands; TASK-41 is the enforcement.
let Some(actions) = gated else { let Some(actions) = gated else {
// Report the belief and its confidence, not a conclusion. This
// said "proximity near — treated as a pocket", which was a
// reading wearing an interpretation's name and was wrong on
// every deliberate squeeze (see `placement`).
let p = { shared.lock().device_state.placement() };
return refuse( return refuse(
RefusalCode::RefusedByState, RefusalCode::RefusedByState,
"proximity near — treated as a pocket", format!(
"believed {:?} at {:.2} (covered={}, locked={}, lit={})",
p.belief, p.confidence, p.covered, p.locked, p.lit
),
); );
}; };
let cancelled = !actions.is_empty(); let cancelled = !actions.is_empty();
@ -1403,7 +1428,10 @@ fn handle_request(
if let Some(v) = lock_ack_budget_secs { if let Some(v) = lock_ack_budget_secs {
if v == 0 { if v == 0 {
drop(d); drop(d);
return refuse(RefusalCode::InvalidArgument, "lock_ack_budget_secs must be at least 1"); return refuse(
RefusalCode::InvalidArgument,
"lock_ack_budget_secs must be at least 1",
);
} }
p.lock_ack_budget = Duration::from_secs(v); p.lock_ack_budget = Duration::from_secs(v);
} }
@ -1432,7 +1460,10 @@ fn handle_request(
if let Some(budget) = p.lock_blank_after { if let Some(budget) = p.lock_blank_after {
if p.dim_warning && p.dim_grace >= budget { if p.dim_warning && p.dim_grace >= budget {
drop(d); drop(d);
return refuse(RefusalCode::InvalidArgument, "dim_grace_secs must be shorter than lock_blank_after_secs"); return refuse(
RefusalCode::InvalidArgument,
"dim_grace_secs must be shorter than lock_blank_after_secs",
);
} }
} }
let applied = serde_json::json!({ let applied = serde_json::json!({
@ -1590,9 +1621,19 @@ mod tests {
let (incoming, _i) = UnixStream::pair().unwrap(); let (incoming, _i) = UnixStream::pair().unwrap();
let mut stale_heartbeat = None; let mut stale_heartbeat = None;
handle_request(Request::ShellReady, &shared, Some(&outgoing), &mut stale_heartbeat); handle_request(
Request::ShellReady,
&shared,
Some(&outgoing),
&mut stale_heartbeat,
);
let mut live_heartbeat = None; let mut live_heartbeat = None;
handle_request(Request::ShellReady, &shared, Some(&incoming), &mut live_heartbeat); handle_request(
Request::ShellReady,
&shared,
Some(&incoming),
&mut live_heartbeat,
);
// Replays the EOF branch of handle_connection for the OLD connection. // Replays the EOF branch of handle_connection for the OLD connection.
let stale_gen = stale_heartbeat.unwrap(); let stale_gen = stale_heartbeat.unwrap();
@ -1672,7 +1713,12 @@ mod tests {
let (shell, _s) = UnixStream::pair().unwrap(); let (shell, _s) = UnixStream::pair().unwrap();
let mut shell_heartbeat = None; let mut shell_heartbeat = None;
handle_request(Request::ShellReady, &shared, Some(&shell), &mut shell_heartbeat); handle_request(
Request::ShellReady,
&shared,
Some(&shell),
&mut shell_heartbeat,
);
let mut anon_heartbeat = None; let mut anon_heartbeat = None;
let anon = handle_request(Request::ShellReady, &shared, None, &mut anon_heartbeat); let anon = handle_request(Request::ShellReady, &shared, None, &mut anon_heartbeat);

View file

@ -1,7 +1,7 @@
#![allow(dead_code, deprecated)] // legacy ratatui render path + WIP scaffolding, pending tuie parity #![allow(dead_code, deprecated)] // legacy ratatui render path + WIP scaffolding, pending tuie parity
use std::io::{stdout, Write};
use std::time::Duration; use std::time::Duration;
use tokio::time::Instant; use tokio::time::Instant;
use std::io::{stdout, Write};
/// Animation system for sexy terminal effects /// Animation system for sexy terminal effects
pub struct Animator { pub struct Animator {
@ -24,7 +24,12 @@ impl Animator {
} }
/// Pulsing between two colors /// Pulsing between two colors
pub fn pulse_color(&self, color1: (u8, u8, u8), color2: (u8, u8, u8), speed_ms: u64) -> (u8, u8, u8) { pub fn pulse_color(
&self,
color1: (u8, u8, u8),
color2: (u8, u8, u8),
speed_ms: u64,
) -> (u8, u8, u8) {
let t = self.breathe(speed_ms); let t = self.breathe(speed_ms);
( (
(color1.0 as f32 * (1.0 - t) + color2.0 as f32 * t) as u8, (color1.0 as f32 * (1.0 - t) + color2.0 as f32 * t) as u8,
@ -62,7 +67,9 @@ pub fn gradient(text: &str, start_hue: f32) -> String {
pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Wave animation for progress /// Wave animation for progress
pub const WAVE: &[&str] = &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"]; pub const WAVE: &[&str] = &[
"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂",
];
/// Persona signature colors /// Persona signature colors
pub mod colors { pub mod colors {
@ -131,10 +138,10 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
/// center outward. Three render modes cycle: braille dots, block fills, /// center outward. Three render modes cycle: braille dots, block fills,
/// and ASCII characters. Inspired by peonia.html. /// and ASCII characters. Inspired by peonia.html.
pub mod bloom { pub mod bloom {
use crate::ui::color_support::rgb;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Style; use ratatui::style::Style;
use crate::ui::color_support::rgb;
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*"; const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*";
/// Sharp spike chars for wicked edges /// Sharp spike chars for wicked edges
@ -152,7 +159,13 @@ pub mod bloom {
impl BloomState { impl BloomState {
pub fn new() -> Self { pub fn new() -> Self {
Self { progress: 0.0, mode: 0, mode_timer: 0.0, flash: 0.0, variant: 0 } Self {
progress: 0.0,
mode: 0,
mode_timer: 0.0,
flash: 0.0,
variant: 0,
}
} }
pub fn advance(&mut self, dt: f32) { pub fn advance(&mut self, dt: f32) {
@ -187,7 +200,9 @@ pub mod bloom {
let stem_top = cy + max_r * 0.3; let stem_top = cy + max_r * 0.3;
let stem_visible = stem_len * stem_progress; let stem_visible = stem_len * stem_progress;
let stem_bot = stem_top + stem_visible; let stem_bot = stem_top + stem_visible;
for y in (stem_top.max(0.0) as u16)..(stem_bot.max(0.0) as u16).min(area.y + area.height) { for y in
(stem_top.max(0.0) as u16)..(stem_bot.max(0.0) as u16).min(area.y + area.height)
{
let tt = (y as f32 - stem_top) / stem_len; let tt = (y as f32 - stem_top) / stem_len;
let curve = (tt * std::f32::consts::PI * 0.3).sin() * 6.0 let curve = (tt * std::f32::consts::PI * 0.3).sin() * 6.0
+ (tt * std::f32::consts::PI * 0.8).sin() * 2.0; + (tt * std::f32::consts::PI * 0.8).sin() * 2.0;
@ -287,8 +302,8 @@ pub mod bloom {
let spike_bloom = ((bloom - 0.5) / 0.4).min(1.0); let spike_bloom = ((bloom - 0.5) / 0.4).min(1.0);
let num_spikes = 16u32; let num_spikes = 16u32;
for i in 0..num_spikes { for i in 0..num_spikes {
let angle = (std::f32::consts::TAU / num_spikes as f32) * i as f32 let angle =
+ (t * 0.05).sin() * 0.2; (std::f32::consts::TAU / num_spikes as f32) * i as f32 + (t * 0.05).sin() * 0.2;
let spike_dist = max_r * 0.85 * spike_bloom; let spike_dist = max_r * 0.85 * spike_bloom;
for si in 0..3 { for si in 0..3 {
let sd = spike_dist + si as f32 * 1.5; let sd = spike_dist + si as f32 * 1.5;
@ -396,10 +411,15 @@ pub mod bloom {
} else { } else {
0.8 0.8
}; };
if density > 0.75 { '█' } if density > 0.75 {
else if density > 0.5 { '▓' } '█'
else if density > 0.3 { '▒' } } else if density > 0.5 {
else { '░' } '▓'
} else if density > 0.3 {
'▒'
} else {
'░'
}
} }
_ => { _ => {
// Braille — use sparser patterns near edges for sharper look // Braille — use sparser patterns near edges for sharper look

View file

@ -1,6 +1,6 @@
use super::{App, Screen}; use super::{App, Screen};
use crate::ui::presence::Presence;
use crate::ui::component::TuiEvent; use crate::ui::component::TuiEvent;
use crate::ui::presence::Presence;
impl App { impl App {
pub(super) fn add_available_agent(&mut self, agent_name: String) { pub(super) fn add_available_agent(&mut self, agent_name: String) {
@ -78,14 +78,22 @@ impl App {
if self.available_agents.is_empty() { if self.available_agents.is_empty() {
// No agents available yet - create a default alias // No agents available yet - create a default alias
// This is WIP - will be expanded with full agent creation flow // This is WIP - will be expanded with full agent creation flow
let default_agents = vec!["Ani".to_string(), "JeanLuc".to_string(), "Eione".to_string()]; let default_agents = vec![
"Ani".to_string(),
"JeanLuc".to_string(),
"Eione".to_string(),
];
for agent in default_agents { for agent in default_agents {
self.add_available_agent(agent); self.add_available_agent(agent);
} }
} }
// Clone the agent name to avoid borrow checker issues // Clone the agent name to avoid borrow checker issues
let agent_to_select = if let Some(current_idx) = self.available_agents.iter().position(|a| a == &self.agent_pref) { let agent_to_select = if let Some(current_idx) = self
.available_agents
.iter()
.position(|a| a == &self.agent_pref)
{
let next_idx = (current_idx + 1) % self.available_agents.len(); let next_idx = (current_idx + 1) % self.available_agents.len();
self.available_agents[next_idx].clone() self.available_agents[next_idx].clone()
} else if !self.available_agents.is_empty() { } else if !self.available_agents.is_empty() {
@ -98,9 +106,9 @@ impl App {
} }
pub(super) fn agent_id_by_name(&self, name: &str) -> Option<String> { pub(super) fn agent_id_by_name(&self, name: &str) -> Option<String> {
self.agent_cards.iter() self.agent_cards
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name)) .find(|c| c.name.eq_ignore_ascii_case(name))
.map(|c| c.id.clone()) .map(|c| c.id.clone())
} }
} }

Some files were not shown because too many files have changed in this diff Show more