two swipes, one progress; home is zone 0; pocket is a belief
This commit is contained in:
parent
6c0730a790
commit
d7965a57fd
188 changed files with 9258 additions and 3970 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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:"));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("..") {
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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}"))),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -17,17 +17,17 @@ use anyhow::{Context, Result};
|
|||
// Ported from PRONUNCIATION_MAP in tts.ts lines 18–30.
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
403
src/main.rs
403
src/main.rs
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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}")))?;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 { .. } => {}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue