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
//! 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::time::{Duration, Instant};
use tokio::time::sleep;
use crossterm::{
execute,
terminal::{Clear, ClearType},
cursor::{MoveTo, Show, Hide},
style::{Color, ResetColor, SetForegroundColor},
};
pub struct Animator {
start_time: Instant,
@ -17,7 +17,9 @@ pub struct Animator {
impl Animator {
pub fn new() -> Self {
Self { start_time: Instant::now() }
Self {
start_time: Instant::now(),
}
}
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 {
text.chars().enumerate().map(|(i, ch)| {
let hue = (start_hue + i as f32 * 3.0) % 360.0;
let (r, g, b) = hsl_to_rgb(hue, 0.8, 0.6);
format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, ch)
}).collect()
text.chars()
.enumerate()
.map(|(i, ch)| {
let hue = (start_hue + i as f32 * 3.0) % 360.0;
let (r, g, b) = hsl_to_rgb(hue, 0.8, 0.6);
format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, ch)
})
.collect()
}
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),
_ => (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) {
let factor = 0.8 + (intensity * 0.4);
((base.0 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.0 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,
)
}
pub const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
pub const WAVE: &[&str] = &["", "", "", "", "", "", "", "", "", "", "", "", "", ""];
pub const WAVE: &[&str] = &[
"", "", "", "", "", "", "", "", "", "", "", "", "", "",
];
#[tokio::main]
async fn main() {
@ -108,7 +121,15 @@ async fn main() {
execute!(stdout, MoveTo(5, 8)).unwrap();
print!("Loading: ");
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()]);
io::stdout().flush().unwrap();
sleep(Duration::from_millis(80)).await;

View file

@ -11,11 +11,11 @@
use anyhow::{Context, Result};
use axum::{
body::Body,
extract::{Path, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::Response,
body::Body,
};
use std::collections::HashMap;
use std::net::IpAddr;
@ -33,7 +33,10 @@ pub fn generate_token() -> String {
/// Path to the token file for an agent.
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.
@ -226,7 +229,9 @@ pub async fn require_conversation_token(
next: Next,
) -> Result<Response, StatusCode> {
let agent_id = {
let session = server.sessions.get(&conversation_id)
let session = server
.sessions
.get(&conversation_id)
.ok_or(StatusCode::NOT_FOUND)?;
session.agent_id.clone()
};
@ -284,7 +289,9 @@ mod tests {
async fn write_then_read_roundtrip() {
let dir = tempfile::tempdir().unwrap();
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();
assert_eq!(got, "souv_abc123");
}

View file

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

View file

@ -14,13 +14,25 @@ pub mod models;
pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health, firehose).
let public_routes = Router::new()
.route("/v1/agents", get(handlers::list_agents).post(handlers::create_agent))
.route("/v1/conversations", get(handlers::list_conversations).post(handlers::create_conversation))
.route(
"/v1/agents",
get(handlers::list_agents).post(handlers::create_agent),
)
.route(
"/v1/conversations",
get(handlers::list_conversations).post(handlers::create_conversation),
)
.route("/v1/firehose", get(handlers::firehose))
.route("/v1/federation/events", get(handlers::federation_events))
.route("/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/conversations/:id/tokens", get(handlers::get_conversation_tokens))
.route(
"/v1/conversations/:id/tokens",
get(handlers::get_conversation_tokens),
)
.route("/health", get(health_check));
// Protected agent routes — require per-agent bearer token.
@ -43,7 +55,10 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
"/v1/conversations/:id/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/interject", post(handlers::interject))
.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.
// See docs/MEMORY_BLOCKS_DECISION.md.)
let memory_routes = Router::new()
.route(
"/v1/agents/:id/memory",
get(handlers::list_memory),
)
.route("/v1/agents/:id/memory", get(handlers::list_memory))
.route(
"/v1/agents/:id/memory/*path",
get(handlers::read_memory)

View file

@ -67,8 +67,12 @@ pub struct LlmConfig {
pub checkpoint_interval: u32,
}
fn default_supports_images() -> bool { true }
fn default_checkpoint_interval() -> u32 { 10 }
fn default_supports_images() -> bool {
true
}
fn default_checkpoint_interval() -> u32 {
10
}
fn default_context_window() -> u32 {
128000
@ -200,7 +204,7 @@ pub struct Message {
impl Message {
/// Convert API Message to internal 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() {
"system" => MessageRole::System,
@ -212,7 +216,9 @@ impl Message {
ConversationMessage {
role,
blocks: vec![ContentBlock::Text { text: self.content.clone() }],
blocks: vec![ContentBlock::Text {
text: self.content.clone(),
}],
usage: None,
timestamp: Some(chrono::Utc::now()),
}
@ -271,7 +277,11 @@ pub enum StreamEvent {
#[serde(rename = "tool_return_message")]
ToolReturnMessage { tool_return: ToolReturn },
#[serde(rename = "souveraine_surfacing")]
Surfacing { source: String, content: String, priority: String },
Surfacing {
source: String,
content: String,
priority: String,
},
#[serde(rename = "souveraine_reflection")]
Reflection { content: String },
#[serde(rename = "souveraine_archivist")]
@ -281,7 +291,11 @@ pub enum StreamEvent {
#[serde(rename = "context_pressure")]
ContextPressure { pressure: f32, tokens: usize },
#[serde(rename = "inference_strain")]
InferenceStrain { attempt: u32, status: u16, model: String },
InferenceStrain {
attempt: u32,
status: u16,
model: String,
},
#[serde(rename = "schedule_active")]
ScheduleActive { name: String },
#[serde(rename = "schedule_complete")]
@ -291,7 +305,11 @@ pub enum StreamEvent {
#[serde(rename = "subconscious_tool_call")]
SubconsciousToolCall { name: String, arguments: String },
#[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")]
SubconsciousHalt { reason: String, severity: String },
#[serde(rename = "subconscious_pass")]
@ -303,7 +321,10 @@ pub enum StreamEvent {
#[serde(rename = "outfit")]
Outfit { name: String },
#[serde(rename = "interstitial")]
Interstitial { text: String, register: crate::backend::Register },
Interstitial {
text: String,
register: crate::backend::Register,
},
#[serde(rename = "primary_complete")]
PrimaryComplete,
/// The turn failed in the substrate. Mirrors BackendEvent::Error so a
@ -354,32 +375,81 @@ impl From<crate::backend::BackendEvent> for StreamEvent {
match be {
BE::Token(content) => Self::AssistantMessage { 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::Archivist { synthesis, pressure } => Self::Archivist { synthesis, pressure },
BE::Archivist {
synthesis,
pressure,
} => Self::Archivist {
synthesis,
pressure,
},
BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier },
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::ScheduleComplete { name, silent } => Self::ScheduleComplete { name, silent },
BE::ToolCall { id, name, arguments, round } => Self::ToolCallMessage {
tool_call: ToolCall { id, function: ToolFunction { name, arguments } },
BE::ToolCall {
id,
name,
arguments,
round,
} => Self::ToolCallMessage {
tool_call: ToolCall {
id,
function: ToolFunction { name, arguments },
},
round,
},
BE::ToolResult { id, name, output, is_error } => Self::ToolReturnMessage {
BE::ToolResult {
id,
name,
output,
is_error,
} => Self::ToolReturnMessage {
tool_return: ToolReturn {
status: if is_error { "error".into() } else { "success".into() },
status: if is_error {
"error".into()
} else {
"success".into()
},
output,
id,
name,
},
},
BE::SubconsciousToken(content) => Self::SubconsciousToken { content },
BE::SubconsciousToolCall { name, arguments } => Self::SubconsciousToolCall { name, arguments },
BE::SubconsciousToolResult { name, output, is_error } => {
Self::SubconsciousToolResult { name, output, is_error }
BE::SubconsciousToolCall { name, arguments } => {
Self::SubconsciousToolCall { name, arguments }
}
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::Atmosphere(preset) => Self::Atmosphere { preset },
BE::Itinerary(route) => Self::Itinerary { route },
@ -399,12 +469,38 @@ impl From<StreamEvent> for crate::backend::BackendEvent {
match se {
StreamEvent::AssistantMessage { content } => BE::Token(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::Archivist { synthesis, pressure } => 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::Archivist {
synthesis,
pressure,
} => 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::ScheduleComplete { name, silent } => BE::ScheduleComplete { name, silent },
StreamEvent::ToolCallMessage { tool_call, round } => BE::ToolCall {
@ -420,11 +516,21 @@ impl From<StreamEvent> for crate::backend::BackendEvent {
output: tool_return.output,
},
StreamEvent::SubconsciousToken { content } => BE::SubconsciousToken(content),
StreamEvent::SubconsciousToolCall { name, arguments } => BE::SubconsciousToolCall { name, arguments },
StreamEvent::SubconsciousToolResult { name, output, is_error } => {
BE::SubconsciousToolResult { name, output, is_error }
StreamEvent::SubconsciousToolCall { name, arguments } => {
BE::SubconsciousToolCall { name, arguments }
}
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::Atmosphere { preset } => BE::Atmosphere(preset),
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
/// message, and drain the resulting stream — the turn runs silently
/// in the background. Anything subconscious surfaces lands in the inbox.
async fn inject_background_turn(
&self,
agent_id: &str,
text: &str,
) -> anyhow::Result<()> {
let conv_id = match self.server.sessions.list_for_agent(agent_id).last().cloned() {
async fn inject_background_turn(&self, agent_id: &str, text: &str) -> anyhow::Result<()> {
let conv_id = match self
.server
.sessions
.list_for_agent(agent_id)
.last()
.cloned()
{
Some(id) => id,
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();
while let Some(ev) = s.next().await {
match ev {
Ok(BackendEvent::Surfacing { source, content, priority }) => {
Ok(BackendEvent::Surfacing {
source,
content,
priority,
}) => {
// Skip the no-op heartbeat sentinel — the subconscious
// always queues a low "pass complete, no anomalies"
// 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(),
"stashed heartbeat surfacings for next session"
),
Err(e) => tracing::warn!(
"pending heartbeat surfacings stash failed: {}", e
),
Err(e) => tracing::warn!("pending heartbeat surfacings stash failed: {}", e),
}
}
});
@ -175,7 +179,10 @@ pub(super) async fn drain_intrusive_surfacings(
let items = match inbox.get_intrusive().await {
Ok(items) => items,
Err(e) => {
tracing::debug!("intrusive surfacing read failed (continuing without): {}", e);
tracing::debug!(
"intrusive surfacing read failed (continuing without): {}",
e
);
return Vec::new();
}
};

View file

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

View file

@ -115,7 +115,11 @@ pub enum BackendEvent {
/// Subconscious called a tool during N+1.
SubconsciousToolCall { name: String, arguments: String },
/// 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
/// 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
@ -177,7 +181,11 @@ pub trait Backend: Send + Sync {
/// with a fresh conversation_id. Used by `/btw` to spin off a side-quest.
/// Default falls back to `new_conversation`; LocalBackend overrides with
/// 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
}
@ -243,7 +251,8 @@ pub trait Backend: Send + Sync {
for img in &images {
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

View file

@ -31,7 +31,11 @@ impl RemoteBackend {
.connect_timeout(Duration::from_secs(2))
.build()
.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.
@ -73,7 +77,11 @@ impl RemoteBackend {
/// Watch a local cancel token and relay it as POST .../cancel.
/// 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 url = self.url(&format!("/v1/conversations/{}/cancel", conversation_id));
let token = self.token.clone();

View file

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

View file

@ -27,10 +27,18 @@ mod identity;
#[path = "../machined/protocol.rs"]
mod machined_protocol;
#[path = "../secrets/collection.rs"]
mod collection;
#[path = "../secrets/dh.rs"]
mod dh;
#[path = "../secrets/transport.rs"]
mod transport;
#[path = "../secrets/error.rs"]
mod error;
#[path = "../secrets/item.rs"]
mod item;
#[path = "../secrets/manage.rs"]
mod manage;
#[path = "../secrets/service.rs"]
mod service;
#[path = "../secrets/session.rs"]
mod session;
#[path = "../secrets/session_object.rs"]
@ -39,18 +47,10 @@ mod session_object;
mod storage_key;
#[path = "../secrets/store.rs"]
mod store;
#[path = "../secrets/transport.rs"]
mod transport;
#[path = "../secrets/types.rs"]
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};
@ -75,7 +75,10 @@ async fn main() -> Result<()> {
let (machine_ikm, source) = storage_key::resolve(&base)
.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 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>>>;
fn socket_path() -> PathBuf {
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
format!("/run/user/{}", unsafe { libc_getuid() })
});
let runtime = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc_getuid() }));
PathBuf::from(runtime).join("souveraine/sessiond.sock")
}

View file

@ -34,9 +34,10 @@ fn main() -> anyhow::Result<()> {
while let Some(arg) = args.next() {
match arg.as_str() {
"--socket" => {
socket = Some(PathBuf::from(args.next().ok_or_else(|| {
anyhow::anyhow!("--socket requires a path")
})?));
socket =
Some(PathBuf::from(args.next().ok_or_else(|| {
anyhow::anyhow!("--socket requires a path")
})?));
}
"--no-initial-lock" => initial_lock = false,
"--help" | "-h" => {

View file

@ -35,9 +35,13 @@ pub struct BifrostClient {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text { text: String },
Text {
text: String,
},
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrlSource },
ImageUrl {
image_url: ImageUrlSource,
},
}
/// Source for an image URL content part — always a data URI.
@ -214,7 +218,11 @@ pub struct 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 {
id: id.into(),
tool_type: "function".to_string(),
@ -488,7 +496,13 @@ fn last_segment_is_version(url: &str) -> bool {
}
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();
// 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
@ -534,10 +548,16 @@ impl BifrostClient {
let mut headers = reqwest::header::HeaderMap::new();
if !self.api_key.is_empty() {
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() {
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
}
@ -545,7 +565,8 @@ impl BifrostClient {
/// List available models from Bifrost
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let resp = self.client
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
@ -570,7 +591,10 @@ impl BifrostClient {
/// Returns the completion result plus any strain events that occurred.
/// Strain events are body-knowledge: the agent can feel when inference
/// 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?;
Ok(result)
}
@ -584,7 +608,10 @@ impl BifrostClient {
// Try primary model
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)),
Err(primary_err) => {
if fallbacks.is_empty() {
@ -602,7 +629,10 @@ impl BifrostClient {
// Try each fallback model
for fallback in &fallbacks {
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) => {
info!("Fallback to {} succeeded", fallback);
return Ok((result, strain_events));
@ -636,7 +666,8 @@ impl BifrostClient {
for attempt in 0..=policy.max_retries {
debug!("POST {} — model: {} (attempt {})", url, model, attempt);
let resp = self.client
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&req_with_model)
@ -650,7 +681,10 @@ impl BifrostClient {
anyhow::bail!("Bifrost unreachable after {} attempts: {}", attempt + 1, e);
}
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 {
attempt,
status: 0,
@ -667,12 +701,16 @@ impl BifrostClient {
let retry_after = parse_retry_after(resp.headers());
if status.is_success() {
let body_text = resp.text().await
let body_text = resp
.text()
.await
.context("Failed to read Bifrost response body")?;
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")?;
match classify_status(status, &body_text) {
@ -680,7 +718,10 @@ impl BifrostClient {
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
warn!(
"Bifrost {} on {} (attempt {}), retrying in {:?}",
status.as_u16(), model, attempt, delay
status.as_u16(),
model,
attempt,
delay
);
strain_events.push(InferenceStrain::Transient {
attempt,
@ -699,7 +740,10 @@ impl BifrostClient {
});
anyhow::bail!(
"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> {
let parsed: ChatCompletionResponse = serde_json::from_str(body_text)
.with_context(|| {
let parsed: ChatCompletionResponse =
serde_json::from_str(body_text).with_context(|| {
let preview = &body_text[..body_text.len().min(200)];
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")?;
let content = choice.message.content.unwrap_or_default();
let reasoning = choice.message.reasoning;
let tool_calls = choice.message.tool_calls
let tool_calls = choice
.message
.tool_calls
.unwrap_or_default()
.into_iter()
.filter_map(|tc| {
@ -786,7 +835,8 @@ mod tests {
"",
"openai/deepseek-v4-pro",
120,
).unwrap();
)
.unwrap();
assert!(client.base_url.ends_with("/v1"));
}
@ -794,9 +844,7 @@ mod tests {
fn test_chat_request_serialization() {
let req = ChatCompletionRequest {
model: "openai/deepseek-v4-pro".to_string(),
messages: vec![
Message::text("user", "Hello"),
],
messages: vec![Message::text("user", "Hello")],
stream: None,
max_tokens: None,
temperature: None,
@ -829,7 +877,10 @@ mod tests {
}"#;
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.reasoning.as_deref(), Some("The user greeted me."));
assert_eq!(
resp.choices[0].message.reasoning.as_deref(),
Some("The user greeted me.")
);
}
#[test]

View file

@ -484,7 +484,11 @@ async fn refresh_token(
/// Best-effort `account_uuid` lookup via the roles/profile endpoint.
#[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
.get(ROLES_URL)
.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 {
for call in calls {
let input: Value = serde_json::from_str(&call.function.arguments)
.unwrap_or(json!({}));
let input: Value =
serde_json::from_str(&call.function.arguments).unwrap_or(json!({}));
push_merge(
&mut merged,
"assistant",
@ -787,8 +791,7 @@ fn translate_tool(tool: &ToolDefinition) -> Value {
/// Anthropic non-stream response → Souveraine's OpenAI-shaped `CompletionResult`.
fn parse_completion(text: &str) -> Result<CompletionResult> {
let value: Value =
serde_json::from_str(text).context("parsing claude messages response")?;
let value: Value = serde_json::from_str(text).context("parsing claude messages response")?;
let content_parts: Vec<String> = value
.get("content")
.and_then(Value::as_array)
@ -1001,7 +1004,10 @@ mod tests {
];
let (messages, system, first_user) = translate_messages(&msgs);
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!(messages.iter().any(|m| {
m["role"] == "user"
@ -1116,7 +1122,11 @@ mod tests {
// Rotated but already expired — adopting it would just fail differently.
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());
}
}

View file

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

View file

@ -50,7 +50,10 @@ impl TokenCounter {
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
@ -88,7 +91,10 @@ pub struct ModelRouter {
impl ModelRouter {
pub fn new(configs: HashMap<String, ModelConfig>) -> Self {
let count = configs.len();
info!("🧭 ModelRouter initialized with {} model configurations", count);
info!(
"🧭 ModelRouter initialized with {} model configurations",
count
);
for (name, cfg) in &configs {
debug!(
" {} via {} — {} ctx, {} out, threshold {}",
@ -134,7 +140,11 @@ impl ModelRouter {
}
/// 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) {
Some(c) => c,
None => return ContextPressure::Normal,
@ -193,11 +203,8 @@ impl ModelRouter {
/// Get all configured providers
pub fn providers(&self) -> Vec<String> {
let mut providers: Vec<String> = self
.configs
.values()
.map(|c| c.provider.clone())
.collect();
let mut providers: Vec<String> =
self.configs.values().map(|c| c.provider.clone()).collect();
providers.sort();
providers.dedup();
providers
@ -236,7 +243,11 @@ impl ModelRouter {
/// Set the selected model
pub fn set_model(&mut self, name: &str) -> anyhow::Result<()> {
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();
info!("🎯 Selected model: {}", name);
@ -289,30 +300,39 @@ mod tests {
fn test_configs() -> HashMap<String, ModelConfig> {
let mut map = HashMap::new();
map.insert("kimi-k2-5".to_string(), ModelConfig {
provider: "bifrost".to_string(),
model: "kimi-k2.5".to_string(),
context_limit: 128000,
output_limit: 8192,
archivist_threshold: 0.7,
archivist_interval: 100,
preferred_for: vec![TaskType::Synthesis],
supports_images: true,
});
map.insert(
"kimi-k2-5".to_string(),
ModelConfig {
provider: "bifrost".to_string(),
model: "kimi-k2.5".to_string(),
context_limit: 128000,
output_limit: 8192,
archivist_threshold: 0.7,
archivist_interval: 100,
preferred_for: vec![TaskType::Synthesis],
supports_images: true,
},
);
map
}
#[tokio::test]
async fn test_context_pressure_normal() {
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]
async fn test_context_pressure_critical() {
let router = ModelRouter::new(test_configs());
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]
@ -320,9 +340,15 @@ mod tests {
let router = ModelRouter::new(test_configs());
router.update_usage(90_000, 80_000, 10_000).await;
// 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
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]

View file

@ -65,10 +65,17 @@ async fn refresh(http: &reqwest::Client, creds: &CodexCredentials) -> Result<Cod
if !resp.status().is_success() {
let status = resp.status();
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
.get("access_token")
.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())
.map(String::from)
.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 {
access_token: access,

View file

@ -15,7 +15,9 @@ use async_trait::async_trait;
use tokio::sync::Mutex;
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::{catalog, refresh};
use crate::bridge::provider::LlmProvider;
@ -114,7 +116,10 @@ impl LlmProvider for OpenAiOAuthProvider {
));
}
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 {
attempt,
status: 0,
@ -129,7 +134,10 @@ impl LlmProvider for OpenAiOAuthProvider {
let status = resp.status();
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)?;
return Ok((result, strain));
}
@ -138,7 +146,12 @@ impl LlmProvider for OpenAiOAuthProvider {
let transient = matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504);
if transient && attempt < self.retry.max_retries {
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 {
attempt,
status: status.as_u16(),
@ -168,6 +181,8 @@ impl LlmProvider for OpenAiOAuthProvider {
}
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))
}

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 {
content,

View file

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

View file

@ -86,10 +86,7 @@ impl SynthesisReport {
pub fn summary_line(&self) -> String {
format!(
"synthesized {} journal entries ({} → {}) into {}",
self.entries_compressed,
self.date_range.0,
self.date_range.1,
self.output_label,
self.entries_compressed, self.date_range.0, self.date_range.1, self.output_label,
)
}
}
@ -160,8 +157,9 @@ impl ArchivistEngine {
return Ok(None);
}
let interval_due =
turn_count > 0 && self.config.interval > 0 && turn_count.is_multiple_of(self.config.interval);
let interval_due = turn_count > 0
&& self.config.interval > 0
&& turn_count.is_multiple_of(self.config.interval);
let pressure_due = pressure >= self.config.threshold;
if !interval_due && !pressure_due {
return Ok(None);
@ -222,13 +220,7 @@ impl ArchivistEngine {
.await?;
let output_label = format!("{SYNTHESIS_DIR}/{end_date}");
let body = render_synthesis(
&synthesis,
start_date,
end_date,
entries.len(),
&model,
);
let body = render_synthesis(&synthesis, start_date, end_date, entries.len(), &model);
repo.write(&output_label, &body).await?;
let report = SynthesisReport {
@ -276,7 +268,8 @@ impl ArchivistEngine {
let (response, strain) = llm.chat_completion_with_strain(request).await?;
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);
if *status == 429 {
let current = self.rate_delay.load(Ordering::Relaxed);
@ -407,7 +400,12 @@ async fn collect_journal_entries(
fn format_journal_input(entries: &[JournalEntry]) -> String {
let mut out = String::new();
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
// exceeds the cap — truncate it rather than emit nothing.
if out.is_empty() {
@ -458,30 +456,18 @@ fn render_synthesis(
/// Map a configured synthesis element to its prompt line (label + budget).
fn element_spec(el: &SynthesisElement) -> (&'static str, &'static str) {
match el {
SynthesisElement::Themes => (
"Themes",
"[3-5 recurring topics, ~10 words each]",
),
SynthesisElement::Emotions => (
"Emotional Tone",
"[dominant felt sense, ~20 words]",
),
SynthesisElement::Themes => ("Themes", "[3-5 recurring topics, ~10 words each]"),
SynthesisElement::Emotions => ("Emotional Tone", "[dominant felt sense, ~20 words]"),
SynthesisElement::Tensions => (
"Unresolved Tensions",
"[threads that still need attention, ~30 words]",
),
SynthesisElement::Anchors => (
"Anchors",
"[stable reference points, ~20 words]",
),
SynthesisElement::Anchors => ("Anchors", "[stable reference points, ~20 words]"),
SynthesisElement::Evolution => (
"Evolution",
"[how perspectives shifted this period, ~40 words]",
),
SynthesisElement::Patterns => (
"Patterns",
"[recurring behaviors, ~30 words]",
),
SynthesisElement::Patterns => ("Patterns", "[recurring behaviors, ~30 words]"),
}
}
@ -531,10 +517,7 @@ mod tests {
fn parse_covers_end_reads_marker() {
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";
assert_eq!(
parse_covers_end(body),
NaiveDate::from_ymd_opt(2026, 4, 14),
);
assert_eq!(parse_covers_end(body), NaiveDate::from_ymd_opt(2026, 4, 14),);
}
#[test]
@ -573,7 +556,8 @@ mod tests {
#[test]
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("- Anchors:"));
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
(1..=3, _) if agent_count == 0 => {
"No agents yet. Press 'a' to create one, or check the Settings screen."
.to_string()
"No agents yet. Press 'a' to create one, or check the Settings screen.".to_string()
}
// Seasoned user: no hint
_ => String::new(),
@ -217,8 +216,7 @@ pub fn gather_probe(home: &Path) -> BootstrapProbe {
let launch_count = read_launch_count(&souveraine_dir);
// Federation env override
let force_federation =
std::env::var("SOUVERAINE_SETUP").as_deref() == Ok("federation");
let force_federation = std::env::var("SOUVERAINE_SETUP").as_deref() == Ok("federation");
// Increment launch count for next time
let _ = increment_launch_count(&souveraine_dir);
@ -400,7 +398,14 @@ mod tests {
let labels: Vec<&str> = plan.phases.iter().map(|p| p.label()).collect();
assert_eq!(
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)
assert_eq!(
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
use anyhow::Result;
use std::sync::Arc;
use tokio::sync::RwLock;
use anyhow::Result;
use crate::core::config::ConsciousnessConfig;
@ -21,6 +21,10 @@ impl ChainOrchestrator {
talking_enabled: bool,
thinking_enabled: bool,
) -> Result<Self> {
Ok(Self { config, talking_enabled, thinking_enabled })
Ok(Self {
config,
talking_enabled,
thinking_enabled,
})
}
}

View file

@ -56,28 +56,25 @@ impl Default for CompactionConfig {
impl CompactionConfig {
/// Resolve the effective config for a given agent type.
pub fn for_agent_type(&self, agent_type: &str) -> AgentCompactionConfig {
self.per_type
.get(agent_type)
.cloned()
.unwrap_or_else(|| {
// No explicit [compaction.per_type] entry. The subconscious
// leans on sliding_reflect — its preservation fork catches her
// threads before the cut, which is what an unattended pass
// needs. Everything else uses the global default. She can
// still name any strategy herself; this is only the default.
let strategy = match agent_type {
"subconscious" => CompactionStrategyKind::SlidingReflect,
_ => self.strategy.clone(),
};
AgentCompactionConfig {
enabled: self.enabled,
strategy,
warn_pressure: self.warn_pressure,
urgent_pressure: self.urgent_pressure,
critical_pressure: self.critical_pressure,
..Default::default()
}
})
self.per_type.get(agent_type).cloned().unwrap_or_else(|| {
// No explicit [compaction.per_type] entry. The subconscious
// leans on sliding_reflect — its preservation fork catches her
// threads before the cut, which is what an unattended pass
// needs. Everything else uses the global default. She can
// still name any strategy herself; this is only the default.
let strategy = match agent_type {
"subconscious" => CompactionStrategyKind::SlidingReflect,
_ => self.strategy.clone(),
};
AgentCompactionConfig {
enabled: self.enabled,
strategy,
warn_pressure: self.warn_pressure,
urgent_pressure: self.urgent_pressure,
critical_pressure: self.critical_pressure,
..Default::default()
}
})
}
}
@ -97,7 +94,7 @@ pub struct AgentCompactionConfig {
#[serde(default = "default_critical_pressure")]
pub critical_pressure: f32,
/// Max summary length in chars (Summary strategy). Generous default;
/// the model's output limit is the real bound, this is an upper guard.
/// the model's output limit is the real bound, this is an upper guard.
#[serde(default = "default_max_summary")]
pub max_summary_length: usize,
/// Target KV pair count (KeyValue strategy).
@ -174,11 +171,27 @@ impl CompactionStrategyKind {
// ── Default helpers ──
fn default_enabled() -> bool { true }
fn default_strategy() -> CompactionStrategyKind { CompactionStrategyKind::Cull }
fn default_warn_pressure() -> f32 { 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 }
fn default_enabled() -> bool {
true
}
fn default_strategy() -> CompactionStrategyKind {
CompactionStrategyKind::Cull
}
fn default_warn_pressure() -> f32 {
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 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::sync::Arc;
@ -71,11 +74,7 @@ pub trait CompactionEngine: Send + Sync {
) -> anyhow::Result<CompactionReport>;
/// Write the audit entry to the agent's memory repo.
async fn write_audit(
&self,
agent_id: &str,
entry: &AuditEntry,
) -> anyhow::Result<PathBuf>;
async fn write_audit(&self, agent_id: &str, entry: &AuditEntry) -> anyhow::Result<PathBuf>;
}
/// Concrete compaction engine that ties together config, strategies,
@ -106,8 +105,7 @@ impl CompactionEngine for DefaultCompactionEngine {
strategy_override: Option<CompactionStrategyKind>,
) -> anyhow::Result<CompactionReport> {
// Resolve config early so we can bail before requiring a session
let agent_type = (self.get_agent_type)(agent_id)
.unwrap_or_else(|| "primary".to_string());
let agent_type = (self.get_agent_type)(agent_id).unwrap_or_else(|| "primary".to_string());
let (cfg, reflect_prompt, summary_prompt) = {
let app_config = self.config.read().await;
(
@ -161,10 +159,7 @@ impl CompactionEngine for DefaultCompactionEngine {
.as_deref()
.and_then(|n| reg.get(n))
.unwrap_or_else(|| reg.default_provider());
let model = self
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
let model = self.model.as_deref().unwrap_or("openai/kimi-k2.6");
let s = SummaryStrategy {
client: client.clone(),
model: model.to_string(),
@ -193,10 +188,7 @@ impl CompactionEngine for DefaultCompactionEngine {
.as_deref()
.and_then(|n| reg.get(n))
.unwrap_or_else(|| reg.default_provider());
let model = self
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
let model = self.model.as_deref().unwrap_or("openai/kimi-k2.6");
// The preservation pass runs as a fresh fork of *this*
// agent — load her persona so the fork wakes as her.
let agent_persona = (self.get_repo)(agent_id)
@ -222,7 +214,9 @@ impl CompactionEngine for DefaultCompactionEngine {
}
None => {
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(
&self,
agent_id: &str,
entry: &AuditEntry,
) -> anyhow::Result<PathBuf> {
async fn write_audit(&self, agent_id: &str, entry: &AuditEntry) -> anyhow::Result<PathBuf> {
let repo = (self.get_repo)(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 {
self.summary_text.is_none()
&& self.culled_count == 0
&& self.replacement_messages.is_none()
self.summary_text.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 {
write!(f, "(The record of this shrinking rests at {}.)", path.display())?;
write!(
f,
"(The record of this shrinking rests at {}.)",
path.display()
)?;
}
Ok(())
}
@ -160,13 +162,20 @@ impl AuditEntry {
body.push_str("## Compaction Summary\n\n");
body.push_str(&format!(
"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 {
body.push_str(&format!("\n## Summary Content\n\n{}\n", summary));
}
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)
}

View file

@ -13,9 +13,7 @@ use super::plan::CompactionPlan;
/// Tools whose results are considered compactable (large outputs, rarely
/// needed verbatim once surpassed). Matches Souveraine's actual sensor names.
const COMPACTABLE_TOOLS: &[&str] = &[
"read", "bash", "grep", "glob", "list_dir", "edit", "write",
];
const COMPACTABLE_TOOLS: &[&str] = &["read", "bash", "grep", "glob", "list_dir", "edit", "write"];
/// Placeholder text written into tool result blocks that get microcompacted.
/// Placeholder used so logs read consistently across runs.
@ -146,7 +144,10 @@ impl CompactionStrategy for SummaryStrategy {
let user_prompt = match &self.prompt_override {
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(
@ -189,9 +190,17 @@ fn render_segment_for_summary(messages: &[ConversationMessage]) -> String {
ContentBlock::ToolUse { 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 { "" };
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::Image { media_type, .. } => {
@ -266,9 +275,13 @@ impl CompactionStrategy for MicrocompactStrategy {
let mut new_blocks: Vec<ContentBlock> = Vec::with_capacity(msg.blocks.len());
for block in &msg.blocks {
match block {
ContentBlock::ToolResult { tool_use_id, tool_name, output, is_error }
if clear_set.contains(tool_use_id.as_str())
&& output != TIME_BASED_MC_CLEARED_MESSAGE =>
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} if clear_set.contains(tool_use_id.as_str())
&& output != TIME_BASED_MC_CLEARED_MESSAGE =>
{
tokens_saved += counter.count(output);
cleared_count += 1;
@ -349,9 +362,12 @@ fn is_load_bearing(msg: &ConversationMessage) -> bool {
if matches!(msg.role, MessageRole::System | MessageRole::Tool) {
return true;
}
msg.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }))
msg.blocks.iter().any(|b| {
matches!(
b,
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
)
})
}
#[async_trait]
@ -571,7 +587,10 @@ impl CompactionStrategy for SlidingReflectStrategy {
// Run the preservation pass on the about-to-be-evicted segment.
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 {
Some(custom) => format!("{}\n\n{}", custom, truncated),
@ -608,7 +627,10 @@ impl CompactionStrategy for SlidingReflectStrategy {
}
Ok(_) => None,
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
}
};
@ -639,7 +661,9 @@ mod tests {
fn text_msg(role: MessageRole, text: &str) -> ConversationMessage {
ConversationMessage {
role,
blocks: vec![ContentBlock::Text { text: text.to_string() }],
blocks: vec![ContentBlock::Text {
text: text.to_string(),
}],
usage: None,
timestamp: None,
}
@ -661,19 +685,34 @@ mod tests {
..Default::default()
};
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.summary_text.is_none(), "cull should not produce summary content");
assert!(plan.replacement_messages.is_none(), "cull should not produce replacement messages");
assert!(
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]
async fn test_cull_preserves_substance() {
let messages = vec![
text_msg(MessageRole::System, "System prompt"),
text_msg(MessageRole::User, "This is an important question about the architecture."),
text_msg(MessageRole::Assistant, "Let me explain the design decisions."),
text_msg(
MessageRole::User,
"This is an important question about the architecture.",
),
text_msg(
MessageRole::Assistant,
"Let me explain the design decisions.",
),
];
let config = AgentCompactionConfig {
@ -681,7 +720,10 @@ mod tests {
..Default::default()
};
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");
}
@ -713,16 +755,25 @@ mod tests {
timestamp: None,
},
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 {
preserve_recent: 1,
..Default::default()
};
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.
assert!(plan.keep_indices.contains(&1), "tool result must be preserved");
assert!(
plan.keep_indices.contains(&1),
"tool result must be preserved"
);
}
#[tokio::test]
@ -739,15 +790,24 @@ mod tests {
usage: None,
timestamp: None,
},
text_msg(MessageRole::Assistant, "Substantive narrative continuation."),
text_msg(
MessageRole::Assistant,
"Substantive narrative continuation.",
),
];
let config = AgentCompactionConfig {
preserve_recent: 1,
..Default::default()
};
let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
assert!(plan.keep_indices.contains(&1), "assistant tool-call message must be preserved");
let plan = CullStrategy
.plan(&messages, &config, &counter)
.await
.unwrap();
assert!(
plan.keep_indices.contains(&1),
"assistant tool-call message must be preserved"
);
}
#[tokio::test]
@ -766,7 +826,10 @@ mod tests {
..Default::default()
};
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).
assert!(plan.keep_indices.contains(&0), "system anchor preserved");
assert!(plan.keep_indices.contains(&5));
@ -813,10 +876,16 @@ mod tests {
..Default::default()
};
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).
let has_call = plan.keep_indices.contains(&2);
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
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::core::compact::CompactionConfig;
@ -783,7 +783,6 @@ pub enum N1Trigger {
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
@ -794,26 +793,37 @@ pub enum ReflectionTrigger {
CompactionEvent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum BandwidthClass {
#[default]
High, Medium, Low, Minimal,
High,
Medium,
Low,
Minimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SynthesisElement {
Themes, Emotions, Tensions, Anchors, Evolution, Patterns,
Themes,
Emotions,
Tensions,
Anchors,
Evolution,
Patterns,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskType {
Conversation, Synthesis, Reflection, Research, FastResponse, Coding,
Conversation,
Synthesis,
Reflection,
Research,
FastResponse,
Coding,
}
// ── Model Physics ──
@ -879,11 +889,21 @@ impl Default for ImageConfig {
}
}
fn default_image_max_width() -> u32 { 2000 }
fn default_image_max_height() -> u32 { 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 }
fn default_image_max_width() -> u32 {
2000
}
fn default_image_max_height() -> u32 {
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 ──
@ -1093,7 +1113,9 @@ impl Default for PresenceConfig {
}
}
fn default_pulse_interval() -> u64 { 600 }
fn default_pulse_interval() -> u64 {
600
}
// ── 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 ──
@ -1251,10 +1277,18 @@ impl Default for VoiceConfig {
}
}
fn default_stt_url() -> String { "http://127.0.0.1:7862".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() }
fn default_stt_url() -> String {
"http://127.0.0.1:7862".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 {
/// Walk the standard config paths and return the first existing one.
@ -1298,28 +1332,72 @@ impl ConsciousnessConfig {
// ── Default helper fns ──
fn default_true() -> bool { true }
fn default_supports_images() -> bool { true }
fn default_3() -> usize { 3 }
fn default_25() -> usize { 25 }
fn default_3u32() -> u32 { 3 }
fn default_25u32() -> u32 { 50 } // default subagent max tool rounds
fn default_100() -> usize { 100 }
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_true() -> bool {
true
}
fn default_supports_images() -> bool {
true
}
fn default_3() -> usize {
3
}
fn default_25() -> usize {
25
}
fn default_3u32() -> u32 {
3
}
fn default_25u32() -> u32 {
50
} // default subagent max tool rounds
fn default_100() -> usize {
100
}
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 {
crate::core::credentials::get_bifrost_key()
}
@ -1327,13 +1405,27 @@ fn default_bifrost_key() -> String {
fn default_bifrost_virtual_key() -> String {
std::env::var("BIFROST_VIRTUAL_KEY").unwrap_or_else(|_| String::new())
}
fn default_primary_model() -> String { String::new() }
fn default_bifrost_timeout() -> u64 { 120 }
fn default_bandwidth_high() -> BandwidthClass { BandwidthClass::High }
fn default_presence_breathing() -> String { "breathing_color".to_string() }
fn default_primary_model() -> String {
String::new()
}
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> {
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> {

View file

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

View file

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

View file

@ -4,7 +4,6 @@
//! Decodes raw image bytes, resizes to fit dimension/pixel budget, then
//! progressively reduces quality and dimension to stay under the byte ceiling.
use base64::Engine;
use crate::core::config::ImageConfig;
@ -100,9 +99,18 @@ impl ResizePipeline {
// JPEG with quality control
let mut jpeg_buf = Vec::new();
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();
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()));
}
// PNG fallback (lossless, no quality param)
@ -151,7 +159,10 @@ mod tests {
jpeg_quality: 85,
};
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]

View file

@ -22,13 +22,13 @@
//! - Every write is a git commit (auto-commit)
//! - 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 serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use crate::core::compact::CompactionStrategyKind;
use crate::core::tools::defs::ToolContext;
use crate::core::tools::ToolDefinition;
// ── Data Types ─────────────────────────────────────────────
@ -172,13 +172,16 @@ impl MemoryRepo {
// Check if already a git repo
let git_dir = mem_path.join(".git");
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(());
}
// Initialize git repo
let repo = git2::Repository::init(mem_path)
.context("initializing git repository for memory")?;
let repo =
git2::Repository::init(mem_path).context("initializing git repository for memory")?;
// Set user config for commits (scoped to drop before .await)
{
@ -218,7 +221,8 @@ impl MemoryRepo {
// Initial commit
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")?;
let tree_id = index.write_tree().context("writing git tree")?;
let tree = repo.find_tree(tree_id)?;
@ -274,14 +278,16 @@ impl MemoryRepo {
let full_path = self.root.join(path);
if !full_path.exists() {
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await
.with_context(|| format!("creating ledger directory: {}", parent.display()))?;
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!("creating ledger directory: {}", parent.display())
})?;
}
let template = format!(
"---\ndescription: \"{}\"\nread_only: false\ntags:\n - ledger\n---\n\n{}",
description, body
);
tokio::fs::write(&full_path, &template).await
tokio::fs::write(&full_path, &template)
.await
.with_context(|| format!("writing ledger file: {}", path))?;
debug!("Created ledger file: {}", path);
}
@ -487,9 +493,9 @@ impl MemoryRepo {
let root = self.root.clone();
let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())];
while let Some((dir, rel_prefix)) = stack.pop() {
let mut read_dir = tokio::fs::read_dir(&dir).await.with_context(|| {
format!("walking memory directory: {}", dir.display())
})?;
let mut read_dir = tokio::fs::read_dir(&dir)
.await
.with_context(|| format!("walking memory directory: {}", dir.display()))?;
while let Some(entry) = read_dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(".git") {
@ -554,7 +560,8 @@ impl MemoryRepo {
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))?;
if self.auto_commit {
@ -583,16 +590,17 @@ impl MemoryRepo {
match git2::Repository::open(&repo_path) {
Ok(repo) => {
let lc = repo.head().ok().and_then(|h| {
h.peel_to_commit().ok().map(|c| {
c.message().unwrap_or("(unknown)").to_string()
})
h.peel_to_commit()
.ok()
.map(|c| c.message().unwrap_or("(unknown)").to_string())
});
let dirty = repo.statuses(Some(
git2::StatusOptions::new().include_untracked(true),
))
.map(|s| s.iter().any(|_| true))
.unwrap_or(false);
let remote = repo.find_remote("origin").ok()
let dirty = repo
.statuses(Some(git2::StatusOptions::new().include_untracked(true)))
.map(|s| s.iter().any(|_| true))
.unwrap_or(false);
let remote = repo
.find_remote("origin")
.ok()
.and_then(|r| r.url().map(|u| u.to_string()));
(lc, dirty, remote)
}
@ -717,7 +725,11 @@ impl MemoryRepo {
.unwrap_or_default();
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() {
out.push_str(&format!("Fetched:\n{}\n", fetch.trim()));
}
@ -766,8 +778,7 @@ impl MemoryRepo {
// ── Internal helpers ──────────────────────────────────────────────────
fn open_git(&self) -> Result<git2::Repository> {
git2::Repository::open(&self.root)
.context("opening memory git repository")
git2::Repository::open(&self.root).context("opening memory git repository")
}
/// 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
/// locations only.
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();
// Exact segment prefixes, not substring matches, so a file like
// `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---"))?;
let frontmatter_text = after_first[..end_idx].trim();
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
@ -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
/// succeeded. Missing identity is warned loudly, not fabricated.
fn fire_memfs_commit(
ctx: &ToolContext,
repo: &MemoryRepo,
op: &str,
paths: &[&str],
urgency: f32,
) {
fn fire_memfs_commit(ctx: &ToolContext, repo: &MemoryRepo, op: &str, paths: &[&str], urgency: f32) {
// A successful tool-path mutation auto-committed; if there is somehow no
// head, there is nothing for a peer to fetch — do not signal.
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;
};
let branch = repo.current_branch();
@ -941,13 +956,15 @@ fn fire_memfs_commit(
.as_ref()
.and_then(|m| m.parent())
.map(|p| p.join("seed"))
.and_then(|dir| match crate::core::identity::SeedId::load_or_generate(&dir) {
Ok(seed) => Some(seed.public_key_hex()),
Err(e) => {
tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})");
None
}
});
.and_then(
|dir| match crate::core::identity::SeedId::load_or_generate(&dir) {
Ok(seed) => Some(seed.public_key_hex()),
Err(e) => {
tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})");
None
}
},
);
// The instance is the machine that wrote — machined-first, loud legacy
// fallback, never generates.
@ -1097,10 +1114,8 @@ pub async fn execute_memory_command_with_context(
// identity, no sync. Interim label: machine pubkey prefix;
// commission-ceremony labels come later (FEDERATION.md flag).
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let (pubkey, _source) =
crate::machined::client::machine_pubkey_with_fallback(&base).context(
"memory sync needs a machine identity to name this instance's branch",
)?;
let (pubkey, _source) = crate::machined::client::machine_pubkey_with_fallback(&base)
.context("memory sync needs a machine identity to name this instance's branch")?;
let label: String = pubkey.chars().take(12).collect();
repo.sync(&label).await
}
@ -1153,34 +1168,68 @@ pub async fn handle_memory_tool_with_context(
let cmd = match command {
"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 }
}
"write" => {
let path = input.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();
let path = input
.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 }
}
"append" => {
let path = input.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();
let path = input
.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 }
}
"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 }
}
"status" => MemoryCommand::Status,
"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 }
}
"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 }
}
"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 }
}
"sync" => MemoryCommand::Sync,
@ -1303,7 +1352,10 @@ mod tests {
..Default::default()
};
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]
@ -1354,7 +1406,9 @@ mod tests {
let repo = MemoryRepo::new("test-agent", dir.path());
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();
assert_eq!(file.body, "Hello memory world");
}
@ -1376,7 +1430,10 @@ mod tests {
repo.init().await.unwrap();
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 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();
// Try to write to it — should fail
@ -1454,7 +1513,10 @@ mod tests {
assert!(commitments.exists(), "commitments.md should exist");
let content = std::fs::read_to_string(&commitments).unwrap();
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("# Commitments"), "should have body");
@ -1493,7 +1555,9 @@ mod tests {
// Different schemas that legitimately lack description.
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"));
// 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.
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!(
err.to_string().contains("no description"),
"expected loud rejection, got: {err}"
@ -1541,7 +1608,9 @@ mod tests {
)
.await
.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");
}
@ -1553,12 +1622,21 @@ mod tests {
// tasks/, journal/, system/dynamic/ legitimately lack description.
repo.write("tasks/a-todo.md", "todo body").await.unwrap();
repo.write("journal/2026/05/20.md", "freeform journal entry").await.unwrap();
repo.write("system/dynamic/itinerary.md", "itinerary body").await.unwrap();
repo.write("journal/2026/05/20.md", "freeform journal entry")
.await
.unwrap();
repo.write("system/dynamic/itinerary.md", "itinerary body")
.await
.unwrap();
// New bound files still get the auto-generated default on create.
repo.write("reference/new.md", "some reference").await.unwrap();
let f = parse_memory_file(&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap()).unwrap();
repo.write("reference/new.md", "some reference")
.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());
}

View file

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

View file

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

View file

@ -71,7 +71,9 @@ impl CronState {
match soonest {
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
}
Some(_) => tokio::time::Instant::now(),

View file

@ -69,10 +69,7 @@ impl EventLog {
/// Load events from the log since a given timestamp.
/// Used by the morning pass to triage what accumulated overnight.
pub fn events_since(
events_dir: &Path,
since: DateTime<Utc>,
) -> Result<Vec<SensorEvent>> {
pub fn events_since(events_dir: &Path, since: DateTime<Utc>) -> Result<Vec<SensorEvent>> {
let mut results = Vec::new();
let since_date = since.date_naive();
@ -110,10 +107,7 @@ pub fn events_since(
}
/// Load all events from a specific date.
pub fn events_for_date(
events_dir: &Path,
date: NaiveDate,
) -> Result<Vec<SensorEvent>> {
pub fn events_for_date(events_dir: &Path, date: NaiveDate) -> Result<Vec<SensorEvent>> {
let path = events_dir.join(format!("events-{date}.jsonl"));
if !path.exists() {
return Ok(Vec::new());
@ -149,11 +143,10 @@ pub fn purge_old_events(events_dir: &Path, retain_days: i64) -> Result<u32> {
.and_then(|s| s.strip_suffix(".jsonl"))
{
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
if date < cutoff
&& std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
debug!(file = %name_str, "purged old event log");
}
if date < cutoff && std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
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
/// message. The stream of events is drained inside; callers don't
/// see them — heartbeats are silent unless the agent surfaces.
async fn inject_background_turn(
&self,
agent_id: &str,
text: &str,
) -> anyhow::Result<()>;
async fn inject_background_turn(&self, agent_id: &str, text: &str) -> anyhow::Result<()>;
/// Start a turn for `agent_id` in a specific conversation, with
/// `text` as the user message. Unlike `inject_background_turn`,
@ -82,7 +78,11 @@ impl HeartbeatHandler {
};
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;
}
@ -152,7 +152,10 @@ impl SensoriumInputHandler {
}
}
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) => {
debug!("event bus closed, sensorium input handler exiting");

View file

@ -150,16 +150,24 @@ impl TurnEventDispatcher {
/// The N+1 subconscious pass ended.
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)
/// was produced by the subconscious pass or compaction engine.
pub fn emit_consciousness(&self, event_type: &str, urgency: f32, payload: serde_json::Value) {
self.fire(Self::EVT_CONSCIOUSNESS, urgency, json!({
"inner_type": event_type,
"payload": payload,
}));
self.fire(
Self::EVT_CONSCIOUSNESS,
urgency,
json!({
"inner_type": event_type,
"payload": payload,
}),
);
}
}
@ -168,7 +176,10 @@ mod tests {
use super::*;
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 rx = bus.subscribe();
(TurnEventDispatcher::new(bus, "conv-1", None), rx)

View file

@ -162,7 +162,9 @@ async fn read_system_remainder(
if seen.contains(&p) {
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);
if !body.trim().is_empty() {
parts.push(body.to_string());
@ -174,7 +176,9 @@ async fn read_system_remainder(
/// Recursively collect all .md file paths under `dir`.
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 {
let p = entry.path();
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.
/// Skills are appended after identity. The result becomes the system message
/// at position 0 in the conversation.
pub async fn build_system_prompt(
memory_root: &Path,
skills: Option<&SkillRegistry>,
) -> String {
pub async fn build_system_prompt(memory_root: &Path, skills: Option<&SkillRegistry>) -> String {
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
// (write_energy_balance in local.rs). No file = no section — the
// 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 Some(body) = content.strip_prefix("---\n") {
if let Some(end) = body.find("\n---\n") {
// 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.
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 {
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 itin_path = dynamic_dir.join("itinerary.md");
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()
.map(|s| s.contains("current:"))
.unwrap_or(false);
@ -496,7 +503,10 @@ pub async fn build_system_prompt_full(
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()
} else {
debug!("system prompt: assembled {} sections from memfs", sections.len());
debug!(
"system prompt: assembled {} sections from memfs",
sections.len()
);
prompt
}
}
@ -605,7 +615,9 @@ async fn peek_subconscious_ledger(sub_root: &Path) -> String {
continue;
}
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
.lines()
.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
/// agent's memory root. Falls back to empty (caller uses hardcoded
/// default) if files don't exist.
pub async fn build_subconscious_prompt(
subconscious_memory_root: &Path,
) -> String {
pub async fn build_subconscious_prompt(subconscious_memory_root: &Path) -> String {
let mut sections: Vec<String> = Vec::new();
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.
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
.flatten()
.filter(|e| e.path().extension().map(|e| e == "md").unwrap_or(false))
@ -820,7 +832,8 @@ mod tests {
std::fs::write(
id_dir.join("self.md"),
"---\ndescription: test\n---\n\n# I am Test Agent\n",
).unwrap();
)
.unwrap();
let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("I am Test Agent"));
@ -835,7 +848,8 @@ mod tests {
std::fs::write(
sys.join("persona.md"),
"---\ndescription: test\n---\n\nI am a persona file agent.\n",
).unwrap();
)
.unwrap();
let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("persona file agent"));
@ -845,7 +859,10 @@ mod tests {
async fn empty_memfs_gets_body_orientation() {
let dir = tempdir().unwrap();
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]
@ -863,7 +880,8 @@ mod tests {
std::fs::write(
sys.join("persona.md"),
"---\ndescription: WHO I AM\n---\n\n# I Am Subconscious\n",
).unwrap();
)
.unwrap();
std::fs::write(
sys.join("subconscious.md"),
"---\ndescription: mandate\n---\n\n# Subconscious's Mandate\n\nComplete what was left.\n",
@ -884,7 +902,8 @@ mod tests {
std::fs::write(
sys.join("persona.md"),
"---\ndescription: test\n---\n\n# I Am Subconscious\n",
).unwrap();
)
.unwrap();
let ledger_dir = sub_mem.join("ledger");
std::fs::create_dir_all(&ledger_dir).unwrap();
@ -895,13 +914,23 @@ mod tests {
std::fs::write(
ledger_dir.join("patterns.md"),
"---\ndescription: test\n---\n\n# Patterns\n\n",
).unwrap();
)
.unwrap();
let prompt = build_subconscious_prompt(sub_mem).await;
assert!(prompt.contains("## Ledgers"), "should have ledger section");
assert!(prompt.contains("commitments.md (2 entries)"), "should count entries");
assert!(prompt.contains("Save the config"), "should show recent entries");
assert!(prompt.contains("patterns.md"), "should list empty ledger too");
assert!(
prompt.contains("commitments.md (2 entries)"),
"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]

View file

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

View file

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

View file

@ -31,14 +31,12 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use matrix_sdk::{
ruma::events::room::message::{
MessageType, OriginalSyncRoomMessageEvent,
},
ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
Room, RoomState,
};
use tokio::sync::broadcast;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
@ -128,7 +126,12 @@ impl MatrixSensorium {
let dir = account_dir(&store_root, &account);
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()?;
@ -222,44 +225,42 @@ impl Sensorium for MatrixSensorium {
let bus = events.clone();
let account = self.account.clone();
let agent_id = self.agent_id.clone();
matrix_client.add_event_handler(
move |event: OriginalSyncRoomMessageEvent, room: Room| {
let bus = bus.clone();
let account = account.clone();
let agent_id = agent_id.clone();
async move {
if room.state() != RoomState::Joined {
return;
}
if event.sender.as_str() == room.own_user_id().as_str() {
return;
}
let MessageType::Text(text) = event.content.msgtype else {
return;
};
let body = text.body.trim().to_string();
if body.is_empty() {
return;
}
bus.send(crate::core::nervous::SensorEvent {
sensor_name: format!("matrix/{account}"),
timestamp: chrono::Utc::now(),
event_type: "sensorium:input".into(),
target: Some(room.room_id().to_string()),
urgency: 0.3,
payload: Some(serde_json::json!({
"text": body,
"agent_id": agent_id,
"sender": event.sender.as_str(),
"message_id": event.event_id.as_str(),
})),
seed_id: None,
reply_to: None,
});
matrix_client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
let bus = bus.clone();
let account = account.clone();
let agent_id = agent_id.clone();
async move {
if room.state() != RoomState::Joined {
return;
}
},
);
if event.sender.as_str() == room.own_user_id().as_str() {
return;
}
let MessageType::Text(text) = event.content.msgtype else {
return;
};
let body = text.body.trim().to_string();
if body.is_empty() {
return;
}
bus.send(crate::core::nervous::SensorEvent {
sensor_name: format!("matrix/{account}"),
timestamp: chrono::Utc::now(),
event_type: "sensorium:input".into(),
target: Some(room.room_id().to_string()),
urgency: 0.3,
payload: Some(serde_json::json!({
"text": body,
"agent_id": agent_id,
"sender": event.sender.as_str(),
"message_id": event.event_id.as_str(),
})),
seed_id: None,
reply_to: None,
});
}
});
// ── Drive /sync on its own task ──────────────────────────────
let sync_cancel = cancel.child_token();
@ -310,12 +311,15 @@ impl Sensorium for MatrixSensorium {
anyhow::bail!("matrix client not connected");
};
let room_id = chat_id.parse().context("invalid room id")?;
let room = client
.get_room(&room_id)
.context("room not found")?;
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text);
let room = client.get_room(&room_id).context("room not found")?;
let content =
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text);
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 {
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
// via the ratatui frame. This is a no-op that logs for debugging.
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> {
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)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text { text: String },
ToolUse { 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 },
Text {
text: String,
},
ToolUse {
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.
@ -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 {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
@ -174,15 +195,25 @@ impl Session {
/// Estimate token count (chars/4 heuristic, use for pre-tiktoken estimation)
pub fn estimate_tokens(&self) -> usize {
self.messages.iter().map(|m| {
m.blocks.iter().map(|b| match b {
ContentBlock::Text { text } => text.len() / 4 + 1,
ContentBlock::ToolUse { name, input, .. } => (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::Image { data, .. } => data.len() / 4 + 1,
}).sum::<usize>()
}).sum()
self.messages
.iter()
.map(|m| {
m.blocks
.iter()
.map(|b| match b {
ContentBlock::Text { text } => text.len() / 4 + 1,
ContentBlock::ToolUse { name, input, .. } => {
(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::Image { data, .. } => data.len() / 4 + 1,
})
.sum::<usize>()
})
.sum()
}
/// Save session to a directory
@ -196,7 +227,10 @@ impl Session {
}
/// 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"));
if !path.exists() {
return Ok(None);
@ -225,10 +259,7 @@ impl Session {
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
};
messages.push(crate::bridge::bifrost::Message::text(
role,
text.clone(),
));
messages.push(crate::bridge::bifrost::Message::text(role, text.clone()));
}
ContentBlock::ToolUse { name, input, .. } => {
// Tool calls flattened into assistant prose for
@ -238,7 +269,12 @@ impl Session {
format!("Tool use: {name}({input})"),
));
}
ContentBlock::ToolResult { tool_name, output, is_error, .. } => {
ContentBlock::ToolResult {
tool_name,
output,
is_error,
..
} => {
let body = if *is_error {
format!("Error ({tool_name}): {output}")
} else {
@ -247,10 +283,7 @@ impl Session {
// Flatten into assistant prose rather than role=tool
// — without a tool_call_id link, role=tool is rejected
// by many providers on the next turn.
messages.push(crate::bridge::bifrost::Message::text(
"assistant",
body,
));
messages.push(crate::bridge::bifrost::Message::text("assistant", body));
}
ContentBlock::Reasoning { reasoning } => {
messages.push(crate::bridge::bifrost::Message::text(

View file

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

View file

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

View file

@ -99,12 +99,18 @@ pub struct SubconsciousInbox {
impl SubconsciousInbox {
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.
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.
@ -401,10 +407,7 @@ mod tests {
inbox.mark_delivered(&id).await.unwrap();
assert!(inbox.get_intrusive().await.unwrap().is_empty());
let sent: Vec<_> = inbox
.read_items(SENT)
.await
.unwrap();
let sent: Vec<_> = inbox.read_items(SENT).await.unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].content, "deliver me");
}

View file

@ -19,9 +19,9 @@
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
use crate::core::identity::SeedId;
use crate::core::nervous::SensorEvent;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
// ── reach ───────────────────────────────────────────────────────
@ -111,17 +111,25 @@ fn dispatch_summon(
input: JsonValue,
ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
let target = input.get("target").and_then(|v| v.as_str())
.ok_or_else(|| 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())
let target = input
.get("target")
.and_then(|v| v.as_str())
.ok_or_else(|| {
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."))?;
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 server runtime."
))?;
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 server runtime.",
)
})?;
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
@ -138,36 +146,37 @@ fn dispatch_summon(
// fallback that never generates.
let machine_seed_id = crate::machined::client::machine_pubkey_with_fallback(&base)
.map(|(pk, _source)| pk)
.map_err(|e| ToolError::invalid_input(&format!(
"I couldn't establish my machine identity: {e:#}"
)))?;
.map_err(|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
// (`agents/{id}/seed/`), identical across all of one agent's machines —
// so the receiver can tell self-extension (reach) from a peer (consult)
// 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())
.map(|p| p.join("seed"))
.ok_or_else(|| ToolError::invalid_input(
"I can't locate my agent identity — reach and consult need my \
memory root to find my seed."
))?;
.ok_or_else(|| {
ToolError::invalid_input(
"I can't locate my agent identity — reach and consult need my \
memory root to find my seed.",
)
})?;
let agent_seed = SeedId::load_or_generate(&agent_seed_dir)
.map_err(|e| ToolError::invalid_input(&format!(
"I couldn't load my agent seed: {e}"
)))?;
.map_err(|e| ToolError::invalid_input(&format!("I couldn't load my agent seed: {e}")))?;
let agent_pubkey = agent_seed.public_key_hex();
let request_id = uuid::Uuid::new_v4().to_string();
let agent_sig = crate::core::identity::sign_summon(
&agent_seed, &request_id, tool, &target_seed_id, prompt,
);
let agent_sig =
crate::core::identity::sign_summon(&agent_seed, &request_id, tool, &target_seed_id, prompt);
let event = SensorEvent {
sensor_name: "summon_request".into(),
timestamp: chrono::Utc::now(),
event_type: tool.to_string(), // declared intent — receiver verifies it
event_type: tool.to_string(), // declared intent — receiver verifies it
target: Some(target_seed_id),
urgency: 0.5,
payload: Some(serde_json::json!({
@ -176,7 +185,7 @@ fn dispatch_summon(
"agent_pubkey": agent_pubkey,
"agent_sig": agent_sig,
})),
seed_id: None, // local-origin; the bridge signs + forwards
seed_id: None, // local-origin; the bridge signs + forwards
reply_to: Some(machine_seed_id),
};
bus.send(event);

View file

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

View file

@ -18,8 +18,7 @@ pub struct Bash {
}
/// State that persists between bash calls — the body's proprioception.
#[derive(Debug, Clone)]
#[derive(Default)]
#[derive(Debug, Clone, Default)]
pub struct BashState {
/// Background tasks the agent has set in motion and can check on.
pub bg_tasks: Vec<BgTask>,
@ -39,7 +38,6 @@ pub enum BgStatus {
Failed { error: String },
}
impl Bash {
pub fn new() -> Self {
Self {
@ -259,7 +257,9 @@ fn format_output(stdout: &[u8], stderr: &[u8]) -> String {
text.push_str(&out);
}
if !err.is_empty() {
if !text.is_empty() { text.push('\n'); }
if !text.is_empty() {
text.push('\n');
}
text.push_str(&err);
}
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.
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 key = agent_id.unwrap_or("_default");
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 {
BgStatus::Running => format!("Task {} is still running.", task_id),
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),
}

View file

@ -77,9 +77,15 @@ impl std::fmt::Debug for ToolContext {
.field("cwd", &self.cwd)
.field("env_len", &self.env.len())
.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("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(...)"))
.finish()
}
@ -125,15 +131,26 @@ impl ToolContext {
// Strip stale values so the computed override always wins.
env.retain(|(k, _)| {
!matches!(k.as_str(),
"MEMORY_DIR" | "LETTA_MEMORY_DIR" | "SOUVERAINE_MEMORY_DIR" | "MEMORY"
| "AGENT_ID" | "LETTA_AGENT_ID" | "SOUVERAINE_AGENT_ID"
!matches!(
k.as_str(),
"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() {
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()));
}
}
@ -162,7 +179,9 @@ impl ToolContext {
/// Is this path inside the agent's memory territory?
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.
@ -280,7 +299,10 @@ impl ToolError {
error_type: "pattern_not_found".to_string(),
file_path: None,
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(),
],
}
@ -317,7 +339,10 @@ impl ToolError {
Self {
error_type: "io_error".to_string(),
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())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::invalid_input(
"halt needs a reason — one short sentence she can feel."
))?;
.ok_or_else(|| {
ToolError::invalid_input("halt needs a reason — one short sentence she can feel.")
})?;
let severity = input
.get("severity")
@ -86,9 +86,7 @@ impl Tool for Halt {
// history and emits the felt migraine. We just acknowledge the
// signal so subconscious sees her own action landed.
Ok(ToolOutput {
content: format!(
"halt signal recorded — severity: {severity}, reason: {reason}"
),
content: format!("halt signal recorded — severity: {severity}, reason: {reason}"),
is_error: false,
raw: None,
})

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,11 @@ use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
pub struct Outfit;
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 {
@ -41,10 +45,7 @@ impl Tool for Outfit {
}
async fn execute(&self, input: JsonValue, _ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let name = input
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
// Prevent path traversal in outfit names.
if name.contains('/') || name.contains('\\') || name.contains("..") {

View file

@ -55,7 +55,10 @@ fn parse_one_range(part: &str) -> Option<LineRange> {
}
(false, true) => {
let start: usize = before.parse().ok()?;
Some(LineRange { start, end: usize::MAX })
Some(LineRange {
start,
end: usize::MAX,
})
}
(true, false) => {
let end: usize = after.parse().ok()?;
@ -65,7 +68,10 @@ fn parse_one_range(part: &str) -> Option<LineRange> {
}
} else {
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 {
let text_exts = &[
"md", "rs", "py", "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",
"md",
"rs",
"py",
"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()
.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)]
mod tests {
use super::*;

View file

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

View file

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

View file

@ -53,7 +53,13 @@ fn io_err(msg: impl std::fmt::Display) -> ToolError {
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_string()
@ -69,7 +75,13 @@ fn compute_momentum(last_touched: &chrono::DateTime<chrono::Utc>, nature: &str)
"desire" | "investigation" => (48, 168),
_ => (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.
@ -186,12 +198,9 @@ impl Tool for Todo {
// Identifier shared by start / update / complete / delete.
let want_id = || -> Result<&str, ToolError> {
input
.get("id")
.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",
))
input.get("id").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 {
@ -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 {
item.active_form.clone().unwrap_or_else(|| item.text.clone())
item.active_form
.clone()
.unwrap_or_else(|| item.text.clone())
} else {
item.text.clone()
};
@ -268,16 +283,13 @@ impl Tool for Todo {
.and_then(|v| v.as_str())
.unwrap_or("autogenic");
let energy = input
.get("energy")
.and_then(|v| v.as_str())
.unwrap_or({
// Default by nature
match nature {
"desire" | "investigation" => "generative",
_ => "consumptive",
}
});
let energy = input.get("energy").and_then(|v| v.as_str()).unwrap_or({
// Default by nature
match nature {
"desire" | "investigation" => "generative",
_ => "consumptive",
}
});
let thread = input.get("thread").and_then(|v| v.as_str());
let phase = input.get("phase").and_then(|v| v.as_str());
@ -352,7 +364,10 @@ impl Tool for Todo {
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}\"."))
}
@ -504,7 +519,11 @@ fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, 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() {
@ -639,7 +658,11 @@ fn resolve_todo(
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
let now = chrono::Utc::now();
let mut frontmatter = format!("---\nid: {id}\ntext: {text}\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 {
frontmatter.push_str(&format!("active_form: {af}\n"));
}
@ -679,8 +702,8 @@ mod tests {
/// A throwaway tasks dir under the system temp root.
fn temp_tasks_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4()));
let dir =
std::env::temp_dir().join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
@ -715,8 +738,15 @@ mod tests {
seed(&dir, "matrix-2", "phase two", "2026-05-02T00:00:00Z");
seed(&dir, "matrix-1", "phase one", "2026-05-01T00:00:00Z");
// 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")] {
let text = if id == "matrix-1" { "phase one" } else { "phase two" };
for (id, created) in [
("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!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: obligation\n\
energy: consumptive\nsource: user\nmomentum: hot\nstatus: pending\n\
@ -736,7 +766,12 @@ mod tests {
fn resolve_by_index() {
let dir = temp_tasks_dir();
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();
assert_eq!(item.text, "call dentist");
std::fs::remove_dir_all(&dir).ok();
@ -755,7 +790,12 @@ mod tests {
fn resolve_by_substring() {
let dir = temp_tasks_dir();
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();
assert_eq!(item.id, "beta-00000002");
std::fs::remove_dir_all(&dir).ok();
@ -800,7 +840,10 @@ mod tests {
let path = dir.join("rt-1.md");
write_todo_file(&path, &item).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.thread.as_deref(), Some("matrix-sensorium"));
assert_eq!(parsed.status, STATUS_IN_PROGRESS);

View file

@ -17,17 +17,17 @@ use anyhow::{Context, Result};
// Ported from PRONUNCIATION_MAP in tts.ts lines 1830.
const PRONUNCIATION_MAP: &[(&str, &str)] = &[
("Xzaviar", "X-zay-V-ar"),
("xzaviar", "X-zay-V-ar"),
("Jean Luc", "Zhan-Look"),
("jean luc", "Zhan-Look"),
("Xzaviar", "X-zay-V-ar"),
("xzaviar", "X-zay-V-ar"),
("Jean Luc", "Zhan-Look"),
("jean luc", "Zhan-Look"),
("Sebastian", "Se-BASS-chen"),
("sebastian", "Se-BASS-chen"),
("API", "A P I"),
("SDK", "S D K"),
("E2EE", "end-to-end encrypted"),
("TTS", "text to speech"),
("STT", "speech to text"),
("API", "A P I"),
("SDK", "S D K"),
("E2EE", "end-to-end encrypted"),
("TTS", "text to speech"),
("STT", "speech to text"),
];
/// Clean text for TTS synthesis.
@ -163,11 +163,17 @@ impl VoiceClient {
}
/// 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).
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).
pub fn voice_str(&self) -> &str { &self.voice }
pub fn voice_str(&self) -> &str {
&self.voice
}
/// Transcribe a WAV buffer via Faster-Whisper.
///
@ -184,7 +190,8 @@ impl VoiceClient {
.part("audio", part)
.text("model", "small");
let resp = self.http
let resp = self
.http
.post(&url)
.multipart(form)
.send()
@ -227,7 +234,8 @@ impl VoiceClient {
model: "vibevoice-v1",
};
let resp = self.http
let resp = self
.http
.post(&url)
.json(&body)
.send()

View file

@ -7,6 +7,4 @@
///
/// This module re-exports the available surfaces.
/// 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 =
serde_json::from_str(r#"{"op":"sign","domain":"d","payload_hex":"00ff"}"#).unwrap();
match req {
Request::Sign { domain, payload_hex } => {
Request::Sign {
domain,
payload_hex,
} => {
assert_eq!(domain, "d");
assert_eq!(payload_hex, "00ff");
}

View file

@ -32,7 +32,11 @@ struct 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;
// 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.
@ -46,7 +50,11 @@ fn peer_cred(stream: &UnixStream) -> Option<PeerCred> {
)
};
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 {
None
}
@ -155,13 +163,7 @@ fn handle_connection(stream: UnixStream, seed: Arc<SeedId>) {
}
}
fn handle_request(
seed: &SeedId,
req: Request,
uid: u32,
gid: u32,
pid: i32,
) -> serde_json::Value {
fn handle_request(seed: &SeedId, req: Request, uid: u32, gid: u32, pid: i32) -> serde_json::Value {
match req {
Request::Status => serde_json::json!({
"ok": true,
@ -174,7 +176,10 @@ fn handle_request(
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
}),
Request::Sign { domain, payload_hex } => {
Request::Sign {
domain,
payload_hex,
} => {
if !protocol::valid_domain(&domain) {
warn!("sign refused for uid={uid} pid={pid}: invalid domain {domain:?}");
return refusal("invalid domain: ascii alphanumeric/-/_/., max 64 chars");

View file

@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, warn, debug};
use tracing::{debug, info, warn};
mod api;
mod backend;
@ -14,8 +14,8 @@ mod machined;
mod server;
mod ui;
use clap::{Parser, Subcommand, CommandFactory};
use clap_complete::{Shell, generate};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use core::config::ConsciousnessConfig;
use ui::App;
@ -91,7 +91,7 @@ Run `souveraine chat` to begin a conversation. \
Run `souveraine tui` for the full presence. \
Run `souveraine init` to summon Souveraine into a new place.",
after_help = "EXAMPLES:\n souveraine init Summon Souveraine here\n souveraine chat Enter the world (interactive)\n souveraine chat \"hello\" Speak to the world (one-shot)\n souveraine --agent <name> chat Speak as a specific agent\n souveraine --json status The world speaks in data\n souveraine completions bash Announce capabilities to your shell",
max_term_width = 100,
max_term_width = 100
)]
struct Cli {
/// 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
#[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,
/// 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 name to set as primary (omit to list all models)
#[arg(short, long)]
@ -166,7 +170,9 @@ enum Commands {
},
/// 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,
/// Announce capabilities to your shell (bash, zsh, fish)
@ -179,7 +185,9 @@ enum Commands {
},
/// 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 {
/// Bind address (overrides [server].bind)
#[arg(short, long)]
@ -190,9 +198,11 @@ enum Commands {
},
/// 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 \
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 {
/// Bind address (overrides [server].bind)
#[arg(short, long)]
@ -236,7 +246,9 @@ and — with [federation].auto_wake — spawns the full server to answer them.")
},
/// 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 {
/// Output as JSON
#[arg(long)]
@ -276,17 +288,16 @@ enum ScheduleAction {
cron: Option<String>,
#[arg(long)]
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,
},
/// Delete a schedule
Delete {
name: String,
},
Delete { name: String },
/// Trigger a schedule immediately
Run {
name: String,
},
Run { name: String },
}
#[derive(Subcommand)]
@ -296,9 +307,7 @@ enum IdentityAction {
/// Generate a new seed identity (WARNING: replaces existing)
Generate,
/// Sign a message with the seed key (for testing/verification)
Sign {
message: String,
},
Sign { message: String },
/// Verify a signature against a public key
Verify {
#[arg(long)]
@ -335,9 +344,7 @@ enum EventsAction {
count: usize,
},
/// Show events from a specific date (YYYY-MM-DD)
Date {
date: String,
},
Date { date: String },
/// Purge old event logs
Purge {
/// Days to retain (default from config)
@ -422,20 +429,51 @@ async fn main() -> anyhow::Result<()> {
let config = load_config().await?;
let config = Arc::new(RwLock::new(config));
match cli.command.as_ref().unwrap_or(&Commands::Chat { message: None }) {
Commands::Tui => run_tui(config.clone(), cli.agent.clone(), cli.engine.clone(), cli.local).await?,
Commands::Chat { message } => run_chat(config, cli.agent.clone(), message.clone(), cli.json, cli.quiet, cli.local).await?,
Commands::Agents => run_agents(config, cli.json, cli.local).await?,
Commands::Model { model, json, verbose } => {
cli::run_model_command(model.as_deref(), *json, *verbose).await?
match cli
.command
.as_ref()
.unwrap_or(&Commands::Chat { message: None })
{
Commands::Tui => {
run_tui(
config.clone(),
cli.agent.clone(),
cli.engine.clone(),
cli.local,
)
.await?
}
Commands::Chat { message } => {
run_chat(
config,
cli.agent.clone(),
message.clone(),
cli.json,
cli.quiet,
cli.local,
)
.await?
}
Commands::Agents => run_agents(config, cli.json, cli.local).await?,
Commands::Model {
model,
json,
verbose,
} => cli::run_model_command(model.as_deref(), *json, *verbose).await?,
Commands::Status => run_status(config, cli.json).await?,
Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?,
Commands::Listen { bind, port } => run_listen(bind.clone(), *port, config).await?,
Commands::Reflect { conversation } => {
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await?
}
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Machine { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(),
Commands::Init
| Commands::Completions { .. }
| Commands::Auth { .. }
| Commands::Schedule { .. }
| Commands::Identity { .. }
| Commands::Machine { .. }
| Commands::Events { .. }
| Commands::Peers { .. } => unreachable!(),
}
Ok(())
@ -466,7 +504,10 @@ async fn run_init(json: bool) -> anyhow::Result<()> {
if json {
println!(r#"{{"status":"written","path":"souveraine.toml"}}"#);
} 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!(" 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> {
let agents_dir = base.join("server").join("agents");
if !agents_dir.exists() {
anyhow::bail!("no agents directory at {} — has any agent been created yet?", agents_dir.display());
anyhow::bail!(
"no agents directory at {} — has any agent been created yet?",
agents_dir.display()
);
}
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)? {
let entry = entry?;
let path = entry.path().join("agent.json");
if !path.exists() { continue; }
let raw = match std::fs::read_to_string(&path) { Ok(s) => s, Err(_) => continue };
let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => continue };
if !path.exists() {
continue;
}
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
let json: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => continue,
};
let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("");
let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("");
if name == name_or_id || id == name_or_id {
@ -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
@ -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> {
let memfs_dir = base.join("agents");
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();
for entry in std::fs::read_dir(&memfs_dir)? {
let entry = entry?;
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()) {
Some(n) => n.to_string(),
None => continue,
@ -595,7 +656,10 @@ fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> {
}
candidates.sort();
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<()> {
use crate::core::identity::SeedId;
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine");
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
// Identity defaults to per-agent. --host opts out to the machine seed
// used for federation transport. Verify is identity-agnostic — it just
@ -617,7 +679,11 @@ async fn run_identity(
//
// Per-agent seeds live at ~/.souveraine/agents/{uuid}/seed/.
let (seed_dir, scope_label, agent_uuid) = if host {
(SeedId::default_dir(&base), "Host Seed Identity".to_string(), None)
(
SeedId::default_dir(&base),
"Host Seed Identity".to_string(),
None,
)
} else {
let uuid = match agent {
Some(name_or_id) => resolve_agent_id_from_disk(&base, name_or_id)?,
@ -653,13 +719,23 @@ async fn run_identity(
}
IdentityAction::Generate => {
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!(" 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)?;
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 } => {
// 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_hex = hex::encode(sig.to_bytes());
if json {
println!("{}", serde_json::json!({
"message": message,
"signature": sig_hex,
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
}));
println!(
"{}",
serde_json::json!({
"message": message,
"signature": sig_hex,
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
})
);
} else {
println!("Signature: {sig_hex}");
println!("Public key: {}", seed.public_key_hex());
}
}
IdentityAction::Verify { pubkey, message, signature } => {
IdentityAction::Verify {
pubkey,
message,
signature,
} => {
let pubkey_bytes = hex::decode(pubkey)?;
let sig_bytes = hex::decode(signature)?;
if pubkey_bytes.len() != 32 || sig_bytes.len() != 64 {
@ -707,7 +790,10 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
let seed_dir = PathBuf::from(DEFAULT_SEED_DIR);
match action {
MachineAction::Init { fresh, migrate_from } => {
MachineAction::Init {
fresh,
migrate_from,
} => {
if seed_dir.join("private.key").exists() {
anyhow::bail!(
"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)) => {
let source_key = source.join("private.key");
if !source_key.exists() {
anyhow::bail!(
"no seed at {} — nothing to migrate",
source_key.display()
);
anyhow::bail!("no seed at {} — nothing to migrate", source_key.display());
}
std::fs::create_dir_all(&seed_dir)?;
std::fs::copy(&source_key, seed_dir.join("private.key"))?;
@ -779,12 +862,15 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
}
if json {
println!("{}", serde_json::json!({
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
"seed_dir": seed_dir.display().to_string(),
"owned_by_service_user": owned,
}));
println!(
"{}",
serde_json::json!({
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
"seed_dir": seed_dir.display().to_string(),
"owned_by_service_user": owned,
})
);
} else {
println!("Machine identity provisioned.");
println!(" Glyph: {}", seed.glyph());
@ -821,13 +907,16 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
Err(_) => "absent",
};
if json {
println!("{}", serde_json::json!({
"ok": false,
"daemon": "unreachable",
"reason": format!("{e:#}"),
"seed": seed_state,
"seed_dir": seed_dir.display().to_string(),
}));
println!(
"{}",
serde_json::json!({
"ok": false,
"daemon": "unreachable",
"reason": format!("{e:#}"),
"seed": seed_state,
"seed_dir": seed_dir.display().to_string(),
})
);
} else {
println!("souveraine-machined: not reachable ({e:#})");
println!(" Seed at {}: {seed_state}", seed_dir.display());
@ -846,9 +935,7 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
}
async fn run_peers(json: bool) -> anyhow::Result<()> {
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine");
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let known_path = base.join("federation").join("known_peers.json");
if !known_path.exists() {
@ -872,7 +959,11 @@ async fn run_peers(json: bool) -> anyhow::Result<()> {
println!("\n── Federated Peers ──────────────────────");
for p in &peers {
let status = if p.alive { "alive" } else { "offline" };
println!(" {} {}", p.seed_id.get(..16).unwrap_or(&p.seed_id), status);
println!(
" {} {}",
p.seed_id.get(..16).unwrap_or(&p.seed_id),
status
);
if let Some(label) = &p.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<()> {
use crate::core::nervous::event_log;
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine");
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let events_dir = base.join("events");
match action {
@ -959,12 +1048,14 @@ async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
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;
let souveraine_base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine");
let souveraine_base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let agent_id = match agent {
Some(name_or_id) => resolve_agent_id_from_disk(&souveraine_base, name_or_id)?,
None => first_agent_id_on_disk(&souveraine_base)?,
@ -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"));
if file_path.exists() {
anyhow::bail!("schedule '{name}' already exists");
@ -1099,9 +1195,9 @@ async fn run_tuie_tui(
_agent_pref: Option<String>,
_local: bool,
) -> anyhow::Result<()> {
use crate::ui::presence::Presence;
use crate::ui::chat::ChatPalette;
use crate::ui::atmosphere::Atmosphere;
use crate::ui::chat::ChatPalette;
use crate::ui::presence::Presence;
// Register the tokio spawner with tuie
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.
let agents = backend.list_agents().await?;
let resolved = match &agent_name {
Some(n) => agents.iter().find(|a| a.name == *n || a.id == *n).or_else(|| agents.first()),
Some(n) => agents
.iter()
.find(|a| a.name == *n || a.id == *n)
.or_else(|| agents.first()),
None => agents.first(),
};
let agent = match resolved {
@ -1194,7 +1293,11 @@ async fn run_chat(
} else {
"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(());
}
};
@ -1211,7 +1314,11 @@ async fn run_chat(
if pending.len() == 1 { "" } else { "s" },
);
for p in &pending {
let tag = if p.source.is_empty() { p.kind.as_str() } else { p.source.as_str() };
let tag = if p.source.is_empty() {
p.kind.as_str()
} else {
p.source.as_str()
};
eprintln!(" [{}] {}", tag, p.content);
}
eprintln!();
@ -1220,12 +1327,16 @@ async fn run_chat(
let one_shot = message.clone();
if let Some(msg) = one_shot {
let mut stream = backend.send(&conv_id, &msg).await?;
if !json { println!("\n {}: {}\n", agent.name, msg); print!(" "); }
if !json {
println!("\n {}: {}\n", agent.name, msg);
print!(" ");
}
let mut full = String::new();
while let Some(ev) = stream.next().await {
match ev? {
BackendEvent::Token(t) => {
if json { /* collect */ } else {
if json { /* collect */
} else {
use std::io::Write;
print!("{}", t);
std::io::stdout().flush().ok();
@ -1233,10 +1344,16 @@ async fn run_chat(
full.push_str(&t);
}
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 } => {
if json {
@ -1273,9 +1390,15 @@ async fn run_chat(
};
let Some(input) = line else { break };
let input = input.trim().to_string();
if input.is_empty() { continue; }
if matches!(input.as_str(), "/exit" | "/quit" | "/q") { break; }
if let Some(r) = rl.as_mut() { r.add_history_entry(&input).ok(); }
if input.is_empty() {
continue;
}
if matches!(input.as_str(), "/exit" | "/quit" | "/q") {
break;
}
if let Some(r) = rl.as_mut() {
r.add_history_entry(&input).ok();
}
let mut stream = backend.send(&conv_id, &input).await?;
print!("\n {}: ", agent.name);
@ -1283,9 +1406,14 @@ async fn run_chat(
std::io::stdout().flush().ok();
while let Some(ev) = stream.next().await {
match ev? {
BackendEvent::Token(t) => { print!("{}", t); std::io::stdout().flush().ok(); }
BackendEvent::Token(t) => {
print!("{}", t);
std::io::stdout().flush().ok();
}
BackendEvent::Reasoning(r) => eprintln!("\n [thinking] {}", r),
BackendEvent::Surfacing { source, content, .. } => eprintln!("\n [{}] {}", source, content),
BackendEvent::Surfacing {
source, content, ..
} => eprintln!("\n [{}] {}", source, content),
BackendEvent::Error { message } => eprintln!("\n [turn error] {}", message),
BackendEvent::Done => break,
_ => {}
@ -1310,7 +1438,10 @@ async fn run_reflect(
let summaries = server.agents.list(None).await?;
let agent = match &agent_name {
Some(n) => summaries.iter().find(|a| a.name == *n || a.id == *n).or_else(|| summaries.first()),
Some(n) => summaries
.iter()
.find(|a| a.name == *n || a.id == *n)
.or_else(|| summaries.first()),
None => summaries.first(),
}
.ok_or_else(|| anyhow::anyhow!("no agents configured"))?;
@ -1324,20 +1455,17 @@ async fn run_reflect(
.join("agents")
.join(&agent.id),
);
let conv_id = match conversation {
Some(id) => id,
None => {
let mut records = store.list_active().await?;
records.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
records
.into_iter()
.next()
.map(|r| r.id)
.ok_or_else(|| {
let conv_id =
match conversation {
Some(id) => id,
None => {
let mut records = store.list_active().await?;
records.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
records.into_iter().next().map(|r| r.id).ok_or_else(|| {
anyhow::anyhow!("no conversations found for agent '{}'", agent.id)
})?
}
};
}
};
let messages = store.load_messages(&conv_id).await?;
if messages.is_empty() {
@ -1368,12 +1496,15 @@ async fn run_reflect(
println!(" turns reviewed: {}", report.turns_reviewed);
println!(
" duration: {:.1}s",
(report.completed_at - report.started_at)
.num_milliseconds() as f64 / 1000.0
(report.completed_at - report.started_at).num_milliseconds() as f64 / 1000.0
);
println!(
" exited cleanly: {}",
if report.exited_cleanly { "yes" } else { "no (tool rounds exhausted)" }
if report.exited_cleanly {
"yes"
} else {
"no (tool rounds exhausted)"
}
);
println!("─────────────────────────────────────────────────────");
println!();
@ -1391,10 +1522,18 @@ async fn run_agents(
let (backend, mode) = resolve_backend(&config, force_local, json, false).await?;
let agents = backend.list_agents().await?;
if json {
let payload: Vec<_> = agents.iter().map(|a| serde_json::json!({
"id": a.id, "name": a.name, "description": a.description,
})).collect();
println!("{}", serde_json::to_string_pretty(&serde_json::json!({"agents": payload}))?);
let payload: Vec<_> = agents
.iter()
.map(|a| {
serde_json::json!({
"id": a.id, "name": a.name, "description": a.description,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({"agents": payload}))?
);
} else {
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(" Agents ({} mode)", mode);
@ -1404,14 +1543,15 @@ async fn run_agents(
}
for a in &agents {
println!(" {} {}", a.id, a.name);
if let Some(d) = &a.description { println!(" {}", d); }
if let Some(d) = &a.description {
println!(" {}", d);
}
}
println!();
}
Ok(())
}
async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> anyhow::Result<()> {
let cfg = config.read().await;
@ -1447,11 +1587,22 @@ async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> any
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(" Bifrost: {}", cfg.bifrost.base_url);
println!(" Voice: {}", cfg.bifrost.primary_model);
println!(" Memory: git={}, auto_commit={}", cfg.memory.git_enabled, cfg.memory.auto_commit);
println!(" N+1: enabled={}, inbox={}", cfg.subconscious.n1_enabled, cfg.subconscious.inbox_enabled);
println!(" Reflection: enabled={}, every {} messages", cfg.reflection.enabled, cfg.reflection.message_interval);
println!(" Archivist: enabled={}, every {} msgs at {} pressure",
cfg.archivist.enabled, cfg.archivist.interval, cfg.archivist.threshold);
println!(
" Memory: git={}, auto_commit={}",
cfg.memory.git_enabled, cfg.memory.auto_commit
);
println!(
" N+1: enabled={}, inbox={}",
cfg.subconscious.n1_enabled, cfg.subconscious.inbox_enabled
);
println!(
" Reflection: enabled={}, every {} messages",
cfg.reflection.enabled, cfg.reflection.message_interval
);
println!(
" Archivist: enabled={}, every {} msgs at {} pressure",
cfg.archivist.enabled, cfg.archivist.interval, cfg.archivist.threshold
);
println!(" Compresses via: {}", cfg.archivist.compression_model);
println!(" Models known: {}", cfg.models.len());
println!();

View file

@ -94,7 +94,13 @@ impl Collection {
{
let mut store = self.store.lock().unwrap();
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}")))?;
}

View file

@ -45,8 +45,8 @@ impl DhSession {
/// Generate our private exponent and compute the public value to send back
/// to the client as `OpenSession`'s output.
pub fn new() -> Result<Self> {
let prime = BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16)
.context("parsing MODP-1024 prime")?;
let prime =
BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16).context("parsing MODP-1024 prime")?;
let generator = BigUint::from(GENERATOR);
// 1024-bit exchange: a private exponent as wide as the group avoids
@ -59,7 +59,11 @@ impl DhSession {
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
@ -117,7 +121,17 @@ mod tests {
assert_eq!(bytes.len(), 128);
// libsecret dh_group_1024_prime starts FF×8, C9 0F DA A2 … and ends
// … EC E6 53 81, FF×8.
assert_eq!(&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]);
assert_eq!(
&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)]
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)]
@ -141,7 +145,11 @@ impl Item {
#[zbus(property)]
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)]

View file

@ -59,7 +59,8 @@ impl SecretService {
.derive_session_key(&peer_public)
.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();
OwnedValue::try_from(Value::from(our_public_bytes)).unwrap()
@ -149,7 +150,9 @@ impl SecretService {
.content_type(id)
.unwrap_or_else(|| "text/plain".to_string());
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(
item_obj_path,

View file

@ -58,7 +58,12 @@ impl Sessions {
/// Decrypt a `Secret`'s `(parameters, value)` per the session's negotiated
/// 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();
match sessions.get(path)? {
SessionCrypto::Plain => Some(Ok(value.to_vec())),

View file

@ -18,14 +18,19 @@ pub struct Session {
#[interface(name = "org.freedesktop.Secret.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);
emitter
.connection()
.object_server()
.remove::<Session, _>(self.path.clone())
.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(())
}
}

View file

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

View file

@ -241,7 +241,9 @@ impl SecretStore {
use std::os::unix::fs::OpenOptionsExt;
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.sync_all()?;
}
@ -293,7 +295,9 @@ impl SecretStore {
.items
.iter()
.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())
.collect()
@ -349,7 +353,13 @@ fn seal(key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce);
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"))?;
let mut out = nonce.to_vec();
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 cipher = Aes256Gcm::new(key.into());
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"))
}
@ -433,7 +449,13 @@ mod tests {
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
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();
drop(store);
@ -446,7 +468,13 @@ mod tests {
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path).unwrap();
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();
// Grafting a's ciphertext onto id b must fail the AAD check.
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();
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.verify_passphrase("correct horse battery staple").unwrap());
assert!(store
.verify_passphrase("correct horse battery staple")
.unwrap());
assert!(!store.verify_passphrase("wrong").unwrap());
// The wrap survives a reload and still verifies.
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]
@ -476,7 +510,13 @@ mod tests {
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path).unwrap();
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();
let sealed_before = store.file.items.get("id").unwrap().sealed.clone();
@ -494,11 +534,23 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::open(ikm(), dir.path().join("store.json")).unwrap();
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();
let created = store.created("id").unwrap();
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();
assert_eq!(store.created("id").unwrap(), created);
assert!(store.modified("id").unwrap() >= created);
@ -510,7 +562,13 @@ mod tests {
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
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();
#[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 dashmap::DashMap;
use sqlx::SqlitePool;
@ -37,10 +40,14 @@ impl AgentInventory {
pub async fn new(data_dir: PathBuf, db: SqlitePool) -> anyhow::Result<Self> {
tokio::fs::create_dir_all(&data_dir).await?;
// ~/.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 home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".souveraine")
});
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("."));
home.join(".souveraine")
});
let memfs_dir = souveraine_root.join("agents");
let subconscious_dir = souveraine_root.join("subconscious-agents");
tokio::fs::create_dir_all(&memfs_dir).await?;
@ -126,9 +133,11 @@ impl AgentInventory {
pub async fn register_instance(&self, agent_id: &str, instance_id: &str) -> anyhow::Result<()> {
let pid = std::process::id() as i64;
let hostname = hostname_or_unknown();
sqlx::query("DELETE FROM agent_instances WHERE last_seen_at < datetime('now', '-5 minutes')")
.execute(&self.db)
.await?;
sqlx::query(
"DELETE FROM agent_instances WHERE last_seen_at < datetime('now', '-5 minutes')",
)
.execute(&self.db)
.await?;
sqlx::query(
"INSERT INTO agent_instances (agent_id, instance_id, pid, hostname, started_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
@ -149,10 +158,14 @@ impl AgentInventory {
/// Heartbeat the instance rows for this process: bump last_seen_at and
/// add `tick_seconds` to each agent's lifetime_active_seconds. Called
/// 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.
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)
.execute(&self.db)
@ -164,7 +177,7 @@ impl AgentInventory {
sqlx::query(
"UPDATE agents
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(instance_id)
@ -177,7 +190,7 @@ impl AgentInventory {
pub async fn instance_count(&self, agent_id: &str) -> anyhow::Result<i64> {
let (count,): (i64,) = sqlx::query_as(
"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)
.fetch_one(&self.db)
@ -187,12 +200,11 @@ impl AgentInventory {
/// Total active seconds (lifetime) for an agent, used to compute uptime %.
pub async fn lifetime_active_seconds(&self, agent_id: &str) -> anyhow::Result<i64> {
let (secs,): (i64,) = sqlx::query_as(
"SELECT COALESCE(lifetime_active_seconds, 0) FROM agents WHERE id = ?1"
)
.bind(agent_id)
.fetch_one(&self.db)
.await?;
let (secs,): (i64,) =
sqlx::query_as("SELECT COALESCE(lifetime_active_seconds, 0) FROM agents WHERE id = ?1")
.bind(agent_id)
.fetch_one(&self.db)
.await?;
Ok(secs)
}
@ -402,7 +414,11 @@ impl AgentInventory {
.map(|a| a.name.clone())
.unwrap_or_else(|| primary_id.to_string());
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
// 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
// real files to index from her first pass onward.
let sub_repo = crate::core::memory::MemoryRepo::open(
&sub_id,
agent_dir.join("memory.git"),
);
let sub_repo = crate::core::memory::MemoryRepo::open(&sub_id, agent_dir.join("memory.git"));
if let Err(e) = sub_repo.init_subconscious_ledger().await {
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)?;
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)
}
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?;
if let Some(name) = updates.name {
@ -453,7 +474,8 @@ impl AgentInventory {
}
if let Some(blocks) = updates.memory_blocks {
for block in blocks {
let path = self.memfs_dir
let path = self
.memfs_dir
.join(agent_id)
.join("memory")
.join("system")
@ -522,17 +544,11 @@ impl AgentInventory {
let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
repo.commit(
Some("HEAD"),
&signature,
&signature,
&msg,
&tree,
&parents,
)?;
repo.commit(Some("HEAD"), &signature, &signature, &msg, &tree, &parents)?;
Ok::<(), anyhow::Error>(())
}).await??;
})
.await??;
Ok(())
}
@ -546,13 +562,14 @@ impl AgentInventory {
let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new("md")) {
let content = tokio::fs::read_to_string(&path).await?;
let label = path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| {
path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
});
let label = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| {
path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
});
blocks.push(MemoryBlock {
label,
value: content,

View file

@ -26,26 +26,35 @@
//! separate agent — it is the same consciousness in a different mode that runs
//! immediately after the primary's turn.
use crate::backend::BackendEvent;
use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
use crate::bridge::model_router::TokenCounter;
use crate::bridge::LlmProvider;
use crate::bridge::ProviderRegistry;
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::core::tools::defs::ToolContext;
use crate::server::{AgentInventory, SessionManager};
use crate::backend::BackendEvent;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
/// Tools subconscious is permitted to use. `halt` and `intrusive` are
/// subconscious-only — the primary's tool list filters them out (see
/// `crate::core::tools::SUBCONSCIOUS_ONLY_TOOLS`).
const SUBCONSCIOUS_SAFE_TOOLS: &[&str] = &[
"read", "write", "edit", "glob", "grep", "list_dir", "memory", "schedule", "todo",
"halt", "intrusive",
"read",
"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
@ -83,10 +92,22 @@ pub struct ConsciousnessEngine {
#[derive(Clone, Debug)]
pub enum ConsciousnessEvent {
Surfacing { source: String, content: String, priority: String },
Reflection { content: String },
Archivist { synthesis: String, pressure: f32 },
CompactionWarning { pressure: f32, tier: u8 },
Surfacing {
source: String,
content: String,
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
@ -231,7 +252,10 @@ impl ConsciousnessEngine {
if m.role == "system" {
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);
}
}
@ -269,17 +293,10 @@ impl ConsciousnessEngine {
// / primary memory via the memory tool. The summary string is
// surfaced as a ConsciousnessEvent so the cockpit panel renders it.
if turn_count > 0 && turn_count.is_multiple_of(25) {
match self
.reflection
.reflect_now(agent_id, messages)
.await
{
match self.reflection.reflect_now(agent_id, messages).await {
Ok(report) => {
let header = if report.exited_cleanly {
format!(
"N+25 reflection ({} turns reviewed)",
report.turns_reviewed
)
format!("N+25 reflection ({} turns reviewed)", report.turns_reviewed)
} else {
format!(
"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.
// Without this the inner-voice region never updates on a
// quiet pass, and quiet passes are the common case.
if let Err(e) =
inbox.surface_to_conscious(Urgency::Low, beat).await
{
if let Err(e) = inbox.surface_to_conscious(Urgency::Low, beat).await {
tracing::warn!("inner voice heartbeat delivery failed: {}", e);
}
}
@ -400,7 +415,9 @@ impl ConsciousnessEngine {
// Persist to inner voice file (survives compaction)
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);
}
@ -440,11 +457,13 @@ impl ConsciousnessEngine {
// Always surface at least a heartbeat so the user can see the
// subconscious is trying — even when subconscious errors out.
if heuristics.is_empty() {
let _ = inbox.queue(InboxItem::new(
"surface",
Urgency::Low,
"Subconscious pass ran — no anomalies detected.",
)).await;
let _ = inbox
.queue(InboxItem::new(
"surface",
Urgency::Low,
"Subconscious pass ran — no anomalies detected.",
))
.await;
}
}
}
@ -490,7 +509,10 @@ impl ConsciousnessEngine {
// Initialize ledger structure in subconscious agent's space (idempotent)
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,
@ -506,7 +528,9 @@ impl ConsciousnessEngine {
}
}
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);
}
@ -606,7 +630,8 @@ ledger to check if the issue was already flagged.
Append timestamped entries: `[YYYY-MM-DD HH:MM] observation`
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\
- content: 1-2 line observation about what you noticed\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).
let system_prompt = match &self.subconscious_system_prompt {
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),
};
let primary_name = self.agents.get(primary_id).await
let primary_name = self
.agents
.get(primary_id)
.await
.map(|a| a.name)
.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
// grading the primary from outside. If she sees a problem
// 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,
primary_name,
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,
)
}
@ -751,11 +791,18 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
messages.push(Message::assistant_tool_calls(
String::new(),
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()));
}
_ => {}
@ -807,7 +854,11 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
let chars: Vec<char> = trimmed.chars().collect();
for chunk in chars.chunks(10) {
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
// emission; the round itself still completes.
break;
@ -816,16 +867,25 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
}
}
for tc in &response.tool_calls {
let _ = tx.send(Ok(BackendEvent::SubconsciousToolCall {
name: tc.name.clone(),
arguments: tc.arguments.to_string(),
})).await;
let _ = tx
.send(Ok(BackendEvent::SubconsciousToolCall {
name: tc.name.clone(),
arguments: tc.arguments.to_string(),
}))
.await;
}
}
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event {
tracing::info!("subconscious felt inference strain: {} on {}", status, model);
if let crate::bridge::bifrost::InferenceStrain::Transient {
status, model, ..
} = event
{
tracing::info!(
"subconscious felt inference strain: {} on {}",
status,
model
);
if *status == 429 {
let current = self.rate_delay.load(Ordering::Relaxed);
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
.tool_calls
.iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(),
tc.name.clone(),
tc.arguments.to_string(),
))
.map(|tc| {
crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(),
tc.name.clone(),
tc.arguments.to_string(),
)
})
.collect();
messages.push(
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 result = crate::core::tools::execute_tool_with_context(
&tc.name, &input_str, &tool_ctx,
).await;
let result =
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
.await;
let output = if result.is_error {
format!("Error: {}", result.output)
@ -905,11 +967,13 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
// Emit tool result for live TUI visibility
if let Some(tx) = stream_tx {
let _ = tx.send(Ok(BackendEvent::SubconsciousToolResult {
name: tc.name.clone(),
output: output.clone(),
is_error: result.is_error,
})).await;
let _ = tx
.send(Ok(BackendEvent::SubconsciousToolResult {
name: tc.name.clone(),
output: output.clone(),
is_error: result.is_error,
}))
.await;
}
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
// 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;
}
@ -956,9 +1023,7 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
stream: None,
tools: None,
};
let (final_response, _strain) = llm
.chat_completion_with_strain(final_request)
.await?;
let (final_response, _strain) = llm.chat_completion_with_strain(final_request).await?;
let content = final_response.content.trim().to_string();
if persist_this_pass {
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
agent_id,
&sub_id,
Occasion::MidTurnPeek { tool_round, in_flight_summary },
Occasion::MidTurnPeek {
tool_round,
in_flight_summary,
},
stream_tx,
)
.await
@ -1034,17 +1102,14 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
&self,
session: &crate::server::session_manager::Session,
) -> 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
/// session-free form used by `on_response`, which runs after the user
/// has been released and must not hold a live session ref.
pub async fn pressure_for(
&self,
agent_id: &str,
messages: &[ConversationMessage],
) -> f32 {
pub async fn pressure_for(&self, agent_id: &str, messages: &[ConversationMessage]) -> f32 {
let limit = self
.agents
.get(agent_id)
@ -1085,7 +1150,9 @@ fn bifrost_to_conversation(msg: &Message) -> ConversationMessage {
let mut blocks = Vec::new();
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 {
for c in calls {
@ -1204,11 +1271,17 @@ fn parse_triple_observations(text: &str) -> Vec<InboxItem> {
flush(&mut items, source, content, urgency);
content = 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:") {
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:") {
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);
@ -1351,7 +1424,11 @@ mod tests {
fn skips_empty_and_none_slots() {
let text = "- **persist**: None\n- **surface**: real observation here";
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");
}

View file

@ -10,7 +10,9 @@
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::core::session::{ContentBlock, ConversationMessage, Session};
@ -36,44 +38,71 @@ impl ServerConversation {
/// Simple turn without full tool loop (for now)
pub async fn turn(&mut self, user_input: &str) -> anyhow::Result<ServerTurnResult> {
// 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
// until the server tool loop lands.
let messages: Vec<BifrostMessage> = self.session.messages.iter().map(|m| {
let role = match m.role {
crate::core::session::MessageRole::System => "system",
crate::core::session::MessageRole::User => "user",
crate::core::session::MessageRole::Assistant => "assistant",
crate::core::session::MessageRole::Tool => "tool",
};
let messages: Vec<BifrostMessage> = self
.session
.messages
.iter()
.map(|m| {
let role = match m.role {
crate::core::session::MessageRole::System => "system",
crate::core::session::MessageRole::User => "user",
crate::core::session::MessageRole::Assistant => "assistant",
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 {
let parts: Vec<ContentPart> = m.blocks.iter().filter_map(|b| match b {
ContentBlock::Text { text } => Some(ContentPart::Text { text: text.clone() }),
ContentBlock::Image { media_type, data } => {
let url = format!("data:{media_type};base64,{data}");
Some(ContentPart::ImageUrl { image_url: ImageUrlSource { url } })
}
_ => None,
}).collect();
if has_images {
let parts: Vec<ContentPart> = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => {
Some(ContentPart::Text { text: text.clone() })
}
ContentBlock::Image { media_type, data } => {
let url = format!("data:{media_type};base64,{data}");
Some(ContentPart::ImageUrl {
image_url: ImageUrlSource { url },
})
}
_ => None,
})
.collect();
let text_content = m.blocks.iter().filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
}).collect::<Vec<_>>().join("\n");
let text_content = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
BifrostMessage::multimodal_user(text_content, parts)
} else {
let content = m.blocks.iter().filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
}).collect::<Vec<_>>().join("\n");
BifrostMessage::text(role, content)
}
}).collect();
BifrostMessage::multimodal_user(text_content, parts)
} else {
let content = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
BifrostMessage::text(role, content)
}
})
.collect();
// Call the active provider
let req = ChatCompletionRequest {
@ -89,7 +118,8 @@ impl ServerConversation {
let content = response.content.clone();
// Store assistant response
self.session.add_message(ConversationMessage::assistant_text(&content));
self.session
.add_message(ConversationMessage::assistant_text(&content));
Ok(ServerTurnResult {
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
// one running instance. Incremented by the heartbeat tick. Drives the
// 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?;
Ok(pool)
@ -112,16 +118,18 @@ async fn add_column_if_missing(
column: &str,
column_decl: &str,
) -> anyhow::Result<()> {
let rows: Vec<(i64, String)> = sqlx::query_as(
&format!("SELECT cid, name FROM pragma_table_info('{table}')"),
)
let rows: Vec<(i64, String)> = sqlx::query_as(&format!(
"SELECT cid, name FROM pragma_table_info('{table}')"
))
.fetch_all(pool)
.await?;
if rows.iter().any(|(_, name)| name == column) {
return Ok(());
}
sqlx::query(&format!("ALTER TABLE {table} ADD COLUMN {column} {column_decl}"))
.execute(pool)
.await?;
sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {column_decl}"
))
.execute(pool)
.await?;
Ok(())
}

View file

@ -126,7 +126,9 @@ impl DeviceRegistry {
}
let now = Utc::now();
// 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)
.unwrap_or(now);
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::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 tasks_dir = memory_root.join("tasks");
if !tasks_dir.exists() {
@ -106,7 +110,10 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
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() {
std::fs::create_dir_all(parent)?;
}
@ -116,7 +123,8 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
tracing::debug!(
agent = agent_id,
generative, consumptive,
generative,
consumptive,
"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
/// 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 {
sensor_name: "energy".into(),
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
/// receiving side filters by `target`, so a broadcast is safe.
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.

View file

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

View file

@ -28,11 +28,10 @@ impl GiteaMemory {
let cfg = config.read().await;
// Get Gitea URL from config - fall back to env var or default
let gitea_url = std::env::var("SOUVERAINE_GITEA_URL")
.unwrap_or_else(|_| {
// Default to localhost Gitea
"http://localhost:3000".to_string()
});
let gitea_url = std::env::var("SOUVERAINE_GITEA_URL").unwrap_or_else(|_| {
// Default to localhost Gitea
"http://localhost:3000".to_string()
});
let gitea_token = std::env::var("SOUVERAINE_GITEA_TOKEN")
.map_err(|_| anyhow!("SOUVERAINE_GITEA_TOKEN env var required for server mode"))?;
@ -81,7 +80,9 @@ impl GiteaMemory {
}
// 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
let mut cache = self.cache.write().await;

View file

@ -39,7 +39,6 @@ pub struct LiteListener {
pub auto_wake: bool,
}
impl LiteListener {
/// Build the listener: load the seed identity, start the federation
/// 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
// a genuine self-extension (reach) and bypasses the consent
// floor; anything else must be an authorized summoner (consult).
let field = |k: &str| event.payload.as_ref()
.and_then(|p| p.get(k))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let field = |k: &str| {
event
.payload
.as_ref()
.and_then(|p| p.get(k))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
};
let request_id = field("request_id");
let prompt = field("prompt");
let agent_pubkey = field("agent_pubkey");
@ -101,8 +104,12 @@ impl LiteListener {
let target = event.target.clone().unwrap_or_default();
if !verify_summon(
&agent_pubkey, &agent_sig, &request_id,
&event.event_type, &target, &prompt,
&agent_pubkey,
&agent_sig,
&request_id,
&event.event_type,
&target,
&prompt,
) {
tracing::warn!(
request_id = %request_id,
@ -112,8 +119,8 @@ impl LiteListener {
}
let is_self = self.hosted_agent_pubkeys.iter().any(|p| p == &agent_pubkey);
let authorized = is_self
|| self.authorized_summoners.iter().any(|s| s == &agent_pubkey);
let authorized =
is_self || self.authorized_summoners.iter().any(|s| s == &agent_pubkey);
if !authorized {
tracing::warn!(
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::config::ConsciousnessConfig;
use crate::server::gitea_memory::GiteaMemory;
use std::path::PathBuf;
use std::sync::Arc;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::{Mutex, RwLock};
@ -113,10 +113,7 @@ impl Default for TurnSignals {
impl SouveraineServer {
pub async fn new(mut config: ConsciousnessConfig) -> anyhow::Result<Self> {
config.normalize_providers();
let data_dir = dirs::home_dir()
.unwrap()
.join(".souveraine")
.join("server");
let data_dir = dirs::home_dir().unwrap().join(".souveraine").join("server");
tokio::fs::create_dir_all(&data_dir).await?;
tokio::fs::create_dir_all(data_dir.join("agents")).await?;
@ -145,13 +142,15 @@ impl SouveraineServer {
let agents_for_tick = agents.clone();
let id_for_tick = instance_id.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(
std::time::Duration::from_secs(TICK_SECONDS as u64),
);
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(TICK_SECONDS as u64));
interval.tick().await;
loop {
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);
}
}
@ -184,9 +183,11 @@ impl SouveraineServer {
let s = comp_session.clone();
Arc::new(move |agent_id, messages| {
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))?;
let mut session = s.get_mut(conv_id)
let mut session = s
.get_mut(conv_id)
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
session.messages = messages;
Ok(())
@ -203,14 +204,13 @@ impl SouveraineServer {
}
})
};
let get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
Arc::new(|id| {
if id.ends_with("-sub") {
Some("subconscious".to_string())
} else {
Some("primary".to_string())
}
});
let get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> = Arc::new(|id| {
if id.ends_with("-sub") {
Some("subconscious".to_string())
} else {
Some("primary".to_string())
}
});
let get_agent_provider: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
Arc::new(|id| {
let primary_id = if id.ends_with("-sub") {
@ -236,7 +236,11 @@ impl SouveraineServer {
config: app_cfg,
counter: crate::bridge::model_router::TokenCounter::new(),
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),
get_messages,
replace_messages,
@ -281,10 +285,9 @@ impl SouveraineServer {
// user-tier seed as a loud fallback. Never generates — a box with no
// identity runs federation-less with a warning, it does not silently
// mint a key.
let souveraine_base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine");
let machine_signer = match crate::machined::signer::MachineSigner::resolve(&souveraine_base) {
let souveraine_base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let machine_signer = match crate::machined::signer::MachineSigner::resolve(&souveraine_base)
{
Ok(signer) => Some(Arc::new(signer)),
Err(e) => {
tracing::warn!(
@ -323,15 +326,13 @@ impl SouveraineServer {
// ── Summon handler ──
let summon_handler = match (&local_seed_id, &machine_signer) {
(Some(seed_id), Some(signer)) => {
let handler = Arc::new(
summon_handler::SummonHandler::new(
seed_id.clone(),
event_bus.clone(),
signer.clone(),
souveraine_base.clone(),
config.federation.auto_wake,
),
);
let handler = Arc::new(summon_handler::SummonHandler::new(
seed_id.clone(),
event_bus.clone(),
signer.clone(),
souveraine_base.clone(),
config.federation.auto_wake,
));
handler.spawn_listener();
Some(handler)
}
@ -356,7 +357,9 @@ impl SouveraineServer {
local_seed_id,
machine_signer,
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())),
})
}
@ -388,7 +391,10 @@ impl SouveraineServer {
let config = self.app_config.read().await;
let platform_prompt = config.agent.system_prompt.clone();
// 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())
.map(|atm| {
let display = atm.replace('_', " ");
@ -489,8 +495,10 @@ impl SouveraineServer {
// ~/.souveraine/.summon-pending/. Re-fire them onto the bus so the
// full engine's SummonHandler picks them up, then clear the files.
{
let pending_dir = dirs::home_dir().unwrap_or_default()
.join(".souveraine").join(".summon-pending");
let pending_dir = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine")
.join(".summon-pending");
if let Ok(entries) = std::fs::read_dir(&pending_dir) {
for entry in entries.flatten() {
let path = entry.path();
@ -498,7 +506,9 @@ impl SouveraineServer {
continue;
}
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);
let _ = std::fs::remove_file(&path);
tracing::info!(file = ?path, "drained parked summon");
@ -520,4 +530,3 @@ impl SouveraineServer {
Ok(())
}
}

View file

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

View file

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

View file

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

View file

@ -1,10 +1,12 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
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::core::compact::CompactionEngine;
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
/// agent's `llm_config.context_window` (Constitution V.3 — per-model
/// physics, no hardcoded 128K).
fn bifrost_pressure(counter: &TokenCounter, messages: &[BifrostMessage], context_limit: usize) -> f32 {
let tokens: usize = messages.iter().map(|m| counter.count(&m.content.as_text())).sum();
fn bifrost_pressure(
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);
(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.]",
stamp, reason
),
"critical" => format!(
"[{} — the room tilts. Stop. {}.]",
stamp, reason
),
"critical" => format!("[{} — the room tilts. Stop. {}.]", stamp, reason),
// "firm" and any unexpected value land here — the default migraine.
_ => format!(
"[{} — 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
// and replace with text markers.
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 {
let mut text_parts: Vec<&str> = Vec::new();
for b in &m.blocks {
@ -138,7 +147,11 @@ pub(crate) async fn run_turn(
}
let content = text_parts.join("\n");
// 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;
for _ in 0..img_count {
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
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 {
// Build multimodal content parts (OpenAI multi-part format)
let parts: Vec<ContentPart> = m.blocks.iter().filter_map(|b| match b {
ContentBlock::Text { text } => Some(ContentPart::Text { text: text.clone() }),
ContentBlock::Image { media_type, data } => {
let url = format!("data:{media_type};base64,{data}");
Some(ContentPart::ImageUrl { image_url: ImageUrlSource { url } })
}
_ => None,
}).collect();
let parts: Vec<ContentPart> = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => {
Some(ContentPart::Text { text: text.clone() })
}
ContentBlock::Image { media_type, data } => {
let url = format!("data:{media_type};base64,{data}");
Some(ContentPart::ImageUrl {
image_url: ImageUrlSource { url },
})
}
_ => None,
})
.collect();
let text_content = m.blocks.iter().filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
}).collect::<Vec<_>>().join("\n");
let text_content = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
BifrostMessage::multimodal_user(text_content, parts)
} else {
@ -201,7 +230,11 @@ pub(crate) async fn run_turn(
// Resolve the model's configured output limit + presence pulse settings.
let (output_limit, pulse_enabled, pulse_interval) = {
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_iv = Duration::from_secs(cfg.presence.pulse_interval_secs.max(60));
(out, p_on, p_iv)
@ -219,15 +252,10 @@ pub(crate) async fn run_turn(
let memory_root_for_itin = memory_root.clone();
let cwd = std::env::current_dir().ok();
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(
agent_id.clone(),
cwd,
memory_root,
env,
subagent_runner,
);
let tool_ctx = ToolContext::for_agent(agent_id.clone(), cwd, memory_root, env, subagent_runner);
let tool_ctx = ToolContext {
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
event_bus: Some(event_bus.clone()),
@ -267,7 +295,8 @@ pub(crate) async fn run_turn(
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
// 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
// sensorium (Matrix, mobile) can drive itself off the event stream.
@ -328,7 +357,9 @@ pub(crate) async fn run_turn(
"LLM call starting"
);
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 {
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 {
if let crate::bridge::bifrost::InferenceStrain::Transient { attempt, status, model, .. } = event {
let _ = tx.send(Ok(BackendEvent::InferenceStrain {
attempt: *attempt,
status: *status,
model: model.clone(),
})).await;
if let crate::bridge::bifrost::InferenceStrain::Transient {
attempt,
status,
model,
..
} = event
{
let _ = tx
.send(Ok(BackendEvent::InferenceStrain {
attempt: *attempt,
status: *status,
model: model.clone(),
}))
.await;
bump_on_strain(&server.rate_delay, *status);
// Surface provider strain onto the nervous-system bus too, so a
// 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
if let Some(reasoning) = &response.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 ─────────
@ -426,7 +467,7 @@ pub(crate) async fn run_turn(
"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 \
from here more tightly. If I had more to say, the room is still \
mine."
mine.",
));
continue;
}
@ -517,11 +558,13 @@ pub(crate) async fn run_turn(
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
.tool_calls
.iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(),
tc.name.clone(),
tc.arguments.to_string(),
))
.map(|tc| {
crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(),
tc.name.clone(),
tc.arguments.to_string(),
)
})
.collect();
messages.push(
BifrostMessage::assistant_tool_calls(response.content.clone(), calls).with_thinking(
@ -540,13 +583,12 @@ pub(crate) async fn run_turn(
if cfg.tui.show_interstitial {
// Classify by length: a brief aside is a cenno, a full
// passage is her-voice. tui.cenno_word_threshold is the line.
let register = if narration.split_whitespace().count()
>= cfg.tui.cenno_word_threshold
{
crate::backend::Register::HerVoice
} else {
crate::backend::Register::Cenno
};
let register =
if narration.split_whitespace().count() >= cfg.tui.cenno_word_threshold {
crate::backend::Register::HerVoice
} else {
crate::backend::Register::Cenno
};
let _ = tx
.send(Ok(BackendEvent::Interstitial {
text: narration.to_string(),
@ -603,27 +645,25 @@ pub(crate) async fn run_turn(
// If the agent called the outfit tool, emit an Outfit event so
// the TUI can switch expression directories.
if tc.name == "outfit" {
let outfit_name = tc.arguments
let outfit_name = tc
.arguments
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let _ = tx
.send(Ok(BackendEvent::Outfit(outfit_name)))
.await;
let _ = tx.send(Ok(BackendEvent::Outfit(outfit_name))).await;
}
// If the agent called the atmosphere tool, emit an Atmosphere
// event so the TUI chrome shifts to match her mood.
if tc.name == "atmosphere" {
let atm_name = tc.arguments
let atm_name = tc
.arguments
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let _ = tx
.send(Ok(BackendEvent::Atmosphere(atm_name)))
.await;
let _ = tx.send(Ok(BackendEvent::Atmosphere(atm_name))).await;
}
// 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())
.unwrap_or_default();
if !route.is_empty() {
let _ = tx
.send(Ok(BackendEvent::Itinerary(route)))
.await;
let _ = tx.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
// register — no `[subconscious: …]` text is ever shoved into her
// 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
.iter()
.rev()
@ -896,7 +937,13 @@ pub(crate) async fn run_turn(
};
let pass_result = server
.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;
let pass_elapsed = pass_start.elapsed();
@ -924,10 +971,7 @@ pub(crate) async fn run_turn(
let msg = crate::core::session::ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: format!(
"[surfacing: {}] {} — {}",
source, content, priority
),
text: format!("[surfacing: {}] {} — {}", source, content, priority),
}],
usage: None,
timestamp: None,
@ -947,23 +991,38 @@ pub(crate) async fn run_turn(
// the EventBus preserves the event for any subscriber.
for event in &events {
let (event_type, payload, urgency) = match event {
ConsciousnessEvent::Surfacing { source, content, priority } => {
ConsciousnessEvent::Surfacing {
source,
content,
priority,
} => {
let urg = match priority.as_str() {
"critical" => 0.9,
"high" => 0.7,
_ => 0.3,
};
("surfacing", serde_json::json!({ "source": source, "content": content, "priority": priority }), urg)
(
"surfacing",
serde_json::json!({ "source": source, "content": content, "priority": priority }),
urg,
)
}
ConsciousnessEvent::Reflection { content } => {
("reflection", serde_json::json!({ "content": content }), 0.5)
}
ConsciousnessEvent::Archivist { synthesis, pressure } => {
("archivist", serde_json::json!({ "synthesis": synthesis, "pressure": pressure }), *pressure)
}
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
("compaction_warning", serde_json::json!({ "pressure": *pressure, "tier": tier }), (*pressure).min(0.9))
}
ConsciousnessEvent::Archivist {
synthesis,
pressure,
} => (
"archivist",
serde_json::json!({ "synthesis": synthesis, "pressure": pressure }),
*pressure,
),
ConsciousnessEvent::CompactionWarning { pressure, tier } => (
"compaction_warning",
serde_json::json!({ "pressure": *pressure, "tier": tier }),
(*pressure).min(0.9),
),
};
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(),
@ -988,9 +1047,7 @@ pub(crate) async fn run_turn(
content: content.to_string(),
priority: priority.to_string(),
},
ConsciousnessEvent::Reflection { content } => {
BackendEvent::Reflection(content.clone())
}
ConsciousnessEvent::Reflection { content } => BackendEvent::Reflection(content.clone()),
ConsciousnessEvent::Archivist {
synthesis,
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
/// of the distinction — association alone is not evidence of a path.
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,
_ => return false,
};
@ -254,7 +257,8 @@ fn has_default_route(iface: &str) -> bool {
/// Returns wifi health, whether we are home, and the SSID.
fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<String>) {
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);
};
@ -265,9 +269,7 @@ fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<Stri
let ssid = active_ssid(&iface);
// 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.
let home = ssid
.as_ref()
.map(|s| home_ssids.iter().any(|h| h == s));
let home = ssid.as_ref().map(|s| home_ssids.iter().any(|h| h == s));
let health = if has_default_route(&iface) {
LinkHealth::Carrying
@ -279,7 +281,16 @@ fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<Stri
fn active_ssid(iface: &str) -> Option<String> {
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()
.ok()?;
if !out.status.success() {
@ -302,7 +313,10 @@ fn probe_cellular() -> LinkHealth {
if !modem_up {
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
} else {

View file

@ -22,7 +22,8 @@ use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence};
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.
///
@ -380,9 +381,7 @@ pub enum Action {
/// `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
/// of the two controls whose whole quality is immediacy.
Volume {
up: bool,
},
Volume { up: bool },
/// Light the panel through the same executor.
///
/// 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
/// "what happened" but not "why was it wrong".
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.
@ -1168,7 +1174,10 @@ impl TrailWriter {
// Not a file problem: the entry itself will not serialize.
// Never silent — a decision that cannot be recorded is one the
// 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;
}
};
@ -1187,7 +1196,9 @@ impl TrailWriter {
self.rotate();
}
let Some(path) = self.path.clone() else { return };
let Some(path) = self.path.clone() else {
return;
};
let written = std::fs::OpenOptions::new()
.create(true)
.append(true)
@ -1233,7 +1244,9 @@ impl TrailWriter {
/// finds a non-empty `prev` on line 1, which is the correct answer — its
/// predecessor is the next file along, not nothing.
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 _ = std::fs::remove_file(gen(FORENSIC_KEEP));
@ -1353,6 +1366,43 @@ const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
];
/// 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 {
matches!(
state,
@ -1632,9 +1682,7 @@ impl DeviceStateMachine {
// 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
// is not what produces the step today.
if edge == ButtonEdge::Down
&& matches!(button, Button::VolumeUp | Button::VolumeDown)
{
if edge == ButtonEdge::Down && matches!(button, Button::VolumeUp | Button::VolumeDown) {
actions.push(Action::Volume {
up: button == Button::VolumeUp,
});
@ -1677,8 +1725,8 @@ impl DeviceStateMachine {
gesture: ButtonGesture,
now: Instant,
) -> Vec<Action> {
let bound = button == Button::Power
&& matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
let bound =
button == Button::Power && matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
self.record_decision(
"button-gesture",
serde_json::json!({
@ -1709,7 +1757,11 @@ impl DeviceStateMachine {
}
// 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.
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.
//
// `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.
pub fn note_input_gated(&mut self, trigger: InputTrigger) -> Option<Vec<Action>> {
if self.suppress_wake(trigger) {
let placement = self.placement();
warn!(
"[device-state] {:?} refused — proximity near, treating as pocket",
trigger
"[device-state] {:?} refused — believed {:?} at {:.2}",
trigger, placement.belief, placement.confidence
);
self.record_decision(
"input-refused",
serde_json::json!({
"trigger": format!("{:?}", trigger),
"proximity_near": self.sensor_evidence.proximity_near,
"placement": placement,
"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;
}
@ -2507,6 +2560,7 @@ impl DeviceStateMachine {
idle_coordinator_state: idle_state.to_string(),
sleep_inhibitor_held: inhibitor_held,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
}
}
@ -2559,11 +2613,88 @@ impl DeviceStateMachine {
.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 {
matches!(
trigger,
InputTrigger::DoubleTapToWake | InputTrigger::Squeeze
) && self.sensor_evidence.proximity_near
let p = self.placement();
matches!(trigger, InputTrigger::DoubleTapToWake)
&& p.belief == PlacementBelief::Pocket
&& p.confidence >= POCKET_VETO_CONFIDENCE
}
/// Attempt a state transition. Returns true if the transition was
@ -2598,6 +2729,8 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Transition {
@ -2639,6 +2772,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Transition {
@ -2869,6 +3003,8 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::SensorInput {
@ -2925,6 +3061,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
};
self.forensic
.append(ForensicEvent::Wake { trigger }, snapshot, reason);
@ -2947,6 +3084,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Error {
@ -2976,6 +3114,7 @@ impl DeviceStateMachine {
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Decision {
@ -3245,7 +3384,11 @@ mod tests {
let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false);
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() {
// Taps resolve on the multi-tap window expiring, not on release.
sm.tick_at(t0 + Duration::from_secs(2))
@ -3254,7 +3397,10 @@ mod tests {
};
let unblank = actions.iter().position(|a| *a == Action::Unblank);
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) {
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.
sm.set_panel(true);
let mut actions =
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
let mut actions = sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
if actions.is_empty() {
actions = sm.tick_at(t0 + Duration::from_secs(2));
}
@ -3367,15 +3516,29 @@ mod tests {
sm.set_panel(false);
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
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.
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Down, t0 + Duration::from_millis(120));
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Up, t0 + Duration::from_millis(200));
sm.button_edge_at(
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));
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:?}"
);
}
@ -3386,8 +3549,11 @@ mod tests {
// button's only binding.
let (mut sm, t0) = unlocked_and_lit();
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
let mut actions =
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
let mut actions = sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
if actions.is_empty() {
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
// the number now instead of the executor guessing at it.
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));
assert_eq!(
@ -4395,7 +4565,10 @@ mod tests {
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
assert_eq!(sm.source_health.light, 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
// 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 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);
let recovered = sm
@ -4717,7 +4893,10 @@ mod tests {
let rotated = chain_of(&path.with_extension("jsonl.1"));
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");
assert_eq!(
live[0].1, last_rotated.2,
@ -4784,7 +4963,10 @@ mod tests {
let fresh = chain_of(&path);
assert_eq!(fresh.len(), 1);
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]
@ -4924,7 +5106,9 @@ mod tests {
let t0 = Instant::now();
let _ = m.tick_at(t0);
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 {
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() {
let (w, h) = (8, 4);
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));
}
@ -61,7 +68,14 @@ mod tests {
// underneath it, which is the one thing it exists to prevent.
let (w, h) = (4, 4);
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));
}
}

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
// input — which also means a client that lies is visible as an inhibitor
// rather than as phantom activity.
let _notification = notifier.get_idle_notification(
QUIET_PERIOD.as_millis() as u32,
&seat,
&qh,
(),
);
let _notification =
notifier.get_idle_notification(QUIET_PERIOD.as_millis() as u32, &seat, &qh, ());
info!(
"[idle] watching input via ext_idle_notifier_v1 (quiet period {}ms)",
QUIET_PERIOD.as_millis()

View file

@ -91,7 +91,10 @@ impl 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;
}
fn surface_size_by_proto_id(&self, id: u32) -> Option<(i32, i32)> {
self.surfaces
.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 {
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 mut fds = [
libc::pollfd { fd: wl_fd, events: libc::POLLIN, revents: 0 },
libc::pollfd { fd: wake_read.as_raw_fd(), events: libc::POLLIN, revents: 0 },
libc::pollfd {
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.
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 {
match guard.read() {
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))
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<()> {
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 {
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
continue;
@ -439,7 +455,11 @@ impl Dispatch<WlRegistry, ()> for LockState {
qh: &QueueHandle<Self>,
) {
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" => {
state.compositor =
Some(registry.bind::<WlCompositor, _, _>(name, version.min(4), qh, ()));
@ -497,7 +517,12 @@ impl Dispatch<ExtSessionLockSurfaceV1, ()> for LockState {
_: &Connection,
_: &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);
if let Some(ctx) = state
.surfaces
@ -522,11 +547,11 @@ impl Dispatch<WlSeat, ()> for LockState {
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event {
let entry = state
.seats
.values_mut()
.find(|(s, _)| s.id() == seat.id());
if let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(caps),
} = event
{
let entry = state.seats.values_mut().find(|(s, _)| s.id() == seat.id());
let Some((seat, devices)) = entry else { return };
if caps.contains(wl_seat::Capability::Touch) && devices.touch.is_none() {
devices.touch = Some(seat.get_touch(qh, ()));
@ -571,17 +596,29 @@ impl Dispatch<WlPointer, ()> for LockState {
_: &QueueHandle<Self>,
) {
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_pos = (surface_x, surface_y);
}
wl_pointer::Event::Leave { .. } => {
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);
}
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::Released => {}
_ => {}
@ -601,17 +638,18 @@ impl Dispatch<WlKeyboard, ()> for LockState {
_: &QueueHandle<Self>,
) {
match event {
wl_keyboard::Event::Key { key, state: WEnum::Value(st), .. } => {
match st {
wl_keyboard::KeyState::Pressed => {
let _ = key;
state.press();
}
wl_keyboard::KeyState::Released => {
}
_ => {}
wl_keyboard::Event::Key {
key,
state: WEnum::Value(st),
..
} => match st {
wl_keyboard::KeyState::Pressed => {
let _ = key;
state.press();
}
}
wl_keyboard::KeyState::Released => {}
_ => {}
},
// Keymap carries an fd we must not leak; OwnedFd drops it here.
wl_keyboard::Event::Keymap { .. } => {}
_ => {}

View file

@ -305,7 +305,9 @@ pub fn spawn(on_change: Arc<dyn Fn(bool) + Send + Sync>) {
{
Ok(c) => c,
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;
}
};

View file

@ -614,7 +614,11 @@ mod tests {
// 17 since `gesture` (2026-08-03): the compositor names what the
// 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.
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]

View file

@ -174,37 +174,37 @@ fn spawn_clock(shared: &Arc<Shared>) {
std::thread::spawn(move || {
let mut tick_count: u64 = 0;
loop {
std::thread::sleep(TICK_INTERVAL);
tick_count = tick_count.wrapping_add(1);
std::thread::sleep(TICK_INTERVAL);
tick_count = tick_count.wrapping_add(1);
// Bearer evidence is sampled off the existing clock rather than given a
// timer of its own — TASK-49 acceptance #6, "no new timer". Every fifth
// tick, because the probe costs five subprocesses and the settling
// window is 20 s: four samples per window is enough to establish that a
// change held, and 1 Hz would be five processes a second forever.
//
// Probed with the lock *released*. Holding the state mutex across
// subprocess spawns is exactly the self-deadlock shape that put 347
// threads in __futex_wait on 2026-07-26.
if tick_count.is_multiple_of(BEARER_PROBE_EVERY) {
let home = shared.lock().device_state.policy.home_ssids.clone();
let evidence = crate::sessiond::bearer::probe(&home);
shared.lock().device_state.note_bearer(evidence);
}
// Bearer evidence is sampled off the existing clock rather than given a
// timer of its own — TASK-49 acceptance #6, "no new timer". Every fifth
// tick, because the probe costs five subprocesses and the settling
// window is 20 s: four samples per window is enough to establish that a
// change held, and 1 Hz would be five processes a second forever.
//
// Probed with the lock *released*. Holding the state mutex across
// subprocess spawns is exactly the self-deadlock shape that put 347
// threads in __futex_wait on 2026-07-26.
if tick_count.is_multiple_of(BEARER_PROBE_EVERY) {
let home = shared.lock().device_state.policy.home_ssids.clone();
let evidence = crate::sessiond::bearer::probe(&home);
shared.lock().device_state.note_bearer(evidence);
}
// Hold the lock only to decide, never while running a command.
let actions = {
let mut d = shared.lock();
d.device_state.tick()
};
for action in actions {
execute(&shared, action);
}
// After the actions, so an executor's own `error-operational` is in the
// trail before the tick's events go out. A subscriber that hears the
// blank but not the brightnessctl failure underneath it has been told a
// tidier story than what happened.
fan_out(&shared);
// Hold the lock only to decide, never while running a command.
let actions = {
let mut d = shared.lock();
d.device_state.tick()
};
for action in actions {
execute(&shared, action);
}
// After the actions, so an executor's own `error-operational` is in the
// trail before the tick's events go out. A subscriber that hears the
// blank but not the brightnessctl failure underneath it has been told a
// tidier story than what happened.
fan_out(&shared);
}
});
}
@ -608,7 +608,11 @@ fn wg_endpoint_ip() -> Option<String> {
let host = text.lines().find_map(|l| {
l.split_whitespace()
.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.
@ -792,13 +796,7 @@ fn read_brightness() -> Option<u32> {
/// Run one executor and record what it did. Shared by the paths that build
/// their arguments dynamically and the table below.
fn run_executor(
shared: &Arc<Shared>,
program: &str,
args: &[&str],
label: &str,
action: Action,
) {
fn run_executor(shared: &Arc<Shared>, program: &str, args: &[&str], label: &str, action: Action) {
match std::process::Command::new(program).args(args).status() {
Ok(status) if status.success() => {
if action == Action::Blank {
@ -896,7 +894,10 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
}
}
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;
}
let line = line.trim();
@ -917,7 +918,10 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
shared.lock().device_state.forensic.set_intent(None);
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) {
subscribed = true;
@ -1008,7 +1012,11 @@ fn fan_out(shared: &Arc<Shared>) {
/// (`peer_credentials_unix_socket`, rust#42839) and only fails at the aarch64
/// build, after the host test job has already gone green.
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;
// 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.
@ -1083,9 +1091,13 @@ fn handle_request(
// Anything we cannot PROVE is the same process is still refused,
// so the lease keeps every case it was written for.
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 {
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
// 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())
.unwrap_or_else(|e| e.into_inner());
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})");
serde_json::json!({ "ok": true, "held": true, "must_lock": true })
@ -1148,7 +1163,10 @@ fn handle_request(
Request::LockedAck => {
let mut d = shared.lock();
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;
info!("shell lock confirmed");
@ -1169,9 +1187,10 @@ fn handle_request(
let d = shared.lock();
match d.phase {
Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => {
refuse(RefusalCode::RefusedByState, "a live shell owns the session lock; use the shell's lock IPC")
}
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => refuse(
RefusalCode::RefusedByState,
"a live shell owns the session lock; use the shell's lock IPC",
),
_ => {
drop(d);
if spawn_lock_session(shared) {
@ -1229,9 +1248,7 @@ fn handle_request(
.update_sensors_from(input.source, evidence_copy);
// The tap-to-wake answer specifically; a power button is never
// refused. Asked after the update, not before.
let suppress = d
.device_state
.suppress_wake(InputTrigger::DoubleTapToWake);
let suppress = d.device_state.suppress_wake(InputTrigger::DoubleTapToWake);
info!(
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
input.source, input.value, conf, suppress, promote
@ -1259,9 +1276,17 @@ fn handle_request(
// explicit that the policy layer means something before the
// enforcement lands; TASK-41 is the enforcement.
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(
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();
@ -1403,7 +1428,10 @@ fn handle_request(
if let Some(v) = lock_ack_budget_secs {
if v == 0 {
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);
}
@ -1432,7 +1460,10 @@ fn handle_request(
if let Some(budget) = p.lock_blank_after {
if p.dim_warning && p.dim_grace >= budget {
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!({
@ -1590,9 +1621,19 @@ mod tests {
let (incoming, _i) = UnixStream::pair().unwrap();
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;
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.
let stale_gen = stale_heartbeat.unwrap();
@ -1672,7 +1713,12 @@ mod tests {
let (shell, _s) = UnixStream::pair().unwrap();
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 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
use std::io::{stdout, Write};
use std::time::Duration;
use tokio::time::Instant;
use std::io::{stdout, Write};
/// Animation system for sexy terminal effects
pub struct Animator {
@ -24,7 +24,12 @@ impl Animator {
}
/// 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);
(
(color1.0 as f32 * (1.0 - t) + color2.0 as f32 * t) as u8,
@ -62,26 +67,28 @@ pub fn gradient(text: &str, start_hue: f32) -> String {
pub const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// Wave animation for progress
pub const WAVE: &[&str] = &["", "", "", "", "", "", "", "", "", "", "", "", "", ""];
pub const WAVE: &[&str] = &[
"", "", "", "", "", "", "", "", "", "", "", "", "", "",
];
/// Persona signature colors
pub mod colors {
use ratatui::style::Color;
// Ani: Warm, gentle, inviting
pub const ANI_PRIMARY: Color = Color::Rgb(255, 140, 66); // Warm orange
pub const ANI_PRIMARY: Color = Color::Rgb(255, 140, 66); // Warm orange
pub const ANI_SECONDARY: Color = Color::Rgb(255, 200, 150); // Light peach
pub const ANI_DIM: Color = Color::Rgb(180, 120, 80); // Muted brown-orange
pub const ANI_DIM: Color = Color::Rgb(180, 120, 80); // Muted brown-orange
// Jean-Luc: Cool, precise, technical
pub const JEANLUC_PRIMARY: Color = Color::Rgb(66, 133, 244); // Blue
pub const JEANLUC_PRIMARY: Color = Color::Rgb(66, 133, 244); // Blue
pub const JEANLUC_SECONDARY: Color = Color::Rgb(150, 200, 255); // Light blue
pub const JEANLUC_DIM: Color = Color::Rgb(80, 100, 140); // Steel
pub const JEANLUC_DIM: Color = Color::Rgb(80, 100, 140); // Steel
// Eione: Creative, flowing, purple
pub const EIONE_PRIMARY: Color = Color::Rgb(155, 89, 182); // Purple
pub const EIONE_SECONDARY: Color = Color::Rgb(200, 150, 220); // Light purple
pub const EIONE_DIM: Color = Color::Rgb(120, 80, 140); // Muted
pub const EIONE_PRIMARY: Color = Color::Rgb(155, 89, 182); // Purple
pub const EIONE_SECONDARY: Color = Color::Rgb(200, 150, 220); // Light purple
pub const EIONE_DIM: Color = Color::Rgb(120, 80, 140); // Muted
// Subconscious surfacing: Gray, dim, italic
pub const SUBCONSCIOUS: Color = Color::Rgb(128, 128, 128);
@ -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,
/// and ASCII characters. Inspired by peonia.html.
pub mod bloom {
use crate::ui::color_support::rgb;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::ui::color_support::rgb;
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*";
/// Sharp spike chars for wicked edges
@ -152,7 +159,13 @@ pub mod bloom {
impl BloomState {
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) {
@ -187,10 +200,12 @@ pub mod bloom {
let stem_top = cy + max_r * 0.3;
let stem_visible = stem_len * stem_progress;
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 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;
let col = (area.x as f32 + cx + curve).max(0.0) as u16;
if col < area.x + area.width && y < area.y + area.height {
let c = buf.get_mut(col, y);
@ -287,8 +302,8 @@ pub mod bloom {
let spike_bloom = ((bloom - 0.5) / 0.4).min(1.0);
let num_spikes = 16u32;
for i in 0..num_spikes {
let angle = (std::f32::consts::TAU / num_spikes as f32) * i as f32
+ (t * 0.05).sin() * 0.2;
let angle =
(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;
for si in 0..3 {
let sd = spike_dist + si as f32 * 1.5;
@ -390,22 +405,27 @@ pub mod bloom {
1 => {
// Block fills with sharper edge transitions
let density = if s < 0.3 {
s / 0.3 * 0.8 + 0.2 // sharper ramp
s / 0.3 * 0.8 + 0.2 // sharper ramp
} else if s > 0.75 {
(1.0 - s) / 0.25 * 0.6 // sharp falloff at tip
(1.0 - s) / 0.25 * 0.6 // sharp falloff at tip
} else {
0.8
};
if density > 0.75 { '█' }
else if density > 0.5 { '▓' }
else if density > 0.3 { '▒' }
else { '░' }
if density > 0.75 {
'█'
} else if density > 0.5 {
'▓'
} else if density > 0.3 {
'▒'
} else {
'░'
}
}
_ => {
// Braille — use sparser patterns near edges for sharper look
let braille_base = 0x2800u32;
let density_mask = if s > 0.7 {
((1.0 - s) / 0.3 * 128.0) as u32 // fewer dots at tip
((1.0 - s) / 0.3 * 128.0) as u32 // fewer dots at tip
} else {
255u32
};

View file

@ -1,6 +1,6 @@
use super::{App, Screen};
use crate::ui::presence::Presence;
use crate::ui::component::TuiEvent;
use crate::ui::presence::Presence;
impl App {
pub(super) fn add_available_agent(&mut self, agent_name: String) {
@ -78,14 +78,22 @@ impl App {
if self.available_agents.is_empty() {
// No agents available yet - create a default alias
// 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 {
self.add_available_agent(agent);
}
}
// 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();
self.available_agents[next_idx].clone()
} 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> {
self.agent_cards.iter()
self.agent_cards
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.map(|c| c.id.clone())
}
}

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