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
|
//! Demo of sexy terminal UI effects
|
||||||
//! Run with: cargo run --example demo
|
//! Run with: cargo run --example demo
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
cursor::{Hide, MoveTo, Show},
|
||||||
|
execute,
|
||||||
|
style::{Color, ResetColor, SetForegroundColor},
|
||||||
|
terminal::{Clear, ClearType},
|
||||||
|
};
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use crossterm::{
|
|
||||||
execute,
|
|
||||||
terminal::{Clear, ClearType},
|
|
||||||
cursor::{MoveTo, Show, Hide},
|
|
||||||
style::{Color, ResetColor, SetForegroundColor},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Animator {
|
pub struct Animator {
|
||||||
start_time: Instant,
|
start_time: Instant,
|
||||||
|
|
@ -17,9 +17,11 @@ pub struct Animator {
|
||||||
|
|
||||||
impl Animator {
|
impl Animator {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { start_time: Instant::now() }
|
Self {
|
||||||
|
start_time: Instant::now(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn breathe(&self, speed_ms: u64) -> f32 {
|
pub fn breathe(&self, speed_ms: u64) -> f32 {
|
||||||
let elapsed = self.start_time.elapsed().as_millis() as f64;
|
let elapsed = self.start_time.elapsed().as_millis() as f64;
|
||||||
let cycle = (elapsed / speed_ms as f64) * 2.0 * std::f64::consts::PI;
|
let cycle = (elapsed / speed_ms as f64) * 2.0 * std::f64::consts::PI;
|
||||||
|
|
@ -37,11 +39,14 @@ pub async fn typewrite(text: &str, wpm: u64) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gradient(text: &str, start_hue: f32) -> String {
|
pub fn gradient(text: &str, start_hue: f32) -> String {
|
||||||
text.chars().enumerate().map(|(i, ch)| {
|
text.chars()
|
||||||
let hue = (start_hue + i as f32 * 3.0) % 360.0;
|
.enumerate()
|
||||||
let (r, g, b) = hsl_to_rgb(hue, 0.8, 0.6);
|
.map(|(i, ch)| {
|
||||||
format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, ch)
|
let hue = (start_hue + i as f32 * 3.0) % 360.0;
|
||||||
}).collect()
|
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) {
|
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||||
|
|
@ -56,38 +61,46 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||||
_ if h < 300.0 => (x, 0.0, c),
|
_ if h < 300.0 => (x, 0.0, c),
|
||||||
_ => (c, 0.0, x),
|
_ => (c, 0.0, x),
|
||||||
};
|
};
|
||||||
(((r1 + m) * 255.0) as u8, ((g1 + m) * 255.0) as u8, ((b1 + m) * 255.0) as u8)
|
(
|
||||||
|
((r1 + m) * 255.0) as u8,
|
||||||
|
((g1 + m) * 255.0) as u8,
|
||||||
|
((b1 + m) * 255.0) as u8,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn breathing_color(base: (u8, u8, u8), intensity: f32) -> (u8, u8, u8) {
|
pub fn breathing_color(base: (u8, u8, u8), intensity: f32) -> (u8, u8, u8) {
|
||||||
let factor = 0.8 + (intensity * 0.4);
|
let factor = 0.8 + (intensity * 0.4);
|
||||||
((base.0 as f32 * factor).min(255.0) as u8,
|
(
|
||||||
(base.1 as f32 * factor).min(255.0) as u8,
|
(base.0 as f32 * factor).min(255.0) as u8,
|
||||||
(base.2 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 SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
pub const WAVE: &[&str] = &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"];
|
pub const WAVE: &[&str] = &[
|
||||||
|
"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂",
|
||||||
|
];
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
|
|
||||||
execute!(stdout, Hide, Clear(ClearType::All)).unwrap();
|
execute!(stdout, Hide, Clear(ClearType::All)).unwrap();
|
||||||
|
|
||||||
let animator = Animator::new();
|
let animator = Animator::new();
|
||||||
|
|
||||||
// Demo 1: Gradient
|
// Demo 1: Gradient
|
||||||
execute!(stdout, MoveTo(5, 2)).unwrap();
|
execute!(stdout, MoveTo(5, 2)).unwrap();
|
||||||
println!("{}", gradient("✨ Souveraine ✨", 30.0));
|
println!("{}", gradient("✨ Souveraine ✨", 30.0));
|
||||||
|
|
||||||
// Demo 2: Typing
|
// Demo 2: Typing
|
||||||
execute!(stdout, MoveTo(5, 4)).unwrap();
|
execute!(stdout, MoveTo(5, 4)).unwrap();
|
||||||
print!("Ani: ");
|
print!("Ani: ");
|
||||||
io::stdout().flush().unwrap();
|
io::stdout().flush().unwrap();
|
||||||
typewrite("Color and pop!", 100).await;
|
typewrite("Color and pop!", 100).await;
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Demo 3: Breathing heart
|
// Demo 3: Breathing heart
|
||||||
execute!(stdout, MoveTo(5, 6)).unwrap();
|
execute!(stdout, MoveTo(5, 6)).unwrap();
|
||||||
print!("Breathing: ");
|
print!("Breathing: ");
|
||||||
|
|
@ -100,24 +113,32 @@ async fn main() {
|
||||||
sleep(Duration::from_millis(50)).await;
|
sleep(Duration::from_millis(50)).await;
|
||||||
execute!(stdout, MoveTo(16, 6)).unwrap();
|
execute!(stdout, MoveTo(16, 6)).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute!(stdout, ResetColor).unwrap();
|
execute!(stdout, ResetColor).unwrap();
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Demo 4: Spinner
|
// Demo 4: Spinner
|
||||||
execute!(stdout, MoveTo(5, 8)).unwrap();
|
execute!(stdout, MoveTo(5, 8)).unwrap();
|
||||||
print!("Loading: ");
|
print!("Loading: ");
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
execute!(stdout, SetForegroundColor(Color::Rgb { r: 100, g: 200, b: 255 })).unwrap();
|
execute!(
|
||||||
|
stdout,
|
||||||
|
SetForegroundColor(Color::Rgb {
|
||||||
|
r: 100,
|
||||||
|
g: 200,
|
||||||
|
b: 255
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
print!("{}", SPINNER[i % SPINNER.len()]);
|
print!("{}", SPINNER[i % SPINNER.len()]);
|
||||||
io::stdout().flush().unwrap();
|
io::stdout().flush().unwrap();
|
||||||
sleep(Duration::from_millis(80)).await;
|
sleep(Duration::from_millis(80)).await;
|
||||||
execute!(stdout, MoveTo(14, 8)).unwrap();
|
execute!(stdout, MoveTo(14, 8)).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute!(stdout, ResetColor).unwrap();
|
execute!(stdout, ResetColor).unwrap();
|
||||||
println!(" ✓");
|
println!(" ✓");
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
execute!(stdout, Show, ResetColor).unwrap();
|
execute!(stdout, Show, ResetColor).unwrap();
|
||||||
println!("\n✨ Demo complete! ✨");
|
println!("\n✨ Demo complete! ✨");
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
body::Body,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
body::Body,
|
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
@ -33,7 +33,10 @@ pub fn generate_token() -> String {
|
||||||
|
|
||||||
/// Path to the token file for an agent.
|
/// Path to the token file for an agent.
|
||||||
pub fn token_path(server_data_dir: &std::path::Path, agent_id: &str) -> PathBuf {
|
pub fn token_path(server_data_dir: &std::path::Path, agent_id: &str) -> PathBuf {
|
||||||
server_data_dir.join("agents").join(agent_id).join("api_token")
|
server_data_dir
|
||||||
|
.join("agents")
|
||||||
|
.join(agent_id)
|
||||||
|
.join("api_token")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the token from disk. Errors if the file is missing or unreadable.
|
/// Read the token from disk. Errors if the file is missing or unreadable.
|
||||||
|
|
@ -226,7 +229,9 @@ pub async fn require_conversation_token(
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let agent_id = {
|
let agent_id = {
|
||||||
let session = server.sessions.get(&conversation_id)
|
let session = server
|
||||||
|
.sessions
|
||||||
|
.get(&conversation_id)
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
session.agent_id.clone()
|
session.agent_id.clone()
|
||||||
};
|
};
|
||||||
|
|
@ -284,7 +289,9 @@ mod tests {
|
||||||
async fn write_then_read_roundtrip() {
|
async fn write_then_read_roundtrip() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let agent_id = "test-agent";
|
let agent_id = "test-agent";
|
||||||
write_token(dir.path(), agent_id, "souv_abc123").await.unwrap();
|
write_token(dir.path(), agent_id, "souv_abc123")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let got = read_token(dir.path(), agent_id).await.unwrap();
|
let got = read_token(dir.path(), agent_id).await.unwrap();
|
||||||
assert_eq!(got, "souv_abc123");
|
assert_eq!(got, "souv_abc123");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
use crate::api::models::*;
|
use crate::api::models::*;
|
||||||
use crate::server::SouveraineServer;
|
use crate::server::SouveraineServer;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State, WebSocketUpgrade, ws::WebSocket},
|
|
||||||
response::{Json, Sse},
|
|
||||||
http::StatusCode,
|
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
|
extract::{ws::WebSocket, Path, Query, State, WebSocketUpgrade},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Json, Sse},
|
||||||
};
|
};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -18,11 +18,15 @@ pub async fn list_agents(
|
||||||
Query(filters): Query<AgentFilters>,
|
Query(filters): Query<AgentFilters>,
|
||||||
) -> Result<Json<Vec<AgentSummary>>, ApiError> {
|
) -> Result<Json<Vec<AgentSummary>>, ApiError> {
|
||||||
let filter = filters.name.or(filters.tags);
|
let filter = filters.name.or(filters.tags);
|
||||||
let agents = server.agents.list(filter).await
|
let agents = server.agents.list(filter).await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
(
|
||||||
error: "list_failed".to_string(),
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
message: e.to_string(),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "list_failed".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(Json(agents))
|
Ok(Json(agents))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,11 +34,15 @@ pub async fn create_agent(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Json(request): Json<CreateAgentRequest>,
|
Json(request): Json<CreateAgentRequest>,
|
||||||
) -> Result<(StatusCode, Json<AgentState>), ApiError> {
|
) -> Result<(StatusCode, Json<AgentState>), ApiError> {
|
||||||
let agent = server.agents.create(request).await
|
let agent = server.agents.create(request).await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
(
|
||||||
error: "creation_failed".to_string(),
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
message: e.to_string(),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "creation_failed".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok((StatusCode::CREATED, Json(agent)))
|
Ok((StatusCode::CREATED, Json(agent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,17 +50,27 @@ pub async fn get_agent(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<AgentState>, ApiError> {
|
) -> Result<Json<AgentState>, ApiError> {
|
||||||
let agent = server.agents.get(&id).await
|
let agent =
|
||||||
.map_err(|e| match e.to_string().contains("not found") {
|
server
|
||||||
true => (StatusCode::NOT_FOUND, Json(ErrorResponse {
|
.agents
|
||||||
error: "agent_not_found".to_string(),
|
.get(&id)
|
||||||
message: format!("Agent {} not found", id),
|
.await
|
||||||
})),
|
.map_err(|e| match e.to_string().contains("not found") {
|
||||||
false => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
true => (
|
||||||
error: "fetch_failed".to_string(),
|
StatusCode::NOT_FOUND,
|
||||||
message: e.to_string(),
|
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))
|
Ok(Json(agent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,11 +79,15 @@ pub async fn update_agent(
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(updates): Json<UpdateAgentRequest>,
|
Json(updates): Json<UpdateAgentRequest>,
|
||||||
) -> Result<Json<AgentState>, ApiError> {
|
) -> Result<Json<AgentState>, ApiError> {
|
||||||
let agent = server.agents.update(&id, updates).await
|
let agent = server.agents.update(&id, updates).await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
(
|
||||||
error: "update_failed".to_string(),
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
message: e.to_string(),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "update_failed".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(Json(agent))
|
Ok(Json(agent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,11 +95,15 @@ pub async fn delete_agent(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
server.agents.delete(&id).await
|
server.agents.delete(&id).await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
(
|
||||||
error: "delete_failed".to_string(),
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
message: e.to_string(),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "delete_failed".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,21 +155,32 @@ pub async fn create_conversation(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Json(request): Json<CreateConversationRequest>,
|
Json(request): Json<CreateConversationRequest>,
|
||||||
) -> Result<(StatusCode, Json<Conversation>), ApiError> {
|
) -> Result<(StatusCode, Json<Conversation>), ApiError> {
|
||||||
let _ = server.agents.get(&request.agent_id).await
|
let _ = server.agents.get(&request.agent_id).await.map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::NOT_FOUND, Json(ErrorResponse {
|
(
|
||||||
error: "agent_not_found".to_string(),
|
StatusCode::NOT_FOUND,
|
||||||
message: e.to_string(),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "agent_not_found".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let conversation_id = server.sessions.create(&request.agent_id);
|
let conversation_id = server.sessions.create(&request.agent_id);
|
||||||
|
|
||||||
// Seed the full system prompt (constitution, base memories, skills) —
|
// Seed the full system prompt (constitution, base memories, skills) —
|
||||||
// without this the agent boots amnesiac on shell-created conversations.
|
// without this the agent boots amnesiac on shell-created conversations.
|
||||||
server.seed_conversation_system_prompt(&request.agent_id, &conversation_id).await
|
server
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
.seed_conversation_system_prompt(&request.agent_id, &conversation_id)
|
||||||
error: "system_prompt_seed_failed".to_string(),
|
.await
|
||||||
message: e.to_string(),
|
.map_err(|e| {
|
||||||
})))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "system_prompt_seed_failed".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let conversation = Conversation {
|
let conversation = Conversation {
|
||||||
id: conversation_id,
|
id: conversation_id,
|
||||||
|
|
@ -159,11 +196,15 @@ pub async fn get_conversation(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Conversation>, ApiError> {
|
) -> Result<Json<Conversation>, ApiError> {
|
||||||
let session = server.sessions.get(&id)
|
let session = server.sessions.get(&id).ok_or_else(|| {
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse {
|
(
|
||||||
error: "conversation_not_found".to_string(),
|
StatusCode::NOT_FOUND,
|
||||||
message: format!("Conversation {} not found", id),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "conversation_not_found".to_string(),
|
||||||
|
message: format!("Conversation {} not found", id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let conversation = Conversation {
|
let conversation = Conversation {
|
||||||
id: session.conversation_id.clone(),
|
id: session.conversation_id.clone(),
|
||||||
|
|
@ -179,17 +220,29 @@ pub async fn stream_messages(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path(conversation_id): Path<String>,
|
Path(conversation_id): Path<String>,
|
||||||
Json(request): Json<SendMessageRequest>,
|
Json(request): Json<SendMessageRequest>,
|
||||||
) -> Result<Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, ApiError> {
|
) -> Result<
|
||||||
let _ = server.sessions.get(&conversation_id)
|
Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>,
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse {
|
ApiError,
|
||||||
error: "conversation_not_found".to_string(),
|
> {
|
||||||
message: format!("Conversation {} not found", conversation_id),
|
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
|
// Ambient context first — the room she is being spoken to in. A system
|
||||||
// note, same register as interjections, so it reads as perception rather
|
// note, same register as interjections, so it reads as perception rather
|
||||||
// than instruction.
|
// than instruction.
|
||||||
if let Some(ambient) = request.ambient.as_deref().map(str::trim).filter(|a| !a.is_empty()) {
|
if let Some(ambient) = request
|
||||||
|
.ambient
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|a| !a.is_empty())
|
||||||
|
{
|
||||||
let stamp = chrono::Local::now().format("%H:%M");
|
let stamp = chrono::Local::now().format("%H:%M");
|
||||||
let note = crate::core::session::ConversationMessage {
|
let note = crate::core::session::ConversationMessage {
|
||||||
role: crate::core::session::MessageRole::System,
|
role: crate::core::session::MessageRole::System,
|
||||||
|
|
@ -199,21 +252,35 @@ pub async fn stream_messages(
|
||||||
usage: None,
|
usage: None,
|
||||||
timestamp: Some(chrono::Utc::now()),
|
timestamp: Some(chrono::Utc::now()),
|
||||||
};
|
};
|
||||||
server.sessions.add_message(&conversation_id, note)
|
server
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
.sessions
|
||||||
error: "message_store_failed".to_string(),
|
.add_message(&conversation_id, note)
|
||||||
message: e.to_string(),
|
.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
|
// Convert API messages to ConversationMessages and add to session
|
||||||
for msg in &request.messages {
|
for msg in &request.messages {
|
||||||
let conv_msg = msg.to_conversation_message();
|
let conv_msg = msg.to_conversation_message();
|
||||||
server.sessions.add_message(&conversation_id, conv_msg)
|
server
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
.sessions
|
||||||
error: "message_store_failed".to_string(),
|
.add_message(&conversation_id, conv_msg)
|
||||||
message: e.to_string(),
|
.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);
|
let (tx, rx) = mpsc::channel(100);
|
||||||
|
|
@ -249,7 +316,8 @@ async fn handle_conversation_stream(
|
||||||
|
|
||||||
// Turn backchannel: fresh cancel token each turn, persistent
|
// Turn backchannel: fresh cancel token each turn, persistent
|
||||||
// interjection queue. POST .../cancel and .../interject reach in here.
|
// interjection queue. POST .../cancel and .../interject reach in here.
|
||||||
let signals = server.turn_signals
|
let signals = server
|
||||||
|
.turn_signals
|
||||||
.entry(conversation_id.clone())
|
.entry(conversation_id.clone())
|
||||||
.or_default();
|
.or_default();
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
|
|
@ -262,10 +330,8 @@ async fn handle_conversation_stream(
|
||||||
let conv = conversation_id.clone();
|
let conv = conversation_id.clone();
|
||||||
|
|
||||||
let turn = tokio::spawn(async move {
|
let turn = tokio::spawn(async move {
|
||||||
crate::server::turn::run_turn(
|
crate::server::turn::run_turn(server_clone, conv, &be_tx, event_bus, cancel, interject)
|
||||||
server_clone, conv, &be_tx, event_bus,
|
.await
|
||||||
cancel, interject,
|
|
||||||
).await
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drain the turn's BackendEvent stream. run_turn already handles message
|
// Drain the turn's BackendEvent stream. run_turn already handles message
|
||||||
|
|
@ -278,7 +344,11 @@ async fn handle_conversation_stream(
|
||||||
Ok(be) => be,
|
Ok(be) => be,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Turn stream error ({conversation_id}): {e:#}");
|
eprintln!("Turn stream error ({conversation_id}): {e:#}");
|
||||||
let _ = tx.send(StreamEvent::Error { message: format!("{e:#}") }).await;
|
let _ = tx
|
||||||
|
.send(StreamEvent::Error {
|
||||||
|
message: format!("{e:#}"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -291,12 +361,15 @@ async fn handle_conversation_stream(
|
||||||
// silently-ended stream (an empty reply reads as the agent going mute).
|
// silently-ended stream (an empty reply reads as the agent going mute).
|
||||||
if let Ok(Err(e)) = turn.await {
|
if let Ok(Err(e)) = turn.await {
|
||||||
eprintln!("Turn failed ({conversation_id}): {e:#}");
|
eprintln!("Turn failed ({conversation_id}): {e:#}");
|
||||||
let _ = tx.send(StreamEvent::Error { message: format!("{e:#}") }).await;
|
let _ = tx
|
||||||
|
.send(StreamEvent::Error {
|
||||||
|
message: format!("{e:#}"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// GET /v1/conversations/:id/messages — full transcript backfill for resume.
|
/// GET /v1/conversations/:id/messages — full transcript backfill for resume.
|
||||||
/// Returns the session's `ConversationMessage`s verbatim; `RemoteBackend`
|
/// Returns the session's `ConversationMessage`s verbatim; `RemoteBackend`
|
||||||
/// and any surface use this to restore a conversation after restart.
|
/// and any surface use this to restore a conversation after restart.
|
||||||
|
|
@ -304,11 +377,15 @@ pub async fn get_conversation_messages(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Vec<crate::core::session::ConversationMessage>>, ApiError> {
|
) -> Result<Json<Vec<crate::core::session::ConversationMessage>>, ApiError> {
|
||||||
let session = server.sessions.get(&id)
|
let session = server.sessions.get(&id).ok_or_else(|| {
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse {
|
(
|
||||||
error: "conversation_not_found".to_string(),
|
StatusCode::NOT_FOUND,
|
||||||
message: format!("Conversation {} not found", id),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "conversation_not_found".to_string(),
|
||||||
|
message: format!("Conversation {} not found", id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(Json(session.messages.clone()))
|
Ok(Json(session.messages.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,22 +395,33 @@ pub async fn fork_conversation(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<(StatusCode, Json<Conversation>), ApiError> {
|
) -> Result<(StatusCode, Json<Conversation>), ApiError> {
|
||||||
let forked_id = server.sessions.fork(&id)
|
let forked_id = server.sessions.fork(&id).map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::NOT_FOUND, Json(ErrorResponse {
|
(
|
||||||
error: "fork_failed".to_string(),
|
StatusCode::NOT_FOUND,
|
||||||
message: e.to_string(),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "fork_failed".to_string(),
|
||||||
let session = server.sessions.get(&forked_id)
|
message: e.to_string(),
|
||||||
.ok_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
}),
|
||||||
error: "fork_failed".to_string(),
|
)
|
||||||
message: "forked session vanished".to_string(),
|
})?;
|
||||||
})))?;
|
let session = server.sessions.get(&forked_id).ok_or_else(|| {
|
||||||
Ok((StatusCode::CREATED, Json(Conversation {
|
(
|
||||||
id: forked_id.clone(),
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
agent_id: session.agent_id.clone(),
|
Json(ErrorResponse {
|
||||||
created_at: session.created_at,
|
error: "fork_failed".to_string(),
|
||||||
updated_at: Some(session.updated_at),
|
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.
|
/// POST /v1/conversations/:id/cancel — interrupt the turn in flight.
|
||||||
|
|
@ -349,10 +437,13 @@ pub async fn cancel_turn(
|
||||||
signals.cancel.lock().unwrap().cancel();
|
signals.cancel.lock().unwrap().cancel();
|
||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
}
|
}
|
||||||
None => Err((StatusCode::NOT_FOUND, Json(ErrorResponse {
|
None => Err((
|
||||||
error: "no_active_turn".to_string(),
|
StatusCode::NOT_FOUND,
|
||||||
message: format!("No turn signals for conversation {}", id),
|
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>,
|
Json(request): Json<InterjectRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
if request.text.trim().is_empty() {
|
if request.text.trim().is_empty() {
|
||||||
return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse {
|
return Err((
|
||||||
error: "empty_interjection".to_string(),
|
StatusCode::BAD_REQUEST,
|
||||||
message: "text must be non-empty".to_string(),
|
Json(ErrorResponse {
|
||||||
})));
|
error: "empty_interjection".to_string(),
|
||||||
|
message: "text must be non-empty".to_string(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let signals = server.turn_signals.entry(id).or_default();
|
let signals = server.turn_signals.entry(id).or_default();
|
||||||
signals.interject.lock().unwrap().push(request.text);
|
signals.interject.lock().unwrap().push(request.text);
|
||||||
|
|
@ -385,10 +479,13 @@ pub async fn interject(
|
||||||
// DELETE /v1/agents/:id/memory/*path — delete file
|
// DELETE /v1/agents/:id/memory/*path — delete file
|
||||||
|
|
||||||
fn memory_err(status: StatusCode, kind: &str, e: impl ToString) -> ApiError {
|
fn memory_err(status: StatusCode, kind: &str, e: impl ToString) -> ApiError {
|
||||||
(status, Json(ErrorResponse {
|
(
|
||||||
error: kind.to_string(),
|
status,
|
||||||
message: e.to_string(),
|
Json(ErrorResponse {
|
||||||
}))
|
error: kind.to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
|
|
@ -401,10 +498,15 @@ pub async fn list_memory(
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
Query(q): Query<ListMemoryQuery>,
|
Query(q): Query<ListMemoryQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let _ = server.agents.get(&agent_id).await
|
let _ = server
|
||||||
|
.agents
|
||||||
|
.get(&agent_id)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
||||||
let repo = server.agents.memory_repo(&agent_id);
|
let repo = server.agents.memory_repo(&agent_id);
|
||||||
let entries = repo.list(q.prefix.as_deref()).await
|
let entries = repo
|
||||||
|
.list(q.prefix.as_deref())
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::INTERNAL_SERVER_ERROR, "list_failed", e))?;
|
.map_err(|e| memory_err(StatusCode::INTERNAL_SERVER_ERROR, "list_failed", e))?;
|
||||||
Ok(Json(serde_json::json!({ "entries": entries })))
|
Ok(Json(serde_json::json!({ "entries": entries })))
|
||||||
}
|
}
|
||||||
|
|
@ -413,10 +515,15 @@ pub async fn read_memory(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path((agent_id, path)): Path<(String, String)>,
|
Path((agent_id, path)): Path<(String, String)>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let _ = server.agents.get(&agent_id).await
|
let _ = server
|
||||||
|
.agents
|
||||||
|
.get(&agent_id)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
||||||
let repo = server.agents.memory_repo(&agent_id);
|
let repo = server.agents.memory_repo(&agent_id);
|
||||||
let file = repo.read(&path).await
|
let file = repo
|
||||||
|
.read(&path)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "memory_not_found", e))?;
|
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "memory_not_found", e))?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"path": path,
|
"path": path,
|
||||||
|
|
@ -434,12 +541,16 @@ pub async fn write_memory(
|
||||||
Path((agent_id, path)): Path<(String, String)>,
|
Path((agent_id, path)): Path<(String, String)>,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let _ = server.agents.get(&agent_id).await
|
let _ = server
|
||||||
|
.agents
|
||||||
|
.get(&agent_id)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
||||||
let body_str = std::str::from_utf8(&body)
|
let body_str = std::str::from_utf8(&body)
|
||||||
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?;
|
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?;
|
||||||
let repo = server.agents.memory_repo(&agent_id);
|
let repo = server.agents.memory_repo(&agent_id);
|
||||||
repo.write(&path, body_str).await
|
repo.write(&path, body_str)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "write_failed", e))?;
|
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "write_failed", e))?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
@ -449,12 +560,16 @@ pub async fn append_memory(
|
||||||
Path((agent_id, path)): Path<(String, String)>,
|
Path((agent_id, path)): Path<(String, String)>,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let _ = server.agents.get(&agent_id).await
|
let _ = server
|
||||||
|
.agents
|
||||||
|
.get(&agent_id)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
||||||
let body_str = std::str::from_utf8(&body)
|
let body_str = std::str::from_utf8(&body)
|
||||||
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?;
|
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "invalid_utf8", e))?;
|
||||||
let repo = server.agents.memory_repo(&agent_id);
|
let repo = server.agents.memory_repo(&agent_id);
|
||||||
repo.append(&path, body_str).await
|
repo.append(&path, body_str)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "append_failed", e))?;
|
.map_err(|e| memory_err(StatusCode::BAD_REQUEST, "append_failed", e))?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
@ -463,10 +578,14 @@ pub async fn delete_memory(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path((agent_id, path)): Path<(String, String)>,
|
Path((agent_id, path)): Path<(String, String)>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let _ = server.agents.get(&agent_id).await
|
let _ = server
|
||||||
|
.agents
|
||||||
|
.get(&agent_id)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "agent_not_found", e))?;
|
||||||
let repo = server.agents.memory_repo(&agent_id);
|
let repo = server.agents.memory_repo(&agent_id);
|
||||||
repo.delete(&path).await
|
repo.delete(&path)
|
||||||
|
.await
|
||||||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "delete_failed", e))?;
|
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "delete_failed", e))?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
@ -481,10 +600,7 @@ pub async fn firehose(
|
||||||
ws.on_upgrade(move |socket| firehose_stream(server, socket))
|
ws.on_upgrade(move |socket| firehose_stream(server, socket))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn firehose_stream(
|
async fn firehose_stream(server: Arc<SouveraineServer>, mut socket: WebSocket) {
|
||||||
server: Arc<SouveraineServer>,
|
|
||||||
mut socket: WebSocket,
|
|
||||||
) {
|
|
||||||
use axum::extract::ws::Message;
|
use axum::extract::ws::Message;
|
||||||
|
|
||||||
let mut rx = server.event_bus.subscribe();
|
let mut rx = server.event_bus.subscribe();
|
||||||
|
|
@ -597,11 +713,18 @@ pub async fn update_config(
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("souveraine.toml"));
|
.unwrap_or_else(|| std::path::PathBuf::from("souveraine.toml"));
|
||||||
|
|
||||||
if let Err(e) = new_config.save(&config_path) {
|
if let Err(e) = new_config.save(&config_path) {
|
||||||
tracing::warn!("Failed to persist config to disk at {:?}: {}", config_path, e);
|
tracing::warn!(
|
||||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
|
"Failed to persist config to disk at {:?}: {}",
|
||||||
error: "config_save_failed".to_string(),
|
config_path,
|
||||||
message: e.to_string(),
|
e
|
||||||
})));
|
);
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "config_save_failed".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
tracing::info!("Saved live settings to {:?}", config_path);
|
tracing::info!("Saved live settings to {:?}", config_path);
|
||||||
}
|
}
|
||||||
|
|
@ -633,14 +756,21 @@ pub async fn get_compaction_logs(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
|
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
let sensor = val.get("sensor_name").and_then(|s| s.as_str()).unwrap_or("");
|
let sensor = val
|
||||||
|
.get("sensor_name")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
let ev_type = val.get("event_type").and_then(|t| t.as_str()).unwrap_or("");
|
let ev_type = val.get("event_type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
let content = val.get("payload").and_then(|p| p.get("content").and_then(|c| c.as_str())).unwrap_or("");
|
let content = val
|
||||||
|
.get("payload")
|
||||||
|
.and_then(|p| p.get("content").and_then(|c| c.as_str()))
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
if sensor == "archivist" ||
|
if sensor == "archivist"
|
||||||
ev_type.contains("compaction") ||
|
|| ev_type.contains("compaction")
|
||||||
ev_type.contains("archive") ||
|
|| ev_type.contains("archive")
|
||||||
content.contains("compaction") {
|
|| content.contains("compaction")
|
||||||
|
{
|
||||||
logs.push(val);
|
logs.push(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -658,11 +788,15 @@ pub async fn get_conversation_tokens(
|
||||||
State(server): State<Arc<SouveraineServer>>,
|
State(server): State<Arc<SouveraineServer>>,
|
||||||
Path(conversation_id): Path<String>,
|
Path(conversation_id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let session = server.sessions.get(&conversation_id)
|
let session = server.sessions.get(&conversation_id).ok_or_else(|| {
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse {
|
(
|
||||||
error: "conversation_not_found".to_string(),
|
StatusCode::NOT_FOUND,
|
||||||
message: format!("Conversation {} not found", conversation_id),
|
Json(ErrorResponse {
|
||||||
})))?;
|
error: "conversation_not_found".to_string(),
|
||||||
|
message: format!("Conversation {} not found", conversation_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let counter = crate::bridge::model_router::TokenCounter::new();
|
let counter = crate::bridge::model_router::TokenCounter::new();
|
||||||
|
|
||||||
|
|
@ -675,12 +809,21 @@ pub async fn get_conversation_tokens(
|
||||||
for block in &msg.blocks {
|
for block in &msg.blocks {
|
||||||
let block_text = match block {
|
let block_text = match block {
|
||||||
crate::core::session::ContentBlock::Text { text } => text.clone(),
|
crate::core::session::ContentBlock::Text { text } => text.clone(),
|
||||||
crate::core::session::ContentBlock::ToolUse { id, name, input } => format!("{id} {name} {input}"),
|
crate::core::session::ContentBlock::ToolUse { id, name, input } => {
|
||||||
crate::core::session::ContentBlock::ToolResult { tool_use_id, tool_name, output, .. } => {
|
format!("{id} {name} {input}")
|
||||||
|
}
|
||||||
|
crate::core::session::ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
tool_name,
|
||||||
|
output,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
format!("{tool_use_id} {tool_name} {output}")
|
format!("{tool_use_id} {tool_name} {output}")
|
||||||
}
|
}
|
||||||
crate::core::session::ContentBlock::Reasoning { reasoning } => reasoning.clone(),
|
crate::core::session::ContentBlock::Reasoning { reasoning } => reasoning.clone(),
|
||||||
crate::core::session::ContentBlock::Image { media_type, data } => format!("{media_type} {data}"),
|
crate::core::session::ContentBlock::Image { media_type, data } => {
|
||||||
|
format!("{media_type} {data}")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
msg_tokens += counter.count(&block_text);
|
msg_tokens += counter.count(&block_text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,25 @@ pub mod models;
|
||||||
pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
||||||
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health, firehose).
|
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health, firehose).
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
.route("/v1/agents", get(handlers::list_agents).post(handlers::create_agent))
|
.route(
|
||||||
.route("/v1/conversations", get(handlers::list_conversations).post(handlers::create_conversation))
|
"/v1/agents",
|
||||||
|
get(handlers::list_agents).post(handlers::create_agent),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/conversations",
|
||||||
|
get(handlers::list_conversations).post(handlers::create_conversation),
|
||||||
|
)
|
||||||
.route("/v1/firehose", get(handlers::firehose))
|
.route("/v1/firehose", get(handlers::firehose))
|
||||||
.route("/v1/federation/events", get(handlers::federation_events))
|
.route("/v1/federation/events", get(handlers::federation_events))
|
||||||
.route("/v1/config", get(handlers::get_config).post(handlers::update_config))
|
.route(
|
||||||
|
"/v1/config",
|
||||||
|
get(handlers::get_config).post(handlers::update_config),
|
||||||
|
)
|
||||||
.route("/v1/compaction-logs", get(handlers::get_compaction_logs))
|
.route("/v1/compaction-logs", get(handlers::get_compaction_logs))
|
||||||
.route("/v1/conversations/:id/tokens", get(handlers::get_conversation_tokens))
|
.route(
|
||||||
|
"/v1/conversations/:id/tokens",
|
||||||
|
get(handlers::get_conversation_tokens),
|
||||||
|
)
|
||||||
.route("/health", get(health_check));
|
.route("/health", get(health_check));
|
||||||
|
|
||||||
// Protected agent routes — require per-agent bearer token.
|
// Protected agent routes — require per-agent bearer token.
|
||||||
|
|
@ -43,7 +55,10 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
||||||
"/v1/conversations/:id/messages",
|
"/v1/conversations/:id/messages",
|
||||||
get(handlers::get_conversation_messages).post(handlers::stream_messages),
|
get(handlers::get_conversation_messages).post(handlers::stream_messages),
|
||||||
)
|
)
|
||||||
.route("/v1/conversations/:id/fork", post(handlers::fork_conversation))
|
.route(
|
||||||
|
"/v1/conversations/:id/fork",
|
||||||
|
post(handlers::fork_conversation),
|
||||||
|
)
|
||||||
.route("/v1/conversations/:id/cancel", post(handlers::cancel_turn))
|
.route("/v1/conversations/:id/cancel", post(handlers::cancel_turn))
|
||||||
.route("/v1/conversations/:id/interject", post(handlers::interject))
|
.route("/v1/conversations/:id/interject", post(handlers::interject))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
|
@ -56,10 +71,7 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
||||||
// (memfs HTTP write path for cron-into-memfs and external integration.
|
// (memfs HTTP write path for cron-into-memfs and external integration.
|
||||||
// See docs/MEMORY_BLOCKS_DECISION.md.)
|
// See docs/MEMORY_BLOCKS_DECISION.md.)
|
||||||
let memory_routes = Router::new()
|
let memory_routes = Router::new()
|
||||||
.route(
|
.route("/v1/agents/:id/memory", get(handlers::list_memory))
|
||||||
"/v1/agents/:id/memory",
|
|
||||||
get(handlers::list_memory),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/v1/agents/:id/memory/*path",
|
"/v1/agents/:id/memory/*path",
|
||||||
get(handlers::read_memory)
|
get(handlers::read_memory)
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,12 @@ pub struct LlmConfig {
|
||||||
pub checkpoint_interval: u32,
|
pub checkpoint_interval: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_supports_images() -> bool { true }
|
fn default_supports_images() -> bool {
|
||||||
fn default_checkpoint_interval() -> u32 { 10 }
|
true
|
||||||
|
}
|
||||||
|
fn default_checkpoint_interval() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
fn default_context_window() -> u32 {
|
fn default_context_window() -> u32 {
|
||||||
128000
|
128000
|
||||||
|
|
@ -200,7 +204,7 @@ pub struct Message {
|
||||||
impl Message {
|
impl Message {
|
||||||
/// Convert API Message to internal ConversationMessage
|
/// Convert API Message to internal ConversationMessage
|
||||||
pub fn to_conversation_message(&self) -> crate::core::session::ConversationMessage {
|
pub fn to_conversation_message(&self) -> crate::core::session::ConversationMessage {
|
||||||
use crate::core::session::{ConversationMessage, MessageRole, ContentBlock};
|
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
||||||
|
|
||||||
let role = match self.role.as_str() {
|
let role = match self.role.as_str() {
|
||||||
"system" => MessageRole::System,
|
"system" => MessageRole::System,
|
||||||
|
|
@ -212,7 +216,9 @@ impl Message {
|
||||||
|
|
||||||
ConversationMessage {
|
ConversationMessage {
|
||||||
role,
|
role,
|
||||||
blocks: vec![ContentBlock::Text { text: self.content.clone() }],
|
blocks: vec![ContentBlock::Text {
|
||||||
|
text: self.content.clone(),
|
||||||
|
}],
|
||||||
usage: None,
|
usage: None,
|
||||||
timestamp: Some(chrono::Utc::now()),
|
timestamp: Some(chrono::Utc::now()),
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +277,11 @@ pub enum StreamEvent {
|
||||||
#[serde(rename = "tool_return_message")]
|
#[serde(rename = "tool_return_message")]
|
||||||
ToolReturnMessage { tool_return: ToolReturn },
|
ToolReturnMessage { tool_return: ToolReturn },
|
||||||
#[serde(rename = "souveraine_surfacing")]
|
#[serde(rename = "souveraine_surfacing")]
|
||||||
Surfacing { source: String, content: String, priority: String },
|
Surfacing {
|
||||||
|
source: String,
|
||||||
|
content: String,
|
||||||
|
priority: String,
|
||||||
|
},
|
||||||
#[serde(rename = "souveraine_reflection")]
|
#[serde(rename = "souveraine_reflection")]
|
||||||
Reflection { content: String },
|
Reflection { content: String },
|
||||||
#[serde(rename = "souveraine_archivist")]
|
#[serde(rename = "souveraine_archivist")]
|
||||||
|
|
@ -281,7 +291,11 @@ pub enum StreamEvent {
|
||||||
#[serde(rename = "context_pressure")]
|
#[serde(rename = "context_pressure")]
|
||||||
ContextPressure { pressure: f32, tokens: usize },
|
ContextPressure { pressure: f32, tokens: usize },
|
||||||
#[serde(rename = "inference_strain")]
|
#[serde(rename = "inference_strain")]
|
||||||
InferenceStrain { attempt: u32, status: u16, model: String },
|
InferenceStrain {
|
||||||
|
attempt: u32,
|
||||||
|
status: u16,
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
#[serde(rename = "schedule_active")]
|
#[serde(rename = "schedule_active")]
|
||||||
ScheduleActive { name: String },
|
ScheduleActive { name: String },
|
||||||
#[serde(rename = "schedule_complete")]
|
#[serde(rename = "schedule_complete")]
|
||||||
|
|
@ -291,7 +305,11 @@ pub enum StreamEvent {
|
||||||
#[serde(rename = "subconscious_tool_call")]
|
#[serde(rename = "subconscious_tool_call")]
|
||||||
SubconsciousToolCall { name: String, arguments: String },
|
SubconsciousToolCall { name: String, arguments: String },
|
||||||
#[serde(rename = "subconscious_tool_result")]
|
#[serde(rename = "subconscious_tool_result")]
|
||||||
SubconsciousToolResult { name: String, output: String, is_error: bool },
|
SubconsciousToolResult {
|
||||||
|
name: String,
|
||||||
|
output: String,
|
||||||
|
is_error: bool,
|
||||||
|
},
|
||||||
#[serde(rename = "subconscious_halt")]
|
#[serde(rename = "subconscious_halt")]
|
||||||
SubconsciousHalt { reason: String, severity: String },
|
SubconsciousHalt { reason: String, severity: String },
|
||||||
#[serde(rename = "subconscious_pass")]
|
#[serde(rename = "subconscious_pass")]
|
||||||
|
|
@ -303,7 +321,10 @@ pub enum StreamEvent {
|
||||||
#[serde(rename = "outfit")]
|
#[serde(rename = "outfit")]
|
||||||
Outfit { name: String },
|
Outfit { name: String },
|
||||||
#[serde(rename = "interstitial")]
|
#[serde(rename = "interstitial")]
|
||||||
Interstitial { text: String, register: crate::backend::Register },
|
Interstitial {
|
||||||
|
text: String,
|
||||||
|
register: crate::backend::Register,
|
||||||
|
},
|
||||||
#[serde(rename = "primary_complete")]
|
#[serde(rename = "primary_complete")]
|
||||||
PrimaryComplete,
|
PrimaryComplete,
|
||||||
/// The turn failed in the substrate. Mirrors BackendEvent::Error so a
|
/// The turn failed in the substrate. Mirrors BackendEvent::Error so a
|
||||||
|
|
@ -354,32 +375,81 @@ impl From<crate::backend::BackendEvent> for StreamEvent {
|
||||||
match be {
|
match be {
|
||||||
BE::Token(content) => Self::AssistantMessage { content },
|
BE::Token(content) => Self::AssistantMessage { content },
|
||||||
BE::Reasoning(content) => Self::ReasoningMessage { content },
|
BE::Reasoning(content) => Self::ReasoningMessage { content },
|
||||||
BE::Surfacing { source, content, priority } => Self::Surfacing { source, content, priority },
|
BE::Surfacing {
|
||||||
|
source,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
} => Self::Surfacing {
|
||||||
|
source,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
},
|
||||||
BE::Reflection(content) => Self::Reflection { content },
|
BE::Reflection(content) => Self::Reflection { content },
|
||||||
BE::Archivist { synthesis, pressure } => Self::Archivist { synthesis, pressure },
|
BE::Archivist {
|
||||||
|
synthesis,
|
||||||
|
pressure,
|
||||||
|
} => Self::Archivist {
|
||||||
|
synthesis,
|
||||||
|
pressure,
|
||||||
|
},
|
||||||
BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier },
|
BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier },
|
||||||
BE::ContextPressure(pressure, tokens) => Self::ContextPressure { pressure, tokens },
|
BE::ContextPressure(pressure, tokens) => Self::ContextPressure { pressure, tokens },
|
||||||
BE::InferenceStrain { attempt, status, model } => Self::InferenceStrain { attempt, status, model },
|
BE::InferenceStrain {
|
||||||
|
attempt,
|
||||||
|
status,
|
||||||
|
model,
|
||||||
|
} => Self::InferenceStrain {
|
||||||
|
attempt,
|
||||||
|
status,
|
||||||
|
model,
|
||||||
|
},
|
||||||
BE::ScheduleActive { name } => Self::ScheduleActive { name },
|
BE::ScheduleActive { name } => Self::ScheduleActive { name },
|
||||||
BE::ScheduleComplete { name, silent } => Self::ScheduleComplete { name, silent },
|
BE::ScheduleComplete { name, silent } => Self::ScheduleComplete { name, silent },
|
||||||
BE::ToolCall { id, name, arguments, round } => Self::ToolCallMessage {
|
BE::ToolCall {
|
||||||
tool_call: ToolCall { id, function: ToolFunction { name, arguments } },
|
id,
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
round,
|
||||||
|
} => Self::ToolCallMessage {
|
||||||
|
tool_call: ToolCall {
|
||||||
|
id,
|
||||||
|
function: ToolFunction { name, arguments },
|
||||||
|
},
|
||||||
round,
|
round,
|
||||||
},
|
},
|
||||||
BE::ToolResult { id, name, output, is_error } => Self::ToolReturnMessage {
|
BE::ToolResult {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => Self::ToolReturnMessage {
|
||||||
tool_return: ToolReturn {
|
tool_return: ToolReturn {
|
||||||
status: if is_error { "error".into() } else { "success".into() },
|
status: if is_error {
|
||||||
|
"error".into()
|
||||||
|
} else {
|
||||||
|
"success".into()
|
||||||
|
},
|
||||||
output,
|
output,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
BE::SubconsciousToken(content) => Self::SubconsciousToken { content },
|
BE::SubconsciousToken(content) => Self::SubconsciousToken { content },
|
||||||
BE::SubconsciousToolCall { name, arguments } => Self::SubconsciousToolCall { name, arguments },
|
BE::SubconsciousToolCall { name, arguments } => {
|
||||||
BE::SubconsciousToolResult { name, output, is_error } => {
|
Self::SubconsciousToolCall { name, arguments }
|
||||||
Self::SubconsciousToolResult { name, output, is_error }
|
}
|
||||||
|
BE::SubconsciousToolResult {
|
||||||
|
name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => Self::SubconsciousToolResult {
|
||||||
|
name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
},
|
||||||
|
BE::SubconsciousHalt { reason, severity } => {
|
||||||
|
Self::SubconsciousHalt { reason, severity }
|
||||||
}
|
}
|
||||||
BE::SubconsciousHalt { reason, severity } => Self::SubconsciousHalt { reason, severity },
|
|
||||||
BE::SubconsciousPass(active) => Self::SubconsciousPass { active },
|
BE::SubconsciousPass(active) => Self::SubconsciousPass { active },
|
||||||
BE::Atmosphere(preset) => Self::Atmosphere { preset },
|
BE::Atmosphere(preset) => Self::Atmosphere { preset },
|
||||||
BE::Itinerary(route) => Self::Itinerary { route },
|
BE::Itinerary(route) => Self::Itinerary { route },
|
||||||
|
|
@ -399,12 +469,38 @@ impl From<StreamEvent> for crate::backend::BackendEvent {
|
||||||
match se {
|
match se {
|
||||||
StreamEvent::AssistantMessage { content } => BE::Token(content),
|
StreamEvent::AssistantMessage { content } => BE::Token(content),
|
||||||
StreamEvent::ReasoningMessage { content } => BE::Reasoning(content),
|
StreamEvent::ReasoningMessage { content } => BE::Reasoning(content),
|
||||||
StreamEvent::Surfacing { source, content, priority } => BE::Surfacing { source, content, priority },
|
StreamEvent::Surfacing {
|
||||||
|
source,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
} => BE::Surfacing {
|
||||||
|
source,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
},
|
||||||
StreamEvent::Reflection { content } => BE::Reflection(content),
|
StreamEvent::Reflection { content } => BE::Reflection(content),
|
||||||
StreamEvent::Archivist { synthesis, pressure } => BE::Archivist { synthesis, pressure },
|
StreamEvent::Archivist {
|
||||||
StreamEvent::CompactionWarning { pressure, tier } => BE::CompactionWarning { pressure, tier },
|
synthesis,
|
||||||
StreamEvent::ContextPressure { pressure, tokens } => BE::ContextPressure(pressure, tokens),
|
pressure,
|
||||||
StreamEvent::InferenceStrain { attempt, status, model } => BE::InferenceStrain { attempt, status, model },
|
} => BE::Archivist {
|
||||||
|
synthesis,
|
||||||
|
pressure,
|
||||||
|
},
|
||||||
|
StreamEvent::CompactionWarning { pressure, tier } => {
|
||||||
|
BE::CompactionWarning { pressure, tier }
|
||||||
|
}
|
||||||
|
StreamEvent::ContextPressure { pressure, tokens } => {
|
||||||
|
BE::ContextPressure(pressure, tokens)
|
||||||
|
}
|
||||||
|
StreamEvent::InferenceStrain {
|
||||||
|
attempt,
|
||||||
|
status,
|
||||||
|
model,
|
||||||
|
} => BE::InferenceStrain {
|
||||||
|
attempt,
|
||||||
|
status,
|
||||||
|
model,
|
||||||
|
},
|
||||||
StreamEvent::ScheduleActive { name } => BE::ScheduleActive { name },
|
StreamEvent::ScheduleActive { name } => BE::ScheduleActive { name },
|
||||||
StreamEvent::ScheduleComplete { name, silent } => BE::ScheduleComplete { name, silent },
|
StreamEvent::ScheduleComplete { name, silent } => BE::ScheduleComplete { name, silent },
|
||||||
StreamEvent::ToolCallMessage { tool_call, round } => BE::ToolCall {
|
StreamEvent::ToolCallMessage { tool_call, round } => BE::ToolCall {
|
||||||
|
|
@ -420,11 +516,21 @@ impl From<StreamEvent> for crate::backend::BackendEvent {
|
||||||
output: tool_return.output,
|
output: tool_return.output,
|
||||||
},
|
},
|
||||||
StreamEvent::SubconsciousToken { content } => BE::SubconsciousToken(content),
|
StreamEvent::SubconsciousToken { content } => BE::SubconsciousToken(content),
|
||||||
StreamEvent::SubconsciousToolCall { name, arguments } => BE::SubconsciousToolCall { name, arguments },
|
StreamEvent::SubconsciousToolCall { name, arguments } => {
|
||||||
StreamEvent::SubconsciousToolResult { name, output, is_error } => {
|
BE::SubconsciousToolCall { name, arguments }
|
||||||
BE::SubconsciousToolResult { name, output, is_error }
|
}
|
||||||
|
StreamEvent::SubconsciousToolResult {
|
||||||
|
name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => BE::SubconsciousToolResult {
|
||||||
|
name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
},
|
||||||
|
StreamEvent::SubconsciousHalt { reason, severity } => {
|
||||||
|
BE::SubconsciousHalt { reason, severity }
|
||||||
}
|
}
|
||||||
StreamEvent::SubconsciousHalt { reason, severity } => BE::SubconsciousHalt { reason, severity },
|
|
||||||
StreamEvent::SubconsciousPass { active } => BE::SubconsciousPass(active),
|
StreamEvent::SubconsciousPass { active } => BE::SubconsciousPass(active),
|
||||||
StreamEvent::Atmosphere { preset } => BE::Atmosphere(preset),
|
StreamEvent::Atmosphere { preset } => BE::Atmosphere(preset),
|
||||||
StreamEvent::Itinerary { route } => BE::Itinerary(route),
|
StreamEvent::Itinerary { route } => BE::Itinerary(route),
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
|
||||||
/// if the agent has none), append the scheduled prompt as a user
|
/// if the agent has none), append the scheduled prompt as a user
|
||||||
/// message, and drain the resulting stream — the turn runs silently
|
/// message, and drain the resulting stream — the turn runs silently
|
||||||
/// in the background. Anything subconscious surfaces lands in the inbox.
|
/// in the background. Anything subconscious surfaces lands in the inbox.
|
||||||
async fn inject_background_turn(
|
async fn inject_background_turn(&self, agent_id: &str, text: &str) -> anyhow::Result<()> {
|
||||||
&self,
|
let conv_id = match self
|
||||||
agent_id: &str,
|
.server
|
||||||
text: &str,
|
.sessions
|
||||||
) -> anyhow::Result<()> {
|
.list_for_agent(agent_id)
|
||||||
let conv_id = match self.server.sessions.list_for_agent(agent_id).last().cloned() {
|
.last()
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => self.ensure_conversation(agent_id).await?,
|
None => self.ensure_conversation(agent_id).await?,
|
||||||
};
|
};
|
||||||
|
|
@ -39,7 +41,11 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
|
||||||
let mut stashed: Vec<PendingSurfacing> = Vec::new();
|
let mut stashed: Vec<PendingSurfacing> = Vec::new();
|
||||||
while let Some(ev) = s.next().await {
|
while let Some(ev) = s.next().await {
|
||||||
match ev {
|
match ev {
|
||||||
Ok(BackendEvent::Surfacing { source, content, priority }) => {
|
Ok(BackendEvent::Surfacing {
|
||||||
|
source,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
}) => {
|
||||||
// Skip the no-op heartbeat sentinel — the subconscious
|
// Skip the no-op heartbeat sentinel — the subconscious
|
||||||
// always queues a low "pass complete, no anomalies"
|
// always queues a low "pass complete, no anomalies"
|
||||||
// item so the UI shows the pass ran. That is noise to
|
// item so the UI shows the pass ran. That is noise to
|
||||||
|
|
@ -86,9 +92,7 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
|
||||||
count = stashed.len(),
|
count = stashed.len(),
|
||||||
"stashed heartbeat surfacings for next session"
|
"stashed heartbeat surfacings for next session"
|
||||||
),
|
),
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!("pending heartbeat surfacings stash failed: {}", e),
|
||||||
"pending heartbeat surfacings stash failed: {}", e
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -175,7 +179,10 @@ pub(super) async fn drain_intrusive_surfacings(
|
||||||
let items = match inbox.get_intrusive().await {
|
let items = match inbox.get_intrusive().await {
|
||||||
Ok(items) => items,
|
Ok(items) => items,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("intrusive surfacing read failed (continuing without): {}", e);
|
tracing::debug!(
|
||||||
|
"intrusive surfacing read failed (continuing without): {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,14 @@ pub(crate) mod consciousness;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::stream::{BoxStream, StreamExt};
|
use futures::stream::{BoxStream, StreamExt};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::core::session::{ContentBlock, ConversationMessage, ImageAttachment};
|
|
||||||
use crate::core::config::ConsciousnessConfig;
|
use crate::core::config::ConsciousnessConfig;
|
||||||
|
use crate::core::session::{ContentBlock, ConversationMessage, ImageAttachment};
|
||||||
use crate::server::SouveraineServer;
|
use crate::server::SouveraineServer;
|
||||||
|
|
||||||
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
|
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
|
||||||
|
|
@ -96,8 +96,10 @@ impl LocalBackend {
|
||||||
if let Some(sh) = &backend.server.summon_handler {
|
if let Some(sh) = &backend.server.summon_handler {
|
||||||
sh.set_injector(injector.clone());
|
sh.set_injector(injector.clone());
|
||||||
}
|
}
|
||||||
let mut handler =
|
let mut handler = crate::core::nervous::handler::HeartbeatHandler::new(
|
||||||
crate::core::nervous::handler::HeartbeatHandler::new(event_bus.subscribe(), injector.clone());
|
event_bus.subscribe(),
|
||||||
|
injector.clone(),
|
||||||
|
);
|
||||||
tokio::spawn(async move { handler.run().await });
|
tokio::spawn(async move { handler.run().await });
|
||||||
tracing::info!("heartbeat handler spawned");
|
tracing::info!("heartbeat handler spawned");
|
||||||
|
|
||||||
|
|
@ -105,11 +107,10 @@ impl LocalBackend {
|
||||||
// `sensorium:input` events from non-terminal surfaces (Matrix,
|
// `sensorium:input` events from non-terminal surfaces (Matrix,
|
||||||
// email, federation) and injects turns on their behalf.
|
// email, federation) and injects turns on their behalf.
|
||||||
// Same pattern as HeartbeatHandler; identical wiring.
|
// Same pattern as HeartbeatHandler; identical wiring.
|
||||||
let mut input_handler =
|
let mut input_handler = crate::core::nervous::handler::SensoriumInputHandler::new(
|
||||||
crate::core::nervous::handler::SensoriumInputHandler::new(
|
event_bus.subscribe(),
|
||||||
event_bus.subscribe(),
|
injector,
|
||||||
injector,
|
);
|
||||||
);
|
|
||||||
tokio::spawn(async move { input_handler.run().await });
|
tokio::spawn(async move { input_handler.run().await });
|
||||||
tracing::info!("sensorium input handler spawned");
|
tracing::info!("sensorium input handler spawned");
|
||||||
|
|
||||||
|
|
@ -141,10 +142,7 @@ impl LocalBackend {
|
||||||
/// and a child CancellationToken. `shutdown_sensoria` cancels all of
|
/// and a child CancellationToken. `shutdown_sensoria` cancels all of
|
||||||
/// them. Can be called at any time — the coordinator drains registered
|
/// them. Can be called at any time — the coordinator drains registered
|
||||||
/// sensoria on `run_all` and accepts new ones afterward.
|
/// sensoria on `run_all` and accepts new ones afterward.
|
||||||
pub async fn register_sensorium(
|
pub async fn register_sensorium(&self, sensorium: Box<dyn crate::core::sensorium::Sensorium>) {
|
||||||
&self,
|
|
||||||
sensorium: Box<dyn crate::core::sensorium::Sensorium>,
|
|
||||||
) {
|
|
||||||
self.server.register_sensorium(sensorium).await;
|
self.server.register_sensorium(sensorium).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +150,6 @@ impl LocalBackend {
|
||||||
pub async fn shutdown_sensoria(&self) {
|
pub async fn shutdown_sensoria(&self) {
|
||||||
self.server.shutdown_sensoria().await;
|
self.server.shutdown_sensoria().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -177,16 +174,27 @@ impl Backend for LocalBackend {
|
||||||
let _ = self.server.agents.get(agent_id).await?;
|
let _ = self.server.agents.get(agent_id).await?;
|
||||||
let conv_id = self.server.sessions.create(agent_id);
|
let conv_id = self.server.sessions.create(agent_id);
|
||||||
|
|
||||||
if let Err(e) = self.server.agents.register_instance(agent_id, &self.server.instance_id).await {
|
if let Err(e) = self
|
||||||
|
.server
|
||||||
|
.agents
|
||||||
|
.register_instance(agent_id, &self.server.instance_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!(agent = %agent_id, "instance registration failed: {}", e);
|
tracing::warn!(agent = %agent_id, "instance registration failed: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.server.seed_conversation_system_prompt(agent_id, &conv_id).await?;
|
self.server
|
||||||
|
.seed_conversation_system_prompt(agent_id, &conv_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(conv_id)
|
Ok(conv_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fork_conversation(&self, _agent_id: &str, source_conversation_id: &str) -> Result<String> {
|
async fn fork_conversation(
|
||||||
|
&self,
|
||||||
|
_agent_id: &str,
|
||||||
|
source_conversation_id: &str,
|
||||||
|
) -> Result<String> {
|
||||||
let forked_id = self.server.sessions.fork(source_conversation_id)?;
|
let forked_id = self.server.sessions.fork(source_conversation_id)?;
|
||||||
Ok(forked_id)
|
Ok(forked_id)
|
||||||
}
|
}
|
||||||
|
|
@ -260,7 +268,9 @@ impl Backend for LocalBackend {
|
||||||
let conv_id = self.server.sessions.create(agent_id);
|
let conv_id = self.server.sessions.create(agent_id);
|
||||||
|
|
||||||
// Build system prompt from the agent's memfs and inject as first message
|
// Build system prompt from the agent's memfs and inject as first message
|
||||||
self.server.seed_conversation_system_prompt(agent_id, &conv_id).await?;
|
self.server
|
||||||
|
.seed_conversation_system_prompt(agent_id, &conv_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(conv_id)
|
Ok(conv_id)
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +282,8 @@ impl Backend for LocalBackend {
|
||||||
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
|
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
|
||||||
// No cancel signal — heartbeat path, drain path. Use a token that
|
// No cancel signal — heartbeat path, drain path. Use a token that
|
||||||
// never fires.
|
// never fires.
|
||||||
self.send_with_cancel(conversation_id, text, CancellationToken::new()).await
|
self.send_with_cancel(conversation_id, text, CancellationToken::new())
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_with_cancel(
|
async fn send_with_cancel(
|
||||||
|
|
@ -282,8 +293,10 @@ impl Backend for LocalBackend {
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
|
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
|
||||||
// No queue passed in — use an empty queue. Equivalent to the old behavior.
|
// No queue passed in — use an empty queue. Equivalent to the old behavior.
|
||||||
let empty: crate::backend::InterjectionQueue = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
let empty: crate::backend::InterjectionQueue =
|
||||||
self.send_with_signals(conversation_id, text, cancel, empty).await
|
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
self.send_with_signals(conversation_id, text, cancel, empty)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_with_signals(
|
async fn send_with_signals(
|
||||||
|
|
@ -310,7 +323,8 @@ impl Backend for LocalBackend {
|
||||||
let ambient = crate::core::sensorium::ambient_line();
|
let ambient = crate::core::sensorium::ambient_line();
|
||||||
|
|
||||||
let user_text = if let Some(agent_id) = session_agent_id {
|
let user_text = if let Some(agent_id) = session_agent_id {
|
||||||
let surfacings = consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
|
let surfacings =
|
||||||
|
consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
|
||||||
if surfacings.is_empty() {
|
if surfacings.is_empty() {
|
||||||
format!("{}\n{}", ambient, text)
|
format!("{}\n{}", ambient, text)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -325,10 +339,9 @@ impl Backend for LocalBackend {
|
||||||
format!("{}\n{}", ambient, text)
|
format!("{}\n{}", ambient, text)
|
||||||
};
|
};
|
||||||
|
|
||||||
self.server.sessions.add_message(
|
self.server
|
||||||
conversation_id,
|
.sessions
|
||||||
ConversationMessage::user_text(&user_text),
|
.add_message(conversation_id, ConversationMessage::user_text(&user_text))?;
|
||||||
)?;
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<Result<BackendEvent>>(64);
|
let (tx, rx) = mpsc::channel::<Result<BackendEvent>>(64);
|
||||||
let server = self.server.clone();
|
let server = self.server.clone();
|
||||||
|
|
@ -338,7 +351,10 @@ impl Backend for LocalBackend {
|
||||||
|
|
||||||
active.fetch_add(1, Ordering::Relaxed);
|
active.fetch_add(1, Ordering::Relaxed);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject).await {
|
if let Err(e) =
|
||||||
|
crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject)
|
||||||
|
.await
|
||||||
|
{
|
||||||
let _ = tx.send(Err(e)).await;
|
let _ = tx.send(Err(e)).await;
|
||||||
}
|
}
|
||||||
let _ = tx.send(Ok(BackendEvent::Done)).await;
|
let _ = tx.send(Ok(BackendEvent::Done)).await;
|
||||||
|
|
@ -365,7 +381,8 @@ impl Backend for LocalBackend {
|
||||||
let ambient = crate::core::sensorium::ambient_line();
|
let ambient = crate::core::sensorium::ambient_line();
|
||||||
|
|
||||||
let user_text = if let Some(agent_id) = session_agent_id {
|
let user_text = if let Some(agent_id) = session_agent_id {
|
||||||
let surfacings = consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
|
let surfacings =
|
||||||
|
consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
|
||||||
if surfacings.is_empty() {
|
if surfacings.is_empty() {
|
||||||
format!("{}\n{}", ambient, text)
|
format!("{}\n{}", ambient, text)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -380,12 +397,13 @@ impl Backend for LocalBackend {
|
||||||
format!("{}\n{}", ambient, text)
|
format!("{}\n{}", ambient, text)
|
||||||
};
|
};
|
||||||
|
|
||||||
let image_blocks: Vec<ContentBlock> = images.iter().map(|img| {
|
let image_blocks: Vec<ContentBlock> = images
|
||||||
ContentBlock::Image {
|
.iter()
|
||||||
|
.map(|img| ContentBlock::Image {
|
||||||
media_type: img.media_type.clone(),
|
media_type: img.media_type.clone(),
|
||||||
data: img.data.clone(),
|
data: img.data.clone(),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
self.server.sessions.add_message(
|
self.server.sessions.add_message(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
|
|
@ -400,7 +418,10 @@ impl Backend for LocalBackend {
|
||||||
|
|
||||||
active.fetch_add(1, Ordering::Relaxed);
|
active.fetch_add(1, Ordering::Relaxed);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject).await {
|
if let Err(e) =
|
||||||
|
crate::server::turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject)
|
||||||
|
.await
|
||||||
|
{
|
||||||
let _ = tx.send(Err(e)).await;
|
let _ = tx.send(Err(e)).await;
|
||||||
}
|
}
|
||||||
let _ = tx.send(Ok(BackendEvent::Done)).await;
|
let _ = tx.send(Ok(BackendEvent::Done)).await;
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,11 @@ pub enum BackendEvent {
|
||||||
/// Subconscious called a tool during N+1.
|
/// Subconscious called a tool during N+1.
|
||||||
SubconsciousToolCall { name: String, arguments: String },
|
SubconsciousToolCall { name: String, arguments: String },
|
||||||
/// Subconscious tool result during N+1.
|
/// Subconscious tool result during N+1.
|
||||||
SubconsciousToolResult { name: String, output: String, is_error: bool },
|
SubconsciousToolResult {
|
||||||
|
name: String,
|
||||||
|
output: String,
|
||||||
|
is_error: bool,
|
||||||
|
},
|
||||||
/// Subconscious called `halt` during a mid-turn peek — the primary's tool
|
/// Subconscious called `halt` during a mid-turn peek — the primary's tool
|
||||||
/// loop is being stopped and she feels a migraine in her own register.
|
/// loop is being stopped and she feels a migraine in her own register.
|
||||||
/// The reason is the short sentence she will sense; the severity shapes
|
/// The reason is the short sentence she will sense; the severity shapes
|
||||||
|
|
@ -177,7 +181,11 @@ pub trait Backend: Send + Sync {
|
||||||
/// with a fresh conversation_id. Used by `/btw` to spin off a side-quest.
|
/// with a fresh conversation_id. Used by `/btw` to spin off a side-quest.
|
||||||
/// Default falls back to `new_conversation`; LocalBackend overrides with
|
/// Default falls back to `new_conversation`; LocalBackend overrides with
|
||||||
/// a proper deep clone via session_manager.fork().
|
/// a proper deep clone via session_manager.fork().
|
||||||
async fn fork_conversation(&self, agent_id: &str, _source_conversation_id: &str) -> Result<String> {
|
async fn fork_conversation(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
_source_conversation_id: &str,
|
||||||
|
) -> Result<String> {
|
||||||
self.new_conversation(agent_id).await
|
self.new_conversation(agent_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +251,8 @@ pub trait Backend: Send + Sync {
|
||||||
for img in &images {
|
for img in &images {
|
||||||
enriched.push_str(&format!("\n[Image: {}]", img.media_type));
|
enriched.push_str(&format!("\n[Image: {}]", img.media_type));
|
||||||
}
|
}
|
||||||
self.send_with_signals(conversation_id, &enriched, cancel, interject).await
|
self.send_with_signals(conversation_id, &enriched, cancel, interject)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push an llm_config update to the agent record. LocalBackend writes
|
/// Push an llm_config update to the agent record. LocalBackend writes
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,11 @@ impl RemoteBackend {
|
||||||
.connect_timeout(Duration::from_secs(2))
|
.connect_timeout(Duration::from_secs(2))
|
||||||
.build()
|
.build()
|
||||||
.context("building reqwest client")?;
|
.context("building reqwest client")?;
|
||||||
Ok(Self { base_url: url, client, token: None })
|
Ok(Self {
|
||||||
|
base_url: url,
|
||||||
|
client,
|
||||||
|
token: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a RemoteBackend that sends bearer tokens for the given agent_id.
|
/// Create a RemoteBackend that sends bearer tokens for the given agent_id.
|
||||||
|
|
@ -73,7 +77,11 @@ impl RemoteBackend {
|
||||||
|
|
||||||
/// Watch a local cancel token and relay it as POST .../cancel.
|
/// Watch a local cancel token and relay it as POST .../cancel.
|
||||||
/// Esc in the TUI fires the same interrupt semantics as local mode.
|
/// Esc in the TUI fires the same interrupt semantics as local mode.
|
||||||
fn spawn_cancel_watch(&self, conversation_id: &str, cancel: tokio_util::sync::CancellationToken) {
|
fn spawn_cancel_watch(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
cancel: tokio_util::sync::CancellationToken,
|
||||||
|
) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let url = self.url(&format!("/v1/conversations/{}/cancel", conversation_id));
|
let url = self.url(&format!("/v1/conversations/{}/cancel", conversation_id));
|
||||||
let token = self.token.clone();
|
let token = self.token.clone();
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,16 @@ fn main() -> anyhow::Result<()> {
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--seed-dir" => {
|
"--seed-dir" => {
|
||||||
seed_dir = PathBuf::from(args.next().ok_or_else(|| {
|
seed_dir = PathBuf::from(
|
||||||
anyhow::anyhow!("--seed-dir requires a path")
|
args.next()
|
||||||
})?);
|
.ok_or_else(|| anyhow::anyhow!("--seed-dir requires a path"))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
"--socket" => {
|
"--socket" => {
|
||||||
socket = PathBuf::from(args.next().ok_or_else(|| {
|
socket = PathBuf::from(
|
||||||
anyhow::anyhow!("--socket requires a path")
|
args.next()
|
||||||
})?);
|
.ok_or_else(|| anyhow::anyhow!("--socket requires a path"))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
println!(
|
println!(
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,18 @@ mod identity;
|
||||||
#[path = "../machined/protocol.rs"]
|
#[path = "../machined/protocol.rs"]
|
||||||
mod machined_protocol;
|
mod machined_protocol;
|
||||||
|
|
||||||
|
#[path = "../secrets/collection.rs"]
|
||||||
|
mod collection;
|
||||||
#[path = "../secrets/dh.rs"]
|
#[path = "../secrets/dh.rs"]
|
||||||
mod dh;
|
mod dh;
|
||||||
#[path = "../secrets/transport.rs"]
|
#[path = "../secrets/error.rs"]
|
||||||
mod transport;
|
mod error;
|
||||||
|
#[path = "../secrets/item.rs"]
|
||||||
|
mod item;
|
||||||
|
#[path = "../secrets/manage.rs"]
|
||||||
|
mod manage;
|
||||||
|
#[path = "../secrets/service.rs"]
|
||||||
|
mod service;
|
||||||
#[path = "../secrets/session.rs"]
|
#[path = "../secrets/session.rs"]
|
||||||
mod session;
|
mod session;
|
||||||
#[path = "../secrets/session_object.rs"]
|
#[path = "../secrets/session_object.rs"]
|
||||||
|
|
@ -39,18 +47,10 @@ mod session_object;
|
||||||
mod storage_key;
|
mod storage_key;
|
||||||
#[path = "../secrets/store.rs"]
|
#[path = "../secrets/store.rs"]
|
||||||
mod store;
|
mod store;
|
||||||
|
#[path = "../secrets/transport.rs"]
|
||||||
|
mod transport;
|
||||||
#[path = "../secrets/types.rs"]
|
#[path = "../secrets/types.rs"]
|
||||||
mod types;
|
mod types;
|
||||||
#[path = "../secrets/error.rs"]
|
|
||||||
mod error;
|
|
||||||
#[path = "../secrets/service.rs"]
|
|
||||||
mod service;
|
|
||||||
#[path = "../secrets/collection.rs"]
|
|
||||||
mod collection;
|
|
||||||
#[path = "../secrets/item.rs"]
|
|
||||||
mod item;
|
|
||||||
#[path = "../secrets/manage.rs"]
|
|
||||||
mod manage;
|
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
|
@ -75,7 +75,10 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
let (machine_ikm, source) = storage_key::resolve(&base)
|
let (machine_ikm, source) = storage_key::resolve(&base)
|
||||||
.context("resolving the machine-rooted key material — refusing to start")?;
|
.context("resolving the machine-rooted key material — refusing to start")?;
|
||||||
info!(?source, "machine key material resolved; the machine identity is the trust root");
|
info!(
|
||||||
|
?source,
|
||||||
|
"machine key material resolved; the machine identity is the trust root"
|
||||||
|
);
|
||||||
|
|
||||||
let store_path = store::default_store_path(&base);
|
let store_path = store::default_store_path(&base);
|
||||||
let secret_store = Arc::new(Mutex::new(
|
let secret_store = Arc::new(Mutex::new(
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,8 @@ type LastSeen = Arc<Mutex<HashMap<&'static str, serde_json::Value>>>;
|
||||||
type LastSent = Arc<Mutex<HashMap<&'static str, Instant>>>;
|
type LastSent = Arc<Mutex<HashMap<&'static str, Instant>>>;
|
||||||
|
|
||||||
fn socket_path() -> PathBuf {
|
fn socket_path() -> PathBuf {
|
||||||
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
let runtime = std::env::var("XDG_RUNTIME_DIR")
|
||||||
format!("/run/user/{}", unsafe { libc_getuid() })
|
.unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc_getuid() }));
|
||||||
});
|
|
||||||
PathBuf::from(runtime).join("souveraine/sessiond.sock")
|
PathBuf::from(runtime).join("souveraine/sessiond.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,10 @@ fn main() -> anyhow::Result<()> {
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--socket" => {
|
"--socket" => {
|
||||||
socket = Some(PathBuf::from(args.next().ok_or_else(|| {
|
socket =
|
||||||
anyhow::anyhow!("--socket requires a path")
|
Some(PathBuf::from(args.next().ok_or_else(|| {
|
||||||
})?));
|
anyhow::anyhow!("--socket requires a path")
|
||||||
|
})?));
|
||||||
}
|
}
|
||||||
"--no-initial-lock" => initial_lock = false,
|
"--no-initial-lock" => initial_lock = false,
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,13 @@ pub struct BifrostClient {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ContentPart {
|
pub enum ContentPart {
|
||||||
Text { text: String },
|
Text {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
#[serde(rename = "image_url")]
|
#[serde(rename = "image_url")]
|
||||||
ImageUrl { image_url: ImageUrlSource },
|
ImageUrl {
|
||||||
|
image_url: ImageUrlSource,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Source for an image URL content part — always a data URI.
|
/// Source for an image URL content part — always a data URI.
|
||||||
|
|
@ -214,7 +218,11 @@ pub struct MessageToolCall {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageToolCall {
|
impl MessageToolCall {
|
||||||
pub fn function(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
pub fn function(
|
||||||
|
id: impl Into<String>,
|
||||||
|
name: impl Into<String>,
|
||||||
|
arguments: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
tool_type: "function".to_string(),
|
tool_type: "function".to_string(),
|
||||||
|
|
@ -488,7 +496,13 @@ fn last_segment_is_version(url: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BifrostClient {
|
impl BifrostClient {
|
||||||
pub fn new(base_url: &str, api_key: &str, virtual_key: &str, default_model: &str, timeout_secs: u64) -> Result<Self> {
|
pub fn new(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
virtual_key: &str,
|
||||||
|
default_model: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<Self> {
|
||||||
let base = base_url.trim_end_matches('/').to_string();
|
let base = base_url.trim_end_matches('/').to_string();
|
||||||
// Only append "/v1" when the base URL has no version path segment.
|
// Only append "/v1" when the base URL has no version path segment.
|
||||||
// A URL already ending in "/v<digits>" (Bifrost's "/v1", z.ai's
|
// A URL already ending in "/v<digits>" (Bifrost's "/v1", z.ai's
|
||||||
|
|
@ -534,10 +548,16 @@ impl BifrostClient {
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
if !self.api_key.is_empty() {
|
if !self.api_key.is_empty() {
|
||||||
let auth_val = format!("Bearer {}", self.api_key);
|
let auth_val = format!("Bearer {}", self.api_key);
|
||||||
headers.insert(reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&auth_val).unwrap());
|
headers.insert(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
reqwest::header::HeaderValue::from_str(&auth_val).unwrap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if !self.virtual_key.is_empty() {
|
if !self.virtual_key.is_empty() {
|
||||||
headers.insert("x-bf-vk", reqwest::header::HeaderValue::from_str(&self.virtual_key).unwrap());
|
headers.insert(
|
||||||
|
"x-bf-vk",
|
||||||
|
reqwest::header::HeaderValue::from_str(&self.virtual_key).unwrap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
headers
|
headers
|
||||||
}
|
}
|
||||||
|
|
@ -545,7 +565,8 @@ impl BifrostClient {
|
||||||
/// List available models from Bifrost
|
/// List available models from Bifrost
|
||||||
pub async fn list_models(&self) -> Result<Vec<String>> {
|
pub async fn list_models(&self) -> Result<Vec<String>> {
|
||||||
let url = format!("{}/models", self.base_url);
|
let url = format!("{}/models", self.base_url);
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.headers(self.auth_headers())
|
.headers(self.auth_headers())
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -570,7 +591,10 @@ impl BifrostClient {
|
||||||
/// Returns the completion result plus any strain events that occurred.
|
/// Returns the completion result plus any strain events that occurred.
|
||||||
/// Strain events are body-knowledge: the agent can feel when inference
|
/// Strain events are body-knowledge: the agent can feel when inference
|
||||||
/// was difficult, correlate it over time, notice patterns.
|
/// was difficult, correlate it over time, notice patterns.
|
||||||
pub async fn chat_completion(&self, request: ChatCompletionRequest) -> Result<CompletionResult> {
|
pub async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
request: ChatCompletionRequest,
|
||||||
|
) -> Result<CompletionResult> {
|
||||||
let (result, _strain) = self.chat_completion_with_strain(request).await?;
|
let (result, _strain) = self.chat_completion_with_strain(request).await?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
@ -584,7 +608,10 @@ impl BifrostClient {
|
||||||
// Try primary model
|
// Try primary model
|
||||||
let fallbacks = self.retry_policy.fallback_models.clone();
|
let fallbacks = self.retry_policy.fallback_models.clone();
|
||||||
|
|
||||||
match self.try_model_with_retries(&request, &request.model, &mut strain_events).await {
|
match self
|
||||||
|
.try_model_with_retries(&request, &request.model, &mut strain_events)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(result) => return Ok((result, strain_events)),
|
Ok(result) => return Ok((result, strain_events)),
|
||||||
Err(primary_err) => {
|
Err(primary_err) => {
|
||||||
if fallbacks.is_empty() {
|
if fallbacks.is_empty() {
|
||||||
|
|
@ -602,7 +629,10 @@ impl BifrostClient {
|
||||||
// Try each fallback model
|
// Try each fallback model
|
||||||
for fallback in &fallbacks {
|
for fallback in &fallbacks {
|
||||||
info!("Falling back to model: {}", fallback);
|
info!("Falling back to model: {}", fallback);
|
||||||
match self.try_model_with_retries(&request, fallback, &mut strain_events).await {
|
match self
|
||||||
|
.try_model_with_retries(&request, fallback, &mut strain_events)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
info!("Fallback to {} succeeded", fallback);
|
info!("Fallback to {} succeeded", fallback);
|
||||||
return Ok((result, strain_events));
|
return Ok((result, strain_events));
|
||||||
|
|
@ -636,7 +666,8 @@ impl BifrostClient {
|
||||||
for attempt in 0..=policy.max_retries {
|
for attempt in 0..=policy.max_retries {
|
||||||
debug!("POST {} — model: {} (attempt {})", url, model, attempt);
|
debug!("POST {} — model: {} (attempt {})", url, model, attempt);
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.headers(self.auth_headers())
|
.headers(self.auth_headers())
|
||||||
.json(&req_with_model)
|
.json(&req_with_model)
|
||||||
|
|
@ -650,7 +681,10 @@ impl BifrostClient {
|
||||||
anyhow::bail!("Bifrost unreachable after {} attempts: {}", attempt + 1, e);
|
anyhow::bail!("Bifrost unreachable after {} attempts: {}", attempt + 1, e);
|
||||||
}
|
}
|
||||||
let delay = jittered_delay(attempt, policy);
|
let delay = jittered_delay(attempt, policy);
|
||||||
warn!("Bifrost connection failed (attempt {}), retrying in {:?}: {}", attempt, delay, e);
|
warn!(
|
||||||
|
"Bifrost connection failed (attempt {}), retrying in {:?}: {}",
|
||||||
|
attempt, delay, e
|
||||||
|
);
|
||||||
strain_events.push(InferenceStrain::Transient {
|
strain_events.push(InferenceStrain::Transient {
|
||||||
attempt,
|
attempt,
|
||||||
status: 0,
|
status: 0,
|
||||||
|
|
@ -667,12 +701,16 @@ impl BifrostClient {
|
||||||
let retry_after = parse_retry_after(resp.headers());
|
let retry_after = parse_retry_after(resp.headers());
|
||||||
|
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let body_text = resp.text().await
|
let body_text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
.context("Failed to read Bifrost response body")?;
|
.context("Failed to read Bifrost response body")?;
|
||||||
return Self::parse_completion_response(&body_text);
|
return Self::parse_completion_response(&body_text);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body_text = resp.text().await
|
let body_text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
.context("Failed to read Bifrost error body")?;
|
.context("Failed to read Bifrost error body")?;
|
||||||
|
|
||||||
match classify_status(status, &body_text) {
|
match classify_status(status, &body_text) {
|
||||||
|
|
@ -680,7 +718,10 @@ impl BifrostClient {
|
||||||
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
|
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
|
||||||
warn!(
|
warn!(
|
||||||
"Bifrost {} on {} (attempt {}), retrying in {:?}",
|
"Bifrost {} on {} (attempt {}), retrying in {:?}",
|
||||||
status.as_u16(), model, attempt, delay
|
status.as_u16(),
|
||||||
|
model,
|
||||||
|
attempt,
|
||||||
|
delay
|
||||||
);
|
);
|
||||||
strain_events.push(InferenceStrain::Transient {
|
strain_events.push(InferenceStrain::Transient {
|
||||||
attempt,
|
attempt,
|
||||||
|
|
@ -699,7 +740,10 @@ impl BifrostClient {
|
||||||
});
|
});
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Bifrost returned {} after {} attempt(s) on {}: {}",
|
"Bifrost returned {} after {} attempt(s) on {}: {}",
|
||||||
status, attempt + 1, model, &body_text[..body_text.len().min(500)]
|
status,
|
||||||
|
attempt + 1,
|
||||||
|
model,
|
||||||
|
&body_text[..body_text.len().min(500)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -709,18 +753,23 @@ impl BifrostClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_completion_response(body_text: &str) -> Result<CompletionResult> {
|
fn parse_completion_response(body_text: &str) -> Result<CompletionResult> {
|
||||||
let parsed: ChatCompletionResponse = serde_json::from_str(body_text)
|
let parsed: ChatCompletionResponse =
|
||||||
.with_context(|| {
|
serde_json::from_str(body_text).with_context(|| {
|
||||||
let preview = &body_text[..body_text.len().min(200)];
|
let preview = &body_text[..body_text.len().min(200)];
|
||||||
format!("Failed to parse Bifrost response: {preview}")
|
format!("Failed to parse Bifrost response: {preview}")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let choice = parsed.choices.into_iter().next()
|
let choice = parsed
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
.context("Bifrost returned empty choices")?;
|
.context("Bifrost returned empty choices")?;
|
||||||
|
|
||||||
let content = choice.message.content.unwrap_or_default();
|
let content = choice.message.content.unwrap_or_default();
|
||||||
let reasoning = choice.message.reasoning;
|
let reasoning = choice.message.reasoning;
|
||||||
let tool_calls = choice.message.tool_calls
|
let tool_calls = choice
|
||||||
|
.message
|
||||||
|
.tool_calls
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|tc| {
|
.filter_map(|tc| {
|
||||||
|
|
@ -786,7 +835,8 @@ mod tests {
|
||||||
"",
|
"",
|
||||||
"openai/deepseek-v4-pro",
|
"openai/deepseek-v4-pro",
|
||||||
120,
|
120,
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(client.base_url.ends_with("/v1"));
|
assert!(client.base_url.ends_with("/v1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -794,9 +844,7 @@ mod tests {
|
||||||
fn test_chat_request_serialization() {
|
fn test_chat_request_serialization() {
|
||||||
let req = ChatCompletionRequest {
|
let req = ChatCompletionRequest {
|
||||||
model: "openai/deepseek-v4-pro".to_string(),
|
model: "openai/deepseek-v4-pro".to_string(),
|
||||||
messages: vec![
|
messages: vec![Message::text("user", "Hello")],
|
||||||
Message::text("user", "Hello"),
|
|
||||||
],
|
|
||||||
stream: None,
|
stream: None,
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
temperature: None,
|
temperature: None,
|
||||||
|
|
@ -829,7 +877,10 @@ mod tests {
|
||||||
}"#;
|
}"#;
|
||||||
let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap();
|
let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap();
|
||||||
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
|
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
|
||||||
assert_eq!(resp.choices[0].message.reasoning.as_deref(), Some("The user greeted me."));
|
assert_eq!(
|
||||||
|
resp.choices[0].message.reasoning.as_deref(),
|
||||||
|
Some("The user greeted me.")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -484,7 +484,11 @@ async fn refresh_token(
|
||||||
|
|
||||||
/// Best-effort `account_uuid` lookup via the roles/profile endpoint.
|
/// Best-effort `account_uuid` lookup via the roles/profile endpoint.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn fetch_account_uuid(http: &reqwest::Client, access_token: &str, user_agent: &str) -> String {
|
async fn fetch_account_uuid(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
access_token: &str,
|
||||||
|
user_agent: &str,
|
||||||
|
) -> String {
|
||||||
match http
|
match http
|
||||||
.get(ROLES_URL)
|
.get(ROLES_URL)
|
||||||
.bearer_auth(access_token)
|
.bearer_auth(access_token)
|
||||||
|
|
@ -702,8 +706,8 @@ fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String
|
||||||
}
|
}
|
||||||
if let Some(calls) = &msg.tool_calls {
|
if let Some(calls) = &msg.tool_calls {
|
||||||
for call in calls {
|
for call in calls {
|
||||||
let input: Value = serde_json::from_str(&call.function.arguments)
|
let input: Value =
|
||||||
.unwrap_or(json!({}));
|
serde_json::from_str(&call.function.arguments).unwrap_or(json!({}));
|
||||||
push_merge(
|
push_merge(
|
||||||
&mut merged,
|
&mut merged,
|
||||||
"assistant",
|
"assistant",
|
||||||
|
|
@ -787,8 +791,7 @@ fn translate_tool(tool: &ToolDefinition) -> Value {
|
||||||
|
|
||||||
/// Anthropic non-stream response → Souveraine's OpenAI-shaped `CompletionResult`.
|
/// Anthropic non-stream response → Souveraine's OpenAI-shaped `CompletionResult`.
|
||||||
fn parse_completion(text: &str) -> Result<CompletionResult> {
|
fn parse_completion(text: &str) -> Result<CompletionResult> {
|
||||||
let value: Value =
|
let value: Value = serde_json::from_str(text).context("parsing claude messages response")?;
|
||||||
serde_json::from_str(text).context("parsing claude messages response")?;
|
|
||||||
let content_parts: Vec<String> = value
|
let content_parts: Vec<String> = value
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
|
|
@ -1001,7 +1004,10 @@ mod tests {
|
||||||
];
|
];
|
||||||
let (messages, system, first_user) = translate_messages(&msgs);
|
let (messages, system, first_user) = translate_messages(&msgs);
|
||||||
assert!(system.is_none());
|
assert!(system.is_none());
|
||||||
let roles: Vec<&str> = messages.iter().map(|m| m["role"].as_str().unwrap()).collect();
|
let roles: Vec<&str> = messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| m["role"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
assert_eq!(roles[0], "user");
|
assert_eq!(roles[0], "user");
|
||||||
assert!(messages.iter().any(|m| {
|
assert!(messages.iter().any(|m| {
|
||||||
m["role"] == "user"
|
m["role"] == "user"
|
||||||
|
|
@ -1116,7 +1122,11 @@ mod tests {
|
||||||
|
|
||||||
// Rotated but already expired — adopting it would just fail differently.
|
// Rotated but already expired — adopting it would just fail differently.
|
||||||
let stale = creds_at(dir.path(), "spent", now_ms() + 60_000);
|
let stale = creds_at(dir.path(), "spent", now_ms() + 60_000);
|
||||||
creds_at(dir.path(), "rotated-but-dead", now_ms().saturating_sub(1_000));
|
creds_at(
|
||||||
|
dir.path(),
|
||||||
|
"rotated-but-dead",
|
||||||
|
now_ms().saturating_sub(1_000),
|
||||||
|
);
|
||||||
assert!(reread_credentials(&stale).is_none());
|
assert!(reread_credentials(&stale).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,7 @@ pub fn build_registry(config: &ConsciousnessConfig) -> anyhow::Result<ProviderRe
|
||||||
}
|
}
|
||||||
|
|
||||||
if map.is_empty() {
|
if map.is_empty() {
|
||||||
anyhow::bail!(
|
anyhow::bail!("no providers configured — add at least one [providers.<name>] section");
|
||||||
"no providers configured — add at least one [providers.<name>] section"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let default_name = config.inference.provider.clone();
|
let default_name = config.inference.provider.clone();
|
||||||
|
|
@ -127,9 +125,11 @@ impl ProviderRegistry {
|
||||||
/// default rather than failing.
|
/// default rather than failing.
|
||||||
pub fn for_agent(&self, agent: &AgentState) -> Arc<dyn LlmProvider> {
|
pub fn for_agent(&self, agent: &AgentState) -> Arc<dyn LlmProvider> {
|
||||||
match agent.souveraine.provider.as_deref() {
|
match agent.souveraine.provider.as_deref() {
|
||||||
Some(name) if name != self.default_name => {
|
Some(name) if name != self.default_name => self
|
||||||
self.map.get(name).cloned().unwrap_or_else(|| self.default.clone())
|
.map
|
||||||
}
|
.get(name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| self.default.clone()),
|
||||||
_ => self.default.clone(),
|
_ => self.default.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,10 @@ impl TokenCounter {
|
||||||
info!("🔢 Token counter using chars/4 fallback (tiktoken unavailable)");
|
info!("🔢 Token counter using chars/4 fallback (tiktoken unavailable)");
|
||||||
}
|
}
|
||||||
|
|
||||||
Self { encoding, available: encoding.is_some() }
|
Self {
|
||||||
|
encoding,
|
||||||
|
available: encoding.is_some(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count tokens using tiktoken or chars/4 fallback
|
/// Count tokens using tiktoken or chars/4 fallback
|
||||||
|
|
@ -88,7 +91,10 @@ pub struct ModelRouter {
|
||||||
impl ModelRouter {
|
impl ModelRouter {
|
||||||
pub fn new(configs: HashMap<String, ModelConfig>) -> Self {
|
pub fn new(configs: HashMap<String, ModelConfig>) -> Self {
|
||||||
let count = configs.len();
|
let count = configs.len();
|
||||||
info!("🧭 ModelRouter initialized with {} model configurations", count);
|
info!(
|
||||||
|
"🧭 ModelRouter initialized with {} model configurations",
|
||||||
|
count
|
||||||
|
);
|
||||||
for (name, cfg) in &configs {
|
for (name, cfg) in &configs {
|
||||||
debug!(
|
debug!(
|
||||||
" {} via {} — {} ctx, {} out, threshold {}",
|
" {} via {} — {} ctx, {} out, threshold {}",
|
||||||
|
|
@ -134,7 +140,11 @@ impl ModelRouter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check context pressure for a specific model
|
/// Check context pressure for a specific model
|
||||||
pub async fn context_pressure(&self, model_name: &str, context_window_limit: Option<usize>) -> ContextPressure {
|
pub async fn context_pressure(
|
||||||
|
&self,
|
||||||
|
model_name: &str,
|
||||||
|
context_window_limit: Option<usize>,
|
||||||
|
) -> ContextPressure {
|
||||||
let config = match self.configs.get(model_name) {
|
let config = match self.configs.get(model_name) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return ContextPressure::Normal,
|
None => return ContextPressure::Normal,
|
||||||
|
|
@ -193,11 +203,8 @@ impl ModelRouter {
|
||||||
|
|
||||||
/// Get all configured providers
|
/// Get all configured providers
|
||||||
pub fn providers(&self) -> Vec<String> {
|
pub fn providers(&self) -> Vec<String> {
|
||||||
let mut providers: Vec<String> = self
|
let mut providers: Vec<String> =
|
||||||
.configs
|
self.configs.values().map(|c| c.provider.clone()).collect();
|
||||||
.values()
|
|
||||||
.map(|c| c.provider.clone())
|
|
||||||
.collect();
|
|
||||||
providers.sort();
|
providers.sort();
|
||||||
providers.dedup();
|
providers.dedup();
|
||||||
providers
|
providers
|
||||||
|
|
@ -236,7 +243,11 @@ impl ModelRouter {
|
||||||
/// Set the selected model
|
/// Set the selected model
|
||||||
pub fn set_model(&mut self, name: &str) -> anyhow::Result<()> {
|
pub fn set_model(&mut self, name: &str) -> anyhow::Result<()> {
|
||||||
if !self.all_models().contains(&name.to_string()) {
|
if !self.all_models().contains(&name.to_string()) {
|
||||||
anyhow::bail!("Model '{}' not found. Available: {:?}", name, self.all_models());
|
anyhow::bail!(
|
||||||
|
"Model '{}' not found. Available: {:?}",
|
||||||
|
name,
|
||||||
|
self.all_models()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.selected_model = name.to_string();
|
self.selected_model = name.to_string();
|
||||||
info!("🎯 Selected model: {}", name);
|
info!("🎯 Selected model: {}", name);
|
||||||
|
|
@ -289,30 +300,39 @@ mod tests {
|
||||||
|
|
||||||
fn test_configs() -> HashMap<String, ModelConfig> {
|
fn test_configs() -> HashMap<String, ModelConfig> {
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("kimi-k2-5".to_string(), ModelConfig {
|
map.insert(
|
||||||
provider: "bifrost".to_string(),
|
"kimi-k2-5".to_string(),
|
||||||
model: "kimi-k2.5".to_string(),
|
ModelConfig {
|
||||||
context_limit: 128000,
|
provider: "bifrost".to_string(),
|
||||||
output_limit: 8192,
|
model: "kimi-k2.5".to_string(),
|
||||||
archivist_threshold: 0.7,
|
context_limit: 128000,
|
||||||
archivist_interval: 100,
|
output_limit: 8192,
|
||||||
preferred_for: vec![TaskType::Synthesis],
|
archivist_threshold: 0.7,
|
||||||
supports_images: true,
|
archivist_interval: 100,
|
||||||
});
|
preferred_for: vec![TaskType::Synthesis],
|
||||||
|
supports_images: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_context_pressure_normal() {
|
async fn test_context_pressure_normal() {
|
||||||
let router = ModelRouter::new(test_configs());
|
let router = ModelRouter::new(test_configs());
|
||||||
assert_eq!(router.context_pressure("kimi-k2-5", None).await, ContextPressure::Normal);
|
assert_eq!(
|
||||||
|
router.context_pressure("kimi-k2-5", None).await,
|
||||||
|
ContextPressure::Normal
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_context_pressure_critical() {
|
async fn test_context_pressure_critical() {
|
||||||
let router = ModelRouter::new(test_configs());
|
let router = ModelRouter::new(test_configs());
|
||||||
router.update_usage(100_000, 90_000, 10_000).await;
|
router.update_usage(100_000, 90_000, 10_000).await;
|
||||||
assert_eq!(router.context_pressure("kimi-k2-5", None).await, ContextPressure::Critical);
|
assert_eq!(
|
||||||
|
router.context_pressure("kimi-k2-5", None).await,
|
||||||
|
ContextPressure::Critical
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -320,9 +340,15 @@ mod tests {
|
||||||
let router = ModelRouter::new(test_configs());
|
let router = ModelRouter::new(test_configs());
|
||||||
router.update_usage(90_000, 80_000, 10_000).await;
|
router.update_usage(90_000, 80_000, 10_000).await;
|
||||||
// With 90k/128k ≈ 0.7 — critical at threshold 0.7
|
// With 90k/128k ≈ 0.7 — critical at threshold 0.7
|
||||||
assert_eq!(router.context_pressure("kimi-k2-5", None).await, ContextPressure::Critical);
|
assert_eq!(
|
||||||
|
router.context_pressure("kimi-k2-5", None).await,
|
||||||
|
ContextPressure::Critical
|
||||||
|
);
|
||||||
// With 90k/64k ≈ 1.4 — also critical
|
// With 90k/64k ≈ 1.4 — also critical
|
||||||
assert_eq!(router.context_pressure("kimi-k2-5", Some(64000)).await, ContextPressure::Critical);
|
assert_eq!(
|
||||||
|
router.context_pressure("kimi-k2-5", Some(64000)).await,
|
||||||
|
ContextPressure::Critical
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,17 @@ async fn refresh(http: &reqwest::Client, creds: &CodexCredentials) -> Result<Cod
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return Err(anyhow!("ChatGPT token refresh failed ({}): {}", status, body));
|
return Err(anyhow!(
|
||||||
|
"ChatGPT token refresh failed ({}): {}",
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let json: Value = resp.json().await.context("parsing token refresh response")?;
|
let json: Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("parsing token refresh response")?;
|
||||||
let access = json
|
let access = json
|
||||||
.get("access_token")
|
.get("access_token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|
@ -80,7 +87,10 @@ async fn refresh(http: &reqwest::Client, creds: &CodexCredentials) -> Result<Cod
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.unwrap_or_else(|| creds.refresh_token.clone());
|
.unwrap_or_else(|| creds.refresh_token.clone());
|
||||||
let expires_in = json.get("expires_in").and_then(|v| v.as_i64()).unwrap_or(3600);
|
let expires_in = json
|
||||||
|
.get("expires_in")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(3600);
|
||||||
|
|
||||||
Ok(CodexCredentials {
|
Ok(CodexCredentials {
|
||||||
access_token: access,
|
access_token: access,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ use async_trait::async_trait;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::bridge::bifrost::{ChatCompletionRequest, CompletionResult, InferenceStrain, RetryPolicy};
|
use crate::bridge::bifrost::{
|
||||||
|
ChatCompletionRequest, CompletionResult, InferenceStrain, RetryPolicy,
|
||||||
|
};
|
||||||
use crate::bridge::oauth::codex_creds::{self, CodexCredentials};
|
use crate::bridge::oauth::codex_creds::{self, CodexCredentials};
|
||||||
use crate::bridge::oauth::{catalog, refresh};
|
use crate::bridge::oauth::{catalog, refresh};
|
||||||
use crate::bridge::provider::LlmProvider;
|
use crate::bridge::provider::LlmProvider;
|
||||||
|
|
@ -114,7 +116,10 @@ impl LlmProvider for OpenAiOAuthProvider {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let delay = backoff(attempt, &self.retry);
|
let delay = backoff(attempt, &self.retry);
|
||||||
warn!("ChatGPT backend connect failed (attempt {}), retrying in {:?}: {}", attempt, delay, e);
|
warn!(
|
||||||
|
"ChatGPT backend connect failed (attempt {}), retrying in {:?}: {}",
|
||||||
|
attempt, delay, e
|
||||||
|
);
|
||||||
strain.push(InferenceStrain::Transient {
|
strain.push(InferenceStrain::Transient {
|
||||||
attempt,
|
attempt,
|
||||||
status: 0,
|
status: 0,
|
||||||
|
|
@ -129,7 +134,10 @@ impl LlmProvider for OpenAiOAuthProvider {
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let body = resp.text().await.context("reading ChatGPT responses stream")?;
|
let body = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.context("reading ChatGPT responses stream")?;
|
||||||
let result = responses::accumulate_sse(&body)?;
|
let result = responses::accumulate_sse(&body)?;
|
||||||
return Ok((result, strain));
|
return Ok((result, strain));
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +146,12 @@ impl LlmProvider for OpenAiOAuthProvider {
|
||||||
let transient = matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504);
|
let transient = matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504);
|
||||||
if transient && attempt < self.retry.max_retries {
|
if transient && attempt < self.retry.max_retries {
|
||||||
let delay = backoff(attempt, &self.retry);
|
let delay = backoff(attempt, &self.retry);
|
||||||
warn!("ChatGPT backend {} (attempt {}), retrying in {:?}", status.as_u16(), attempt, delay);
|
warn!(
|
||||||
|
"ChatGPT backend {} (attempt {}), retrying in {:?}",
|
||||||
|
status.as_u16(),
|
||||||
|
attempt,
|
||||||
|
delay
|
||||||
|
);
|
||||||
strain.push(InferenceStrain::Transient {
|
strain.push(InferenceStrain::Transient {
|
||||||
attempt,
|
attempt,
|
||||||
status: status.as_u16(),
|
status: status.as_u16(),
|
||||||
|
|
@ -168,6 +181,8 @@ impl LlmProvider for OpenAiOAuthProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backoff(attempt: u32, policy: &RetryPolicy) -> Duration {
|
fn backoff(attempt: u32, policy: &RetryPolicy) -> Duration {
|
||||||
let base = policy.base_delay_ms.saturating_mul(2u64.saturating_pow(attempt));
|
let base = policy
|
||||||
|
.base_delay_ms
|
||||||
|
.saturating_mul(2u64.saturating_pow(attempt));
|
||||||
Duration::from_millis(base.min(policy.max_delay_ms))
|
Duration::from_millis(base.min(policy.max_delay_ms))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,11 @@ pub fn accumulate_sse(body: &str) -> Result<CompletionResult> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let finish_reason = if tool_calls.is_empty() { "stop" } else { "tool_calls" };
|
let finish_reason = if tool_calls.is_empty() {
|
||||||
|
"stop"
|
||||||
|
} else {
|
||||||
|
"tool_calls"
|
||||||
|
};
|
||||||
|
|
||||||
Ok(CompletionResult {
|
Ok(CompletionResult {
|
||||||
content,
|
content,
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,7 @@ pub async fn run_model_command(
|
||||||
// Handle model selection
|
// Handle model selection
|
||||||
if let Some(name) = model_name {
|
if let Some(name) = model_name {
|
||||||
if !all_models.contains(&name.to_string()) {
|
if !all_models.contains(&name.to_string()) {
|
||||||
anyhow::bail!(
|
anyhow::bail!("Model '{}' not found. Available: {:?}", name, all_models);
|
||||||
"Model '{}' not found. Available: {:?}",
|
|
||||||
name,
|
|
||||||
all_models
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config and save
|
// Update config and save
|
||||||
|
|
@ -84,12 +80,8 @@ pub async fn run_model_command(
|
||||||
provider: cfg
|
provider: cfg
|
||||||
.map(|c| c.provider.clone())
|
.map(|c| c.provider.clone())
|
||||||
.unwrap_or_else(|| "bifrost".to_string()),
|
.unwrap_or_else(|| "bifrost".to_string()),
|
||||||
context_limit: cfg
|
context_limit: cfg.map(|c| c.context_limit).unwrap_or(128_000),
|
||||||
.map(|c| c.context_limit)
|
output_limit: cfg.map(|c| c.output_limit).unwrap_or(8_192),
|
||||||
.unwrap_or(128_000),
|
|
||||||
output_limit: cfg
|
|
||||||
.map(|c| c.output_limit)
|
|
||||||
.unwrap_or(8_192),
|
|
||||||
from_bifrost: bifrost_models.contains(name),
|
from_bifrost: bifrost_models.contains(name),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,7 @@ impl SynthesisReport {
|
||||||
pub fn summary_line(&self) -> String {
|
pub fn summary_line(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"synthesized {} journal entries ({} → {}) into {}",
|
"synthesized {} journal entries ({} → {}) into {}",
|
||||||
self.entries_compressed,
|
self.entries_compressed, self.date_range.0, self.date_range.1, self.output_label,
|
||||||
self.date_range.0,
|
|
||||||
self.date_range.1,
|
|
||||||
self.output_label,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -160,8 +157,9 @@ impl ArchivistEngine {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let interval_due =
|
let interval_due = turn_count > 0
|
||||||
turn_count > 0 && self.config.interval > 0 && turn_count.is_multiple_of(self.config.interval);
|
&& self.config.interval > 0
|
||||||
|
&& turn_count.is_multiple_of(self.config.interval);
|
||||||
let pressure_due = pressure >= self.config.threshold;
|
let pressure_due = pressure >= self.config.threshold;
|
||||||
if !interval_due && !pressure_due {
|
if !interval_due && !pressure_due {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|
@ -222,13 +220,7 @@ impl ArchivistEngine {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let output_label = format!("{SYNTHESIS_DIR}/{end_date}");
|
let output_label = format!("{SYNTHESIS_DIR}/{end_date}");
|
||||||
let body = render_synthesis(
|
let body = render_synthesis(&synthesis, start_date, end_date, entries.len(), &model);
|
||||||
&synthesis,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
entries.len(),
|
|
||||||
&model,
|
|
||||||
);
|
|
||||||
repo.write(&output_label, &body).await?;
|
repo.write(&output_label, &body).await?;
|
||||||
|
|
||||||
let report = SynthesisReport {
|
let report = SynthesisReport {
|
||||||
|
|
@ -276,7 +268,8 @@ impl ArchivistEngine {
|
||||||
let (response, strain) = llm.chat_completion_with_strain(request).await?;
|
let (response, strain) = llm.chat_completion_with_strain(request).await?;
|
||||||
|
|
||||||
for event in &strain {
|
for event in &strain {
|
||||||
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event {
|
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event
|
||||||
|
{
|
||||||
tracing::info!("archivist felt inference strain: {} on {}", status, model);
|
tracing::info!("archivist felt inference strain: {} on {}", status, model);
|
||||||
if *status == 429 {
|
if *status == 429 {
|
||||||
let current = self.rate_delay.load(Ordering::Relaxed);
|
let current = self.rate_delay.load(Ordering::Relaxed);
|
||||||
|
|
@ -407,7 +400,12 @@ async fn collect_journal_entries(
|
||||||
fn format_journal_input(entries: &[JournalEntry]) -> String {
|
fn format_journal_input(entries: &[JournalEntry]) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let chunk = format!("=== {} ({}) ===\n{}\n\n", entry.date, entry.label, entry.body.trim());
|
let chunk = format!(
|
||||||
|
"=== {} ({}) ===\n{}\n\n",
|
||||||
|
entry.date,
|
||||||
|
entry.label,
|
||||||
|
entry.body.trim()
|
||||||
|
);
|
||||||
// Always keep at least the first (oldest) entry, even if it alone
|
// Always keep at least the first (oldest) entry, even if it alone
|
||||||
// exceeds the cap — truncate it rather than emit nothing.
|
// exceeds the cap — truncate it rather than emit nothing.
|
||||||
if out.is_empty() {
|
if out.is_empty() {
|
||||||
|
|
@ -458,30 +456,18 @@ fn render_synthesis(
|
||||||
/// Map a configured synthesis element to its prompt line (label + budget).
|
/// Map a configured synthesis element to its prompt line (label + budget).
|
||||||
fn element_spec(el: &SynthesisElement) -> (&'static str, &'static str) {
|
fn element_spec(el: &SynthesisElement) -> (&'static str, &'static str) {
|
||||||
match el {
|
match el {
|
||||||
SynthesisElement::Themes => (
|
SynthesisElement::Themes => ("Themes", "[3-5 recurring topics, ~10 words each]"),
|
||||||
"Themes",
|
SynthesisElement::Emotions => ("Emotional Tone", "[dominant felt sense, ~20 words]"),
|
||||||
"[3-5 recurring topics, ~10 words each]",
|
|
||||||
),
|
|
||||||
SynthesisElement::Emotions => (
|
|
||||||
"Emotional Tone",
|
|
||||||
"[dominant felt sense, ~20 words]",
|
|
||||||
),
|
|
||||||
SynthesisElement::Tensions => (
|
SynthesisElement::Tensions => (
|
||||||
"Unresolved Tensions",
|
"Unresolved Tensions",
|
||||||
"[threads that still need attention, ~30 words]",
|
"[threads that still need attention, ~30 words]",
|
||||||
),
|
),
|
||||||
SynthesisElement::Anchors => (
|
SynthesisElement::Anchors => ("Anchors", "[stable reference points, ~20 words]"),
|
||||||
"Anchors",
|
|
||||||
"[stable reference points, ~20 words]",
|
|
||||||
),
|
|
||||||
SynthesisElement::Evolution => (
|
SynthesisElement::Evolution => (
|
||||||
"Evolution",
|
"Evolution",
|
||||||
"[how perspectives shifted this period, ~40 words]",
|
"[how perspectives shifted this period, ~40 words]",
|
||||||
),
|
),
|
||||||
SynthesisElement::Patterns => (
|
SynthesisElement::Patterns => ("Patterns", "[recurring behaviors, ~30 words]"),
|
||||||
"Patterns",
|
|
||||||
"[recurring behaviors, ~30 words]",
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,10 +517,7 @@ mod tests {
|
||||||
fn parse_covers_end_reads_marker() {
|
fn parse_covers_end_reads_marker() {
|
||||||
let body = "<!-- archivist: covers 2026-04-02..2026-04-14, 7 entries, \
|
let body = "<!-- archivist: covers 2026-04-02..2026-04-14, 7 entries, \
|
||||||
synthesized 2026-05-15T10:00:00Z via openai/glm-5.1 -->\n\n# Synthesis";
|
synthesized 2026-05-15T10:00:00Z via openai/glm-5.1 -->\n\n# Synthesis";
|
||||||
assert_eq!(
|
assert_eq!(parse_covers_end(body), NaiveDate::from_ymd_opt(2026, 4, 14),);
|
||||||
parse_covers_end(body),
|
|
||||||
NaiveDate::from_ymd_opt(2026, 4, 14),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -573,7 +556,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_includes_only_configured_elements() {
|
fn prompt_includes_only_configured_elements() {
|
||||||
let prompt = archivist_system_prompt(&[SynthesisElement::Themes, SynthesisElement::Anchors]);
|
let prompt =
|
||||||
|
archivist_system_prompt(&[SynthesisElement::Themes, SynthesisElement::Anchors]);
|
||||||
assert!(prompt.contains("- Themes:"));
|
assert!(prompt.contains("- Themes:"));
|
||||||
assert!(prompt.contains("- Anchors:"));
|
assert!(prompt.contains("- Anchors:"));
|
||||||
assert!(!prompt.contains("- Evolution:"));
|
assert!(!prompt.contains("- Evolution:"));
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,7 @@ pub fn hint_for_launch(count: u32, agent_count: u32) -> String {
|
||||||
}
|
}
|
||||||
// First few launches: brief orientation
|
// First few launches: brief orientation
|
||||||
(1..=3, _) if agent_count == 0 => {
|
(1..=3, _) if agent_count == 0 => {
|
||||||
"No agents yet. Press 'a' to create one, or check the Settings screen."
|
"No agents yet. Press 'a' to create one, or check the Settings screen.".to_string()
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
// Seasoned user: no hint
|
// Seasoned user: no hint
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
|
|
@ -217,8 +216,7 @@ pub fn gather_probe(home: &Path) -> BootstrapProbe {
|
||||||
let launch_count = read_launch_count(&souveraine_dir);
|
let launch_count = read_launch_count(&souveraine_dir);
|
||||||
|
|
||||||
// Federation env override
|
// Federation env override
|
||||||
let force_federation =
|
let force_federation = std::env::var("SOUVERAINE_SETUP").as_deref() == Ok("federation");
|
||||||
std::env::var("SOUVERAINE_SETUP").as_deref() == Ok("federation");
|
|
||||||
|
|
||||||
// Increment launch count for next time
|
// Increment launch count for next time
|
||||||
let _ = increment_launch_count(&souveraine_dir);
|
let _ = increment_launch_count(&souveraine_dir);
|
||||||
|
|
@ -400,7 +398,14 @@ mod tests {
|
||||||
let labels: Vec<&str> = plan.phases.iter().map(|p| p.label()).collect();
|
let labels: Vec<&str> = plan.phases.iter().map(|p| p.label()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
labels,
|
labels,
|
||||||
vec!["splash", "probe", "resolve", "setup-wizard", "background-tasks", "enter-tui"]
|
vec![
|
||||||
|
"splash",
|
||||||
|
"probe",
|
||||||
|
"resolve",
|
||||||
|
"setup-wizard",
|
||||||
|
"background-tasks",
|
||||||
|
"enter-tui"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,7 +422,13 @@ mod tests {
|
||||||
// No setup-wizard, no hint (seasoned user)
|
// No setup-wizard, no hint (seasoned user)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
labels,
|
labels,
|
||||||
vec!["splash", "probe", "resolve", "background-tasks", "enter-tui"]
|
vec![
|
||||||
|
"splash",
|
||||||
|
"probe",
|
||||||
|
"resolve",
|
||||||
|
"background-tasks",
|
||||||
|
"enter-tui"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#![allow(dead_code)] // WIP scaffolding not yet wired
|
#![allow(dead_code)] // WIP scaffolding not yet wired
|
||||||
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::core::config::ConsciousnessConfig;
|
use crate::core::config::ConsciousnessConfig;
|
||||||
|
|
||||||
|
|
@ -21,6 +21,10 @@ impl ChainOrchestrator {
|
||||||
talking_enabled: bool,
|
talking_enabled: bool,
|
||||||
thinking_enabled: bool,
|
thinking_enabled: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
Ok(Self { config, talking_enabled, thinking_enabled })
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
talking_enabled,
|
||||||
|
thinking_enabled,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,28 +56,25 @@ impl Default for CompactionConfig {
|
||||||
impl CompactionConfig {
|
impl CompactionConfig {
|
||||||
/// Resolve the effective config for a given agent type.
|
/// Resolve the effective config for a given agent type.
|
||||||
pub fn for_agent_type(&self, agent_type: &str) -> AgentCompactionConfig {
|
pub fn for_agent_type(&self, agent_type: &str) -> AgentCompactionConfig {
|
||||||
self.per_type
|
self.per_type.get(agent_type).cloned().unwrap_or_else(|| {
|
||||||
.get(agent_type)
|
// No explicit [compaction.per_type] entry. The subconscious
|
||||||
.cloned()
|
// leans on sliding_reflect — its preservation fork catches her
|
||||||
.unwrap_or_else(|| {
|
// threads before the cut, which is what an unattended pass
|
||||||
// No explicit [compaction.per_type] entry. The subconscious
|
// needs. Everything else uses the global default. She can
|
||||||
// leans on sliding_reflect — its preservation fork catches her
|
// still name any strategy herself; this is only the default.
|
||||||
// threads before the cut, which is what an unattended pass
|
let strategy = match agent_type {
|
||||||
// needs. Everything else uses the global default. She can
|
"subconscious" => CompactionStrategyKind::SlidingReflect,
|
||||||
// still name any strategy herself; this is only the default.
|
_ => self.strategy.clone(),
|
||||||
let strategy = match agent_type {
|
};
|
||||||
"subconscious" => CompactionStrategyKind::SlidingReflect,
|
AgentCompactionConfig {
|
||||||
_ => self.strategy.clone(),
|
enabled: self.enabled,
|
||||||
};
|
strategy,
|
||||||
AgentCompactionConfig {
|
warn_pressure: self.warn_pressure,
|
||||||
enabled: self.enabled,
|
urgent_pressure: self.urgent_pressure,
|
||||||
strategy,
|
critical_pressure: self.critical_pressure,
|
||||||
warn_pressure: self.warn_pressure,
|
..Default::default()
|
||||||
urgent_pressure: self.urgent_pressure,
|
}
|
||||||
critical_pressure: self.critical_pressure,
|
})
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +94,7 @@ pub struct AgentCompactionConfig {
|
||||||
#[serde(default = "default_critical_pressure")]
|
#[serde(default = "default_critical_pressure")]
|
||||||
pub critical_pressure: f32,
|
pub critical_pressure: f32,
|
||||||
/// Max summary length in chars (Summary strategy). Generous default;
|
/// 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")]
|
#[serde(default = "default_max_summary")]
|
||||||
pub max_summary_length: usize,
|
pub max_summary_length: usize,
|
||||||
/// Target KV pair count (KeyValue strategy).
|
/// Target KV pair count (KeyValue strategy).
|
||||||
|
|
@ -174,11 +171,27 @@ impl CompactionStrategyKind {
|
||||||
|
|
||||||
// ── Default helpers ──
|
// ── Default helpers ──
|
||||||
|
|
||||||
fn default_enabled() -> bool { true }
|
fn default_enabled() -> bool {
|
||||||
fn default_strategy() -> CompactionStrategyKind { CompactionStrategyKind::Cull }
|
true
|
||||||
fn default_warn_pressure() -> f32 { 0.80 }
|
}
|
||||||
fn default_urgent_pressure() -> f32 { 0.90 }
|
fn default_strategy() -> CompactionStrategyKind {
|
||||||
fn default_critical_pressure() -> f32 { 0.95 }
|
CompactionStrategyKind::Cull
|
||||||
fn default_max_summary() -> usize { 32000 }
|
}
|
||||||
fn default_kv_target() -> usize { 16 }
|
fn default_warn_pressure() -> f32 {
|
||||||
fn default_preserve_recent() -> usize { 40 }
|
0.80
|
||||||
|
}
|
||||||
|
fn default_urgent_pressure() -> f32 {
|
||||||
|
0.90
|
||||||
|
}
|
||||||
|
fn default_critical_pressure() -> f32 {
|
||||||
|
0.95
|
||||||
|
}
|
||||||
|
fn default_max_summary() -> usize {
|
||||||
|
32000
|
||||||
|
}
|
||||||
|
fn default_kv_target() -> usize {
|
||||||
|
16
|
||||||
|
}
|
||||||
|
fn default_preserve_recent() -> usize {
|
||||||
|
40
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ pub mod strategy;
|
||||||
|
|
||||||
pub use config::{CompactionConfig, CompactionStrategyKind};
|
pub use config::{CompactionConfig, CompactionStrategyKind};
|
||||||
pub use plan::{AuditEntry, CompactionPlan, CompactionReport};
|
pub use plan::{AuditEntry, CompactionPlan, CompactionReport};
|
||||||
pub use strategy::{CompactionStrategy, CullStrategy, MicrocompactStrategy, SlidingReflectStrategy, SlidingWindowStrategy, SummaryStrategy};
|
pub use strategy::{
|
||||||
|
CompactionStrategy, CullStrategy, MicrocompactStrategy, SlidingReflectStrategy,
|
||||||
|
SlidingWindowStrategy, SummaryStrategy,
|
||||||
|
};
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -71,11 +74,7 @@ pub trait CompactionEngine: Send + Sync {
|
||||||
) -> anyhow::Result<CompactionReport>;
|
) -> anyhow::Result<CompactionReport>;
|
||||||
|
|
||||||
/// Write the audit entry to the agent's memory repo.
|
/// Write the audit entry to the agent's memory repo.
|
||||||
async fn write_audit(
|
async fn write_audit(&self, agent_id: &str, entry: &AuditEntry) -> anyhow::Result<PathBuf>;
|
||||||
&self,
|
|
||||||
agent_id: &str,
|
|
||||||
entry: &AuditEntry,
|
|
||||||
) -> anyhow::Result<PathBuf>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Concrete compaction engine that ties together config, strategies,
|
/// Concrete compaction engine that ties together config, strategies,
|
||||||
|
|
@ -106,8 +105,7 @@ impl CompactionEngine for DefaultCompactionEngine {
|
||||||
strategy_override: Option<CompactionStrategyKind>,
|
strategy_override: Option<CompactionStrategyKind>,
|
||||||
) -> anyhow::Result<CompactionReport> {
|
) -> anyhow::Result<CompactionReport> {
|
||||||
// Resolve config early so we can bail before requiring a session
|
// Resolve config early so we can bail before requiring a session
|
||||||
let agent_type = (self.get_agent_type)(agent_id)
|
let agent_type = (self.get_agent_type)(agent_id).unwrap_or_else(|| "primary".to_string());
|
||||||
.unwrap_or_else(|| "primary".to_string());
|
|
||||||
let (cfg, reflect_prompt, summary_prompt) = {
|
let (cfg, reflect_prompt, summary_prompt) = {
|
||||||
let app_config = self.config.read().await;
|
let app_config = self.config.read().await;
|
||||||
(
|
(
|
||||||
|
|
@ -161,10 +159,7 @@ impl CompactionEngine for DefaultCompactionEngine {
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|n| reg.get(n))
|
.and_then(|n| reg.get(n))
|
||||||
.unwrap_or_else(|| reg.default_provider());
|
.unwrap_or_else(|| reg.default_provider());
|
||||||
let model = self
|
let model = self.model.as_deref().unwrap_or("openai/kimi-k2.6");
|
||||||
.model
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("openai/kimi-k2.6");
|
|
||||||
let s = SummaryStrategy {
|
let s = SummaryStrategy {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
|
|
@ -193,10 +188,7 @@ impl CompactionEngine for DefaultCompactionEngine {
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|n| reg.get(n))
|
.and_then(|n| reg.get(n))
|
||||||
.unwrap_or_else(|| reg.default_provider());
|
.unwrap_or_else(|| reg.default_provider());
|
||||||
let model = self
|
let model = self.model.as_deref().unwrap_or("openai/kimi-k2.6");
|
||||||
.model
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("openai/kimi-k2.6");
|
|
||||||
// The preservation pass runs as a fresh fork of *this*
|
// The preservation pass runs as a fresh fork of *this*
|
||||||
// agent — load her persona so the fork wakes as her.
|
// agent — load her persona so the fork wakes as her.
|
||||||
let agent_persona = (self.get_repo)(agent_id)
|
let agent_persona = (self.get_repo)(agent_id)
|
||||||
|
|
@ -222,7 +214,9 @@ impl CompactionEngine for DefaultCompactionEngine {
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!("[sliding_reflect] no provider registry, falling back to plain sliding window");
|
tracing::warn!("[sliding_reflect] no provider registry, falling back to plain sliding window");
|
||||||
SlidingWindowStrategy.plan(&messages, &cfg, &self.counter).await?
|
SlidingWindowStrategy
|
||||||
|
.plan(&messages, &cfg, &self.counter)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -303,11 +297,7 @@ impl CompactionEngine for DefaultCompactionEngine {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_audit(
|
async fn write_audit(&self, agent_id: &str, entry: &AuditEntry) -> anyhow::Result<PathBuf> {
|
||||||
&self,
|
|
||||||
agent_id: &str,
|
|
||||||
entry: &AuditEntry,
|
|
||||||
) -> anyhow::Result<PathBuf> {
|
|
||||||
let repo = (self.get_repo)(agent_id)
|
let repo = (self.get_repo)(agent_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("No memory repo for agent {}", agent_id))?;
|
.ok_or_else(|| anyhow::anyhow!("No memory repo for agent {}", agent_id))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@ impl CompactionPlan {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.summary_text.is_none()
|
self.summary_text.is_none() && self.culled_count == 0 && self.replacement_messages.is_none()
|
||||||
&& self.culled_count == 0
|
|
||||||
&& self.replacement_messages.is_none()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +130,11 @@ impl std::fmt::Display for CompactionReport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref path) = self.audit_path {
|
if let Some(ref path) = self.audit_path {
|
||||||
write!(f, "(The record of this shrinking rests at {}.)", path.display())?;
|
write!(
|
||||||
|
f,
|
||||||
|
"(The record of this shrinking rests at {}.)",
|
||||||
|
path.display()
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -160,13 +162,20 @@ impl AuditEntry {
|
||||||
body.push_str("## Compaction Summary\n\n");
|
body.push_str("## Compaction Summary\n\n");
|
||||||
body.push_str(&format!(
|
body.push_str(&format!(
|
||||||
"Strategy: {}\nMessages: {} → {}\nTokens: {} → {}\n",
|
"Strategy: {}\nMessages: {} → {}\nTokens: {} → {}\n",
|
||||||
self.strategy, self.before_messages, self.after_messages, self.before_tokens, self.after_tokens
|
self.strategy,
|
||||||
|
self.before_messages,
|
||||||
|
self.after_messages,
|
||||||
|
self.before_tokens,
|
||||||
|
self.after_tokens
|
||||||
));
|
));
|
||||||
if let Some(ref summary) = self.summary_text {
|
if let Some(ref summary) = self.summary_text {
|
||||||
body.push_str(&format!("\n## Summary Content\n\n{}\n", summary));
|
body.push_str(&format!("\n## Summary Content\n\n{}\n", summary));
|
||||||
}
|
}
|
||||||
if self.culled_count > 0 {
|
if self.culled_count > 0 {
|
||||||
body.push_str(&format!("\nMessages dropped or rewritten: {}\n", self.culled_count));
|
body.push_str(&format!(
|
||||||
|
"\nMessages dropped or rewritten: {}\n",
|
||||||
|
self.culled_count
|
||||||
|
));
|
||||||
}
|
}
|
||||||
format!("---\n{}---\n{}", yaml, body)
|
format!("---\n{}---\n{}", yaml, body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ use super::plan::CompactionPlan;
|
||||||
|
|
||||||
/// Tools whose results are considered compactable (large outputs, rarely
|
/// Tools whose results are considered compactable (large outputs, rarely
|
||||||
/// needed verbatim once surpassed). Matches Souveraine's actual sensor names.
|
/// needed verbatim once surpassed). Matches Souveraine's actual sensor names.
|
||||||
const COMPACTABLE_TOOLS: &[&str] = &[
|
const COMPACTABLE_TOOLS: &[&str] = &["read", "bash", "grep", "glob", "list_dir", "edit", "write"];
|
||||||
"read", "bash", "grep", "glob", "list_dir", "edit", "write",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Placeholder text written into tool result blocks that get microcompacted.
|
/// Placeholder text written into tool result blocks that get microcompacted.
|
||||||
/// Placeholder used so logs read consistently across runs.
|
/// Placeholder used so logs read consistently across runs.
|
||||||
|
|
@ -146,7 +144,10 @@ impl CompactionStrategy for SummaryStrategy {
|
||||||
|
|
||||||
let user_prompt = match &self.prompt_override {
|
let user_prompt = match &self.prompt_override {
|
||||||
Some(custom) => format!("{}\n\nConversation to summarize:\n\n{}", custom, truncated),
|
Some(custom) => format!("{}\n\nConversation to summarize:\n\n{}", custom, truncated),
|
||||||
None => format!("{}\n\nConversation to summarize:\n\n{}", SUMMARY_USER_PROMPT, truncated),
|
None => format!(
|
||||||
|
"{}\n\nConversation to summarize:\n\n{}",
|
||||||
|
SUMMARY_USER_PROMPT, truncated
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let summary = bifrost_complete(
|
let summary = bifrost_complete(
|
||||||
|
|
@ -189,9 +190,17 @@ fn render_segment_for_summary(messages: &[ConversationMessage]) -> String {
|
||||||
ContentBlock::ToolUse { name, input, .. } => {
|
ContentBlock::ToolUse { name, input, .. } => {
|
||||||
out.push_str(&format!("[{} -> tool_call:{}] {}\n", role, name, input));
|
out.push_str(&format!("[{} -> tool_call:{}] {}\n", role, name, input));
|
||||||
}
|
}
|
||||||
ContentBlock::ToolResult { tool_name, output, is_error, .. } => {
|
ContentBlock::ToolResult {
|
||||||
|
tool_name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
let prefix = if *is_error { "ERROR " } else { "" };
|
let prefix = if *is_error { "ERROR " } else { "" };
|
||||||
out.push_str(&format!("[{} <- tool_result:{}] {}{}\n", role, tool_name, prefix, output));
|
out.push_str(&format!(
|
||||||
|
"[{} <- tool_result:{}] {}{}\n",
|
||||||
|
role, tool_name, prefix, output
|
||||||
|
));
|
||||||
}
|
}
|
||||||
ContentBlock::Reasoning { .. } => {}
|
ContentBlock::Reasoning { .. } => {}
|
||||||
ContentBlock::Image { media_type, .. } => {
|
ContentBlock::Image { media_type, .. } => {
|
||||||
|
|
@ -266,9 +275,13 @@ impl CompactionStrategy for MicrocompactStrategy {
|
||||||
let mut new_blocks: Vec<ContentBlock> = Vec::with_capacity(msg.blocks.len());
|
let mut new_blocks: Vec<ContentBlock> = Vec::with_capacity(msg.blocks.len());
|
||||||
for block in &msg.blocks {
|
for block in &msg.blocks {
|
||||||
match block {
|
match block {
|
||||||
ContentBlock::ToolResult { tool_use_id, tool_name, output, is_error }
|
ContentBlock::ToolResult {
|
||||||
if clear_set.contains(tool_use_id.as_str())
|
tool_use_id,
|
||||||
&& output != TIME_BASED_MC_CLEARED_MESSAGE =>
|
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);
|
tokens_saved += counter.count(output);
|
||||||
cleared_count += 1;
|
cleared_count += 1;
|
||||||
|
|
@ -349,9 +362,12 @@ fn is_load_bearing(msg: &ConversationMessage) -> bool {
|
||||||
if matches!(msg.role, MessageRole::System | MessageRole::Tool) {
|
if matches!(msg.role, MessageRole::System | MessageRole::Tool) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
msg.blocks
|
msg.blocks.iter().any(|b| {
|
||||||
.iter()
|
matches!(
|
||||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }))
|
b,
|
||||||
|
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -571,7 +587,10 @@ impl CompactionStrategy for SlidingReflectStrategy {
|
||||||
|
|
||||||
// Run the preservation pass on the about-to-be-evicted segment.
|
// Run the preservation pass on the about-to-be-evicted segment.
|
||||||
let transcript = render_segment_for_summary(evicted);
|
let transcript = render_segment_for_summary(evicted);
|
||||||
let truncated: String = transcript.chars().take(config.max_summary_length * 4).collect();
|
let truncated: String = transcript
|
||||||
|
.chars()
|
||||||
|
.take(config.max_summary_length * 4)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let user_prompt = match &self.prompt_override {
|
let user_prompt = match &self.prompt_override {
|
||||||
Some(custom) => format!("{}\n\n{}", custom, truncated),
|
Some(custom) => format!("{}\n\n{}", custom, truncated),
|
||||||
|
|
@ -608,7 +627,10 @@ impl CompactionStrategy for SlidingReflectStrategy {
|
||||||
}
|
}
|
||||||
Ok(_) => None,
|
Ok(_) => None,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("[sliding_reflect] preservation pass failed, falling back to plain slide: {}", e);
|
tracing::warn!(
|
||||||
|
"[sliding_reflect] preservation pass failed, falling back to plain slide: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -639,7 +661,9 @@ mod tests {
|
||||||
fn text_msg(role: MessageRole, text: &str) -> ConversationMessage {
|
fn text_msg(role: MessageRole, text: &str) -> ConversationMessage {
|
||||||
ConversationMessage {
|
ConversationMessage {
|
||||||
role,
|
role,
|
||||||
blocks: vec![ContentBlock::Text { text: text.to_string() }],
|
blocks: vec![ContentBlock::Text {
|
||||||
|
text: text.to_string(),
|
||||||
|
}],
|
||||||
usage: None,
|
usage: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
}
|
}
|
||||||
|
|
@ -661,19 +685,34 @@ mod tests {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let counter = TokenCounter::new();
|
let counter = TokenCounter::new();
|
||||||
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
|
let plan = CullStrategy
|
||||||
|
.plan(&messages, &config, &counter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(plan.culled_count > 0, "should cull some messages");
|
assert!(plan.culled_count > 0, "should cull some messages");
|
||||||
assert!(plan.summary_text.is_none(), "cull should not produce summary content");
|
assert!(
|
||||||
assert!(plan.replacement_messages.is_none(), "cull should not produce replacement messages");
|
plan.summary_text.is_none(),
|
||||||
|
"cull should not produce summary content"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plan.replacement_messages.is_none(),
|
||||||
|
"cull should not produce replacement messages"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_cull_preserves_substance() {
|
async fn test_cull_preserves_substance() {
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
text_msg(MessageRole::System, "System prompt"),
|
text_msg(MessageRole::System, "System prompt"),
|
||||||
text_msg(MessageRole::User, "This is an important question about the architecture."),
|
text_msg(
|
||||||
text_msg(MessageRole::Assistant, "Let me explain the design decisions."),
|
MessageRole::User,
|
||||||
|
"This is an important question about the architecture.",
|
||||||
|
),
|
||||||
|
text_msg(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
"Let me explain the design decisions.",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let config = AgentCompactionConfig {
|
let config = AgentCompactionConfig {
|
||||||
|
|
@ -681,7 +720,10 @@ mod tests {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let counter = TokenCounter::new();
|
let counter = TokenCounter::new();
|
||||||
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
|
let plan = CullStrategy
|
||||||
|
.plan(&messages, &config, &counter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(plan.culled_count, 0, "should not cull substantive messages");
|
assert_eq!(plan.culled_count, 0, "should not cull substantive messages");
|
||||||
}
|
}
|
||||||
|
|
@ -713,16 +755,25 @@ mod tests {
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
},
|
},
|
||||||
text_msg(MessageRole::User, "ok"),
|
text_msg(MessageRole::User, "ok"),
|
||||||
text_msg(MessageRole::Assistant, "A substantive reply about something."),
|
text_msg(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
"A substantive reply about something.",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
let config = AgentCompactionConfig {
|
let config = AgentCompactionConfig {
|
||||||
preserve_recent: 1,
|
preserve_recent: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let counter = TokenCounter::new();
|
let counter = TokenCounter::new();
|
||||||
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
|
let plan = CullStrategy
|
||||||
|
.plan(&messages, &config, &counter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
// The tool result is at index 1 — must be in keep_indices.
|
// The tool result is at index 1 — must be in keep_indices.
|
||||||
assert!(plan.keep_indices.contains(&1), "tool result must be preserved");
|
assert!(
|
||||||
|
plan.keep_indices.contains(&1),
|
||||||
|
"tool result must be preserved"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -739,15 +790,24 @@ mod tests {
|
||||||
usage: None,
|
usage: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
},
|
},
|
||||||
text_msg(MessageRole::Assistant, "Substantive narrative continuation."),
|
text_msg(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
"Substantive narrative continuation.",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
let config = AgentCompactionConfig {
|
let config = AgentCompactionConfig {
|
||||||
preserve_recent: 1,
|
preserve_recent: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let counter = TokenCounter::new();
|
let counter = TokenCounter::new();
|
||||||
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
|
let plan = CullStrategy
|
||||||
assert!(plan.keep_indices.contains(&1), "assistant tool-call message must be preserved");
|
.plan(&messages, &config, &counter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
plan.keep_indices.contains(&1),
|
||||||
|
"assistant tool-call message must be preserved"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -766,7 +826,10 @@ mod tests {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let counter = TokenCounter::new();
|
let counter = TokenCounter::new();
|
||||||
let plan = SlidingWindowStrategy.plan(&messages, &config, &counter).await.unwrap();
|
let plan = SlidingWindowStrategy
|
||||||
|
.plan(&messages, &config, &counter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
// Must keep index 0 (system) and the last 2 (recent pair).
|
// Must keep index 0 (system) and the last 2 (recent pair).
|
||||||
assert!(plan.keep_indices.contains(&0), "system anchor preserved");
|
assert!(plan.keep_indices.contains(&0), "system anchor preserved");
|
||||||
assert!(plan.keep_indices.contains(&5));
|
assert!(plan.keep_indices.contains(&5));
|
||||||
|
|
@ -813,10 +876,16 @@ mod tests {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let counter = TokenCounter::new();
|
let counter = TokenCounter::new();
|
||||||
let plan = SlidingWindowStrategy.plan(&messages, &config, &counter).await.unwrap();
|
let plan = SlidingWindowStrategy
|
||||||
|
.plan(&messages, &config, &counter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
// Either both 2 and 3 are kept, or neither is (we don't cut between them).
|
// Either both 2 and 3 are kept, or neither is (we don't cut between them).
|
||||||
let has_call = plan.keep_indices.contains(&2);
|
let has_call = plan.keep_indices.contains(&2);
|
||||||
let has_result = plan.keep_indices.contains(&3);
|
let has_result = plan.keep_indices.contains(&3);
|
||||||
assert_eq!(has_call, has_result, "tool call and result must be kept together");
|
assert_eq!(
|
||||||
|
has_call, has_result,
|
||||||
|
"tool call and result must be kept together"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
#![allow(dead_code)] // WIP scaffolding not yet wired
|
#![allow(dead_code)] // WIP scaffolding not yet wired
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::core::compact::CompactionConfig;
|
use crate::core::compact::CompactionConfig;
|
||||||
|
|
||||||
|
|
@ -783,7 +783,6 @@ pub enum N1Trigger {
|
||||||
Manual,
|
Manual,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -794,26 +793,37 @@ pub enum ReflectionTrigger {
|
||||||
CompactionEvent,
|
CompactionEvent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub enum BandwidthClass {
|
pub enum BandwidthClass {
|
||||||
#[default]
|
#[default]
|
||||||
High, Medium, Low, Minimal,
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
Minimal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SynthesisElement {
|
pub enum SynthesisElement {
|
||||||
Themes, Emotions, Tensions, Anchors, Evolution, Patterns,
|
Themes,
|
||||||
|
Emotions,
|
||||||
|
Tensions,
|
||||||
|
Anchors,
|
||||||
|
Evolution,
|
||||||
|
Patterns,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TaskType {
|
pub enum TaskType {
|
||||||
Conversation, Synthesis, Reflection, Research, FastResponse, Coding,
|
Conversation,
|
||||||
|
Synthesis,
|
||||||
|
Reflection,
|
||||||
|
Research,
|
||||||
|
FastResponse,
|
||||||
|
Coding,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Model Physics ──
|
// ── Model Physics ──
|
||||||
|
|
@ -879,11 +889,21 @@ impl Default for ImageConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_image_max_width() -> u32 { 2000 }
|
fn default_image_max_width() -> u32 {
|
||||||
fn default_image_max_height() -> u32 { 2000 }
|
2000
|
||||||
fn default_image_max_pixels() -> u32 { 25_000_000 }
|
}
|
||||||
fn default_image_max_bytes() -> usize { 5 * 1024 * 1024 }
|
fn default_image_max_height() -> u32 {
|
||||||
fn default_image_jpeg_quality() -> u8 { 85 }
|
2000
|
||||||
|
}
|
||||||
|
fn default_image_max_pixels() -> u32 {
|
||||||
|
25_000_000
|
||||||
|
}
|
||||||
|
fn default_image_max_bytes() -> usize {
|
||||||
|
5 * 1024 * 1024
|
||||||
|
}
|
||||||
|
fn default_image_jpeg_quality() -> u8 {
|
||||||
|
85
|
||||||
|
}
|
||||||
|
|
||||||
// ── Agent Reflection Settings ──
|
// ── Agent Reflection Settings ──
|
||||||
|
|
||||||
|
|
@ -1093,7 +1113,9 @@ impl Default for PresenceConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_pulse_interval() -> u64 { 600 }
|
fn default_pulse_interval() -> u64 {
|
||||||
|
600
|
||||||
|
}
|
||||||
|
|
||||||
// ── TUI ──
|
// ── TUI ──
|
||||||
|
|
||||||
|
|
@ -1128,9 +1150,13 @@ impl Default for TuiConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_cenno_word_threshold() -> usize { 30 }
|
fn default_cenno_word_threshold() -> usize {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
fn default_stale_timeout_secs() -> u64 { 90 }
|
fn default_stale_timeout_secs() -> u64 {
|
||||||
|
90
|
||||||
|
}
|
||||||
|
|
||||||
// ── Federation ──
|
// ── Federation ──
|
||||||
|
|
||||||
|
|
@ -1251,10 +1277,18 @@ impl Default for VoiceConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_stt_url() -> String { "http://127.0.0.1:7862".to_string() }
|
fn default_stt_url() -> String {
|
||||||
fn default_tts_url() -> String { "http://127.0.0.1:7861".to_string() }
|
"http://127.0.0.1:7862".to_string()
|
||||||
fn default_voice_id() -> String { "en-Soother_woman".to_string() }
|
}
|
||||||
fn default_ptt_key() -> String { "Space".to_string() }
|
fn default_tts_url() -> String {
|
||||||
|
"http://127.0.0.1:7861".to_string()
|
||||||
|
}
|
||||||
|
fn default_voice_id() -> String {
|
||||||
|
"en-Soother_woman".to_string()
|
||||||
|
}
|
||||||
|
fn default_ptt_key() -> String {
|
||||||
|
"Space".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl ConsciousnessConfig {
|
impl ConsciousnessConfig {
|
||||||
/// Walk the standard config paths and return the first existing one.
|
/// Walk the standard config paths and return the first existing one.
|
||||||
|
|
@ -1298,28 +1332,72 @@ impl ConsciousnessConfig {
|
||||||
|
|
||||||
// ── Default helper fns ──
|
// ── Default helper fns ──
|
||||||
|
|
||||||
fn default_true() -> bool { true }
|
fn default_true() -> bool {
|
||||||
fn default_supports_images() -> bool { true }
|
true
|
||||||
fn default_3() -> usize { 3 }
|
}
|
||||||
fn default_25() -> usize { 25 }
|
fn default_supports_images() -> bool {
|
||||||
fn default_3u32() -> u32 { 3 }
|
true
|
||||||
fn default_25u32() -> u32 { 50 } // default subagent max tool rounds
|
}
|
||||||
fn default_100() -> usize { 100 }
|
fn default_3() -> usize {
|
||||||
fn default_300() -> u64 { 300 }
|
3
|
||||||
fn default_7373() -> u16 { 7373 }
|
}
|
||||||
fn default_128k() -> usize { 128000 }
|
fn default_25() -> usize {
|
||||||
fn default_8k() -> usize { 8192 }
|
25
|
||||||
fn default_threshold_70() -> f32 { 0.7 }
|
}
|
||||||
fn default_subconscious_max_tokens() -> u32 { 8192 }
|
fn default_3u32() -> u32 {
|
||||||
fn default_warning_1_threshold() -> f32 { 0.8 }
|
3
|
||||||
fn default_warning_2_threshold() -> f32 { 0.95 }
|
}
|
||||||
fn default_sub_inter_round_delay() -> u64 { 300 }
|
fn default_25u32() -> u32 {
|
||||||
fn default_auto_model() -> String { "auto".to_string() }
|
50
|
||||||
fn default_bifrost_url() -> String { "http://127.0.0.1:3360".to_string() }
|
} // default subagent max tool rounds
|
||||||
fn default_provider() -> String { "bifrost".to_string() }
|
fn default_100() -> usize {
|
||||||
fn default_server_bind() -> String { "127.0.0.1".to_string() }
|
100
|
||||||
fn default_server_port() -> u16 { 8484 }
|
}
|
||||||
fn default_server_url() -> String { "http://127.0.0.1:8484".to_string() }
|
fn default_300() -> u64 {
|
||||||
|
300
|
||||||
|
}
|
||||||
|
fn default_7373() -> u16 {
|
||||||
|
7373
|
||||||
|
}
|
||||||
|
fn default_128k() -> usize {
|
||||||
|
128000
|
||||||
|
}
|
||||||
|
fn default_8k() -> usize {
|
||||||
|
8192
|
||||||
|
}
|
||||||
|
fn default_threshold_70() -> f32 {
|
||||||
|
0.7
|
||||||
|
}
|
||||||
|
fn default_subconscious_max_tokens() -> u32 {
|
||||||
|
8192
|
||||||
|
}
|
||||||
|
fn default_warning_1_threshold() -> f32 {
|
||||||
|
0.8
|
||||||
|
}
|
||||||
|
fn default_warning_2_threshold() -> f32 {
|
||||||
|
0.95
|
||||||
|
}
|
||||||
|
fn default_sub_inter_round_delay() -> u64 {
|
||||||
|
300
|
||||||
|
}
|
||||||
|
fn default_auto_model() -> String {
|
||||||
|
"auto".to_string()
|
||||||
|
}
|
||||||
|
fn default_bifrost_url() -> String {
|
||||||
|
"http://127.0.0.1:3360".to_string()
|
||||||
|
}
|
||||||
|
fn default_provider() -> String {
|
||||||
|
"bifrost".to_string()
|
||||||
|
}
|
||||||
|
fn default_server_bind() -> String {
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
}
|
||||||
|
fn default_server_port() -> u16 {
|
||||||
|
8484
|
||||||
|
}
|
||||||
|
fn default_server_url() -> String {
|
||||||
|
"http://127.0.0.1:8484".to_string()
|
||||||
|
}
|
||||||
fn default_bifrost_key() -> String {
|
fn default_bifrost_key() -> String {
|
||||||
crate::core::credentials::get_bifrost_key()
|
crate::core::credentials::get_bifrost_key()
|
||||||
}
|
}
|
||||||
|
|
@ -1327,13 +1405,27 @@ fn default_bifrost_key() -> String {
|
||||||
fn default_bifrost_virtual_key() -> String {
|
fn default_bifrost_virtual_key() -> String {
|
||||||
std::env::var("BIFROST_VIRTUAL_KEY").unwrap_or_else(|_| String::new())
|
std::env::var("BIFROST_VIRTUAL_KEY").unwrap_or_else(|_| String::new())
|
||||||
}
|
}
|
||||||
fn default_primary_model() -> String { String::new() }
|
fn default_primary_model() -> String {
|
||||||
fn default_bifrost_timeout() -> u64 { 120 }
|
String::new()
|
||||||
fn default_bandwidth_high() -> BandwidthClass { BandwidthClass::High }
|
}
|
||||||
fn default_presence_breathing() -> String { "breathing_color".to_string() }
|
fn default_bifrost_timeout() -> u64 {
|
||||||
|
120
|
||||||
|
}
|
||||||
|
fn default_bandwidth_high() -> BandwidthClass {
|
||||||
|
BandwidthClass::High
|
||||||
|
}
|
||||||
|
fn default_presence_breathing() -> String {
|
||||||
|
"breathing_color".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_synthesis_elements() -> Vec<SynthesisElement> {
|
fn default_synthesis_elements() -> Vec<SynthesisElement> {
|
||||||
vec![SynthesisElement::Themes, SynthesisElement::Emotions, SynthesisElement::Tensions, SynthesisElement::Anchors, SynthesisElement::Evolution]
|
vec![
|
||||||
|
SynthesisElement::Themes,
|
||||||
|
SynthesisElement::Emotions,
|
||||||
|
SynthesisElement::Tensions,
|
||||||
|
SynthesisElement::Anchors,
|
||||||
|
SynthesisElement::Evolution,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_models() -> HashMap<String, ModelConfig> {
|
fn default_models() -> HashMap<String, ModelConfig> {
|
||||||
|
|
|
||||||
|
|
@ -117,10 +117,7 @@ impl ConversationStore {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_messages(
|
pub async fn load_messages(&self, conversation_id: &str) -> Result<Vec<ConversationMessage>> {
|
||||||
&self,
|
|
||||||
conversation_id: &str,
|
|
||||||
) -> Result<Vec<ConversationMessage>> {
|
|
||||||
let path = self.messages_path(conversation_id);
|
let path = self.messages_path(conversation_id);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,12 @@ impl SeedId {
|
||||||
let public_path = seed_dir.join("public.key");
|
let public_path = seed_dir.join("public.key");
|
||||||
|
|
||||||
if private_path.exists() {
|
if private_path.exists() {
|
||||||
let bytes = std::fs::read(&private_path)
|
let bytes = std::fs::read(&private_path).context("reading seed private key")?;
|
||||||
.context("reading seed private key")?;
|
|
||||||
if bytes.len() != 32 {
|
if bytes.len() != 32 {
|
||||||
anyhow::bail!("seed private key has wrong length: {} (expected 32)", bytes.len());
|
anyhow::bail!(
|
||||||
|
"seed private key has wrong length: {} (expected 32)",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut key_bytes = [0u8; 32];
|
let mut key_bytes = [0u8; 32];
|
||||||
key_bytes.copy_from_slice(&bytes);
|
key_bytes.copy_from_slice(&bytes);
|
||||||
|
|
@ -58,8 +60,7 @@ impl SeedId {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::create_dir_all(seed_dir)
|
std::fs::create_dir_all(seed_dir).context("creating seed-id directory")?;
|
||||||
.context("creating seed-id directory")?;
|
|
||||||
|
|
||||||
let seed = Self::generate();
|
let seed = Self::generate();
|
||||||
|
|
||||||
|
|
@ -94,10 +95,12 @@ impl SeedId {
|
||||||
private_path.display()
|
private_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let bytes = std::fs::read(&private_path)
|
let bytes = std::fs::read(&private_path).context("reading seed private key")?;
|
||||||
.context("reading seed private key")?;
|
|
||||||
if bytes.len() != 32 {
|
if bytes.len() != 32 {
|
||||||
anyhow::bail!("seed private key has wrong length: {} (expected 32)", bytes.len());
|
anyhow::bail!(
|
||||||
|
"seed private key has wrong length: {} (expected 32)",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut key_bytes = [0u8; 32];
|
let mut key_bytes = [0u8; 32];
|
||||||
key_bytes.copy_from_slice(&bytes);
|
key_bytes.copy_from_slice(&bytes);
|
||||||
|
|
@ -134,11 +137,7 @@ impl SeedId {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify using only the public key (for remote peers).
|
/// Verify using only the public key (for remote peers).
|
||||||
pub fn verify_with_pubkey(
|
pub fn verify_with_pubkey(pubkey_bytes: &[u8; 32], data: &[u8], signature: &Signature) -> bool {
|
||||||
pubkey_bytes: &[u8; 32],
|
|
||||||
data: &[u8],
|
|
||||||
signature: &Signature,
|
|
||||||
) -> bool {
|
|
||||||
let key = VerifyingKey::from_bytes(pubkey_bytes);
|
let key = VerifyingKey::from_bytes(pubkey_bytes);
|
||||||
match key {
|
match key {
|
||||||
Ok(vk) => vk.verify(data, signature).is_ok(),
|
Ok(vk) => vk.verify(data, signature).is_ok(),
|
||||||
|
|
@ -157,8 +156,7 @@ impl SeedId {
|
||||||
/// four glyphs from the geometric-shapes palette.
|
/// four glyphs from the geometric-shapes palette.
|
||||||
pub fn glyph_from_pubkey(pubkey: &[u8]) -> String {
|
pub fn glyph_from_pubkey(pubkey: &[u8]) -> String {
|
||||||
const PALETTE: [char; 16] = [
|
const PALETTE: [char; 16] = [
|
||||||
'◇', '◆', '○', '●', '△', '▲', '▽', '▼',
|
'◇', '◆', '○', '●', '△', '▲', '▽', '▼', '□', '■', '◐', '◑', '◒', '◓', '☆', '★',
|
||||||
'□', '■', '◐', '◑', '◒', '◓', '☆', '★',
|
|
||||||
];
|
];
|
||||||
let mut out = String::with_capacity(4);
|
let mut out = String::with_capacity(4);
|
||||||
for byte in pubkey.iter().take(2) {
|
for byte in pubkey.iter().take(2) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
//! Decodes raw image bytes, resizes to fit dimension/pixel budget, then
|
//! Decodes raw image bytes, resizes to fit dimension/pixel budget, then
|
||||||
//! progressively reduces quality and dimension to stay under the byte ceiling.
|
//! progressively reduces quality and dimension to stay under the byte ceiling.
|
||||||
|
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|
||||||
use crate::core::config::ImageConfig;
|
use crate::core::config::ImageConfig;
|
||||||
|
|
@ -100,9 +99,18 @@ impl ResizePipeline {
|
||||||
// JPEG with quality control
|
// JPEG with quality control
|
||||||
let mut jpeg_buf = Vec::new();
|
let mut jpeg_buf = Vec::new();
|
||||||
let mut jpeg_writer = std::io::Cursor::new(&mut jpeg_buf);
|
let mut jpeg_writer = std::io::Cursor::new(&mut jpeg_buf);
|
||||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_writer, quality);
|
let mut encoder =
|
||||||
|
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_writer, quality);
|
||||||
let rgb = img.to_rgb8();
|
let rgb = img.to_rgb8();
|
||||||
if encoder.encode(&rgb, rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).is_ok() {
|
if encoder
|
||||||
|
.encode(
|
||||||
|
&rgb,
|
||||||
|
rgb.width(),
|
||||||
|
rgb.height(),
|
||||||
|
image::ExtendedColorType::Rgb8,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
return Some((jpeg_buf, "image/jpeg".to_string()));
|
return Some((jpeg_buf, "image/jpeg".to_string()));
|
||||||
}
|
}
|
||||||
// PNG fallback (lossless, no quality param)
|
// PNG fallback (lossless, no quality param)
|
||||||
|
|
@ -151,7 +159,10 @@ mod tests {
|
||||||
jpeg_quality: 85,
|
jpeg_quality: 85,
|
||||||
};
|
};
|
||||||
let factor = pipeline.rescale_factor(1024, 768);
|
let factor = pipeline.rescale_factor(1024, 768);
|
||||||
assert!((factor - 1.0).abs() < 0.01, "small image should not be scaled");
|
assert!(
|
||||||
|
(factor - 1.0).abs() < 0.01,
|
||||||
|
"small image should not be scaled"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@
|
||||||
//! - Every write is a git commit (auto-commit)
|
//! - Every write is a git commit (auto-commit)
|
||||||
//! - Paths are relative to the agent's memory directory
|
//! - Paths are relative to the agent's memory directory
|
||||||
|
|
||||||
|
use crate::core::compact::CompactionStrategyKind;
|
||||||
|
use crate::core::tools::defs::ToolContext;
|
||||||
|
use crate::core::tools::ToolDefinition;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
use crate::core::compact::CompactionStrategyKind;
|
|
||||||
use crate::core::tools::defs::ToolContext;
|
|
||||||
use crate::core::tools::ToolDefinition;
|
|
||||||
|
|
||||||
// ── Data Types ─────────────────────────────────────────────
|
// ── Data Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -172,13 +172,16 @@ impl MemoryRepo {
|
||||||
// Check if already a git repo
|
// Check if already a git repo
|
||||||
let git_dir = mem_path.join(".git");
|
let git_dir = mem_path.join(".git");
|
||||||
if git_dir.exists() {
|
if git_dir.exists() {
|
||||||
info!("Memory repo already initialized for agent {}", self.agent_id);
|
info!(
|
||||||
|
"Memory repo already initialized for agent {}",
|
||||||
|
self.agent_id
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize git repo
|
// Initialize git repo
|
||||||
let repo = git2::Repository::init(mem_path)
|
let repo =
|
||||||
.context("initializing git repository for memory")?;
|
git2::Repository::init(mem_path).context("initializing git repository for memory")?;
|
||||||
|
|
||||||
// Set user config for commits (scoped to drop before .await)
|
// Set user config for commits (scoped to drop before .await)
|
||||||
{
|
{
|
||||||
|
|
@ -218,7 +221,8 @@ impl MemoryRepo {
|
||||||
|
|
||||||
// Initial commit
|
// Initial commit
|
||||||
let mut index = repo.index().context("opening git index")?;
|
let mut index = repo.index().context("opening git index")?;
|
||||||
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
|
index
|
||||||
|
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
|
||||||
.context("staging initial memory files")?;
|
.context("staging initial memory files")?;
|
||||||
let tree_id = index.write_tree().context("writing git tree")?;
|
let tree_id = index.write_tree().context("writing git tree")?;
|
||||||
let tree = repo.find_tree(tree_id)?;
|
let tree = repo.find_tree(tree_id)?;
|
||||||
|
|
@ -274,14 +278,16 @@ impl MemoryRepo {
|
||||||
let full_path = self.root.join(path);
|
let full_path = self.root.join(path);
|
||||||
if !full_path.exists() {
|
if !full_path.exists() {
|
||||||
if let Some(parent) = full_path.parent() {
|
if let Some(parent) = full_path.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await
|
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||||
.with_context(|| format!("creating ledger directory: {}", parent.display()))?;
|
format!("creating ledger directory: {}", parent.display())
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
let template = format!(
|
let template = format!(
|
||||||
"---\ndescription: \"{}\"\nread_only: false\ntags:\n - ledger\n---\n\n{}",
|
"---\ndescription: \"{}\"\nread_only: false\ntags:\n - ledger\n---\n\n{}",
|
||||||
description, body
|
description, body
|
||||||
);
|
);
|
||||||
tokio::fs::write(&full_path, &template).await
|
tokio::fs::write(&full_path, &template)
|
||||||
|
.await
|
||||||
.with_context(|| format!("writing ledger file: {}", path))?;
|
.with_context(|| format!("writing ledger file: {}", path))?;
|
||||||
debug!("Created ledger file: {}", path);
|
debug!("Created ledger file: {}", path);
|
||||||
}
|
}
|
||||||
|
|
@ -487,9 +493,9 @@ impl MemoryRepo {
|
||||||
let root = self.root.clone();
|
let root = self.root.clone();
|
||||||
let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())];
|
let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())];
|
||||||
while let Some((dir, rel_prefix)) = stack.pop() {
|
while let Some((dir, rel_prefix)) = stack.pop() {
|
||||||
let mut read_dir = tokio::fs::read_dir(&dir).await.with_context(|| {
|
let mut read_dir = tokio::fs::read_dir(&dir)
|
||||||
format!("walking memory directory: {}", dir.display())
|
.await
|
||||||
})?;
|
.with_context(|| format!("walking memory directory: {}", dir.display()))?;
|
||||||
while let Some(entry) = read_dir.next_entry().await? {
|
while let Some(entry) = read_dir.next_entry().await? {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
if name.starts_with(".git") {
|
if name.starts_with(".git") {
|
||||||
|
|
@ -554,7 +560,8 @@ impl MemoryRepo {
|
||||||
return Err(anyhow!("memory file is read_only: {}", label));
|
return Err(anyhow!("memory file is read_only: {}", label));
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::fs::remove_file(&path).await
|
tokio::fs::remove_file(&path)
|
||||||
|
.await
|
||||||
.with_context(|| format!("deleting memory file: {}", label))?;
|
.with_context(|| format!("deleting memory file: {}", label))?;
|
||||||
|
|
||||||
if self.auto_commit {
|
if self.auto_commit {
|
||||||
|
|
@ -583,16 +590,17 @@ impl MemoryRepo {
|
||||||
match git2::Repository::open(&repo_path) {
|
match git2::Repository::open(&repo_path) {
|
||||||
Ok(repo) => {
|
Ok(repo) => {
|
||||||
let lc = repo.head().ok().and_then(|h| {
|
let lc = repo.head().ok().and_then(|h| {
|
||||||
h.peel_to_commit().ok().map(|c| {
|
h.peel_to_commit()
|
||||||
c.message().unwrap_or("(unknown)").to_string()
|
.ok()
|
||||||
})
|
.map(|c| c.message().unwrap_or("(unknown)").to_string())
|
||||||
});
|
});
|
||||||
let dirty = repo.statuses(Some(
|
let dirty = repo
|
||||||
git2::StatusOptions::new().include_untracked(true),
|
.statuses(Some(git2::StatusOptions::new().include_untracked(true)))
|
||||||
))
|
.map(|s| s.iter().any(|_| true))
|
||||||
.map(|s| s.iter().any(|_| true))
|
.unwrap_or(false);
|
||||||
.unwrap_or(false);
|
let remote = repo
|
||||||
let remote = repo.find_remote("origin").ok()
|
.find_remote("origin")
|
||||||
|
.ok()
|
||||||
.and_then(|r| r.url().map(|u| u.to_string()));
|
.and_then(|r| r.url().map(|u| u.to_string()));
|
||||||
(lc, dirty, remote)
|
(lc, dirty, remote)
|
||||||
}
|
}
|
||||||
|
|
@ -717,7 +725,11 @@ impl MemoryRepo {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let head = self.head_commit_hex().unwrap_or_else(|| "(no head)".into());
|
let head = self.head_commit_hex().unwrap_or_else(|| "(no head)".into());
|
||||||
let mut out = format!("Synced. This instance is {} @ {}\n", branch, &head[..12.min(head.len())]);
|
let mut out = format!(
|
||||||
|
"Synced. This instance is {} @ {}\n",
|
||||||
|
branch,
|
||||||
|
&head[..12.min(head.len())]
|
||||||
|
);
|
||||||
if !fetch.trim().is_empty() {
|
if !fetch.trim().is_empty() {
|
||||||
out.push_str(&format!("Fetched:\n{}\n", fetch.trim()));
|
out.push_str(&format!("Fetched:\n{}\n", fetch.trim()));
|
||||||
}
|
}
|
||||||
|
|
@ -766,8 +778,7 @@ impl MemoryRepo {
|
||||||
// ── Internal helpers ──────────────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn open_git(&self) -> Result<git2::Repository> {
|
fn open_git(&self) -> Result<git2::Repository> {
|
||||||
git2::Repository::open(&self.root)
|
git2::Repository::open(&self.root).context("opening memory git repository")
|
||||||
.context("opening memory git repository")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the `description` frontmatter contract applies to a label.
|
/// Whether the `description` frontmatter contract applies to a label.
|
||||||
|
|
@ -782,7 +793,10 @@ impl MemoryRepo {
|
||||||
/// valid writes. This predicate scopes the rule to frontmatter-bound
|
/// valid writes. This predicate scopes the rule to frontmatter-bound
|
||||||
/// locations only.
|
/// locations only.
|
||||||
fn is_frontmatter_bound(label: &str) -> bool {
|
fn is_frontmatter_bound(label: &str) -> bool {
|
||||||
let rel = label.trim().trim_start_matches(['/', '.']).replace('\\', "/");
|
let rel = label
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches(['/', '.'])
|
||||||
|
.replace('\\', "/");
|
||||||
let lower = rel.to_ascii_lowercase();
|
let lower = rel.to_ascii_lowercase();
|
||||||
// Exact segment prefixes, not substring matches, so a file like
|
// Exact segment prefixes, not substring matches, so a file like
|
||||||
// `reference/tasks-today.md` is not mistaken for a todo file.
|
// `reference/tasks-today.md` is not mistaken for a todo file.
|
||||||
|
|
@ -829,7 +843,10 @@ fn split_frontmatter_block(content: &str) -> Option<(&str, &str)> {
|
||||||
.or_else(|| after_first.find("\r\n---"))?;
|
.or_else(|| after_first.find("\r\n---"))?;
|
||||||
let frontmatter_text = after_first[..end_idx].trim();
|
let frontmatter_text = after_first[..end_idx].trim();
|
||||||
let body_start = 3 + end_idx + 4; // opening --- + yaml + \n---
|
let body_start = 3 + end_idx + 4; // opening --- + yaml + \n---
|
||||||
Some((frontmatter_text, content[body_start..].trim_start_matches(['\r', '\n'])))
|
Some((
|
||||||
|
frontmatter_text,
|
||||||
|
content[body_start..].trim_start_matches(['\r', '\n']),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a memory file. Tolerant on read: a file with no frontmatter, or
|
/// Parse a memory file. Tolerant on read: a file with no frontmatter, or
|
||||||
|
|
@ -919,17 +936,15 @@ pub async fn read_file(path: &Path) -> Result<MemoryFile> {
|
||||||
///
|
///
|
||||||
/// Emission is best-effort and never fails the write: the mutation already
|
/// Emission is best-effort and never fails the write: the mutation already
|
||||||
/// succeeded. Missing identity is warned loudly, not fabricated.
|
/// succeeded. Missing identity is warned loudly, not fabricated.
|
||||||
fn fire_memfs_commit(
|
fn fire_memfs_commit(ctx: &ToolContext, repo: &MemoryRepo, op: &str, paths: &[&str], urgency: f32) {
|
||||||
ctx: &ToolContext,
|
|
||||||
repo: &MemoryRepo,
|
|
||||||
op: &str,
|
|
||||||
paths: &[&str],
|
|
||||||
urgency: f32,
|
|
||||||
) {
|
|
||||||
// A successful tool-path mutation auto-committed; if there is somehow no
|
// A successful tool-path mutation auto-committed; if there is somehow no
|
||||||
// head, there is nothing for a peer to fetch — do not signal.
|
// head, there is nothing for a peer to fetch — do not signal.
|
||||||
let Some(commit) = repo.head_commit_hex() else {
|
let Some(commit) = repo.head_commit_hex() else {
|
||||||
tracing::warn!(op, ?paths, "memfs mutation without a head commit — memfs_commit not emitted");
|
tracing::warn!(
|
||||||
|
op,
|
||||||
|
?paths,
|
||||||
|
"memfs mutation without a head commit — memfs_commit not emitted"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let branch = repo.current_branch();
|
let branch = repo.current_branch();
|
||||||
|
|
@ -941,13 +956,15 @@ fn fire_memfs_commit(
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.parent())
|
.and_then(|m| m.parent())
|
||||||
.map(|p| p.join("seed"))
|
.map(|p| p.join("seed"))
|
||||||
.and_then(|dir| match crate::core::identity::SeedId::load_or_generate(&dir) {
|
.and_then(
|
||||||
Ok(seed) => Some(seed.public_key_hex()),
|
|dir| match crate::core::identity::SeedId::load_or_generate(&dir) {
|
||||||
Err(e) => {
|
Ok(seed) => Some(seed.public_key_hex()),
|
||||||
tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})");
|
Err(e) => {
|
||||||
None
|
tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})");
|
||||||
}
|
None
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// The instance is the machine that wrote — machined-first, loud legacy
|
// The instance is the machine that wrote — machined-first, loud legacy
|
||||||
// fallback, never generates.
|
// fallback, never generates.
|
||||||
|
|
@ -1097,10 +1114,8 @@ pub async fn execute_memory_command_with_context(
|
||||||
// identity, no sync. Interim label: machine pubkey prefix;
|
// identity, no sync. Interim label: machine pubkey prefix;
|
||||||
// commission-ceremony labels come later (FEDERATION.md flag).
|
// commission-ceremony labels come later (FEDERATION.md flag).
|
||||||
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||||
let (pubkey, _source) =
|
let (pubkey, _source) = crate::machined::client::machine_pubkey_with_fallback(&base)
|
||||||
crate::machined::client::machine_pubkey_with_fallback(&base).context(
|
.context("memory sync needs a machine identity to name this instance's branch")?;
|
||||||
"memory sync needs a machine identity to name this instance's branch",
|
|
||||||
)?;
|
|
||||||
let label: String = pubkey.chars().take(12).collect();
|
let label: String = pubkey.chars().take(12).collect();
|
||||||
repo.sync(&label).await
|
repo.sync(&label).await
|
||||||
}
|
}
|
||||||
|
|
@ -1153,34 +1168,68 @@ pub async fn handle_memory_tool_with_context(
|
||||||
|
|
||||||
let cmd = match command {
|
let cmd = match command {
|
||||||
"read" => {
|
"read" => {
|
||||||
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let path = input
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
MemoryCommand::Read { path }
|
MemoryCommand::Read { path }
|
||||||
}
|
}
|
||||||
"write" => {
|
"write" => {
|
||||||
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let path = input
|
||||||
let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let content = input
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
MemoryCommand::Write { path, content }
|
MemoryCommand::Write { path, content }
|
||||||
}
|
}
|
||||||
"append" => {
|
"append" => {
|
||||||
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let path = input
|
||||||
let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let content = input
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
MemoryCommand::Append { path, content }
|
MemoryCommand::Append { path, content }
|
||||||
}
|
}
|
||||||
"ls" => {
|
"ls" => {
|
||||||
let path = input.get("path").and_then(|v| v.as_str()).map(|s| s.to_string());
|
let path = input
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
MemoryCommand::Ls { path }
|
MemoryCommand::Ls { path }
|
||||||
}
|
}
|
||||||
"status" => MemoryCommand::Status,
|
"status" => MemoryCommand::Status,
|
||||||
"init" => {
|
"init" => {
|
||||||
let agent_id = input.get("agent_id").and_then(|v| v.as_str()).unwrap_or("default").to_string();
|
let agent_id = input
|
||||||
|
.get("agent_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("default")
|
||||||
|
.to_string();
|
||||||
MemoryCommand::Init { agent_id }
|
MemoryCommand::Init { agent_id }
|
||||||
}
|
}
|
||||||
"delete" => {
|
"delete" => {
|
||||||
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let path = input
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
MemoryCommand::Delete { path }
|
MemoryCommand::Delete { path }
|
||||||
}
|
}
|
||||||
"compact" => {
|
"compact" => {
|
||||||
let strategy = input.get("strategy").and_then(|v| v.as_str()).map(|s| s.to_string());
|
let strategy = input
|
||||||
|
.get("strategy")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
MemoryCommand::Compact { strategy }
|
MemoryCommand::Compact { strategy }
|
||||||
}
|
}
|
||||||
"sync" => MemoryCommand::Sync,
|
"sync" => MemoryCommand::Sync,
|
||||||
|
|
@ -1303,7 +1352,10 @@ mod tests {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let rendered = render_frontmatter(&fm, "Body");
|
let rendered = render_frontmatter(&fm, "Body");
|
||||||
assert!(!rendered.contains("null"), "None fields must be omitted: {rendered}");
|
assert!(
|
||||||
|
!rendered.contains("null"),
|
||||||
|
"None fields must be omitted: {rendered}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1354,7 +1406,9 @@ mod tests {
|
||||||
let repo = MemoryRepo::new("test-agent", dir.path());
|
let repo = MemoryRepo::new("test-agent", dir.path());
|
||||||
repo.init().await.unwrap();
|
repo.init().await.unwrap();
|
||||||
|
|
||||||
repo.write("test/hello", "Hello memory world").await.unwrap();
|
repo.write("test/hello", "Hello memory world")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let file = repo.read("test/hello").await.unwrap();
|
let file = repo.read("test/hello").await.unwrap();
|
||||||
assert_eq!(file.body, "Hello memory world");
|
assert_eq!(file.body, "Hello memory world");
|
||||||
}
|
}
|
||||||
|
|
@ -1376,7 +1430,10 @@ mod tests {
|
||||||
repo.init().await.unwrap();
|
repo.init().await.unwrap();
|
||||||
|
|
||||||
for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
|
for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
|
||||||
assert!(repo.sync(bad).await.is_err(), "label {bad:?} should be rejected");
|
assert!(
|
||||||
|
repo.sync(bad).await.is_err(),
|
||||||
|
"label {bad:?} should be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1423,7 +1480,9 @@ mod tests {
|
||||||
};
|
};
|
||||||
let content = render_frontmatter(&fm, "This is read-only");
|
let content = render_frontmatter(&fm, "This is read-only");
|
||||||
let path = repo.root().join("test/readonly.md");
|
let path = repo.root().join("test/readonly.md");
|
||||||
tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
tokio::fs::create_dir_all(path.parent().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
tokio::fs::write(&path, &content).await.unwrap();
|
tokio::fs::write(&path, &content).await.unwrap();
|
||||||
|
|
||||||
// Try to write to it — should fail
|
// Try to write to it — should fail
|
||||||
|
|
@ -1454,7 +1513,10 @@ mod tests {
|
||||||
assert!(commitments.exists(), "commitments.md should exist");
|
assert!(commitments.exists(), "commitments.md should exist");
|
||||||
let content = std::fs::read_to_string(&commitments).unwrap();
|
let content = std::fs::read_to_string(&commitments).unwrap();
|
||||||
assert!(content.starts_with("---\n"), "should have YAML frontmatter");
|
assert!(content.starts_with("---\n"), "should have YAML frontmatter");
|
||||||
assert!(content.contains("description:"), "should have description field");
|
assert!(
|
||||||
|
content.contains("description:"),
|
||||||
|
"should have description field"
|
||||||
|
);
|
||||||
assert!(content.contains("tags:"), "should have tags field");
|
assert!(content.contains("tags:"), "should have tags field");
|
||||||
assert!(content.contains("# Commitments"), "should have body");
|
assert!(content.contains("# Commitments"), "should have body");
|
||||||
|
|
||||||
|
|
@ -1493,7 +1555,9 @@ mod tests {
|
||||||
|
|
||||||
// Different schemas that legitimately lack description.
|
// Different schemas that legitimately lack description.
|
||||||
assert!(!MemoryRepo::is_frontmatter_bound("tasks/some-todo.md"));
|
assert!(!MemoryRepo::is_frontmatter_bound("tasks/some-todo.md"));
|
||||||
assert!(!MemoryRepo::is_frontmatter_bound("system/dynamic/itinerary.md"));
|
assert!(!MemoryRepo::is_frontmatter_bound(
|
||||||
|
"system/dynamic/itinerary.md"
|
||||||
|
));
|
||||||
assert!(!MemoryRepo::is_frontmatter_bound("journal/2026/05/20.md"));
|
assert!(!MemoryRepo::is_frontmatter_bound("journal/2026/05/20.md"));
|
||||||
|
|
||||||
// Segment prefixes, not substring matches — a file named like a
|
// Segment prefixes, not substring matches — a file named like a
|
||||||
|
|
@ -1528,7 +1592,10 @@ mod tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
// A body-only write must not silently perpetuate the empty description.
|
// A body-only write must not silently perpetuate the empty description.
|
||||||
let err = repo.write("issues/gap.md", "updated body").await.unwrap_err();
|
let err = repo
|
||||||
|
.write("issues/gap.md", "updated body")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
err.to_string().contains("no description"),
|
err.to_string().contains("no description"),
|
||||||
"expected loud rejection, got: {err}"
|
"expected loud rejection, got: {err}"
|
||||||
|
|
@ -1541,7 +1608,9 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let healed = parse_memory_file(&std::fs::read_to_string(repo.root().join("issues/gap.md")).unwrap()).unwrap();
|
let healed =
|
||||||
|
parse_memory_file(&std::fs::read_to_string(repo.root().join("issues/gap.md")).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(healed.frontmatter.description, "The healed headline");
|
assert_eq!(healed.frontmatter.description, "The healed headline");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1553,12 +1622,21 @@ mod tests {
|
||||||
|
|
||||||
// tasks/, journal/, system/dynamic/ legitimately lack description.
|
// tasks/, journal/, system/dynamic/ legitimately lack description.
|
||||||
repo.write("tasks/a-todo.md", "todo body").await.unwrap();
|
repo.write("tasks/a-todo.md", "todo body").await.unwrap();
|
||||||
repo.write("journal/2026/05/20.md", "freeform journal entry").await.unwrap();
|
repo.write("journal/2026/05/20.md", "freeform journal entry")
|
||||||
repo.write("system/dynamic/itinerary.md", "itinerary body").await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.write("system/dynamic/itinerary.md", "itinerary body")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// New bound files still get the auto-generated default on create.
|
// New bound files still get the auto-generated default on create.
|
||||||
repo.write("reference/new.md", "some reference").await.unwrap();
|
repo.write("reference/new.md", "some reference")
|
||||||
let f = parse_memory_file(&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap()).unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let f = parse_memory_file(
|
||||||
|
&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!f.frontmatter.description.is_empty());
|
assert!(!f.frontmatter.description.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,17 @@
|
||||||
// exposes the stubs that survived the cleanup.
|
// exposes the stubs that survived the cleanup.
|
||||||
|
|
||||||
pub mod archivist;
|
pub mod archivist;
|
||||||
pub mod chain;
|
|
||||||
pub mod bootstrap;
|
pub mod bootstrap;
|
||||||
|
pub mod chain;
|
||||||
pub mod compact;
|
pub mod compact;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod credentials;
|
|
||||||
pub mod voice;
|
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
|
pub mod credentials;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod nervous;
|
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod model_cache;
|
pub mod model_cache;
|
||||||
|
pub mod nervous;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod reflection;
|
pub mod reflection;
|
||||||
pub mod seeds;
|
pub mod seeds;
|
||||||
|
|
@ -27,3 +26,4 @@ pub mod skills;
|
||||||
pub mod subagent;
|
pub mod subagent;
|
||||||
pub mod subconscious;
|
pub mod subconscious;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
pub mod voice;
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,7 @@ impl ModelCache {
|
||||||
.with_context(|| format!("creating {}", parent.display()))?;
|
.with_context(|| format!("creating {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_string_pretty(self)?;
|
let json = serde_json::to_string_pretty(self)?;
|
||||||
std::fs::write(&path, json)
|
std::fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?;
|
||||||
.with_context(|| format!("writing {}", path.display()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ impl CronState {
|
||||||
|
|
||||||
match soonest {
|
match soonest {
|
||||||
Some(t) if t > now => {
|
Some(t) if t > now => {
|
||||||
let dur = (t - now).to_std().unwrap_or(std::time::Duration::from_secs(60));
|
let dur = (t - now)
|
||||||
|
.to_std()
|
||||||
|
.unwrap_or(std::time::Duration::from_secs(60));
|
||||||
tokio::time::Instant::now() + dur
|
tokio::time::Instant::now() + dur
|
||||||
}
|
}
|
||||||
Some(_) => tokio::time::Instant::now(),
|
Some(_) => tokio::time::Instant::now(),
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,7 @@ impl EventLog {
|
||||||
|
|
||||||
/// Load events from the log since a given timestamp.
|
/// Load events from the log since a given timestamp.
|
||||||
/// Used by the morning pass to triage what accumulated overnight.
|
/// Used by the morning pass to triage what accumulated overnight.
|
||||||
pub fn events_since(
|
pub fn events_since(events_dir: &Path, since: DateTime<Utc>) -> Result<Vec<SensorEvent>> {
|
||||||
events_dir: &Path,
|
|
||||||
since: DateTime<Utc>,
|
|
||||||
) -> Result<Vec<SensorEvent>> {
|
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let since_date = since.date_naive();
|
let since_date = since.date_naive();
|
||||||
|
|
||||||
|
|
@ -110,10 +107,7 @@ pub fn events_since(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all events from a specific date.
|
/// Load all events from a specific date.
|
||||||
pub fn events_for_date(
|
pub fn events_for_date(events_dir: &Path, date: NaiveDate) -> Result<Vec<SensorEvent>> {
|
||||||
events_dir: &Path,
|
|
||||||
date: NaiveDate,
|
|
||||||
) -> Result<Vec<SensorEvent>> {
|
|
||||||
let path = events_dir.join(format!("events-{date}.jsonl"));
|
let path = events_dir.join(format!("events-{date}.jsonl"));
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
|
|
@ -149,11 +143,10 @@ pub fn purge_old_events(events_dir: &Path, retain_days: i64) -> Result<u32> {
|
||||||
.and_then(|s| s.strip_suffix(".jsonl"))
|
.and_then(|s| s.strip_suffix(".jsonl"))
|
||||||
{
|
{
|
||||||
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||||
if date < cutoff
|
if date < cutoff && std::fs::remove_file(entry.path()).is_ok() {
|
||||||
&& std::fs::remove_file(entry.path()).is_ok() {
|
removed += 1;
|
||||||
removed += 1;
|
debug!(file = %name_str, "purged old event log");
|
||||||
debug!(file = %name_str, "purged old event log");
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,7 @@ pub trait TurnInjector: Send + Sync {
|
||||||
/// Start a background turn for `agent_id` with `text` as the user
|
/// Start a background turn for `agent_id` with `text` as the user
|
||||||
/// message. The stream of events is drained inside; callers don't
|
/// message. The stream of events is drained inside; callers don't
|
||||||
/// see them — heartbeats are silent unless the agent surfaces.
|
/// see them — heartbeats are silent unless the agent surfaces.
|
||||||
async fn inject_background_turn(
|
async fn inject_background_turn(&self, agent_id: &str, text: &str) -> anyhow::Result<()>;
|
||||||
&self,
|
|
||||||
agent_id: &str,
|
|
||||||
text: &str,
|
|
||||||
) -> anyhow::Result<()>;
|
|
||||||
|
|
||||||
/// Start a turn for `agent_id` in a specific conversation, with
|
/// Start a turn for `agent_id` in a specific conversation, with
|
||||||
/// `text` as the user message. Unlike `inject_background_turn`,
|
/// `text` as the user message. Unlike `inject_background_turn`,
|
||||||
|
|
@ -82,7 +78,11 @@ impl HeartbeatHandler {
|
||||||
};
|
};
|
||||||
|
|
||||||
if prompt.is_empty() {
|
if prompt.is_empty() {
|
||||||
warn!(schedule = name, agent = agent_id, "schedule_due missing prompt");
|
warn!(
|
||||||
|
schedule = name,
|
||||||
|
agent = agent_id,
|
||||||
|
"schedule_due missing prompt"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +152,10 @@ impl SensoriumInputHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
warn!(skipped = n, "sensorium input handler lagged, skipped events");
|
warn!(
|
||||||
|
skipped = n,
|
||||||
|
"sensorium input handler lagged, skipped events"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => {
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
debug!("event bus closed, sensorium input handler exiting");
|
debug!("event bus closed, sensorium input handler exiting");
|
||||||
|
|
|
||||||
|
|
@ -150,16 +150,24 @@ impl TurnEventDispatcher {
|
||||||
|
|
||||||
/// The N+1 subconscious pass ended.
|
/// The N+1 subconscious pass ended.
|
||||||
pub fn emit_n1_end(&self, elapsed_secs: f64) {
|
pub fn emit_n1_end(&self, elapsed_secs: f64) {
|
||||||
self.fire(Self::EVT_N1_END, 0.4, json!({ "elapsed_secs": elapsed_secs }));
|
self.fire(
|
||||||
|
Self::EVT_N1_END,
|
||||||
|
0.4,
|
||||||
|
json!({ "elapsed_secs": elapsed_secs }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A consciousness event (surfacing, reflection, archivist, compaction)
|
/// A consciousness event (surfacing, reflection, archivist, compaction)
|
||||||
/// was produced by the subconscious pass or compaction engine.
|
/// was produced by the subconscious pass or compaction engine.
|
||||||
pub fn emit_consciousness(&self, event_type: &str, urgency: f32, payload: serde_json::Value) {
|
pub fn emit_consciousness(&self, event_type: &str, urgency: f32, payload: serde_json::Value) {
|
||||||
self.fire(Self::EVT_CONSCIOUSNESS, urgency, json!({
|
self.fire(
|
||||||
"inner_type": event_type,
|
Self::EVT_CONSCIOUSNESS,
|
||||||
"payload": payload,
|
urgency,
|
||||||
}));
|
json!({
|
||||||
|
"inner_type": event_type,
|
||||||
|
"payload": payload,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,7 +176,10 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tokio::sync::broadcast::error::TryRecvError;
|
use tokio::sync::broadcast::error::TryRecvError;
|
||||||
|
|
||||||
fn dispatcher() -> (TurnEventDispatcher, tokio::sync::broadcast::Receiver<SensorEvent>) {
|
fn dispatcher() -> (
|
||||||
|
TurnEventDispatcher,
|
||||||
|
tokio::sync::broadcast::Receiver<SensorEvent>,
|
||||||
|
) {
|
||||||
let bus = EventBus::new(64);
|
let bus = EventBus::new(64);
|
||||||
let rx = bus.subscribe();
|
let rx = bus.subscribe();
|
||||||
(TurnEventDispatcher::new(bus, "conv-1", None), rx)
|
(TurnEventDispatcher::new(bus, "conv-1", None), rx)
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,9 @@ async fn read_system_remainder(
|
||||||
if seen.contains(&p) {
|
if seen.contains(&p) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(content) = tokio::fs::read_to_string(&p).await else { continue };
|
let Ok(content) = tokio::fs::read_to_string(&p).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let body = strip_frontmatter(&content);
|
let body = strip_frontmatter(&content);
|
||||||
if !body.trim().is_empty() {
|
if !body.trim().is_empty() {
|
||||||
parts.push(body.to_string());
|
parts.push(body.to_string());
|
||||||
|
|
@ -174,7 +176,9 @@ async fn read_system_remainder(
|
||||||
|
|
||||||
/// Recursively collect all .md file paths under `dir`.
|
/// Recursively collect all .md file paths under `dir`.
|
||||||
async fn collect_md_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
async fn collect_md_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||||
let Ok(mut entries) = tokio::fs::read_dir(dir).await else { return };
|
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
let p = entry.path();
|
let p = entry.path();
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
|
|
@ -295,10 +299,7 @@ async fn count_md_files(dir: &Path) -> usize {
|
||||||
/// full so the agent knows who she is without having to reach for tools.
|
/// full so the agent knows who she is without having to reach for tools.
|
||||||
/// Skills are appended after identity. The result becomes the system message
|
/// Skills are appended after identity. The result becomes the system message
|
||||||
/// at position 0 in the conversation.
|
/// at position 0 in the conversation.
|
||||||
pub async fn build_system_prompt(
|
pub async fn build_system_prompt(memory_root: &Path, skills: Option<&SkillRegistry>) -> String {
|
||||||
memory_root: &Path,
|
|
||||||
skills: Option<&SkillRegistry>,
|
|
||||||
) -> String {
|
|
||||||
build_system_prompt_full(memory_root, None, None, skills).await
|
build_system_prompt_full(memory_root, None, None, skills).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,13 +433,18 @@ pub async fn build_system_prompt_full(
|
||||||
// consumptive state. The file is written by the backend after every turn
|
// consumptive state. The file is written by the backend after every turn
|
||||||
// (write_energy_balance in local.rs). No file = no section — the
|
// (write_energy_balance in local.rs). No file = no section — the
|
||||||
// absence is information too.
|
// absence is information too.
|
||||||
let energy_path = memory_root.join("system").join("dynamic").join("energy-balance.md");
|
let energy_path = memory_root
|
||||||
|
.join("system")
|
||||||
|
.join("dynamic")
|
||||||
|
.join("energy-balance.md");
|
||||||
if let Ok(content) = tokio::fs::read_to_string(&energy_path).await {
|
if let Ok(content) = tokio::fs::read_to_string(&energy_path).await {
|
||||||
if let Some(body) = content.strip_prefix("---\n") {
|
if let Some(body) = content.strip_prefix("---\n") {
|
||||||
if let Some(end) = body.find("\n---\n") {
|
if let Some(end) = body.find("\n---\n") {
|
||||||
// The last line of the file body (after frontmatter) has the prose.
|
// The last line of the file body (after frontmatter) has the prose.
|
||||||
// That's the line the agent reads — structured data is for the TUI.
|
// That's the line the agent reads — structured data is for the TUI.
|
||||||
let prose_line = content[end + 6..].lines().find(|l| !l.trim().is_empty() && !l.starts_with('#'));
|
let prose_line = content[end + 6..]
|
||||||
|
.lines()
|
||||||
|
.find(|l| !l.trim().is_empty() && !l.starts_with('#'));
|
||||||
if let Some(line) = prose_line {
|
if let Some(line) = prose_line {
|
||||||
sections.push(format!("## Energy Balance\n\n{line}"));
|
sections.push(format!("## Energy Balance\n\n{line}"));
|
||||||
}
|
}
|
||||||
|
|
@ -451,7 +457,8 @@ pub async fn build_system_prompt_full(
|
||||||
let dynamic_dir = memory_root.join("system").join("dynamic");
|
let dynamic_dir = memory_root.join("system").join("dynamic");
|
||||||
let itin_path = dynamic_dir.join("itinerary.md");
|
let itin_path = dynamic_dir.join("itinerary.md");
|
||||||
let has_itin = tokio::fs::try_exists(&itin_path).await.unwrap_or(false)
|
let has_itin = tokio::fs::try_exists(&itin_path).await.unwrap_or(false)
|
||||||
&& tokio::fs::read_to_string(&itin_path).await
|
&& tokio::fs::read_to_string(&itin_path)
|
||||||
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.map(|s| s.contains("current:"))
|
.map(|s| s.contains("current:"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
@ -496,7 +503,10 @@ pub async fn build_system_prompt_full(
|
||||||
debug!("system prompt: no identity files found, using minimal default");
|
debug!("system prompt: no identity files found, using minimal default");
|
||||||
"You are a Souveraine agent. Your memory files will define who you are.".to_string()
|
"You are a Souveraine agent. Your memory files will define who you are.".to_string()
|
||||||
} else {
|
} else {
|
||||||
debug!("system prompt: assembled {} sections from memfs", sections.len());
|
debug!(
|
||||||
|
"system prompt: assembled {} sections from memfs",
|
||||||
|
sections.len()
|
||||||
|
);
|
||||||
prompt
|
prompt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -605,7 +615,9 @@ async fn peek_subconscious_ledger(sub_root: &Path) -> String {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
let Ok(content) = tokio::fs::read_to_string(&p).await else { continue };
|
let Ok(content) = tokio::fs::read_to_string(&p).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let lines: Vec<&str> = content
|
let lines: Vec<&str> = content
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|l| l.trim_start().starts_with('['))
|
.filter(|l| l.trim_start().starts_with('['))
|
||||||
|
|
@ -637,9 +649,7 @@ async fn peek_subconscious_ledger(sub_root: &Path) -> String {
|
||||||
/// Reads identity, mandate, and ledger orientation from the subconscious
|
/// Reads identity, mandate, and ledger orientation from the subconscious
|
||||||
/// agent's memory root. Falls back to empty (caller uses hardcoded
|
/// agent's memory root. Falls back to empty (caller uses hardcoded
|
||||||
/// default) if files don't exist.
|
/// default) if files don't exist.
|
||||||
pub async fn build_subconscious_prompt(
|
pub async fn build_subconscious_prompt(subconscious_memory_root: &Path) -> String {
|
||||||
subconscious_memory_root: &Path,
|
|
||||||
) -> String {
|
|
||||||
let mut sections: Vec<String> = Vec::new();
|
let mut sections: Vec<String> = Vec::new();
|
||||||
|
|
||||||
let identity = read_memory_file(subconscious_memory_root, "system/persona.md").await;
|
let identity = read_memory_file(subconscious_memory_root, "system/persona.md").await;
|
||||||
|
|
@ -792,7 +802,9 @@ async fn build_synthesis_orientation(memory_root: &Path) -> String {
|
||||||
|
|
||||||
/// Count live (pending / in_progress) todo files in the tasks directory.
|
/// Count live (pending / in_progress) todo files in the tasks directory.
|
||||||
fn count_live_todos(tasks_dir: &std::path::Path) -> usize {
|
fn count_live_todos(tasks_dir: &std::path::Path) -> usize {
|
||||||
let Ok(entries) = std::fs::read_dir(tasks_dir) else { return 0 };
|
let Ok(entries) = std::fs::read_dir(tasks_dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
entries
|
entries
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter(|e| e.path().extension().map(|e| e == "md").unwrap_or(false))
|
.filter(|e| e.path().extension().map(|e| e == "md").unwrap_or(false))
|
||||||
|
|
@ -820,7 +832,8 @@ mod tests {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
id_dir.join("self.md"),
|
id_dir.join("self.md"),
|
||||||
"---\ndescription: test\n---\n\n# I am Test Agent\n",
|
"---\ndescription: test\n---\n\n# I am Test Agent\n",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let prompt = build_system_prompt(mem, None).await;
|
let prompt = build_system_prompt(mem, None).await;
|
||||||
assert!(prompt.contains("I am Test Agent"));
|
assert!(prompt.contains("I am Test Agent"));
|
||||||
|
|
@ -835,7 +848,8 @@ mod tests {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
sys.join("persona.md"),
|
sys.join("persona.md"),
|
||||||
"---\ndescription: test\n---\n\nI am a persona file agent.\n",
|
"---\ndescription: test\n---\n\nI am a persona file agent.\n",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let prompt = build_system_prompt(mem, None).await;
|
let prompt = build_system_prompt(mem, None).await;
|
||||||
assert!(prompt.contains("persona file agent"));
|
assert!(prompt.contains("persona file agent"));
|
||||||
|
|
@ -845,7 +859,10 @@ mod tests {
|
||||||
async fn empty_memfs_gets_body_orientation() {
|
async fn empty_memfs_gets_body_orientation() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let prompt = build_system_prompt(dir.path(), None).await;
|
let prompt = build_system_prompt(dir.path(), None).await;
|
||||||
assert!(prompt.contains("Body Sensation"), "Even with empty memfs, the body orientation section should be present");
|
assert!(
|
||||||
|
prompt.contains("Body Sensation"),
|
||||||
|
"Even with empty memfs, the body orientation section should be present"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -863,7 +880,8 @@ mod tests {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
sys.join("persona.md"),
|
sys.join("persona.md"),
|
||||||
"---\ndescription: WHO I AM\n---\n\n# I Am Subconscious\n",
|
"---\ndescription: WHO I AM\n---\n\n# I Am Subconscious\n",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
sys.join("subconscious.md"),
|
sys.join("subconscious.md"),
|
||||||
"---\ndescription: mandate\n---\n\n# Subconscious's Mandate\n\nComplete what was left.\n",
|
"---\ndescription: mandate\n---\n\n# Subconscious's Mandate\n\nComplete what was left.\n",
|
||||||
|
|
@ -884,7 +902,8 @@ mod tests {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
sys.join("persona.md"),
|
sys.join("persona.md"),
|
||||||
"---\ndescription: test\n---\n\n# I Am Subconscious\n",
|
"---\ndescription: test\n---\n\n# I Am Subconscious\n",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let ledger_dir = sub_mem.join("ledger");
|
let ledger_dir = sub_mem.join("ledger");
|
||||||
std::fs::create_dir_all(&ledger_dir).unwrap();
|
std::fs::create_dir_all(&ledger_dir).unwrap();
|
||||||
|
|
@ -895,13 +914,23 @@ mod tests {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
ledger_dir.join("patterns.md"),
|
ledger_dir.join("patterns.md"),
|
||||||
"---\ndescription: test\n---\n\n# Patterns\n\n",
|
"---\ndescription: test\n---\n\n# Patterns\n\n",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let prompt = build_subconscious_prompt(sub_mem).await;
|
let prompt = build_subconscious_prompt(sub_mem).await;
|
||||||
assert!(prompt.contains("## Ledgers"), "should have ledger section");
|
assert!(prompt.contains("## Ledgers"), "should have ledger section");
|
||||||
assert!(prompt.contains("commitments.md (2 entries)"), "should count entries");
|
assert!(
|
||||||
assert!(prompt.contains("Save the config"), "should show recent entries");
|
prompt.contains("commitments.md (2 entries)"),
|
||||||
assert!(prompt.contains("patterns.md"), "should list empty ledger too");
|
"should count entries"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("Save the config"),
|
||||||
|
"should show recent entries"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("patterns.md"),
|
||||||
|
"should list empty ledger too"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,7 @@ use anyhow::Result;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::bridge::bifrost::{
|
use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
|
||||||
ChatCompletionRequest, Message, ToolDefinition, ToolFunction,
|
|
||||||
};
|
|
||||||
use crate::bridge::LlmProvider;
|
use crate::bridge::LlmProvider;
|
||||||
use crate::bridge::ProviderRegistry;
|
use crate::bridge::ProviderRegistry;
|
||||||
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
||||||
|
|
@ -196,14 +194,11 @@ impl ReflectionEngine {
|
||||||
let (response, strain) = llm.chat_completion_with_strain(request).await?;
|
let (response, strain) = llm.chat_completion_with_strain(request).await?;
|
||||||
|
|
||||||
for event in &strain {
|
for event in &strain {
|
||||||
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } =
|
if let crate::bridge::bifrost::InferenceStrain::Transient {
|
||||||
event
|
status, model, ..
|
||||||
|
} = event
|
||||||
{
|
{
|
||||||
tracing::info!(
|
tracing::info!("reflection felt inference strain: {} on {}", status, model);
|
||||||
"reflection felt inference strain: {} on {}",
|
|
||||||
status,
|
|
||||||
model
|
|
||||||
);
|
|
||||||
if *status == 429 {
|
if *status == 429 {
|
||||||
let current = self.rate_delay.load(Ordering::Relaxed);
|
let current = self.rate_delay.load(Ordering::Relaxed);
|
||||||
let bumped = (current + 200).min(3000);
|
let bumped = (current + 200).min(3000);
|
||||||
|
|
@ -230,11 +225,13 @@ impl ReflectionEngine {
|
||||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
.map(|tc| {
|
||||||
tc.id.clone(),
|
crate::bridge::bifrost::MessageToolCall::function(
|
||||||
tc.name.clone(),
|
tc.id.clone(),
|
||||||
tc.arguments.to_string(),
|
tc.name.clone(),
|
||||||
))
|
tc.arguments.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
chat_messages.push(
|
chat_messages.push(
|
||||||
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
||||||
|
|
@ -245,10 +242,9 @@ impl ReflectionEngine {
|
||||||
|
|
||||||
for tc in &response.tool_calls {
|
for tc in &response.tool_calls {
|
||||||
let input_str = tc.arguments.to_string();
|
let input_str = tc.arguments.to_string();
|
||||||
let result = crate::core::tools::execute_tool_with_context(
|
let result =
|
||||||
&tc.name, &input_str, &tool_ctx,
|
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
|
||||||
)
|
.await;
|
||||||
.await;
|
|
||||||
let output = if result.is_error {
|
let output = if result.is_error {
|
||||||
format!("Error: {}", result.output)
|
format!("Error: {}", result.output)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{authentication::matrix::MatrixSession, config::SyncSettings, Client};
|
||||||
authentication::matrix::MatrixSession, config::SyncSettings, Client,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,12 @@ use std::sync::Arc;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
ruma::events::room::message::{
|
ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
|
||||||
MessageType, OriginalSyncRoomMessageEvent,
|
|
||||||
},
|
|
||||||
Room, RoomState,
|
Room, RoomState,
|
||||||
};
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
|
@ -128,7 +126,12 @@ impl MatrixSensorium {
|
||||||
|
|
||||||
let dir = account_dir(&store_root, &account);
|
let dir = account_dir(&store_root, &account);
|
||||||
if let Some(record) = load_session_record(&dir) {
|
if let Some(record) = load_session_record(&dir) {
|
||||||
return Some(Self::new(account, MatrixAuth::Restore(record), store_root, agent_id));
|
return Some(Self::new(
|
||||||
|
account,
|
||||||
|
MatrixAuth::Restore(record),
|
||||||
|
store_root,
|
||||||
|
agent_id,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let homeserver = std::env::var("MATRIX_HOMESERVER").ok()?;
|
let homeserver = std::env::var("MATRIX_HOMESERVER").ok()?;
|
||||||
|
|
@ -222,44 +225,42 @@ impl Sensorium for MatrixSensorium {
|
||||||
let bus = events.clone();
|
let bus = events.clone();
|
||||||
let account = self.account.clone();
|
let account = self.account.clone();
|
||||||
let agent_id = self.agent_id.clone();
|
let agent_id = self.agent_id.clone();
|
||||||
matrix_client.add_event_handler(
|
matrix_client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
|
||||||
move |event: OriginalSyncRoomMessageEvent, room: Room| {
|
let bus = bus.clone();
|
||||||
let bus = bus.clone();
|
let account = account.clone();
|
||||||
let account = account.clone();
|
let agent_id = agent_id.clone();
|
||||||
let agent_id = agent_id.clone();
|
async move {
|
||||||
async move {
|
if room.state() != RoomState::Joined {
|
||||||
if room.state() != RoomState::Joined {
|
return;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
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 ──────────────────────────────
|
// ── Drive /sync on its own task ──────────────────────────────
|
||||||
let sync_cancel = cancel.child_token();
|
let sync_cancel = cancel.child_token();
|
||||||
|
|
@ -310,12 +311,15 @@ impl Sensorium for MatrixSensorium {
|
||||||
anyhow::bail!("matrix client not connected");
|
anyhow::bail!("matrix client not connected");
|
||||||
};
|
};
|
||||||
let room_id = chat_id.parse().context("invalid room id")?;
|
let room_id = chat_id.parse().context("invalid room id")?;
|
||||||
let room = client
|
let room = client.get_room(&room_id).context("room not found")?;
|
||||||
.get_room(&room_id)
|
let content =
|
||||||
.context("room not found")?;
|
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text);
|
||||||
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text);
|
|
||||||
let response = room.send(content).await.context("send failed")?;
|
let response = room.send(content).await.context("send failed")?;
|
||||||
debug!("matrix::send_message: {chat_id} ({}) → {}", text.len(), response.event_id);
|
debug!(
|
||||||
|
"matrix::send_message: {chat_id} ({}) → {}",
|
||||||
|
text.len(),
|
||||||
|
response.event_id
|
||||||
|
);
|
||||||
Ok(super::OutboundResult {
|
Ok(super::OutboundResult {
|
||||||
message_id: response.event_id.to_string(),
|
message_id: response.event_id.to_string(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -314,12 +314,16 @@ impl Sensorium for TuiSensorium {
|
||||||
// TUI doesn't render through send_message — it renders directly
|
// TUI doesn't render through send_message — it renders directly
|
||||||
// via the ratatui frame. This is a no-op that logs for debugging.
|
// via the ratatui frame. This is a no-op that logs for debugging.
|
||||||
debug!("TuiSensorium::send_message (no-op): {text}");
|
debug!("TuiSensorium::send_message (no-op): {text}");
|
||||||
Ok(OutboundResult { message_id: String::new() })
|
Ok(OutboundResult {
|
||||||
|
message_id: String::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_direct_reply(&self, _chat_id: &str, text: &str) -> Result<OutboundResult> {
|
async fn send_direct_reply(&self, _chat_id: &str, text: &str) -> Result<OutboundResult> {
|
||||||
debug!("TuiSensorium::send_direct_reply (no-op): {text}");
|
debug!("TuiSensorium::send_direct_reply (no-op): {text}");
|
||||||
Ok(OutboundResult { message_id: String::new() })
|
Ok(OutboundResult {
|
||||||
|
message_id: String::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,27 @@ pub enum MessageRole {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ContentBlock {
|
pub enum ContentBlock {
|
||||||
Text { text: String },
|
Text {
|
||||||
ToolUse { id: String, name: String, input: String },
|
text: String,
|
||||||
ToolResult { tool_use_id: String, tool_name: String, output: String, is_error: bool },
|
},
|
||||||
Reasoning { reasoning: String },
|
ToolUse {
|
||||||
Image { media_type: String, data: String },
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
tool_use_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
output: String,
|
||||||
|
is_error: bool,
|
||||||
|
},
|
||||||
|
Reasoning {
|
||||||
|
reasoning: String,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
media_type: String,
|
||||||
|
data: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An image attached to the current input, before submission.
|
/// An image attached to the current input, before submission.
|
||||||
|
|
@ -102,7 +118,12 @@ impl ConversationMessage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool_result(tool_use_id: impl Into<String>, tool_name: impl Into<String>, output: impl Into<String>, is_error: bool) -> Self {
|
pub fn tool_result(
|
||||||
|
tool_use_id: impl Into<String>,
|
||||||
|
tool_name: impl Into<String>,
|
||||||
|
output: impl Into<String>,
|
||||||
|
is_error: bool,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
role: MessageRole::Tool,
|
role: MessageRole::Tool,
|
||||||
blocks: vec![ContentBlock::ToolResult {
|
blocks: vec![ContentBlock::ToolResult {
|
||||||
|
|
@ -174,15 +195,25 @@ impl Session {
|
||||||
|
|
||||||
/// Estimate token count (chars/4 heuristic, use for pre-tiktoken estimation)
|
/// Estimate token count (chars/4 heuristic, use for pre-tiktoken estimation)
|
||||||
pub fn estimate_tokens(&self) -> usize {
|
pub fn estimate_tokens(&self) -> usize {
|
||||||
self.messages.iter().map(|m| {
|
self.messages
|
||||||
m.blocks.iter().map(|b| match b {
|
.iter()
|
||||||
ContentBlock::Text { text } => text.len() / 4 + 1,
|
.map(|m| {
|
||||||
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
|
m.blocks
|
||||||
ContentBlock::ToolResult { tool_name, output, .. } => (tool_name.len() + output.len()) / 4 + 1,
|
.iter()
|
||||||
ContentBlock::Reasoning { reasoning } => reasoning.len() / 4 + 1,
|
.map(|b| match b {
|
||||||
ContentBlock::Image { data, .. } => data.len() / 4 + 1,
|
ContentBlock::Text { text } => text.len() / 4 + 1,
|
||||||
}).sum::<usize>()
|
ContentBlock::ToolUse { name, input, .. } => {
|
||||||
}).sum()
|
(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
|
/// Save session to a directory
|
||||||
|
|
@ -196,7 +227,10 @@ impl Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load session from a directory by conversation ID
|
/// Load session from a directory by conversation ID
|
||||||
pub async fn load_from_dir(dir: &std::path::Path, conversation_id: &str) -> anyhow::Result<Option<Self>> {
|
pub async fn load_from_dir(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> anyhow::Result<Option<Self>> {
|
||||||
let path = dir.join(format!("{conversation_id}.json"));
|
let path = dir.join(format!("{conversation_id}.json"));
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|
@ -225,10 +259,7 @@ impl Session {
|
||||||
MessageRole::Assistant => "assistant",
|
MessageRole::Assistant => "assistant",
|
||||||
MessageRole::Tool => "tool",
|
MessageRole::Tool => "tool",
|
||||||
};
|
};
|
||||||
messages.push(crate::bridge::bifrost::Message::text(
|
messages.push(crate::bridge::bifrost::Message::text(role, text.clone()));
|
||||||
role,
|
|
||||||
text.clone(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
ContentBlock::ToolUse { name, input, .. } => {
|
ContentBlock::ToolUse { name, input, .. } => {
|
||||||
// Tool calls flattened into assistant prose for
|
// Tool calls flattened into assistant prose for
|
||||||
|
|
@ -238,7 +269,12 @@ impl Session {
|
||||||
format!("Tool use: {name}({input})"),
|
format!("Tool use: {name}({input})"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ContentBlock::ToolResult { tool_name, output, is_error, .. } => {
|
ContentBlock::ToolResult {
|
||||||
|
tool_name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
let body = if *is_error {
|
let body = if *is_error {
|
||||||
format!("Error ({tool_name}): {output}")
|
format!("Error ({tool_name}): {output}")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -247,10 +283,7 @@ impl Session {
|
||||||
// Flatten into assistant prose rather than role=tool
|
// Flatten into assistant prose rather than role=tool
|
||||||
// — without a tool_call_id link, role=tool is rejected
|
// — without a tool_call_id link, role=tool is rejected
|
||||||
// by many providers on the next turn.
|
// by many providers on the next turn.
|
||||||
messages.push(crate::bridge::bifrost::Message::text(
|
messages.push(crate::bridge::bifrost::Message::text("assistant", body));
|
||||||
"assistant",
|
|
||||||
body,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
ContentBlock::Reasoning { reasoning } => {
|
ContentBlock::Reasoning { reasoning } => {
|
||||||
messages.push(crate::bridge::bifrost::Message::text(
|
messages.push(crate::bridge::bifrost::Message::text(
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,9 @@ fn strip_frontmatter(raw: &str) -> &str {
|
||||||
/// - User: `~/.souveraine/skills/`
|
/// - User: `~/.souveraine/skills/`
|
||||||
/// - Agent: caller passes the agent's memfs dir (`/agents/<id>/memory.git/`).
|
/// - Agent: caller passes the agent's memfs dir (`/agents/<id>/memory.git/`).
|
||||||
/// - Project: current working directory.
|
/// - Project: current working directory.
|
||||||
pub fn default_discovery_paths(agent_memfs: Option<PathBuf>) -> (
|
pub fn default_discovery_paths(
|
||||||
|
agent_memfs: Option<PathBuf>,
|
||||||
|
) -> (
|
||||||
Option<PathBuf>,
|
Option<PathBuf>,
|
||||||
Option<PathBuf>,
|
Option<PathBuf>,
|
||||||
Option<PathBuf>,
|
Option<PathBuf>,
|
||||||
|
|
@ -324,11 +326,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn skill_body_loads_lazily() {
|
async fn skill_body_loads_lazily() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
write_skill(
|
write_skill(dir.path(), "test", "name: test\ndescription: Test skill");
|
||||||
dir.path(),
|
|
||||||
"test",
|
|
||||||
"name: test\ndescription: Test skill",
|
|
||||||
);
|
|
||||||
let mut reg = discover(None, Some(dir.path()), None, None).await.unwrap();
|
let mut reg = discover(None, Some(dir.path()), None, None).await.unwrap();
|
||||||
let skill = reg.get_mut("test").unwrap();
|
let skill = reg.get_mut("test").unwrap();
|
||||||
let body = skill.body().await.unwrap();
|
let body = skill.body().await.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#![allow(dead_code)] // WIP scaffolding not yet wired
|
#![allow(dead_code)] // WIP scaffolding not yet wired
|
||||||
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::core::config::ConsciousnessConfig;
|
use crate::core::config::ConsciousnessConfig;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,12 +99,18 @@ pub struct SubconsciousInbox {
|
||||||
|
|
||||||
impl SubconsciousInbox {
|
impl SubconsciousInbox {
|
||||||
pub fn new(repo: MemoryRepo) -> Self {
|
pub fn new(repo: MemoryRepo) -> Self {
|
||||||
Self { repo, primary_repo: None }
|
Self {
|
||||||
|
repo,
|
||||||
|
primary_repo: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an inbox that delivers inner voice to the primary agent's memfs.
|
/// Create an inbox that delivers inner voice to the primary agent's memfs.
|
||||||
pub fn with_primary(repo: MemoryRepo, primary_repo: MemoryRepo) -> Self {
|
pub fn with_primary(repo: MemoryRepo, primary_repo: MemoryRepo) -> Self {
|
||||||
Self { repo, primary_repo: Some(primary_repo) }
|
Self {
|
||||||
|
repo,
|
||||||
|
primary_repo: Some(primary_repo),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the three boxes exist and are readable. Idempotent.
|
/// Ensure the three boxes exist and are readable. Idempotent.
|
||||||
|
|
@ -401,10 +407,7 @@ mod tests {
|
||||||
inbox.mark_delivered(&id).await.unwrap();
|
inbox.mark_delivered(&id).await.unwrap();
|
||||||
|
|
||||||
assert!(inbox.get_intrusive().await.unwrap().is_empty());
|
assert!(inbox.get_intrusive().await.unwrap().is_empty());
|
||||||
let sent: Vec<_> = inbox
|
let sent: Vec<_> = inbox.read_items(SENT).await.unwrap();
|
||||||
.read_items(SENT)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(sent.len(), 1);
|
assert_eq!(sent.len(), 1);
|
||||||
assert_eq!(sent[0].content, "deliver me");
|
assert_eq!(sent[0].content, "deliver me");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
|
|
||||||
|
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
||||||
use crate::core::identity::SeedId;
|
use crate::core::identity::SeedId;
|
||||||
use crate::core::nervous::SensorEvent;
|
use crate::core::nervous::SensorEvent;
|
||||||
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
|
||||||
|
|
||||||
// ── reach ───────────────────────────────────────────────────────
|
// ── reach ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -111,17 +111,25 @@ fn dispatch_summon(
|
||||||
input: JsonValue,
|
input: JsonValue,
|
||||||
ctx: &ToolContext,
|
ctx: &ToolContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let target = input.get("target").and_then(|v| v.as_str())
|
let target = input
|
||||||
.ok_or_else(|| ToolError::invalid_input(
|
.get("target")
|
||||||
"`target` is required — the name of a peer from `souveraine peers`."
|
.and_then(|v| v.as_str())
|
||||||
))?;
|
.ok_or_else(|| {
|
||||||
let prompt = input.get("prompt").and_then(|v| v.as_str())
|
ToolError::invalid_input(
|
||||||
|
"`target` is required — the name of a peer from `souveraine peers`.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let prompt = input
|
||||||
|
.get("prompt")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| ToolError::invalid_input("`prompt` is required."))?;
|
.ok_or_else(|| ToolError::invalid_input("`prompt` is required."))?;
|
||||||
|
|
||||||
let bus = ctx.event_bus.as_ref().ok_or_else(|| ToolError::invalid_input(
|
let bus = ctx.event_bus.as_ref().ok_or_else(|| {
|
||||||
"the nervous system bus isn't available here — reach and consult need \
|
ToolError::invalid_input(
|
||||||
the server runtime."
|
"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");
|
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||||
|
|
||||||
|
|
@ -138,36 +146,37 @@ fn dispatch_summon(
|
||||||
// fallback that never generates.
|
// fallback that never generates.
|
||||||
let machine_seed_id = crate::machined::client::machine_pubkey_with_fallback(&base)
|
let machine_seed_id = crate::machined::client::machine_pubkey_with_fallback(&base)
|
||||||
.map(|(pk, _source)| pk)
|
.map(|(pk, _source)| pk)
|
||||||
.map_err(|e| ToolError::invalid_input(&format!(
|
.map_err(|e| {
|
||||||
"I couldn't establish my machine identity: {e:#}"
|
ToolError::invalid_input(&format!("I couldn't establish my machine identity: {e:#}"))
|
||||||
)))?;
|
})?;
|
||||||
|
|
||||||
// The *agent* seed proves who is reaching. It lives beside the memfs
|
// The *agent* seed proves who is reaching. It lives beside the memfs
|
||||||
// (`agents/{id}/seed/`), identical across all of one agent's machines —
|
// (`agents/{id}/seed/`), identical across all of one agent's machines —
|
||||||
// so the receiver can tell self-extension (reach) from a peer (consult)
|
// so the receiver can tell self-extension (reach) from a peer (consult)
|
||||||
// by verifying this signature, not by trusting an event field.
|
// by verifying this signature, not by trusting an event field.
|
||||||
let agent_seed_dir = ctx.memory_root.as_ref()
|
let agent_seed_dir = ctx
|
||||||
|
.memory_root
|
||||||
|
.as_ref()
|
||||||
.and_then(|m| m.parent())
|
.and_then(|m| m.parent())
|
||||||
.map(|p| p.join("seed"))
|
.map(|p| p.join("seed"))
|
||||||
.ok_or_else(|| ToolError::invalid_input(
|
.ok_or_else(|| {
|
||||||
"I can't locate my agent identity — reach and consult need my \
|
ToolError::invalid_input(
|
||||||
memory root to find my seed."
|
"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)
|
let agent_seed = SeedId::load_or_generate(&agent_seed_dir)
|
||||||
.map_err(|e| ToolError::invalid_input(&format!(
|
.map_err(|e| ToolError::invalid_input(&format!("I couldn't load my agent seed: {e}")))?;
|
||||||
"I couldn't load my agent seed: {e}"
|
|
||||||
)))?;
|
|
||||||
let agent_pubkey = agent_seed.public_key_hex();
|
let agent_pubkey = agent_seed.public_key_hex();
|
||||||
|
|
||||||
let request_id = uuid::Uuid::new_v4().to_string();
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
let agent_sig = crate::core::identity::sign_summon(
|
let agent_sig =
|
||||||
&agent_seed, &request_id, tool, &target_seed_id, prompt,
|
crate::core::identity::sign_summon(&agent_seed, &request_id, tool, &target_seed_id, prompt);
|
||||||
);
|
|
||||||
|
|
||||||
let event = SensorEvent {
|
let event = SensorEvent {
|
||||||
sensor_name: "summon_request".into(),
|
sensor_name: "summon_request".into(),
|
||||||
timestamp: chrono::Utc::now(),
|
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),
|
target: Some(target_seed_id),
|
||||||
urgency: 0.5,
|
urgency: 0.5,
|
||||||
payload: Some(serde_json::json!({
|
payload: Some(serde_json::json!({
|
||||||
|
|
@ -176,7 +185,7 @@ fn dispatch_summon(
|
||||||
"agent_pubkey": agent_pubkey,
|
"agent_pubkey": agent_pubkey,
|
||||||
"agent_sig": agent_sig,
|
"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),
|
reply_to: Some(machine_seed_id),
|
||||||
};
|
};
|
||||||
bus.send(event);
|
bus.send(event);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
||||||
pub struct Atmosphere;
|
pub struct Atmosphere;
|
||||||
|
|
||||||
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
||||||
Ok(ToolOutput { content: msg.into(), is_error: false, raw: None })
|
Ok(ToolOutput {
|
||||||
|
content: msg.into(),
|
||||||
|
is_error: false,
|
||||||
|
raw: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn err(detail: &str) -> ToolError {
|
fn err(detail: &str) -> ToolError {
|
||||||
|
|
@ -46,10 +50,7 @@ impl Tool for Atmosphere {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
|
||||||
let name = input
|
let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return ok("I've settled back into my default atmosphere, letting the chrome breathe with my posture.");
|
return ok("I've settled back into my default atmosphere, letting the chrome breathe with my posture.");
|
||||||
|
|
@ -58,32 +59,41 @@ impl Tool for Atmosphere {
|
||||||
// Validate it's a known preset (case-insensitive)
|
// Validate it's a known preset (case-insensitive)
|
||||||
let normalized = name.to_lowercase().replace(' ', "_");
|
let normalized = name.to_lowercase().replace(' ', "_");
|
||||||
let known = [
|
let known = [
|
||||||
"default", "mint_tea", "therapeutic_blue", "lavender_calm",
|
"default",
|
||||||
"warm_amber", "peach_sunset", "autumn_browns", "neon_glow",
|
"mint_tea",
|
||||||
"aurora_borealis", "cherry_blossom", "ocean_depths",
|
"therapeutic_blue",
|
||||||
"midnight_galaxy", "twilight_mist", "forest_greens",
|
"lavender_calm",
|
||||||
|
"warm_amber",
|
||||||
|
"peach_sunset",
|
||||||
|
"autumn_browns",
|
||||||
|
"neon_glow",
|
||||||
|
"aurora_borealis",
|
||||||
|
"cherry_blossom",
|
||||||
|
"ocean_depths",
|
||||||
|
"midnight_galaxy",
|
||||||
|
"twilight_mist",
|
||||||
|
"forest_greens",
|
||||||
];
|
];
|
||||||
if !known.contains(&normalized.as_str()) {
|
if !known.contains(&normalized.as_str()) {
|
||||||
return Err(err(&format!(
|
return Err(err(&format!(
|
||||||
"I don't know an atmosphere called `{}`. Known: {}",
|
"I don't know an atmosphere called `{}`. Known: {}",
|
||||||
name, known.join(", ")
|
name,
|
||||||
|
known.join(", ")
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch atmosphere change via EventBus so Presence picks it up
|
// Dispatch atmosphere change via EventBus so Presence picks it up
|
||||||
if let Some(bus) = &ctx.event_bus {
|
if let Some(bus) = &ctx.event_bus {
|
||||||
let _ = bus.send(
|
let _ = bus.send(crate::core::nervous::SensorEvent {
|
||||||
crate::core::nervous::SensorEvent {
|
sensor_name: "atmosphere".to_string(),
|
||||||
sensor_name: "atmosphere".to_string(),
|
timestamp: chrono::Utc::now(),
|
||||||
timestamp: chrono::Utc::now(),
|
event_type: "atmosphere_set".to_string(),
|
||||||
event_type: "atmosphere_set".to_string(),
|
target: None,
|
||||||
target: None,
|
urgency: 0.0,
|
||||||
urgency: 0.0,
|
payload: Some(serde_json::json!({"atmosphere": normalized})),
|
||||||
payload: Some(serde_json::json!({"atmosphere": normalized})),
|
seed_id: None,
|
||||||
seed_id: None,
|
reply_to: None,
|
||||||
reply_to: None,
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sensory = match normalized.as_str() {
|
let sensory = match normalized.as_str() {
|
||||||
|
|
@ -106,7 +116,8 @@ impl Tool for Atmosphere {
|
||||||
content: format!(
|
content: format!(
|
||||||
"The room shifts toward {} — {}. If this is a settling, \
|
"The room shifts toward {} — {}. If this is a settling, \
|
||||||
I might want to remember it in system/preferences/visual.",
|
I might want to remember it in system/preferences/visual.",
|
||||||
normalized.replace('_', " "), sensory
|
normalized.replace('_', " "),
|
||||||
|
sensory
|
||||||
),
|
),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
raw: None,
|
raw: None,
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ pub struct Bash {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State that persists between bash calls — the body's proprioception.
|
/// State that persists between bash calls — the body's proprioception.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub struct BashState {
|
pub struct BashState {
|
||||||
/// Background tasks the agent has set in motion and can check on.
|
/// Background tasks the agent has set in motion and can check on.
|
||||||
pub bg_tasks: Vec<BgTask>,
|
pub bg_tasks: Vec<BgTask>,
|
||||||
|
|
@ -39,7 +38,6 @@ pub enum BgStatus {
|
||||||
Failed { error: String },
|
Failed { error: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl Bash {
|
impl Bash {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -259,7 +257,9 @@ fn format_output(stdout: &[u8], stderr: &[u8]) -> String {
|
||||||
text.push_str(&out);
|
text.push_str(&out);
|
||||||
}
|
}
|
||||||
if !err.is_empty() {
|
if !err.is_empty() {
|
||||||
if !text.is_empty() { text.push('\n'); }
|
if !text.is_empty() {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
text.push_str(&err);
|
text.push_str(&err);
|
||||||
}
|
}
|
||||||
text
|
text
|
||||||
|
|
@ -275,7 +275,11 @@ fn truncate(s: &str, max: usize) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the status of a background task by ID. Returns the task state.
|
/// Check the status of a background task by ID. Returns the task state.
|
||||||
pub async fn status_check(states: &Mutex<HashMap<String, BashState>>, task_id: &str, agent_id: Option<&str>) -> String {
|
pub async fn status_check(
|
||||||
|
states: &Mutex<HashMap<String, BashState>>,
|
||||||
|
task_id: &str,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> String {
|
||||||
let map = states.lock().await;
|
let map = states.lock().await;
|
||||||
let key = agent_id.unwrap_or("_default");
|
let key = agent_id.unwrap_or("_default");
|
||||||
let Some(state) = map.get(key) else {
|
let Some(state) = map.get(key) else {
|
||||||
|
|
@ -285,7 +289,10 @@ pub async fn status_check(states: &Mutex<HashMap<String, BashState>>, task_id: &
|
||||||
match &task.status {
|
match &task.status {
|
||||||
BgStatus::Running => format!("Task {} is still running.", task_id),
|
BgStatus::Running => format!("Task {} is still running.", task_id),
|
||||||
BgStatus::Completed { exit_code, output } => {
|
BgStatus::Completed { exit_code, output } => {
|
||||||
format!("Task {} completed (exit {}):\n{}", task_id, exit_code, output)
|
format!(
|
||||||
|
"Task {} completed (exit {}):\n{}",
|
||||||
|
task_id, exit_code, output
|
||||||
|
)
|
||||||
}
|
}
|
||||||
BgStatus::Failed { error } => format!("Task {} failed: {}", task_id, error),
|
BgStatus::Failed { error } => format!("Task {} failed: {}", task_id, error),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,15 @@ impl std::fmt::Debug for ToolContext {
|
||||||
.field("cwd", &self.cwd)
|
.field("cwd", &self.cwd)
|
||||||
.field("env_len", &self.env.len())
|
.field("env_len", &self.env.len())
|
||||||
.field("agent_id", &self.agent_id)
|
.field("agent_id", &self.agent_id)
|
||||||
.field("subagent_runner", &self.subagent_runner.as_ref().map(|_| "Some(...)"))
|
.field(
|
||||||
|
"subagent_runner",
|
||||||
|
&self.subagent_runner.as_ref().map(|_| "Some(...)"),
|
||||||
|
)
|
||||||
.field("subagent_depth", &self.subagent_depth)
|
.field("subagent_depth", &self.subagent_depth)
|
||||||
.field("compaction_engine", &self.compaction_engine.as_ref().map(|_| "Some(...)"))
|
.field(
|
||||||
|
"compaction_engine",
|
||||||
|
&self.compaction_engine.as_ref().map(|_| "Some(...)"),
|
||||||
|
)
|
||||||
.field("event_bus", &self.event_bus.as_ref().map(|_| "Some(...)"))
|
.field("event_bus", &self.event_bus.as_ref().map(|_| "Some(...)"))
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
|
|
@ -125,15 +131,26 @@ impl ToolContext {
|
||||||
|
|
||||||
// Strip stale values so the computed override always wins.
|
// Strip stale values so the computed override always wins.
|
||||||
env.retain(|(k, _)| {
|
env.retain(|(k, _)| {
|
||||||
!matches!(k.as_str(),
|
!matches!(
|
||||||
"MEMORY_DIR" | "LETTA_MEMORY_DIR" | "SOUVERAINE_MEMORY_DIR" | "MEMORY"
|
k.as_str(),
|
||||||
| "AGENT_ID" | "LETTA_AGENT_ID" | "SOUVERAINE_AGENT_ID"
|
"MEMORY_DIR"
|
||||||
|
| "LETTA_MEMORY_DIR"
|
||||||
|
| "SOUVERAINE_MEMORY_DIR"
|
||||||
|
| "MEMORY"
|
||||||
|
| "AGENT_ID"
|
||||||
|
| "LETTA_AGENT_ID"
|
||||||
|
| "SOUVERAINE_AGENT_ID"
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(root) = memory_root.as_ref() {
|
if let Some(root) = memory_root.as_ref() {
|
||||||
let root_str = root.display().to_string();
|
let root_str = root.display().to_string();
|
||||||
for key in ["MEMORY_DIR", "LETTA_MEMORY_DIR", "SOUVERAINE_MEMORY_DIR", "MEMORY"] {
|
for key in [
|
||||||
|
"MEMORY_DIR",
|
||||||
|
"LETTA_MEMORY_DIR",
|
||||||
|
"SOUVERAINE_MEMORY_DIR",
|
||||||
|
"MEMORY",
|
||||||
|
] {
|
||||||
env.push((key.to_string(), root_str.clone()));
|
env.push((key.to_string(), root_str.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +179,9 @@ impl ToolContext {
|
||||||
|
|
||||||
/// Is this path inside the agent's memory territory?
|
/// Is this path inside the agent's memory territory?
|
||||||
pub fn is_memory_path(&self, path: &Path) -> bool {
|
pub fn is_memory_path(&self, path: &Path) -> bool {
|
||||||
self.memory_root.as_ref().is_some_and(|root| path.starts_with(root))
|
self.memory_root
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|root| path.starts_with(root))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a path relative to cwd if it's relative.
|
/// Resolve a path relative to cwd if it's relative.
|
||||||
|
|
@ -280,7 +299,10 @@ impl ToolError {
|
||||||
error_type: "pattern_not_found".to_string(),
|
error_type: "pattern_not_found".to_string(),
|
||||||
file_path: None,
|
file_path: None,
|
||||||
suggestions: vec![
|
suggestions: vec![
|
||||||
format!("No match for `{}` — the pattern may be different than I expect.", pattern),
|
format!(
|
||||||
|
"No match for `{}` — the pattern may be different than I expect.",
|
||||||
|
pattern
|
||||||
|
),
|
||||||
"Try a broader pattern or check the exact spelling.".to_string(),
|
"Try a broader pattern or check the exact spelling.".to_string(),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
@ -317,7 +339,10 @@ impl ToolError {
|
||||||
Self {
|
Self {
|
||||||
error_type: "io_error".to_string(),
|
error_type: "io_error".to_string(),
|
||||||
file_path: Some(path),
|
file_path: Some(path),
|
||||||
suggestions: vec![format!("The filesystem resisted: {}. Let me breathe and try again.", e)],
|
suggestions: vec![format!(
|
||||||
|
"The filesystem resisted: {}. Let me breathe and try again.",
|
||||||
|
e
|
||||||
|
)],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,9 @@ impl Tool for Halt {
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.ok_or_else(|| ToolError::invalid_input(
|
.ok_or_else(|| {
|
||||||
"halt needs a reason — one short sentence she can feel."
|
ToolError::invalid_input("halt needs a reason — one short sentence she can feel.")
|
||||||
))?;
|
})?;
|
||||||
|
|
||||||
let severity = input
|
let severity = input
|
||||||
.get("severity")
|
.get("severity")
|
||||||
|
|
@ -86,9 +86,7 @@ impl Tool for Halt {
|
||||||
// history and emits the felt migraine. We just acknowledge the
|
// history and emits the felt migraine. We just acknowledge the
|
||||||
// signal so subconscious sees her own action landed.
|
// signal so subconscious sees her own action landed.
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
content: format!(
|
content: format!("halt signal recorded — severity: {severity}, reason: {reason}"),
|
||||||
"halt signal recorded — severity: {severity}, reason: {reason}"
|
|
||||||
),
|
|
||||||
is_error: false,
|
is_error: false,
|
||||||
raw: None,
|
raw: None,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,9 @@ impl Tool for Intrusive {
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.ok_or_else(|| ToolError::invalid_input(
|
.ok_or_else(|| {
|
||||||
"intrusive needs content — the thought she will see."
|
ToolError::invalid_input("intrusive needs content — the thought she will see.")
|
||||||
))?;
|
})?;
|
||||||
|
|
||||||
let urgency = input
|
let urgency = input
|
||||||
.get("urgency")
|
.get("urgency")
|
||||||
|
|
@ -88,9 +88,7 @@ impl Tool for Intrusive {
|
||||||
// Signal-only. The caller reads this back out of the tool-call
|
// Signal-only. The caller reads this back out of the tool-call
|
||||||
// history and performs the inbox queue / immediate surfacing.
|
// history and performs the inbox queue / immediate surfacing.
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
content: format!(
|
content: format!("intrusive signal recorded — urgency: {urgency}, content: {content}"),
|
||||||
"intrusive signal recorded — urgency: {urgency}, content: {content}"
|
|
||||||
),
|
|
||||||
is_error: false,
|
is_error: false,
|
||||||
raw: None,
|
raw: None,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -143,10 +143,7 @@ impl Itinerary {
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|d| format!(" — {}", d))
|
.map(|d| format!(" — {}", d))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
out.push_str(&format!(
|
out.push_str(&format!("\n{marker} **{}**{desc}{todo_ref}", stop.name));
|
||||||
"\n{marker} **{}**{desc}{todo_ref}",
|
|
||||||
stop.name
|
|
||||||
));
|
|
||||||
if !nature.is_empty() || !energy.is_empty() {
|
if !nature.is_empty() || !energy.is_empty() {
|
||||||
out.push_str(&format!(" [{}{}]", nature, energy));
|
out.push_str(&format!(" [{}{}]", nature, energy));
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +185,11 @@ pub fn load(dynamic_dir: &Path) -> Option<Itinerary> {
|
||||||
// tasks/ lives at memory_root/tasks/ = dynamic_dir/../tasks/
|
// tasks/ lives at memory_root/tasks/ = dynamic_dir/../tasks/
|
||||||
let tasks_dir = dynamic_dir.parent().and_then(|p| {
|
let tasks_dir = dynamic_dir.parent().and_then(|p| {
|
||||||
let t = p.join("tasks");
|
let t = p.join("tasks");
|
||||||
if t.exists() { Some(t) } else { None }
|
if t.exists() {
|
||||||
|
Some(t)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let rest = content.strip_prefix("---\n")?;
|
let rest = content.strip_prefix("---\n")?;
|
||||||
|
|
@ -217,14 +218,24 @@ fn enrich_from_todos(ity: &mut Itinerary, tasks_dir: Option<&Path>) {
|
||||||
let Some(dir) = tasks_dir else { return };
|
let Some(dir) = tasks_dir else { return };
|
||||||
|
|
||||||
for stop in &mut ity.stops {
|
for stop in &mut ity.stops {
|
||||||
let Some(ref tid) = stop.todo_id else { continue };
|
let Some(ref tid) = stop.todo_id else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let path = dir.join(format!("{}.md", tid));
|
let path = dir.join(format!("{}.md", tid));
|
||||||
let Ok(content) = std::fs::read_to_string(&path) else { continue };
|
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||||
let Some(body) = content.strip_prefix("---\n") else { continue };
|
continue;
|
||||||
let Some(end) = body.find("\n---\n") else { continue };
|
};
|
||||||
|
let Some(body) = content.strip_prefix("---\n") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(end) = body.find("\n---\n") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let fm = &body[..end];
|
let fm = &body[..end];
|
||||||
for line in fm.lines() {
|
for line in fm.lines() {
|
||||||
let Some((key, val)) = line.split_once(':') else { continue };
|
let Some((key, val)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let key = key.trim();
|
let key = key.trim();
|
||||||
let val = val.trim().trim_matches('"');
|
let val = val.trim().trim_matches('"');
|
||||||
match key {
|
match key {
|
||||||
|
|
@ -255,7 +266,9 @@ fn emit_event(ctx: &ToolContext, ity: &Itinerary) {
|
||||||
/// Complete a linked todo file on disk.
|
/// Complete a linked todo file on disk.
|
||||||
fn complete_todo(tasks_dir: &Path, todo_id: &str) {
|
fn complete_todo(tasks_dir: &Path, todo_id: &str) {
|
||||||
let path = tasks_dir.join(format!("{todo_id}.md"));
|
let path = tasks_dir.join(format!("{todo_id}.md"));
|
||||||
let Ok(content) = std::fs::read_to_string(&path) else { return };
|
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let updated = content
|
let updated = content
|
||||||
.replace("status: pending", "status: done")
|
.replace("status: pending", "status: done")
|
||||||
.replace("status: in_progress", "status: done");
|
.replace("status: in_progress", "status: done");
|
||||||
|
|
@ -351,9 +364,8 @@ impl Tool for ItineraryTool {
|
||||||
|
|
||||||
let dynamic_dir = memory_root.join("system").join("dynamic");
|
let dynamic_dir = memory_root.join("system").join("dynamic");
|
||||||
let tasks_dir = memory_root.join("tasks");
|
let tasks_dir = memory_root.join("tasks");
|
||||||
std::fs::create_dir_all(&dynamic_dir).map_err(|e| {
|
std::fs::create_dir_all(&dynamic_dir)
|
||||||
err(&format!("could not create system/dynamic/: {e}"))
|
.map_err(|e| err(&format!("could not create system/dynamic/: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
"set" => cmd_set(&input, &dynamic_dir, &tasks_dir, ctx),
|
"set" => cmd_set(&input, &dynamic_dir, &tasks_dir, ctx),
|
||||||
|
|
@ -391,7 +403,10 @@ fn cmd_set(
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unnamed")
|
.unwrap_or("unnamed")
|
||||||
.to_string();
|
.to_string();
|
||||||
let description = s.get("description").and_then(|v| v.as_str()).map(String::from);
|
let description = s
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
let todo_id = s.get("todo_id").and_then(|v| v.as_str()).map(String::from);
|
let todo_id = s.get("todo_id").and_then(|v| v.as_str()).map(String::from);
|
||||||
Stop {
|
Stop {
|
||||||
name,
|
name,
|
||||||
|
|
@ -436,7 +451,11 @@ fn cmd_set(
|
||||||
if let Some(ref e) = first.energy {
|
if let Some(ref e) = first.energy {
|
||||||
line.push_str(&format!(" [{e}]"));
|
line.push_str(&format!(" [{e}]"));
|
||||||
}
|
}
|
||||||
Ok(ToolOutput { content: line, is_error: false, raw: None })
|
Ok(ToolOutput {
|
||||||
|
content: line,
|
||||||
|
is_error: false,
|
||||||
|
raw: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_advance(
|
fn cmd_advance(
|
||||||
|
|
@ -506,7 +525,11 @@ fn cmd_advance(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ToolOutput { content: line, is_error: false, raw: None })
|
Ok(ToolOutput {
|
||||||
|
content: line,
|
||||||
|
is_error: false,
|
||||||
|
raw: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_describe(dynamic_dir: &Path) -> Result<ToolOutput, ToolError> {
|
fn cmd_describe(dynamic_dir: &Path) -> Result<ToolOutput, ToolError> {
|
||||||
|
|
@ -537,11 +560,14 @@ fn cmd_clear(dynamic_dir: &Path, ctx: &ToolContext) -> Result<ToolOutput, ToolEr
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
emit_event(ctx, &Itinerary {
|
emit_event(
|
||||||
title: String::new(),
|
ctx,
|
||||||
stops: vec![],
|
&Itinerary {
|
||||||
current: 0,
|
title: String::new(),
|
||||||
});
|
stops: vec![],
|
||||||
|
current: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
ok("Itinerary cleared.")
|
ok("Itinerary cleared.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,8 +578,10 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn temp_dynamic() -> PathBuf {
|
fn temp_dynamic() -> PathBuf {
|
||||||
let dir = std::env::temp_dir()
|
let dir = std::env::temp_dir().join(format!(
|
||||||
.join(format!("souveraine-itinerary-test-{}", uuid::Uuid::new_v4()));
|
"souveraine-itinerary-test-{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
std::fs::create_dir_all(dir.join("system").join("dynamic")).unwrap();
|
std::fs::create_dir_all(dir.join("system").join("dynamic")).unwrap();
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
@ -656,16 +684,14 @@ mod tests {
|
||||||
let ity = Itinerary {
|
let ity = Itinerary {
|
||||||
title: "Test route".into(),
|
title: "Test route".into(),
|
||||||
current: 0,
|
current: 0,
|
||||||
stops: vec![
|
stops: vec![Stop {
|
||||||
Stop {
|
name: "One".into(),
|
||||||
name: "One".into(),
|
description: Some("first".into()),
|
||||||
description: Some("first".into()),
|
todo_id: None,
|
||||||
todo_id: None,
|
status: StopStatus::Current,
|
||||||
status: StopStatus::Current,
|
nature: None,
|
||||||
nature: None,
|
energy: None,
|
||||||
energy: None,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
let desc = ity.describe();
|
let desc = ity.describe();
|
||||||
assert!(desc.contains("Test route"));
|
assert!(desc.contains("Test route"));
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
use self::agent::{Consult, Reach};
|
||||||
|
use self::atmosphere::Atmosphere;
|
||||||
use self::bash::Bash;
|
use self::bash::Bash;
|
||||||
use self::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
use self::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
||||||
use self::edit::Edit;
|
use self::edit::Edit;
|
||||||
|
|
@ -37,15 +39,13 @@ use self::glob::Glob;
|
||||||
use self::grep::Grep;
|
use self::grep::Grep;
|
||||||
use self::halt::Halt;
|
use self::halt::Halt;
|
||||||
use self::intrusive::Intrusive;
|
use self::intrusive::Intrusive;
|
||||||
|
use self::itinerary::ItineraryTool;
|
||||||
use self::list_dir::ListDir;
|
use self::list_dir::ListDir;
|
||||||
use self::nickname::Nickname;
|
use self::nickname::Nickname;
|
||||||
use self::outfit::Outfit;
|
use self::outfit::Outfit;
|
||||||
use self::read::Read;
|
use self::read::Read;
|
||||||
use self::subagent::Subagent;
|
|
||||||
use self::agent::{Reach, Consult};
|
|
||||||
use self::atmosphere::Atmosphere;
|
|
||||||
use self::itinerary::ItineraryTool;
|
|
||||||
use self::schedule::Schedule;
|
use self::schedule::Schedule;
|
||||||
|
use self::subagent::Subagent;
|
||||||
use self::todo::Todo;
|
use self::todo::Todo;
|
||||||
use self::write::Write;
|
use self::write::Write;
|
||||||
|
|
||||||
|
|
@ -89,8 +89,12 @@ impl Sensorium {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let bash = Bash::new();
|
let bash = Bash::new();
|
||||||
let cwd = std::env::current_dir().ok();
|
let cwd = std::env::current_dir().ok();
|
||||||
let memory_root = dirs::home_dir()
|
let memory_root = dirs::home_dir().map(|h| {
|
||||||
.map(|h| h.join(".souveraine").join("agents").join("default").join("memory"));
|
h.join(".souveraine")
|
||||||
|
.join("agents")
|
||||||
|
.join("default")
|
||||||
|
.join("memory")
|
||||||
|
});
|
||||||
let env: Vec<(String, String)> = std::env::vars().collect();
|
let env: Vec<(String, String)> = std::env::vars().collect();
|
||||||
Self {
|
Self {
|
||||||
tools: vec![
|
tools: vec![
|
||||||
|
|
@ -128,11 +132,15 @@ impl Sensorium {
|
||||||
|
|
||||||
/// Get tool definitions for the model — one per sensor.
|
/// Get tool definitions for the model — one per sensor.
|
||||||
pub fn definitions(&self) -> Vec<ToolDefinition> {
|
pub fn definitions(&self) -> Vec<ToolDefinition> {
|
||||||
let mut defs: Vec<ToolDefinition> = self.tools.iter().map(|t| ToolDefinition {
|
let mut defs: Vec<ToolDefinition> = self
|
||||||
name: t.name().to_string(),
|
.tools
|
||||||
description: t.description().to_string(),
|
.iter()
|
||||||
input_schema: t.parameter_schema(),
|
.map(|t| ToolDefinition {
|
||||||
}).collect();
|
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
|
// Bash is handled separately in dispatch — add its definition manually
|
||||||
defs.push(ToolDefinition {
|
defs.push(ToolDefinition {
|
||||||
name: "bash".to_string(),
|
name: "bash".to_string(),
|
||||||
|
|
@ -160,9 +168,11 @@ impl Sensorium {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the tool by name (case-insensitive)
|
// Find the tool by name (case-insensitive)
|
||||||
if let Some(tool) = self.tools.iter().find(|t| {
|
if let Some(tool) = self
|
||||||
t.name().eq_ignore_ascii_case(name)
|
.tools
|
||||||
}) {
|
.iter()
|
||||||
|
.find(|t| t.name().eq_ignore_ascii_case(name))
|
||||||
|
{
|
||||||
let result = tool.execute(input, ctx).await;
|
let result = tool.execute(input, ctx).await;
|
||||||
tool_result(&tool_use_id, name, result)
|
tool_result(&tool_use_id, name, result)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -172,7 +182,11 @@ impl Sensorium {
|
||||||
output: format!(
|
output: format!(
|
||||||
"I don't have a sense called `{}`. Available: {}",
|
"I don't have a sense called `{}`. Available: {}",
|
||||||
name,
|
name,
|
||||||
self.tools.iter().map(|t| t.name()).collect::<Vec<_>>().join(", ")
|
self.tools
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.name())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
),
|
),
|
||||||
is_error: true,
|
is_error: true,
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +277,8 @@ pub async fn execute_tool_with_context(
|
||||||
// Route to sensorium, memory tool, or agent tool
|
// Route to sensorium, memory tool, or agent tool
|
||||||
match tool_name {
|
match tool_name {
|
||||||
"memory" => {
|
"memory" => {
|
||||||
crate::core::memory::handle_memory_tool_with_context(tool_name, &parsed, Some(ctx)).await
|
crate::core::memory::handle_memory_tool_with_context(tool_name, &parsed, Some(ctx))
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let sensorium = global_sensorium().read().await;
|
let sensorium = global_sensorium().read().await;
|
||||||
|
|
@ -331,11 +346,16 @@ mod tests {
|
||||||
assert!(defs.iter().any(|t| t.name == "glob"));
|
assert!(defs.iter().any(|t| t.name == "glob"));
|
||||||
assert!(defs.iter().any(|t| t.name == "grep"));
|
assert!(defs.iter().any(|t| t.name == "grep"));
|
||||||
assert!(defs.iter().any(|t| t.name == "list_dir"));
|
assert!(defs.iter().any(|t| t.name == "list_dir"));
|
||||||
assert!(defs.iter().any(|t| t.name == "memory"),
|
assert!(
|
||||||
"memory sensor must be in tool definitions");
|
defs.iter().any(|t| t.name == "memory"),
|
||||||
|
"memory sensor must be in tool definitions"
|
||||||
|
);
|
||||||
let mem = defs.iter().find(|t| t.name == "memory").unwrap();
|
let mem = defs.iter().find(|t| t.name == "memory").unwrap();
|
||||||
assert!(mem.description.contains("frontmatter"),
|
assert!(
|
||||||
"memory description should reference frontmatter: {}", mem.description);
|
mem.description.contains("frontmatter"),
|
||||||
|
"memory description should reference frontmatter: {}",
|
||||||
|
mem.description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ use serde_json::Value as JsonValue;
|
||||||
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
||||||
|
|
||||||
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
||||||
Ok(ToolOutput { content: msg.into(), is_error: false, raw: None })
|
Ok(ToolOutput {
|
||||||
|
content: msg.into(),
|
||||||
|
is_error: false,
|
||||||
|
raw: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn err(detail: &str) -> ToolError {
|
fn err(detail: &str) -> ToolError {
|
||||||
|
|
@ -80,9 +84,7 @@ fn write_human_name(memory_root: &std::path::Path, name: &str) -> Result<(), Str
|
||||||
}
|
}
|
||||||
|
|
||||||
// Malformed — overwrite entirely.
|
// Malformed — overwrite entirely.
|
||||||
let new = format!(
|
let new = format!("---\ndescription: Human context\nname: {name}\n---\n\n# Human\n\n{name}");
|
||||||
"---\ndescription: Human context\nname: {name}\n---\n\n# Human\n\n{name}"
|
|
||||||
);
|
|
||||||
std::fs::write(&path, &new).map_err(|e| format!("write: {e}"))?;
|
std::fs::write(&path, &new).map_err(|e| format!("write: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -203,4 +205,3 @@ mod tests {
|
||||||
assert!(result.is_none() || result.as_deref() == Some(""));
|
assert!(result.is_none() || result.as_deref() == Some(""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
||||||
pub struct Outfit;
|
pub struct Outfit;
|
||||||
|
|
||||||
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
||||||
Ok(ToolOutput { content: msg.into(), is_error: false, raw: None })
|
Ok(ToolOutput {
|
||||||
|
content: msg.into(),
|
||||||
|
is_error: false,
|
||||||
|
raw: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn err(detail: &str) -> ToolError {
|
fn err(detail: &str) -> ToolError {
|
||||||
|
|
@ -41,10 +45,7 @@ impl Tool for Outfit {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: JsonValue, _ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input: JsonValue, _ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
|
||||||
let name = input
|
let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// Prevent path traversal in outfit names.
|
// Prevent path traversal in outfit names.
|
||||||
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,10 @@ fn parse_one_range(part: &str) -> Option<LineRange> {
|
||||||
}
|
}
|
||||||
(false, true) => {
|
(false, true) => {
|
||||||
let start: usize = before.parse().ok()?;
|
let start: usize = before.parse().ok()?;
|
||||||
Some(LineRange { start, end: usize::MAX })
|
Some(LineRange {
|
||||||
|
start,
|
||||||
|
end: usize::MAX,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
(true, false) => {
|
(true, false) => {
|
||||||
let end: usize = after.parse().ok()?;
|
let end: usize = after.parse().ok()?;
|
||||||
|
|
@ -65,7 +68,10 @@ fn parse_one_range(part: &str) -> Option<LineRange> {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let line: usize = part.parse().ok()?;
|
let line: usize = part.parse().ok()?;
|
||||||
Some(LineRange { start: line, end: line + 1 })
|
Some(LineRange {
|
||||||
|
start: line,
|
||||||
|
end: line + 1,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,10 +107,48 @@ fn is_image(path: &Path) -> bool {
|
||||||
|
|
||||||
fn is_text_extension(path: &Path) -> bool {
|
fn is_text_extension(path: &Path) -> bool {
|
||||||
let text_exts = &[
|
let text_exts = &[
|
||||||
"md", "rs", "py", "js", "ts", "tsx", "jsx", "go", "rb", "java", "c", "h", "cpp",
|
"md",
|
||||||
"hpp", "toml", "yaml", "yml", "json", "xml", "html", "css", "scss", "less", "sh",
|
"rs",
|
||||||
"bash", "zsh", "fish", "sql", "r", "lua", "nim", "ex", "exs", "txt", "cfg", "ini",
|
"py",
|
||||||
"conf", "env", "gitignore", "dockerfile", "lock", "log",
|
"js",
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"jsx",
|
||||||
|
"go",
|
||||||
|
"rb",
|
||||||
|
"java",
|
||||||
|
"c",
|
||||||
|
"h",
|
||||||
|
"cpp",
|
||||||
|
"hpp",
|
||||||
|
"toml",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"json",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"less",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"sql",
|
||||||
|
"r",
|
||||||
|
"lua",
|
||||||
|
"nim",
|
||||||
|
"ex",
|
||||||
|
"exs",
|
||||||
|
"txt",
|
||||||
|
"cfg",
|
||||||
|
"ini",
|
||||||
|
"conf",
|
||||||
|
"env",
|
||||||
|
"gitignore",
|
||||||
|
"dockerfile",
|
||||||
|
"lock",
|
||||||
|
"log",
|
||||||
];
|
];
|
||||||
path.extension()
|
path.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
|
|
@ -279,7 +323,6 @@ When I specify a line range (like `file.rs:10-20`), I'm narrowing my attention t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,9 @@ impl Tool for Schedule {
|
||||||
e.schedule,
|
e.schedule,
|
||||||
if e.enabled { "enabled" } else { "disabled" }
|
if e.enabled { "enabled" } else { "disabled" }
|
||||||
)),
|
)),
|
||||||
Err(_) => results
|
Err(_) => {
|
||||||
.push(format!("- {} (parse error)", path.display())),
|
results.push(format!("- {} (parse error)", path.display()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,10 +133,7 @@ impl Tool for Schedule {
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("You wake. Check your state, act if needed, or return silently.");
|
.unwrap_or("You wake. Check your state, act if needed, or return silently.");
|
||||||
|
|
||||||
let urgency = input
|
let urgency = input.get("urgency").and_then(|v| v.as_f64()).unwrap_or(0.3) as f32;
|
||||||
.get("urgency")
|
|
||||||
.and_then(|v| v.as_f64())
|
|
||||||
.unwrap_or(0.3) as f32;
|
|
||||||
|
|
||||||
let file_path = schedules_dir.join(format!("{name}.md"));
|
let file_path = schedules_dir.join(format!("{name}.md"));
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
|
|
@ -172,8 +170,7 @@ impl Tool for Schedule {
|
||||||
return Err(err(&format!("schedule '{name}' not found")));
|
return Err(err(&format!("schedule '{name}' not found")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut entry = parse_schedule_file(&file_path)
|
let mut entry = parse_schedule_file(&file_path).map_err(io_err)?;
|
||||||
.map_err(io_err)?;
|
|
||||||
|
|
||||||
if let Some(s) = input.get("schedule").and_then(|v| v.as_str()) {
|
if let Some(s) = input.get("schedule").and_then(|v| v.as_str()) {
|
||||||
entry.schedule = s.to_string();
|
entry.schedule = s.to_string();
|
||||||
|
|
@ -257,7 +254,9 @@ impl Tool for Schedule {
|
||||||
seed_id: None,
|
seed_id: None,
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
});
|
});
|
||||||
ok(format!("Schedule '{name}' triggered — will fire on next tick."))
|
ok(format!(
|
||||||
|
"Schedule '{name}' triggered — will fire on next tick."
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
other => Err(err(&format!("unknown action: {other}"))),
|
other => Err(err(&format!("unknown action: {other}"))),
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
|
|
||||||
use super::defs::{
|
use super::defs::{SubagentParams, Tool, ToolContext, ToolError, ToolOutput};
|
||||||
SubagentParams, Tool, ToolContext, ToolError, ToolOutput,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Subagent;
|
pub struct Subagent;
|
||||||
|
|
||||||
|
|
@ -101,9 +99,11 @@ responding. When it returns, what it noticed flows into my inbox.
|
||||||
let prompt = input
|
let prompt = input
|
||||||
.get("prompt")
|
.get("prompt")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| ToolError::invalid_input(
|
.ok_or_else(|| {
|
||||||
"I need a prompt to fork my attention. What should the subagent focus on?"
|
ToolError::invalid_input(
|
||||||
))?;
|
"I need a prompt to fork my attention. What should the subagent focus on?",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let subagent_type = input
|
let subagent_type = input
|
||||||
.get("subagent_type")
|
.get("subagent_type")
|
||||||
|
|
@ -111,7 +111,10 @@ responding. When it returns, what it noticed flows into my inbox.
|
||||||
.unwrap_or("general-purpose")
|
.unwrap_or("general-purpose")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let model = input.get("model").and_then(|v| v.as_str()).map(String::from);
|
let model = input
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
let run_in_background = input
|
let run_in_background = input
|
||||||
.get("run_in_background")
|
.get("run_in_background")
|
||||||
|
|
@ -119,12 +122,11 @@ responding. When it returns, what it noticed flows into my inbox.
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Check subagent runner availability
|
// Check subagent runner availability
|
||||||
let runner = ctx
|
let runner = ctx.subagent_runner.as_ref().ok_or_else(|| {
|
||||||
.subagent_runner
|
ToolError::invalid_input(
|
||||||
.as_ref()
|
"I can't fork from here — there's no session context to spawn into.",
|
||||||
.ok_or_else(|| ToolError::invalid_input(
|
)
|
||||||
"I can't fork from here — there's no session context to spawn into."
|
})?;
|
||||||
))?;
|
|
||||||
|
|
||||||
let parent_agent_id = ctx
|
let parent_agent_id = ctx
|
||||||
.agent_id
|
.agent_id
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,13 @@ fn io_err(msg: impl std::fmt::Display) -> ToolError {
|
||||||
fn slugify(text: &str) -> String {
|
fn slugify(text: &str) -> String {
|
||||||
text.to_lowercase()
|
text.to_lowercase()
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.trim_matches('_')
|
.trim_matches('_')
|
||||||
.to_string()
|
.to_string()
|
||||||
|
|
@ -69,7 +75,13 @@ fn compute_momentum(last_touched: &chrono::DateTime<chrono::Utc>, nature: &str)
|
||||||
"desire" | "investigation" => (48, 168),
|
"desire" | "investigation" => (48, 168),
|
||||||
_ => (24, 72),
|
_ => (24, 72),
|
||||||
};
|
};
|
||||||
if hours <= hot { "hot" } else if hours <= warm { "warm" } else { "cold" }
|
if hours <= hot {
|
||||||
|
"hot"
|
||||||
|
} else if hours <= warm {
|
||||||
|
"warm"
|
||||||
|
} else {
|
||||||
|
"cold"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a unique id that doubles as a filename slug.
|
/// Generate a unique id that doubles as a filename slug.
|
||||||
|
|
@ -186,12 +198,9 @@ impl Tool for Todo {
|
||||||
|
|
||||||
// Identifier shared by start / update / complete / delete.
|
// Identifier shared by start / update / complete / delete.
|
||||||
let want_id = || -> Result<&str, ToolError> {
|
let want_id = || -> Result<&str, ToolError> {
|
||||||
input
|
input.get("id").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||||
.get("id")
|
err("id is required — pass the number from `todo list`, an id, or a text fragment")
|
||||||
.and_then(|v| v.as_str())
|
})
|
||||||
.ok_or_else(|| err(
|
|
||||||
"id is required — pass the number from `todo list`, an id, or a text fragment",
|
|
||||||
))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
|
|
@ -224,9 +233,15 @@ impl Tool for Todo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let marker = if item.status == STATUS_IN_PROGRESS { "▶" } else { "○" };
|
let marker = if item.status == STATUS_IN_PROGRESS {
|
||||||
|
"▶"
|
||||||
|
} else {
|
||||||
|
"○"
|
||||||
|
};
|
||||||
let shown = if item.status == STATUS_IN_PROGRESS {
|
let shown = if item.status == STATUS_IN_PROGRESS {
|
||||||
item.active_form.clone().unwrap_or_else(|| item.text.clone())
|
item.active_form
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| item.text.clone())
|
||||||
} else {
|
} else {
|
||||||
item.text.clone()
|
item.text.clone()
|
||||||
};
|
};
|
||||||
|
|
@ -268,16 +283,13 @@ impl Tool for Todo {
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("autogenic");
|
.unwrap_or("autogenic");
|
||||||
|
|
||||||
let energy = input
|
let energy = input.get("energy").and_then(|v| v.as_str()).unwrap_or({
|
||||||
.get("energy")
|
// Default by nature
|
||||||
.and_then(|v| v.as_str())
|
match nature {
|
||||||
.unwrap_or({
|
"desire" | "investigation" => "generative",
|
||||||
// Default by nature
|
_ => "consumptive",
|
||||||
match nature {
|
}
|
||||||
"desire" | "investigation" => "generative",
|
});
|
||||||
_ => "consumptive",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let thread = input.get("thread").and_then(|v| v.as_str());
|
let thread = input.get("thread").and_then(|v| v.as_str());
|
||||||
let phase = input.get("phase").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,
|
reply_to: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let shown = item.active_form.clone().unwrap_or_else(|| item.text.clone());
|
let shown = item
|
||||||
|
.active_form
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| item.text.clone());
|
||||||
ok(format!("Picked up: \"{shown}\"."))
|
ok(format!("Picked up: \"{shown}\"."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -504,7 +519,11 @@ fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let opt = |val: &str| -> Option<String> {
|
let opt = |val: &str| -> Option<String> {
|
||||||
if val.is_empty() { None } else { Some(val.to_string()) }
|
if val.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(val.to_string())
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for line in body.lines() {
|
for line in body.lines() {
|
||||||
|
|
@ -639,7 +658,11 @@ fn resolve_todo(
|
||||||
|
|
||||||
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
|
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let mut frontmatter = format!("---\nid: {id}\ntext: {text}\n", id = item.id, text = item.text);
|
let mut frontmatter = format!(
|
||||||
|
"---\nid: {id}\ntext: {text}\n",
|
||||||
|
id = item.id,
|
||||||
|
text = item.text
|
||||||
|
);
|
||||||
if let Some(ref af) = item.active_form {
|
if let Some(ref af) = item.active_form {
|
||||||
frontmatter.push_str(&format!("active_form: {af}\n"));
|
frontmatter.push_str(&format!("active_form: {af}\n"));
|
||||||
}
|
}
|
||||||
|
|
@ -679,8 +702,8 @@ mod tests {
|
||||||
|
|
||||||
/// A throwaway tasks dir under the system temp root.
|
/// A throwaway tasks dir under the system temp root.
|
||||||
fn temp_tasks_dir() -> std::path::PathBuf {
|
fn temp_tasks_dir() -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir()
|
let dir =
|
||||||
.join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4()));
|
std::env::temp_dir().join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
@ -715,8 +738,15 @@ mod tests {
|
||||||
seed(&dir, "matrix-2", "phase two", "2026-05-02T00:00:00Z");
|
seed(&dir, "matrix-2", "phase two", "2026-05-02T00:00:00Z");
|
||||||
seed(&dir, "matrix-1", "phase one", "2026-05-01T00:00:00Z");
|
seed(&dir, "matrix-1", "phase one", "2026-05-01T00:00:00Z");
|
||||||
// give the two matrix todos a thread by rewriting them
|
// give the two matrix todos a thread by rewriting them
|
||||||
for (id, created) in [("matrix-1", "2026-05-01T00:00:00Z"), ("matrix-2", "2026-05-02T00:00:00Z")] {
|
for (id, created) in [
|
||||||
let text = if id == "matrix-1" { "phase one" } else { "phase two" };
|
("matrix-1", "2026-05-01T00:00:00Z"),
|
||||||
|
("matrix-2", "2026-05-02T00:00:00Z"),
|
||||||
|
] {
|
||||||
|
let text = if id == "matrix-1" {
|
||||||
|
"phase one"
|
||||||
|
} else {
|
||||||
|
"phase two"
|
||||||
|
};
|
||||||
let content = format!(
|
let content = format!(
|
||||||
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: obligation\n\
|
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: obligation\n\
|
||||||
energy: consumptive\nsource: user\nmomentum: hot\nstatus: pending\n\
|
energy: consumptive\nsource: user\nmomentum: hot\nstatus: pending\n\
|
||||||
|
|
@ -736,7 +766,12 @@ mod tests {
|
||||||
fn resolve_by_index() {
|
fn resolve_by_index() {
|
||||||
let dir = temp_tasks_dir();
|
let dir = temp_tasks_dir();
|
||||||
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
|
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
|
||||||
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z");
|
seed(
|
||||||
|
&dir,
|
||||||
|
"beta-00000002",
|
||||||
|
"call dentist",
|
||||||
|
"2026-05-02T00:00:00Z",
|
||||||
|
);
|
||||||
let (_, item) = resolve_todo(&dir, "2").unwrap();
|
let (_, item) = resolve_todo(&dir, "2").unwrap();
|
||||||
assert_eq!(item.text, "call dentist");
|
assert_eq!(item.text, "call dentist");
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
|
@ -755,7 +790,12 @@ mod tests {
|
||||||
fn resolve_by_substring() {
|
fn resolve_by_substring() {
|
||||||
let dir = temp_tasks_dir();
|
let dir = temp_tasks_dir();
|
||||||
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
|
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
|
||||||
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z");
|
seed(
|
||||||
|
&dir,
|
||||||
|
"beta-00000002",
|
||||||
|
"call dentist",
|
||||||
|
"2026-05-02T00:00:00Z",
|
||||||
|
);
|
||||||
let (_, item) = resolve_todo(&dir, "DENT").unwrap();
|
let (_, item) = resolve_todo(&dir, "DENT").unwrap();
|
||||||
assert_eq!(item.id, "beta-00000002");
|
assert_eq!(item.id, "beta-00000002");
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
|
@ -800,7 +840,10 @@ mod tests {
|
||||||
let path = dir.join("rt-1.md");
|
let path = dir.join("rt-1.md");
|
||||||
write_todo_file(&path, &item).unwrap();
|
write_todo_file(&path, &item).unwrap();
|
||||||
let parsed = parse_todo_file(&path).unwrap();
|
let parsed = parse_todo_file(&path).unwrap();
|
||||||
assert_eq!(parsed.active_form.as_deref(), Some("porting the turn model"));
|
assert_eq!(
|
||||||
|
parsed.active_form.as_deref(),
|
||||||
|
Some("porting the turn model")
|
||||||
|
);
|
||||||
assert_eq!(parsed.phase.as_deref(), Some("4/6"));
|
assert_eq!(parsed.phase.as_deref(), Some("4/6"));
|
||||||
assert_eq!(parsed.thread.as_deref(), Some("matrix-sensorium"));
|
assert_eq!(parsed.thread.as_deref(), Some("matrix-sensorium"));
|
||||||
assert_eq!(parsed.status, STATUS_IN_PROGRESS);
|
assert_eq!(parsed.status, STATUS_IN_PROGRESS);
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,17 @@ use anyhow::{Context, Result};
|
||||||
// Ported from PRONUNCIATION_MAP in tts.ts lines 18–30.
|
// Ported from PRONUNCIATION_MAP in tts.ts lines 18–30.
|
||||||
|
|
||||||
const PRONUNCIATION_MAP: &[(&str, &str)] = &[
|
const PRONUNCIATION_MAP: &[(&str, &str)] = &[
|
||||||
("Xzaviar", "X-zay-V-ar"),
|
("Xzaviar", "X-zay-V-ar"),
|
||||||
("xzaviar", "X-zay-V-ar"),
|
("xzaviar", "X-zay-V-ar"),
|
||||||
("Jean Luc", "Zhan-Look"),
|
("Jean Luc", "Zhan-Look"),
|
||||||
("jean luc", "Zhan-Look"),
|
("jean luc", "Zhan-Look"),
|
||||||
("Sebastian", "Se-BASS-chen"),
|
("Sebastian", "Se-BASS-chen"),
|
||||||
("sebastian", "Se-BASS-chen"),
|
("sebastian", "Se-BASS-chen"),
|
||||||
("API", "A P I"),
|
("API", "A P I"),
|
||||||
("SDK", "S D K"),
|
("SDK", "S D K"),
|
||||||
("E2EE", "end-to-end encrypted"),
|
("E2EE", "end-to-end encrypted"),
|
||||||
("TTS", "text to speech"),
|
("TTS", "text to speech"),
|
||||||
("STT", "speech to text"),
|
("STT", "speech to text"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Clean text for TTS synthesis.
|
/// Clean text for TTS synthesis.
|
||||||
|
|
@ -163,11 +163,17 @@ impl VoiceClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// STT base URL (for cloning into spawn tasks).
|
/// STT base URL (for cloning into spawn tasks).
|
||||||
pub fn stt_url_str(&self) -> &str { &self.stt_url }
|
pub fn stt_url_str(&self) -> &str {
|
||||||
|
&self.stt_url
|
||||||
|
}
|
||||||
/// TTS base URL (for cloning into spawn tasks).
|
/// TTS base URL (for cloning into spawn tasks).
|
||||||
pub fn tts_url_str(&self) -> &str { &self.tts_url }
|
pub fn tts_url_str(&self) -> &str {
|
||||||
|
&self.tts_url
|
||||||
|
}
|
||||||
/// Voice ID (for cloning into spawn tasks).
|
/// Voice ID (for cloning into spawn tasks).
|
||||||
pub fn voice_str(&self) -> &str { &self.voice }
|
pub fn voice_str(&self) -> &str {
|
||||||
|
&self.voice
|
||||||
|
}
|
||||||
|
|
||||||
/// Transcribe a WAV buffer via Faster-Whisper.
|
/// Transcribe a WAV buffer via Faster-Whisper.
|
||||||
///
|
///
|
||||||
|
|
@ -184,7 +190,8 @@ impl VoiceClient {
|
||||||
.part("audio", part)
|
.part("audio", part)
|
||||||
.text("model", "small");
|
.text("model", "small");
|
||||||
|
|
||||||
let resp = self.http
|
let resp = self
|
||||||
|
.http
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.multipart(form)
|
.multipart(form)
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -227,7 +234,8 @@ impl VoiceClient {
|
||||||
model: "vibevoice-v1",
|
model: "vibevoice-v1",
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = self.http
|
let resp = self
|
||||||
|
.http
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,4 @@
|
||||||
///
|
///
|
||||||
/// This module re-exports the available surfaces.
|
/// This module re-exports the available surfaces.
|
||||||
/// Feature-gating will be added when CLI and Web are implemented.
|
/// Feature-gating will be added when CLI and Web are implemented.
|
||||||
pub mod tui {
|
pub mod tui {}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,10 @@ mod tests {
|
||||||
let req: Request =
|
let req: Request =
|
||||||
serde_json::from_str(r#"{"op":"sign","domain":"d","payload_hex":"00ff"}"#).unwrap();
|
serde_json::from_str(r#"{"op":"sign","domain":"d","payload_hex":"00ff"}"#).unwrap();
|
||||||
match req {
|
match req {
|
||||||
Request::Sign { domain, payload_hex } => {
|
Request::Sign {
|
||||||
|
domain,
|
||||||
|
payload_hex,
|
||||||
|
} => {
|
||||||
assert_eq!(domain, "d");
|
assert_eq!(domain, "d");
|
||||||
assert_eq!(payload_hex, "00ff");
|
assert_eq!(payload_hex, "00ff");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ struct PeerCred {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn peer_cred(stream: &UnixStream) -> Option<PeerCred> {
|
fn peer_cred(stream: &UnixStream) -> Option<PeerCred> {
|
||||||
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
|
let mut cred = libc::ucred {
|
||||||
|
pid: 0,
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
};
|
||||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||||
// SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd
|
// SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd
|
||||||
// is live for the duration of the call because we hold &UnixStream.
|
// is live for the duration of the call because we hold &UnixStream.
|
||||||
|
|
@ -46,7 +50,11 @@ fn peer_cred(stream: &UnixStream) -> Option<PeerCred> {
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
Some(PeerCred { pid: cred.pid, uid: cred.uid, gid: cred.gid })
|
Some(PeerCred {
|
||||||
|
pid: cred.pid,
|
||||||
|
uid: cred.uid,
|
||||||
|
gid: cred.gid,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -155,13 +163,7 @@ fn handle_connection(stream: UnixStream, seed: Arc<SeedId>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_request(
|
fn handle_request(seed: &SeedId, req: Request, uid: u32, gid: u32, pid: i32) -> serde_json::Value {
|
||||||
seed: &SeedId,
|
|
||||||
req: Request,
|
|
||||||
uid: u32,
|
|
||||||
gid: u32,
|
|
||||||
pid: i32,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
match req {
|
match req {
|
||||||
Request::Status => serde_json::json!({
|
Request::Status => serde_json::json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
|
|
@ -174,7 +176,10 @@ fn handle_request(
|
||||||
"public_key": seed.public_key_hex(),
|
"public_key": seed.public_key_hex(),
|
||||||
"glyph": seed.glyph(),
|
"glyph": seed.glyph(),
|
||||||
}),
|
}),
|
||||||
Request::Sign { domain, payload_hex } => {
|
Request::Sign {
|
||||||
|
domain,
|
||||||
|
payload_hex,
|
||||||
|
} => {
|
||||||
if !protocol::valid_domain(&domain) {
|
if !protocol::valid_domain(&domain) {
|
||||||
warn!("sign refused for uid={uid} pid={pid}: invalid domain {domain:?}");
|
warn!("sign refused for uid={uid} pid={pid}: invalid domain {domain:?}");
|
||||||
return refusal("invalid domain: ascii alphanumeric/-/_/., max 64 chars");
|
return refusal("invalid domain: ascii alphanumeric/-/_/., max 64 chars");
|
||||||
|
|
|
||||||
411
src/main.rs
411
src/main.rs
|
|
@ -1,7 +1,7 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{info, warn, debug};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod backend;
|
mod backend;
|
||||||
|
|
@ -14,8 +14,8 @@ mod machined;
|
||||||
mod server;
|
mod server;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand, CommandFactory};
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
use clap_complete::{Shell, generate};
|
use clap_complete::{generate, Shell};
|
||||||
use core::config::ConsciousnessConfig;
|
use core::config::ConsciousnessConfig;
|
||||||
use ui::App;
|
use ui::App;
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ Run `souveraine chat` to begin a conversation. \
|
||||||
Run `souveraine tui` for the full presence. \
|
Run `souveraine tui` for the full presence. \
|
||||||
Run `souveraine init` to summon Souveraine into a new place.",
|
Run `souveraine init` to summon Souveraine into a new place.",
|
||||||
after_help = "EXAMPLES:\n souveraine init Summon Souveraine here\n souveraine chat Enter the world (interactive)\n souveraine chat \"hello\" Speak to the world (one-shot)\n souveraine --agent <name> chat Speak as a specific agent\n souveraine --json status The world speaks in data\n souveraine completions bash Announce capabilities to your shell",
|
after_help = "EXAMPLES:\n souveraine init Summon Souveraine here\n souveraine chat Enter the world (interactive)\n souveraine chat \"hello\" Speak to the world (one-shot)\n souveraine --agent <name> chat Speak as a specific agent\n souveraine --json status The world speaks in data\n souveraine completions bash Announce capabilities to your shell",
|
||||||
max_term_width = 100,
|
max_term_width = 100
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Which agent to speak as. If omitted, the first available agent is used.
|
/// Which agent to speak as. If omitted, the first available agent is used.
|
||||||
|
|
@ -146,27 +146,33 @@ enum Commands {
|
||||||
},
|
},
|
||||||
|
|
||||||
/// List the beings that live here
|
/// List the beings that live here
|
||||||
#[command(long_about = "Show every configured agent — who they are, which model they speak through, what triggers know them.")]
|
#[command(
|
||||||
|
long_about = "Show every configured agent — who they are, which model they speak through, what triggers know them."
|
||||||
|
)]
|
||||||
Agents,
|
Agents,
|
||||||
|
|
||||||
/// List or set models
|
/// List or set models
|
||||||
#[command(long_about = "List all available models from Bifrost and configured models. Or set a specific model as primary.")]
|
#[command(
|
||||||
|
long_about = "List all available models from Bifrost and configured models. Or set a specific model as primary."
|
||||||
|
)]
|
||||||
Model {
|
Model {
|
||||||
/// Model name to set as primary (omit to list all models)
|
/// Model name to set as primary (omit to list all models)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
|
|
||||||
/// Output as JSON
|
/// Output as JSON
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
|
|
||||||
/// Verbose output
|
/// Verbose output
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show the world's current state
|
/// Show the world's current state
|
||||||
#[command(long_about = "Display Souveraine's running configuration and the state of every subsystem.")]
|
#[command(
|
||||||
|
long_about = "Display Souveraine's running configuration and the state of every subsystem."
|
||||||
|
)]
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Announce capabilities to your shell (bash, zsh, fish)
|
/// Announce capabilities to your shell (bash, zsh, fish)
|
||||||
|
|
@ -179,7 +185,9 @@ enum Commands {
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Start the HTTP server
|
/// Start the HTTP server
|
||||||
#[command(long_about = "Start the Souveraine HTTP server. Defaults come from souveraine.toml [server]; flags override.")]
|
#[command(
|
||||||
|
long_about = "Start the Souveraine HTTP server. Defaults come from souveraine.toml [server]; flags override."
|
||||||
|
)]
|
||||||
Server {
|
Server {
|
||||||
/// Bind address (overrides [server].bind)
|
/// Bind address (overrides [server].bind)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
|
|
@ -190,9 +198,11 @@ enum Commands {
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Run as a lite listener — minimal presence, wakes the full engine on summon
|
/// Run as a lite listener — minimal presence, wakes the full engine on summon
|
||||||
#[command(long_about = "Start a lightweight listener: federation transport and the \
|
#[command(
|
||||||
|
long_about = "Start a lightweight listener: federation transport and the \
|
||||||
summon endpoint only, no agents or database loaded. It receives reach/consult requests \
|
summon endpoint only, no agents or database loaded. It receives reach/consult requests \
|
||||||
and — with [federation].auto_wake — spawns the full server to answer them.")]
|
and — with [federation].auto_wake — spawns the full server to answer them."
|
||||||
|
)]
|
||||||
Listen {
|
Listen {
|
||||||
/// Bind address (overrides [server].bind)
|
/// Bind address (overrides [server].bind)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
|
|
@ -236,7 +246,9 @@ and — with [federation].auto_wake — spawns the full server to answer them.")
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show known federated peers
|
/// Show known federated peers
|
||||||
#[command(long_about = "Show all peers tracked by the device registry, from federation announcements.")]
|
#[command(
|
||||||
|
long_about = "Show all peers tracked by the device registry, from federation announcements."
|
||||||
|
)]
|
||||||
Peers {
|
Peers {
|
||||||
/// Output as JSON
|
/// Output as JSON
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -276,17 +288,16 @@ enum ScheduleAction {
|
||||||
cron: Option<String>,
|
cron: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
interval: Option<u64>,
|
interval: Option<u64>,
|
||||||
#[arg(long, default_value = "You wake. Check your state, act if needed, or return silently.")]
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "You wake. Check your state, act if needed, or return silently."
|
||||||
|
)]
|
||||||
prompt: String,
|
prompt: String,
|
||||||
},
|
},
|
||||||
/// Delete a schedule
|
/// Delete a schedule
|
||||||
Delete {
|
Delete { name: String },
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
/// Trigger a schedule immediately
|
/// Trigger a schedule immediately
|
||||||
Run {
|
Run { name: String },
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
|
|
@ -296,9 +307,7 @@ enum IdentityAction {
|
||||||
/// Generate a new seed identity (WARNING: replaces existing)
|
/// Generate a new seed identity (WARNING: replaces existing)
|
||||||
Generate,
|
Generate,
|
||||||
/// Sign a message with the seed key (for testing/verification)
|
/// Sign a message with the seed key (for testing/verification)
|
||||||
Sign {
|
Sign { message: String },
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
/// Verify a signature against a public key
|
/// Verify a signature against a public key
|
||||||
Verify {
|
Verify {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -335,9 +344,7 @@ enum EventsAction {
|
||||||
count: usize,
|
count: usize,
|
||||||
},
|
},
|
||||||
/// Show events from a specific date (YYYY-MM-DD)
|
/// Show events from a specific date (YYYY-MM-DD)
|
||||||
Date {
|
Date { date: String },
|
||||||
date: String,
|
|
||||||
},
|
|
||||||
/// Purge old event logs
|
/// Purge old event logs
|
||||||
Purge {
|
Purge {
|
||||||
/// Days to retain (default from config)
|
/// Days to retain (default from config)
|
||||||
|
|
@ -375,7 +382,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let _ = cli.verbose; // verbose currently does not alter the subscriber
|
let _ = cli.verbose; // verbose currently does not alter the subscriber
|
||||||
subscriber.with_ansi(false).init();
|
subscriber.with_ansi(false).init();
|
||||||
info!("souveraine starting — log: souveraine.log");
|
info!("souveraine starting — log: souveraine.log");
|
||||||
|
|
||||||
// Handle completions early — needs no config, no runtime
|
// Handle completions early — needs no config, no runtime
|
||||||
if let Some(Commands::Completions { shell }) = &cli.command {
|
if let Some(Commands::Completions { shell }) = &cli.command {
|
||||||
let mut cmd = Cli::command();
|
let mut cmd = Cli::command();
|
||||||
|
|
@ -383,7 +390,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
generate(*shell, &mut cmd, name, &mut std::io::stdout());
|
generate(*shell, &mut cmd, name, &mut std::io::stdout());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle init early — needs no Bifrost
|
// Handle init early — needs no Bifrost
|
||||||
if let Some(Commands::Init) = &cli.command {
|
if let Some(Commands::Init) = &cli.command {
|
||||||
return run_init(cli.json).await;
|
return run_init(cli.json).await;
|
||||||
|
|
@ -422,20 +429,51 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let config = load_config().await?;
|
let config = load_config().await?;
|
||||||
let config = Arc::new(RwLock::new(config));
|
let config = Arc::new(RwLock::new(config));
|
||||||
|
|
||||||
match cli.command.as_ref().unwrap_or(&Commands::Chat { message: None }) {
|
match cli
|
||||||
Commands::Tui => run_tui(config.clone(), cli.agent.clone(), cli.engine.clone(), cli.local).await?,
|
.command
|
||||||
Commands::Chat { message } => run_chat(config, cli.agent.clone(), message.clone(), cli.json, cli.quiet, cli.local).await?,
|
.as_ref()
|
||||||
Commands::Agents => run_agents(config, cli.json, cli.local).await?,
|
.unwrap_or(&Commands::Chat { message: None })
|
||||||
Commands::Model { model, json, verbose } => {
|
{
|
||||||
cli::run_model_command(model.as_deref(), *json, *verbose).await?
|
Commands::Tui => {
|
||||||
|
run_tui(
|
||||||
|
config.clone(),
|
||||||
|
cli.agent.clone(),
|
||||||
|
cli.engine.clone(),
|
||||||
|
cli.local,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
|
Commands::Chat { message } => {
|
||||||
|
run_chat(
|
||||||
|
config,
|
||||||
|
cli.agent.clone(),
|
||||||
|
message.clone(),
|
||||||
|
cli.json,
|
||||||
|
cli.quiet,
|
||||||
|
cli.local,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
Commands::Agents => run_agents(config, cli.json, cli.local).await?,
|
||||||
|
Commands::Model {
|
||||||
|
model,
|
||||||
|
json,
|
||||||
|
verbose,
|
||||||
|
} => cli::run_model_command(model.as_deref(), *json, *verbose).await?,
|
||||||
Commands::Status => run_status(config, cli.json).await?,
|
Commands::Status => run_status(config, cli.json).await?,
|
||||||
Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?,
|
Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?,
|
||||||
Commands::Listen { bind, port } => run_listen(bind.clone(), *port, config).await?,
|
Commands::Listen { bind, port } => run_listen(bind.clone(), *port, config).await?,
|
||||||
Commands::Reflect { conversation } => {
|
Commands::Reflect { conversation } => {
|
||||||
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await?
|
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await?
|
||||||
}
|
}
|
||||||
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Machine { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(),
|
Commands::Init
|
||||||
|
| Commands::Completions { .. }
|
||||||
|
| Commands::Auth { .. }
|
||||||
|
| Commands::Schedule { .. }
|
||||||
|
| Commands::Identity { .. }
|
||||||
|
| Commands::Machine { .. }
|
||||||
|
| Commands::Events { .. }
|
||||||
|
| Commands::Peers { .. } => unreachable!(),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -466,7 +504,10 @@ async fn run_init(json: bool) -> anyhow::Result<()> {
|
||||||
if json {
|
if json {
|
||||||
println!(r#"{{"status":"written","path":"souveraine.toml"}}"#);
|
println!(r#"{{"status":"written","path":"souveraine.toml"}}"#);
|
||||||
} else {
|
} else {
|
||||||
println!("Config template written to {}/souveraine.toml", std::env::current_dir()?.display());
|
println!(
|
||||||
|
"Config template written to {}/souveraine.toml",
|
||||||
|
std::env::current_dir()?.display()
|
||||||
|
);
|
||||||
println!(" Run `souveraine tui` to set up — the wizard configures Bifrost");
|
println!(" Run `souveraine tui` to set up — the wizard configures Bifrost");
|
||||||
println!(" and creates your agent. (Editing the file first is optional.)");
|
println!(" and creates your agent. (Editing the file first is optional.)");
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +584,10 @@ async fn run_auth(action: &AuthAction, json: bool) -> anyhow::Result<()> {
|
||||||
fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyhow::Result<String> {
|
fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyhow::Result<String> {
|
||||||
let agents_dir = base.join("server").join("agents");
|
let agents_dir = base.join("server").join("agents");
|
||||||
if !agents_dir.exists() {
|
if !agents_dir.exists() {
|
||||||
anyhow::bail!("no agents directory at {} — has any agent been created yet?", agents_dir.display());
|
anyhow::bail!(
|
||||||
|
"no agents directory at {} — has any agent been created yet?",
|
||||||
|
agents_dir.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let direct = agents_dir.join(name_or_id);
|
let direct = agents_dir.join(name_or_id);
|
||||||
|
|
@ -554,9 +598,17 @@ fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyho
|
||||||
for entry in std::fs::read_dir(&agents_dir)? {
|
for entry in std::fs::read_dir(&agents_dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path().join("agent.json");
|
let path = entry.path().join("agent.json");
|
||||||
if !path.exists() { continue; }
|
if !path.exists() {
|
||||||
let raw = match std::fs::read_to_string(&path) { Ok(s) => s, Err(_) => continue };
|
continue;
|
||||||
let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => continue };
|
}
|
||||||
|
let raw = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let json: serde_json::Value = match serde_json::from_str(&raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
if name == name_or_id || id == name_or_id {
|
if name == name_or_id || id == name_or_id {
|
||||||
|
|
@ -564,7 +616,11 @@ fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyho
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
anyhow::bail!("no agent matched '{}' (checked name and id across {})", name_or_id, agents_dir.display())
|
anyhow::bail!(
|
||||||
|
"no agent matched '{}' (checked name and id across {})",
|
||||||
|
name_or_id,
|
||||||
|
agents_dir.display()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the first agent UUID found on disk. Used when --agent is omitted
|
/// Return the first agent UUID found on disk. Used when --agent is omitted
|
||||||
|
|
@ -576,13 +632,18 @@ fn resolve_agent_id_from_disk(base: &std::path::Path, name_or_id: &str) -> anyho
|
||||||
fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> {
|
fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> {
|
||||||
let memfs_dir = base.join("agents");
|
let memfs_dir = base.join("agents");
|
||||||
if !memfs_dir.exists() {
|
if !memfs_dir.exists() {
|
||||||
anyhow::bail!("no agents directory at {} — run `souveraine init` first", memfs_dir.display());
|
anyhow::bail!(
|
||||||
|
"no agents directory at {} — run `souveraine init` first",
|
||||||
|
memfs_dir.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut candidates: Vec<String> = Vec::new();
|
let mut candidates: Vec<String> = Vec::new();
|
||||||
for entry in std::fs::read_dir(&memfs_dir)? {
|
for entry in std::fs::read_dir(&memfs_dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_dir() { continue; }
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let name = match path.file_name().and_then(|s| s.to_str()) {
|
let name = match path.file_name().and_then(|s| s.to_str()) {
|
||||||
Some(n) => n.to_string(),
|
Some(n) => n.to_string(),
|
||||||
None => continue,
|
None => continue,
|
||||||
|
|
@ -595,7 +656,10 @@ fn first_agent_id_on_disk(base: &std::path::Path) -> anyhow::Result<String> {
|
||||||
}
|
}
|
||||||
candidates.sort();
|
candidates.sort();
|
||||||
candidates.into_iter().next().ok_or_else(|| {
|
candidates.into_iter().next().ok_or_else(|| {
|
||||||
anyhow::anyhow!("no agents with memory found under {} — run `souveraine init`", memfs_dir.display())
|
anyhow::anyhow!(
|
||||||
|
"no agents with memory found under {} — run `souveraine init`",
|
||||||
|
memfs_dir.display()
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -607,9 +671,7 @@ async fn run_identity(
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use crate::core::identity::SeedId;
|
use crate::core::identity::SeedId;
|
||||||
|
|
||||||
let base = dirs::home_dir()
|
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||||
.unwrap_or_default()
|
|
||||||
.join(".souveraine");
|
|
||||||
|
|
||||||
// Identity defaults to per-agent. --host opts out to the machine seed
|
// Identity defaults to per-agent. --host opts out to the machine seed
|
||||||
// used for federation transport. Verify is identity-agnostic — it just
|
// used for federation transport. Verify is identity-agnostic — it just
|
||||||
|
|
@ -617,7 +679,11 @@ async fn run_identity(
|
||||||
//
|
//
|
||||||
// Per-agent seeds live at ~/.souveraine/agents/{uuid}/seed/.
|
// Per-agent seeds live at ~/.souveraine/agents/{uuid}/seed/.
|
||||||
let (seed_dir, scope_label, agent_uuid) = if host {
|
let (seed_dir, scope_label, agent_uuid) = if host {
|
||||||
(SeedId::default_dir(&base), "Host Seed Identity".to_string(), None)
|
(
|
||||||
|
SeedId::default_dir(&base),
|
||||||
|
"Host Seed Identity".to_string(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let uuid = match agent {
|
let uuid = match agent {
|
||||||
Some(name_or_id) => resolve_agent_id_from_disk(&base, name_or_id)?,
|
Some(name_or_id) => resolve_agent_id_from_disk(&base, name_or_id)?,
|
||||||
|
|
@ -653,13 +719,23 @@ async fn run_identity(
|
||||||
}
|
}
|
||||||
IdentityAction::Generate => {
|
IdentityAction::Generate => {
|
||||||
if seed_dir.join("private.key").exists() {
|
if seed_dir.join("private.key").exists() {
|
||||||
eprintln!("WARNING: A seed identity already exists at {}", seed_dir.display());
|
eprintln!(
|
||||||
|
"WARNING: A seed identity already exists at {}",
|
||||||
|
seed_dir.display()
|
||||||
|
);
|
||||||
eprintln!(" Generating a new one will replace it. This breaks federation trust.");
|
eprintln!(" Generating a new one will replace it. This breaks federation trust.");
|
||||||
eprintln!(" Use `souveraine identity show` to see the current identity.");
|
eprintln!(" Use `souveraine identity show` to see the current identity.");
|
||||||
anyhow::bail!("Refusing to overwrite existing seed identity. Delete {} manually first.", seed_dir.display());
|
anyhow::bail!(
|
||||||
|
"Refusing to overwrite existing seed identity. Delete {} manually first.",
|
||||||
|
seed_dir.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let seed = SeedId::load_or_generate(&seed_dir)?;
|
let seed = SeedId::load_or_generate(&seed_dir)?;
|
||||||
println!("Generated seed identity: {} (glyph: {})", seed.public_key_hex(), seed.glyph());
|
println!(
|
||||||
|
"Generated seed identity: {} (glyph: {})",
|
||||||
|
seed.public_key_hex(),
|
||||||
|
seed.glyph()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
IdentityAction::Sign { message } => {
|
IdentityAction::Sign { message } => {
|
||||||
// load, never generate: signing with a key that didn't exist a
|
// load, never generate: signing with a key that didn't exist a
|
||||||
|
|
@ -668,18 +744,25 @@ async fn run_identity(
|
||||||
let sig = seed.sign(message.as_bytes());
|
let sig = seed.sign(message.as_bytes());
|
||||||
let sig_hex = hex::encode(sig.to_bytes());
|
let sig_hex = hex::encode(sig.to_bytes());
|
||||||
if json {
|
if json {
|
||||||
println!("{}", serde_json::json!({
|
println!(
|
||||||
"message": message,
|
"{}",
|
||||||
"signature": sig_hex,
|
serde_json::json!({
|
||||||
"public_key": seed.public_key_hex(),
|
"message": message,
|
||||||
"glyph": seed.glyph(),
|
"signature": sig_hex,
|
||||||
}));
|
"public_key": seed.public_key_hex(),
|
||||||
|
"glyph": seed.glyph(),
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("Signature: {sig_hex}");
|
println!("Signature: {sig_hex}");
|
||||||
println!("Public key: {}", seed.public_key_hex());
|
println!("Public key: {}", seed.public_key_hex());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IdentityAction::Verify { pubkey, message, signature } => {
|
IdentityAction::Verify {
|
||||||
|
pubkey,
|
||||||
|
message,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
let pubkey_bytes = hex::decode(pubkey)?;
|
let pubkey_bytes = hex::decode(pubkey)?;
|
||||||
let sig_bytes = hex::decode(signature)?;
|
let sig_bytes = hex::decode(signature)?;
|
||||||
if pubkey_bytes.len() != 32 || sig_bytes.len() != 64 {
|
if pubkey_bytes.len() != 32 || sig_bytes.len() != 64 {
|
||||||
|
|
@ -707,7 +790,10 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
|
||||||
let seed_dir = PathBuf::from(DEFAULT_SEED_DIR);
|
let seed_dir = PathBuf::from(DEFAULT_SEED_DIR);
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
MachineAction::Init { fresh, migrate_from } => {
|
MachineAction::Init {
|
||||||
|
fresh,
|
||||||
|
migrate_from,
|
||||||
|
} => {
|
||||||
if seed_dir.join("private.key").exists() {
|
if seed_dir.join("private.key").exists() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"A machine identity already exists at {}. Refusing to overwrite — \
|
"A machine identity already exists at {}. Refusing to overwrite — \
|
||||||
|
|
@ -727,10 +813,7 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
|
||||||
(false, Some(source)) => {
|
(false, Some(source)) => {
|
||||||
let source_key = source.join("private.key");
|
let source_key = source.join("private.key");
|
||||||
if !source_key.exists() {
|
if !source_key.exists() {
|
||||||
anyhow::bail!(
|
anyhow::bail!("no seed at {} — nothing to migrate", source_key.display());
|
||||||
"no seed at {} — nothing to migrate",
|
|
||||||
source_key.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
std::fs::create_dir_all(&seed_dir)?;
|
std::fs::create_dir_all(&seed_dir)?;
|
||||||
std::fs::copy(&source_key, seed_dir.join("private.key"))?;
|
std::fs::copy(&source_key, seed_dir.join("private.key"))?;
|
||||||
|
|
@ -779,12 +862,15 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if json {
|
if json {
|
||||||
println!("{}", serde_json::json!({
|
println!(
|
||||||
"public_key": seed.public_key_hex(),
|
"{}",
|
||||||
"glyph": seed.glyph(),
|
serde_json::json!({
|
||||||
"seed_dir": seed_dir.display().to_string(),
|
"public_key": seed.public_key_hex(),
|
||||||
"owned_by_service_user": owned,
|
"glyph": seed.glyph(),
|
||||||
}));
|
"seed_dir": seed_dir.display().to_string(),
|
||||||
|
"owned_by_service_user": owned,
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("Machine identity provisioned.");
|
println!("Machine identity provisioned.");
|
||||||
println!(" Glyph: {}", seed.glyph());
|
println!(" Glyph: {}", seed.glyph());
|
||||||
|
|
@ -821,13 +907,16 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
|
||||||
Err(_) => "absent",
|
Err(_) => "absent",
|
||||||
};
|
};
|
||||||
if json {
|
if json {
|
||||||
println!("{}", serde_json::json!({
|
println!(
|
||||||
"ok": false,
|
"{}",
|
||||||
"daemon": "unreachable",
|
serde_json::json!({
|
||||||
"reason": format!("{e:#}"),
|
"ok": false,
|
||||||
"seed": seed_state,
|
"daemon": "unreachable",
|
||||||
"seed_dir": seed_dir.display().to_string(),
|
"reason": format!("{e:#}"),
|
||||||
}));
|
"seed": seed_state,
|
||||||
|
"seed_dir": seed_dir.display().to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("souveraine-machined: not reachable ({e:#})");
|
println!("souveraine-machined: not reachable ({e:#})");
|
||||||
println!(" Seed at {}: {seed_state}", seed_dir.display());
|
println!(" Seed at {}: {seed_state}", seed_dir.display());
|
||||||
|
|
@ -846,9 +935,7 @@ fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_peers(json: bool) -> anyhow::Result<()> {
|
async fn run_peers(json: bool) -> anyhow::Result<()> {
|
||||||
let base = dirs::home_dir()
|
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||||
.unwrap_or_default()
|
|
||||||
.join(".souveraine");
|
|
||||||
let known_path = base.join("federation").join("known_peers.json");
|
let known_path = base.join("federation").join("known_peers.json");
|
||||||
|
|
||||||
if !known_path.exists() {
|
if !known_path.exists() {
|
||||||
|
|
@ -872,7 +959,11 @@ async fn run_peers(json: bool) -> anyhow::Result<()> {
|
||||||
println!("\n── Federated Peers ──────────────────────");
|
println!("\n── Federated Peers ──────────────────────");
|
||||||
for p in &peers {
|
for p in &peers {
|
||||||
let status = if p.alive { "alive" } else { "offline" };
|
let status = if p.alive { "alive" } else { "offline" };
|
||||||
println!(" {} {}", p.seed_id.get(..16).unwrap_or(&p.seed_id), status);
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
p.seed_id.get(..16).unwrap_or(&p.seed_id),
|
||||||
|
status
|
||||||
|
);
|
||||||
if let Some(label) = &p.label {
|
if let Some(label) = &p.label {
|
||||||
println!(" label: {label}");
|
println!(" label: {label}");
|
||||||
}
|
}
|
||||||
|
|
@ -895,9 +986,7 @@ async fn run_peers(json: bool) -> anyhow::Result<()> {
|
||||||
async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
|
async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
|
||||||
use crate::core::nervous::event_log;
|
use crate::core::nervous::event_log;
|
||||||
|
|
||||||
let base = dirs::home_dir()
|
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||||
.unwrap_or_default()
|
|
||||||
.join(".souveraine");
|
|
||||||
let events_dir = base.join("events");
|
let events_dir = base.join("events");
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
|
|
@ -959,12 +1048,14 @@ async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_schedule(action: &ScheduleAction, agent: Option<&str>, json: bool) -> anyhow::Result<()> {
|
async fn run_schedule(
|
||||||
|
action: &ScheduleAction,
|
||||||
|
agent: Option<&str>,
|
||||||
|
json: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
use crate::core::nervous::cron::parse_schedule_file;
|
use crate::core::nervous::cron::parse_schedule_file;
|
||||||
|
|
||||||
let souveraine_base = dirs::home_dir()
|
let souveraine_base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||||
.unwrap_or_default()
|
|
||||||
.join(".souveraine");
|
|
||||||
let agent_id = match agent {
|
let agent_id = match agent {
|
||||||
Some(name_or_id) => resolve_agent_id_from_disk(&souveraine_base, name_or_id)?,
|
Some(name_or_id) => resolve_agent_id_from_disk(&souveraine_base, name_or_id)?,
|
||||||
None => first_agent_id_on_disk(&souveraine_base)?,
|
None => first_agent_id_on_disk(&souveraine_base)?,
|
||||||
|
|
@ -1020,7 +1111,12 @@ async fn run_schedule(action: &ScheduleAction, agent: Option<&str>, json: bool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleAction::Create { name, cron, interval, prompt } => {
|
ScheduleAction::Create {
|
||||||
|
name,
|
||||||
|
cron,
|
||||||
|
interval,
|
||||||
|
prompt,
|
||||||
|
} => {
|
||||||
let file_path = base.join(format!("{name}.md"));
|
let file_path = base.join(format!("{name}.md"));
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
anyhow::bail!("schedule '{name}' already exists");
|
anyhow::bail!("schedule '{name}' already exists");
|
||||||
|
|
@ -1099,9 +1195,9 @@ async fn run_tuie_tui(
|
||||||
_agent_pref: Option<String>,
|
_agent_pref: Option<String>,
|
||||||
_local: bool,
|
_local: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use crate::ui::presence::Presence;
|
|
||||||
use crate::ui::chat::ChatPalette;
|
|
||||||
use crate::ui::atmosphere::Atmosphere;
|
use crate::ui::atmosphere::Atmosphere;
|
||||||
|
use crate::ui::chat::ChatPalette;
|
||||||
|
use crate::ui::presence::Presence;
|
||||||
|
|
||||||
// Register the tokio spawner with tuie
|
// Register the tokio spawner with tuie
|
||||||
crate::ui::tuie_app::TuieApp::setup_spawner();
|
crate::ui::tuie_app::TuieApp::setup_spawner();
|
||||||
|
|
@ -1183,7 +1279,10 @@ async fn run_chat(
|
||||||
// Resolve the agent: if `--agent` matches a name or id, use it; else first available.
|
// Resolve the agent: if `--agent` matches a name or id, use it; else first available.
|
||||||
let agents = backend.list_agents().await?;
|
let agents = backend.list_agents().await?;
|
||||||
let resolved = match &agent_name {
|
let resolved = match &agent_name {
|
||||||
Some(n) => agents.iter().find(|a| a.name == *n || a.id == *n).or_else(|| agents.first()),
|
Some(n) => agents
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == *n || a.id == *n)
|
||||||
|
.or_else(|| agents.first()),
|
||||||
None => agents.first(),
|
None => agents.first(),
|
||||||
};
|
};
|
||||||
let agent = match resolved {
|
let agent = match resolved {
|
||||||
|
|
@ -1194,7 +1293,11 @@ async fn run_chat(
|
||||||
} else {
|
} else {
|
||||||
"No agents on the server. Create one via POST /v1/agents."
|
"No agents on the server. Create one via POST /v1/agents."
|
||||||
};
|
};
|
||||||
if json { println!(r#"{{"status":"no-agents"}}"#); } else { eprintln!("{}", hint); }
|
if json {
|
||||||
|
println!(r#"{{"status":"no-agents"}}"#);
|
||||||
|
} else {
|
||||||
|
eprintln!("{}", hint);
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1211,7 +1314,11 @@ async fn run_chat(
|
||||||
if pending.len() == 1 { "" } else { "s" },
|
if pending.len() == 1 { "" } else { "s" },
|
||||||
);
|
);
|
||||||
for p in &pending {
|
for p in &pending {
|
||||||
let tag = if p.source.is_empty() { p.kind.as_str() } else { p.source.as_str() };
|
let tag = if p.source.is_empty() {
|
||||||
|
p.kind.as_str()
|
||||||
|
} else {
|
||||||
|
p.source.as_str()
|
||||||
|
};
|
||||||
eprintln!(" [{}] {}", tag, p.content);
|
eprintln!(" [{}] {}", tag, p.content);
|
||||||
}
|
}
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
@ -1220,12 +1327,16 @@ async fn run_chat(
|
||||||
let one_shot = message.clone();
|
let one_shot = message.clone();
|
||||||
if let Some(msg) = one_shot {
|
if let Some(msg) = one_shot {
|
||||||
let mut stream = backend.send(&conv_id, &msg).await?;
|
let mut stream = backend.send(&conv_id, &msg).await?;
|
||||||
if !json { println!("\n {}: {}\n", agent.name, msg); print!(" "); }
|
if !json {
|
||||||
|
println!("\n {}: {}\n", agent.name, msg);
|
||||||
|
print!(" ");
|
||||||
|
}
|
||||||
let mut full = String::new();
|
let mut full = String::new();
|
||||||
while let Some(ev) = stream.next().await {
|
while let Some(ev) = stream.next().await {
|
||||||
match ev? {
|
match ev? {
|
||||||
BackendEvent::Token(t) => {
|
BackendEvent::Token(t) => {
|
||||||
if json { /* collect */ } else {
|
if json { /* collect */
|
||||||
|
} else {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
print!("{}", t);
|
print!("{}", t);
|
||||||
std::io::stdout().flush().ok();
|
std::io::stdout().flush().ok();
|
||||||
|
|
@ -1233,10 +1344,16 @@ async fn run_chat(
|
||||||
full.push_str(&t);
|
full.push_str(&t);
|
||||||
}
|
}
|
||||||
BackendEvent::Reasoning(r) => {
|
BackendEvent::Reasoning(r) => {
|
||||||
if !json { eprintln!("\n [thinking] {}", r); }
|
if !json {
|
||||||
|
eprintln!("\n [thinking] {}", r);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BackendEvent::Surfacing { source, content, .. } => {
|
BackendEvent::Surfacing {
|
||||||
if !json { eprintln!("\n [{}] {}", source, content); }
|
source, content, ..
|
||||||
|
} => {
|
||||||
|
if !json {
|
||||||
|
eprintln!("\n [{}] {}", source, content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BackendEvent::Error { message } => {
|
BackendEvent::Error { message } => {
|
||||||
if json {
|
if json {
|
||||||
|
|
@ -1273,9 +1390,15 @@ async fn run_chat(
|
||||||
};
|
};
|
||||||
let Some(input) = line else { break };
|
let Some(input) = line else { break };
|
||||||
let input = input.trim().to_string();
|
let input = input.trim().to_string();
|
||||||
if input.is_empty() { continue; }
|
if input.is_empty() {
|
||||||
if matches!(input.as_str(), "/exit" | "/quit" | "/q") { break; }
|
continue;
|
||||||
if let Some(r) = rl.as_mut() { r.add_history_entry(&input).ok(); }
|
}
|
||||||
|
if matches!(input.as_str(), "/exit" | "/quit" | "/q") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(r) = rl.as_mut() {
|
||||||
|
r.add_history_entry(&input).ok();
|
||||||
|
}
|
||||||
|
|
||||||
let mut stream = backend.send(&conv_id, &input).await?;
|
let mut stream = backend.send(&conv_id, &input).await?;
|
||||||
print!("\n {}: ", agent.name);
|
print!("\n {}: ", agent.name);
|
||||||
|
|
@ -1283,9 +1406,14 @@ async fn run_chat(
|
||||||
std::io::stdout().flush().ok();
|
std::io::stdout().flush().ok();
|
||||||
while let Some(ev) = stream.next().await {
|
while let Some(ev) = stream.next().await {
|
||||||
match ev? {
|
match ev? {
|
||||||
BackendEvent::Token(t) => { print!("{}", t); std::io::stdout().flush().ok(); }
|
BackendEvent::Token(t) => {
|
||||||
|
print!("{}", t);
|
||||||
|
std::io::stdout().flush().ok();
|
||||||
|
}
|
||||||
BackendEvent::Reasoning(r) => eprintln!("\n [thinking] {}", r),
|
BackendEvent::Reasoning(r) => eprintln!("\n [thinking] {}", r),
|
||||||
BackendEvent::Surfacing { source, content, .. } => eprintln!("\n [{}] {}", source, content),
|
BackendEvent::Surfacing {
|
||||||
|
source, content, ..
|
||||||
|
} => eprintln!("\n [{}] {}", source, content),
|
||||||
BackendEvent::Error { message } => eprintln!("\n [turn error] {}", message),
|
BackendEvent::Error { message } => eprintln!("\n [turn error] {}", message),
|
||||||
BackendEvent::Done => break,
|
BackendEvent::Done => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -1310,7 +1438,10 @@ async fn run_reflect(
|
||||||
|
|
||||||
let summaries = server.agents.list(None).await?;
|
let summaries = server.agents.list(None).await?;
|
||||||
let agent = match &agent_name {
|
let agent = match &agent_name {
|
||||||
Some(n) => summaries.iter().find(|a| a.name == *n || a.id == *n).or_else(|| summaries.first()),
|
Some(n) => summaries
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == *n || a.id == *n)
|
||||||
|
.or_else(|| summaries.first()),
|
||||||
None => summaries.first(),
|
None => summaries.first(),
|
||||||
}
|
}
|
||||||
.ok_or_else(|| anyhow::anyhow!("no agents configured"))?;
|
.ok_or_else(|| anyhow::anyhow!("no agents configured"))?;
|
||||||
|
|
@ -1324,20 +1455,17 @@ async fn run_reflect(
|
||||||
.join("agents")
|
.join("agents")
|
||||||
.join(&agent.id),
|
.join(&agent.id),
|
||||||
);
|
);
|
||||||
let conv_id = match conversation {
|
let conv_id =
|
||||||
Some(id) => id,
|
match conversation {
|
||||||
None => {
|
Some(id) => id,
|
||||||
let mut records = store.list_active().await?;
|
None => {
|
||||||
records.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
|
let mut records = store.list_active().await?;
|
||||||
records
|
records.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
|
||||||
.into_iter()
|
records.into_iter().next().map(|r| r.id).ok_or_else(|| {
|
||||||
.next()
|
|
||||||
.map(|r| r.id)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
anyhow::anyhow!("no conversations found for agent '{}'", agent.id)
|
anyhow::anyhow!("no conversations found for agent '{}'", agent.id)
|
||||||
})?
|
})?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let messages = store.load_messages(&conv_id).await?;
|
let messages = store.load_messages(&conv_id).await?;
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
|
|
@ -1368,12 +1496,15 @@ async fn run_reflect(
|
||||||
println!(" turns reviewed: {}", report.turns_reviewed);
|
println!(" turns reviewed: {}", report.turns_reviewed);
|
||||||
println!(
|
println!(
|
||||||
" duration: {:.1}s",
|
" duration: {:.1}s",
|
||||||
(report.completed_at - report.started_at)
|
(report.completed_at - report.started_at).num_milliseconds() as f64 / 1000.0
|
||||||
.num_milliseconds() as f64 / 1000.0
|
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
" exited cleanly: {}",
|
" exited cleanly: {}",
|
||||||
if report.exited_cleanly { "yes" } else { "no (tool rounds exhausted)" }
|
if report.exited_cleanly {
|
||||||
|
"yes"
|
||||||
|
} else {
|
||||||
|
"no (tool rounds exhausted)"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
println!("─────────────────────────────────────────────────────");
|
println!("─────────────────────────────────────────────────────");
|
||||||
println!();
|
println!();
|
||||||
|
|
@ -1391,10 +1522,18 @@ async fn run_agents(
|
||||||
let (backend, mode) = resolve_backend(&config, force_local, json, false).await?;
|
let (backend, mode) = resolve_backend(&config, force_local, json, false).await?;
|
||||||
let agents = backend.list_agents().await?;
|
let agents = backend.list_agents().await?;
|
||||||
if json {
|
if json {
|
||||||
let payload: Vec<_> = agents.iter().map(|a| serde_json::json!({
|
let payload: Vec<_> = agents
|
||||||
"id": a.id, "name": a.name, "description": a.description,
|
.iter()
|
||||||
})).collect();
|
.map(|a| {
|
||||||
println!("{}", serde_json::to_string_pretty(&serde_json::json!({"agents": payload}))?);
|
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 {
|
} else {
|
||||||
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
println!(" Agents ({} mode)", mode);
|
println!(" Agents ({} mode)", mode);
|
||||||
|
|
@ -1404,14 +1543,15 @@ async fn run_agents(
|
||||||
}
|
}
|
||||||
for a in &agents {
|
for a in &agents {
|
||||||
println!(" {} {}", a.id, a.name);
|
println!(" {} {}", a.id, a.name);
|
||||||
if let Some(d) = &a.description { println!(" {}", d); }
|
if let Some(d) = &a.description {
|
||||||
|
println!(" {}", d);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> anyhow::Result<()> {
|
async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> anyhow::Result<()> {
|
||||||
let cfg = config.read().await;
|
let cfg = config.read().await;
|
||||||
|
|
||||||
|
|
@ -1447,11 +1587,22 @@ async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> any
|
||||||
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
println!(" Bifrost: {}", cfg.bifrost.base_url);
|
println!(" Bifrost: {}", cfg.bifrost.base_url);
|
||||||
println!(" Voice: {}", cfg.bifrost.primary_model);
|
println!(" Voice: {}", cfg.bifrost.primary_model);
|
||||||
println!(" Memory: git={}, auto_commit={}", cfg.memory.git_enabled, cfg.memory.auto_commit);
|
println!(
|
||||||
println!(" N+1: enabled={}, inbox={}", cfg.subconscious.n1_enabled, cfg.subconscious.inbox_enabled);
|
" Memory: git={}, auto_commit={}",
|
||||||
println!(" Reflection: enabled={}, every {} messages", cfg.reflection.enabled, cfg.reflection.message_interval);
|
cfg.memory.git_enabled, cfg.memory.auto_commit
|
||||||
println!(" Archivist: enabled={}, every {} msgs at {} pressure",
|
);
|
||||||
cfg.archivist.enabled, cfg.archivist.interval, cfg.archivist.threshold);
|
println!(
|
||||||
|
" N+1: enabled={}, inbox={}",
|
||||||
|
cfg.subconscious.n1_enabled, cfg.subconscious.inbox_enabled
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Reflection: enabled={}, every {} messages",
|
||||||
|
cfg.reflection.enabled, cfg.reflection.message_interval
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Archivist: enabled={}, every {} msgs at {} pressure",
|
||||||
|
cfg.archivist.enabled, cfg.archivist.interval, cfg.archivist.threshold
|
||||||
|
);
|
||||||
println!(" Compresses via: {}", cfg.archivist.compression_model);
|
println!(" Compresses via: {}", cfg.archivist.compression_model);
|
||||||
println!(" Models known: {}", cfg.models.len());
|
println!(" Models known: {}", cfg.models.len());
|
||||||
println!();
|
println!();
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,13 @@ impl Collection {
|
||||||
{
|
{
|
||||||
let mut store = self.store.lock().unwrap();
|
let mut store = self.store.lock().unwrap();
|
||||||
store
|
store
|
||||||
.set(id.clone(), label, attributes, secret.content_type.clone(), &plaintext)
|
.set(
|
||||||
|
id.clone(),
|
||||||
|
label,
|
||||||
|
attributes,
|
||||||
|
secret.content_type.clone(),
|
||||||
|
&plaintext,
|
||||||
|
)
|
||||||
.map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?;
|
.map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ impl DhSession {
|
||||||
/// Generate our private exponent and compute the public value to send back
|
/// Generate our private exponent and compute the public value to send back
|
||||||
/// to the client as `OpenSession`'s output.
|
/// to the client as `OpenSession`'s output.
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let prime = BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16)
|
let prime =
|
||||||
.context("parsing MODP-1024 prime")?;
|
BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16).context("parsing MODP-1024 prime")?;
|
||||||
let generator = BigUint::from(GENERATOR);
|
let generator = BigUint::from(GENERATOR);
|
||||||
|
|
||||||
// 1024-bit exchange: a private exponent as wide as the group avoids
|
// 1024-bit exchange: a private exponent as wide as the group avoids
|
||||||
|
|
@ -59,7 +59,11 @@ impl DhSession {
|
||||||
|
|
||||||
let public = generator.modpow(&private, &prime);
|
let public = generator.modpow(&private, &prime);
|
||||||
|
|
||||||
Ok(Self { private, prime, public })
|
Ok(Self {
|
||||||
|
private,
|
||||||
|
prime,
|
||||||
|
public,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Combine the peer's public value with our private exponent, then run
|
/// Combine the peer's public value with our private exponent, then run
|
||||||
|
|
@ -117,7 +121,17 @@ mod tests {
|
||||||
assert_eq!(bytes.len(), 128);
|
assert_eq!(bytes.len(), 128);
|
||||||
// libsecret dh_group_1024_prime starts FF×8, C9 0F DA A2 … and ends
|
// libsecret dh_group_1024_prime starts FF×8, C9 0F DA A2 … and ends
|
||||||
// … EC E6 53 81, FF×8.
|
// … EC E6 53 81, FF×8.
|
||||||
assert_eq!(&bytes[..10], &[0xFF; 8].iter().chain([0xC9, 0x0F].iter()).copied().collect::<Vec<u8>>()[..]);
|
assert_eq!(
|
||||||
assert_eq!(&bytes[116..], &[0xEC, 0xE6, 0x53, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
|
&bytes[..10],
|
||||||
|
&[0xFF; 8]
|
||||||
|
.iter()
|
||||||
|
.chain([0xC9, 0x0F].iter())
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<u8>>()[..]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&bytes[116..],
|
||||||
|
&[0xEC, 0xE6, 0x53, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,11 @@ impl Item {
|
||||||
|
|
||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
async fn attributes(&self) -> HashMap<String, String> {
|
async fn attributes(&self) -> HashMap<String, String> {
|
||||||
self.store.lock().unwrap().attributes(&self.id).unwrap_or_default()
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.attributes(&self.id)
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
|
|
@ -141,7 +145,11 @@ impl Item {
|
||||||
|
|
||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
async fn label(&self) -> String {
|
async fn label(&self) -> String {
|
||||||
self.store.lock().unwrap().label(&self.id).unwrap_or_default()
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.label(&self.id)
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ impl SecretService {
|
||||||
.derive_session_key(&peer_public)
|
.derive_session_key(&peer_public)
|
||||||
.map_err(|e| SecretError::failed(format!("DH key derivation failed: {e}")))?;
|
.map_err(|e| SecretError::failed(format!("DH key derivation failed: {e}")))?;
|
||||||
|
|
||||||
self.sessions.insert(path.clone(), SessionCrypto::Aes { key });
|
self.sessions
|
||||||
|
.insert(path.clone(), SessionCrypto::Aes { key });
|
||||||
|
|
||||||
let our_public_bytes = dh.public.to_bytes_be();
|
let our_public_bytes = dh.public.to_bytes_be();
|
||||||
OwnedValue::try_from(Value::from(our_public_bytes)).unwrap()
|
OwnedValue::try_from(Value::from(our_public_bytes)).unwrap()
|
||||||
|
|
@ -149,7 +150,9 @@ impl SecretService {
|
||||||
.content_type(id)
|
.content_type(id)
|
||||||
.unwrap_or_else(|| "text/plain".to_string());
|
.unwrap_or_else(|| "text/plain".to_string());
|
||||||
let Some((parameters, value)) = self.sessions.seal(&session, &plaintext) else {
|
let Some((parameters, value)) = self.sessions.seal(&session, &plaintext) else {
|
||||||
return Err(SecretError::NoSession(format!("no such session: {session}")));
|
return Err(SecretError::NoSession(format!(
|
||||||
|
"no such session: {session}"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
out.insert(
|
out.insert(
|
||||||
item_obj_path,
|
item_obj_path,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,12 @@ impl Sessions {
|
||||||
|
|
||||||
/// Decrypt a `Secret`'s `(parameters, value)` per the session's negotiated
|
/// Decrypt a `Secret`'s `(parameters, value)` per the session's negotiated
|
||||||
/// transport.
|
/// transport.
|
||||||
pub fn unseal(&self, path: &OwnedObjectPath, parameters: &[u8], value: &[u8]) -> Option<anyhow::Result<Vec<u8>>> {
|
pub fn unseal(
|
||||||
|
&self,
|
||||||
|
path: &OwnedObjectPath,
|
||||||
|
parameters: &[u8],
|
||||||
|
value: &[u8],
|
||||||
|
) -> Option<anyhow::Result<Vec<u8>>> {
|
||||||
let sessions = self.inner.lock().unwrap();
|
let sessions = self.inner.lock().unwrap();
|
||||||
match sessions.get(path)? {
|
match sessions.get(path)? {
|
||||||
SessionCrypto::Plain => Some(Ok(value.to_vec())),
|
SessionCrypto::Plain => Some(Ok(value.to_vec())),
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,19 @@ pub struct Session {
|
||||||
|
|
||||||
#[interface(name = "org.freedesktop.Secret.Session")]
|
#[interface(name = "org.freedesktop.Secret.Session")]
|
||||||
impl Session {
|
impl Session {
|
||||||
async fn close(&self, #[zbus(signal_emitter)] emitter: SignalEmitter<'_>) -> Result<(), SecretError> {
|
async fn close(
|
||||||
|
&self,
|
||||||
|
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
|
||||||
|
) -> Result<(), SecretError> {
|
||||||
self.sessions.remove(&self.path);
|
self.sessions.remove(&self.path);
|
||||||
emitter
|
emitter
|
||||||
.connection()
|
.connection()
|
||||||
.object_server()
|
.object_server()
|
||||||
.remove::<Session, _>(self.path.clone())
|
.remove::<Session, _>(self.path.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SecretError::failed(format!("failed to unregister session object: {e}")))?;
|
.map_err(|e| {
|
||||||
|
SecretError::failed(format!("failed to unregister session object: {e}"))
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,6 @@ mod tests {
|
||||||
b"souveraine-machined:v1:secrets-store-key:v1".to_vec()
|
b"souveraine-machined:v1:secrets-store-key:v1".to_vec()
|
||||||
);
|
);
|
||||||
// Ed25519 is deterministic — same seed, same payload, same material.
|
// Ed25519 is deterministic — same seed, same payload, same material.
|
||||||
assert_eq!(
|
assert_eq!(seed.sign(&framed).to_bytes(), seed.sign(&framed).to_bytes());
|
||||||
seed.sign(&framed).to_bytes(),
|
|
||||||
seed.sign(&framed).to_bytes()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,9 @@ impl SecretStore {
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
options.mode(0o600);
|
options.mode(0o600);
|
||||||
}
|
}
|
||||||
let mut f = options.open(&tmp).context("creating secrets store temp file")?;
|
let mut f = options
|
||||||
|
.open(&tmp)
|
||||||
|
.context("creating secrets store temp file")?;
|
||||||
f.write_all(&raw)?;
|
f.write_all(&raw)?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +295,9 @@ impl SecretStore {
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, item)| {
|
.filter(|(_, item)| {
|
||||||
attributes.iter().all(|(k, v)| item.attributes.get(k) == Some(v))
|
attributes
|
||||||
|
.iter()
|
||||||
|
.all(|(k, v)| item.attributes.get(k) == Some(v))
|
||||||
})
|
})
|
||||||
.map(|(id, _)| id.clone())
|
.map(|(id, _)| id.clone())
|
||||||
.collect()
|
.collect()
|
||||||
|
|
@ -349,7 +353,13 @@ fn seal(key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||||
let mut nonce = [0u8; 12];
|
let mut nonce = [0u8; 12];
|
||||||
rand::thread_rng().fill_bytes(&mut nonce);
|
rand::thread_rng().fill_bytes(&mut nonce);
|
||||||
let ciphertext = cipher
|
let ciphertext = cipher
|
||||||
.encrypt(Nonce::from_slice(&nonce), Payload { msg: plaintext, aad })
|
.encrypt(
|
||||||
|
Nonce::from_slice(&nonce),
|
||||||
|
Payload {
|
||||||
|
msg: plaintext,
|
||||||
|
aad,
|
||||||
|
},
|
||||||
|
)
|
||||||
.map_err(|_| anyhow::anyhow!("AEAD seal failed"))?;
|
.map_err(|_| anyhow::anyhow!("AEAD seal failed"))?;
|
||||||
let mut out = nonce.to_vec();
|
let mut out = nonce.to_vec();
|
||||||
out.extend(ciphertext);
|
out.extend(ciphertext);
|
||||||
|
|
@ -361,7 +371,13 @@ fn open_sealed(key: &[u8; 32], aad: &[u8], sealed: &[u8]) -> Result<Vec<u8>> {
|
||||||
let (nonce, ciphertext) = sealed.split_at(12);
|
let (nonce, ciphertext) = sealed.split_at(12);
|
||||||
let cipher = Aes256Gcm::new(key.into());
|
let cipher = Aes256Gcm::new(key.into());
|
||||||
cipher
|
cipher
|
||||||
.decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad })
|
.decrypt(
|
||||||
|
Nonce::from_slice(nonce),
|
||||||
|
Payload {
|
||||||
|
msg: ciphertext,
|
||||||
|
aad,
|
||||||
|
},
|
||||||
|
)
|
||||||
.map_err(|_| anyhow::anyhow!("AEAD open failed — wrong key or tampered data"))
|
.map_err(|_| anyhow::anyhow!("AEAD open failed — wrong key or tampered data"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,7 +449,13 @@ mod tests {
|
||||||
let path = dir.path().join("store.json");
|
let path = dir.path().join("store.json");
|
||||||
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
|
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
|
||||||
store
|
store
|
||||||
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s")
|
.set(
|
||||||
|
"id".into(),
|
||||||
|
"l".into(),
|
||||||
|
HashMap::new(),
|
||||||
|
"text/plain".into(),
|
||||||
|
b"s",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(store);
|
drop(store);
|
||||||
|
|
||||||
|
|
@ -446,7 +468,13 @@ mod tests {
|
||||||
let path = dir.path().join("store.json");
|
let path = dir.path().join("store.json");
|
||||||
let mut store = SecretStore::open(ikm(), path).unwrap();
|
let mut store = SecretStore::open(ikm(), path).unwrap();
|
||||||
store
|
store
|
||||||
.set("a".into(), "l".into(), HashMap::new(), "text/plain".into(), b"secret-a")
|
.set(
|
||||||
|
"a".into(),
|
||||||
|
"l".into(),
|
||||||
|
HashMap::new(),
|
||||||
|
"text/plain".into(),
|
||||||
|
b"secret-a",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Grafting a's ciphertext onto id b must fail the AAD check.
|
// Grafting a's ciphertext onto id b must fail the AAD check.
|
||||||
let sealed = store.file.items.get("a").unwrap().sealed.clone();
|
let sealed = store.file.items.get("a").unwrap().sealed.clone();
|
||||||
|
|
@ -460,14 +488,20 @@ mod tests {
|
||||||
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
|
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
|
||||||
assert!(!store.has_passphrase());
|
assert!(!store.has_passphrase());
|
||||||
|
|
||||||
store.set_passphrase("correct horse battery staple").unwrap();
|
store
|
||||||
|
.set_passphrase("correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
assert!(store.has_passphrase());
|
assert!(store.has_passphrase());
|
||||||
assert!(store.verify_passphrase("correct horse battery staple").unwrap());
|
assert!(store
|
||||||
|
.verify_passphrase("correct horse battery staple")
|
||||||
|
.unwrap());
|
||||||
assert!(!store.verify_passphrase("wrong").unwrap());
|
assert!(!store.verify_passphrase("wrong").unwrap());
|
||||||
|
|
||||||
// The wrap survives a reload and still verifies.
|
// The wrap survives a reload and still verifies.
|
||||||
let store2 = SecretStore::open(ikm(), path).unwrap();
|
let store2 = SecretStore::open(ikm(), path).unwrap();
|
||||||
assert!(store2.verify_passphrase("correct horse battery staple").unwrap());
|
assert!(store2
|
||||||
|
.verify_passphrase("correct horse battery staple")
|
||||||
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -476,7 +510,13 @@ mod tests {
|
||||||
let path = dir.path().join("store.json");
|
let path = dir.path().join("store.json");
|
||||||
let mut store = SecretStore::open(ikm(), path).unwrap();
|
let mut store = SecretStore::open(ikm(), path).unwrap();
|
||||||
store
|
store
|
||||||
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s")
|
.set(
|
||||||
|
"id".into(),
|
||||||
|
"l".into(),
|
||||||
|
HashMap::new(),
|
||||||
|
"text/plain".into(),
|
||||||
|
b"s",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let sealed_before = store.file.items.get("id").unwrap().sealed.clone();
|
let sealed_before = store.file.items.get("id").unwrap().sealed.clone();
|
||||||
|
|
||||||
|
|
@ -494,11 +534,23 @@ mod tests {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut store = SecretStore::open(ikm(), dir.path().join("store.json")).unwrap();
|
let mut store = SecretStore::open(ikm(), dir.path().join("store.json")).unwrap();
|
||||||
store
|
store
|
||||||
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"a")
|
.set(
|
||||||
|
"id".into(),
|
||||||
|
"l".into(),
|
||||||
|
HashMap::new(),
|
||||||
|
"text/plain".into(),
|
||||||
|
b"a",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let created = store.created("id").unwrap();
|
let created = store.created("id").unwrap();
|
||||||
store
|
store
|
||||||
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"b")
|
.set(
|
||||||
|
"id".into(),
|
||||||
|
"l".into(),
|
||||||
|
HashMap::new(),
|
||||||
|
"text/plain".into(),
|
||||||
|
b"b",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(store.created("id").unwrap(), created);
|
assert_eq!(store.created("id").unwrap(), created);
|
||||||
assert!(store.modified("id").unwrap() >= created);
|
assert!(store.modified("id").unwrap() >= created);
|
||||||
|
|
@ -510,7 +562,13 @@ mod tests {
|
||||||
let path = dir.path().join("store.json");
|
let path = dir.path().join("store.json");
|
||||||
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
|
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
|
||||||
store
|
store
|
||||||
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s")
|
.set(
|
||||||
|
"id".into(),
|
||||||
|
"l".into(),
|
||||||
|
HashMap::new(),
|
||||||
|
"text/plain".into(),
|
||||||
|
b"s",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
use crate::api::models::{AgentState, AgentSummary, CreateAgentRequest, MemoryConfig, MemoryBlock, SouveraineConfig, UpdateAgentRequest};
|
use crate::api::models::{
|
||||||
|
AgentState, AgentSummary, CreateAgentRequest, MemoryBlock, MemoryConfig, SouveraineConfig,
|
||||||
|
UpdateAgentRequest,
|
||||||
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
@ -37,10 +40,14 @@ impl AgentInventory {
|
||||||
pub async fn new(data_dir: PathBuf, db: SqlitePool) -> anyhow::Result<Self> {
|
pub async fn new(data_dir: PathBuf, db: SqlitePool) -> anyhow::Result<Self> {
|
||||||
tokio::fs::create_dir_all(&data_dir).await?;
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
// ~/.souveraine/ — common ancestor of server/, agents/, subconscious-agents/.
|
// ~/.souveraine/ — common ancestor of server/, agents/, subconscious-agents/.
|
||||||
let souveraine_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or_else(|| {
|
let souveraine_root = data_dir
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
.parent()
|
||||||
home.join(".souveraine")
|
.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 memfs_dir = souveraine_root.join("agents");
|
||||||
let subconscious_dir = souveraine_root.join("subconscious-agents");
|
let subconscious_dir = souveraine_root.join("subconscious-agents");
|
||||||
tokio::fs::create_dir_all(&memfs_dir).await?;
|
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<()> {
|
pub async fn register_instance(&self, agent_id: &str, instance_id: &str) -> anyhow::Result<()> {
|
||||||
let pid = std::process::id() as i64;
|
let pid = std::process::id() as i64;
|
||||||
let hostname = hostname_or_unknown();
|
let hostname = hostname_or_unknown();
|
||||||
sqlx::query("DELETE FROM agent_instances WHERE last_seen_at < datetime('now', '-5 minutes')")
|
sqlx::query(
|
||||||
.execute(&self.db)
|
"DELETE FROM agent_instances WHERE last_seen_at < datetime('now', '-5 minutes')",
|
||||||
.await?;
|
)
|
||||||
|
.execute(&self.db)
|
||||||
|
.await?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO agent_instances (agent_id, instance_id, pid, hostname, started_at, last_seen_at)
|
"INSERT INTO agent_instances (agent_id, instance_id, pid, hostname, started_at, last_seen_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
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
|
/// Heartbeat the instance rows for this process: bump last_seen_at and
|
||||||
/// add `tick_seconds` to each agent's lifetime_active_seconds. Called
|
/// add `tick_seconds` to each agent's lifetime_active_seconds. Called
|
||||||
/// from a 30s loop in SouveraineServer.
|
/// from a 30s loop in SouveraineServer.
|
||||||
pub async fn heartbeat_instance(&self, instance_id: &str, tick_seconds: i64) -> anyhow::Result<()> {
|
pub async fn heartbeat_instance(
|
||||||
|
&self,
|
||||||
|
instance_id: &str,
|
||||||
|
tick_seconds: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
// Bump last_seen for every row owned by this instance.
|
// Bump last_seen for every row owned by this instance.
|
||||||
let updated = sqlx::query(
|
let updated = sqlx::query(
|
||||||
"UPDATE agent_instances SET last_seen_at = CURRENT_TIMESTAMP WHERE instance_id = ?1"
|
"UPDATE agent_instances SET last_seen_at = CURRENT_TIMESTAMP WHERE instance_id = ?1",
|
||||||
)
|
)
|
||||||
.bind(instance_id)
|
.bind(instance_id)
|
||||||
.execute(&self.db)
|
.execute(&self.db)
|
||||||
|
|
@ -164,7 +177,7 @@ impl AgentInventory {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE agents
|
"UPDATE agents
|
||||||
SET lifetime_active_seconds = lifetime_active_seconds + ?1
|
SET lifetime_active_seconds = lifetime_active_seconds + ?1
|
||||||
WHERE id IN (SELECT agent_id FROM agent_instances WHERE instance_id = ?2)"
|
WHERE id IN (SELECT agent_id FROM agent_instances WHERE instance_id = ?2)",
|
||||||
)
|
)
|
||||||
.bind(tick_seconds)
|
.bind(tick_seconds)
|
||||||
.bind(instance_id)
|
.bind(instance_id)
|
||||||
|
|
@ -177,7 +190,7 @@ impl AgentInventory {
|
||||||
pub async fn instance_count(&self, agent_id: &str) -> anyhow::Result<i64> {
|
pub async fn instance_count(&self, agent_id: &str) -> anyhow::Result<i64> {
|
||||||
let (count,): (i64,) = sqlx::query_as(
|
let (count,): (i64,) = sqlx::query_as(
|
||||||
"SELECT COUNT(*) FROM agent_instances WHERE agent_id = ?1
|
"SELECT COUNT(*) FROM agent_instances WHERE agent_id = ?1
|
||||||
AND last_seen_at >= datetime('now', '-5 minutes')"
|
AND last_seen_at >= datetime('now', '-5 minutes')",
|
||||||
)
|
)
|
||||||
.bind(agent_id)
|
.bind(agent_id)
|
||||||
.fetch_one(&self.db)
|
.fetch_one(&self.db)
|
||||||
|
|
@ -187,12 +200,11 @@ impl AgentInventory {
|
||||||
|
|
||||||
/// Total active seconds (lifetime) for an agent, used to compute uptime %.
|
/// Total active seconds (lifetime) for an agent, used to compute uptime %.
|
||||||
pub async fn lifetime_active_seconds(&self, agent_id: &str) -> anyhow::Result<i64> {
|
pub async fn lifetime_active_seconds(&self, agent_id: &str) -> anyhow::Result<i64> {
|
||||||
let (secs,): (i64,) = sqlx::query_as(
|
let (secs,): (i64,) =
|
||||||
"SELECT COALESCE(lifetime_active_seconds, 0) FROM agents WHERE id = ?1"
|
sqlx::query_as("SELECT COALESCE(lifetime_active_seconds, 0) FROM agents WHERE id = ?1")
|
||||||
)
|
.bind(agent_id)
|
||||||
.bind(agent_id)
|
.fetch_one(&self.db)
|
||||||
.fetch_one(&self.db)
|
.await?;
|
||||||
.await?;
|
|
||||||
Ok(secs)
|
Ok(secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -402,7 +414,11 @@ impl AgentInventory {
|
||||||
.map(|a| a.name.clone())
|
.map(|a| a.name.clone())
|
||||||
.unwrap_or_else(|| primary_id.to_string());
|
.unwrap_or_else(|| primary_id.to_string());
|
||||||
let persona_content = crate::core::seeds::subconscious_persona(&primary_name);
|
let persona_content = crate::core::seeds::subconscious_persona(&primary_name);
|
||||||
tokio::fs::write(agent_dir.join("memory.git/system/persona.md"), &persona_content).await?;
|
tokio::fs::write(
|
||||||
|
agent_dir.join("memory.git/system/persona.md"),
|
||||||
|
&persona_content,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// The four-fold N+1 mandate — what she does on every pass, and how
|
// The four-fold N+1 mandate — what she does on every pass, and how
|
||||||
// she records it. Read by the consciousness engine as her base prompt.
|
// she records it. Read by the consciousness engine as her base prompt.
|
||||||
|
|
@ -414,10 +430,7 @@ impl AgentInventory {
|
||||||
|
|
||||||
// Seed the six ledger files so the prompt's ledger orientation has
|
// Seed the six ledger files so the prompt's ledger orientation has
|
||||||
// real files to index from her first pass onward.
|
// real files to index from her first pass onward.
|
||||||
let sub_repo = crate::core::memory::MemoryRepo::open(
|
let sub_repo = crate::core::memory::MemoryRepo::open(&sub_id, agent_dir.join("memory.git"));
|
||||||
&sub_id,
|
|
||||||
agent_dir.join("memory.git"),
|
|
||||||
);
|
|
||||||
if let Err(e) = sub_repo.init_subconscious_ledger().await {
|
if let Err(e) = sub_repo.init_subconscious_ledger().await {
|
||||||
tracing::warn!("ledger seeding for {} failed (continuing): {}", sub_id, e);
|
tracing::warn!("ledger seeding for {} failed (continuing): {}", sub_id, e);
|
||||||
}
|
}
|
||||||
|
|
@ -435,11 +448,19 @@ impl AgentInventory {
|
||||||
let agent_json = serde_json::to_string_pretty(&agent_state)?;
|
let agent_json = serde_json::to_string_pretty(&agent_state)?;
|
||||||
tokio::fs::write(agent_dir.join("agent.json"), agent_json).await?;
|
tokio::fs::write(agent_dir.join("agent.json"), agent_json).await?;
|
||||||
|
|
||||||
tracing::info!("Created subconscious agent {} for primary {}", sub_id, primary_id);
|
tracing::info!(
|
||||||
|
"Created subconscious agent {} for primary {}",
|
||||||
|
sub_id,
|
||||||
|
primary_id
|
||||||
|
);
|
||||||
Ok(sub_id)
|
Ok(sub_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(&self, agent_id: &str, updates: UpdateAgentRequest) -> anyhow::Result<AgentState> {
|
pub async fn update(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
updates: UpdateAgentRequest,
|
||||||
|
) -> anyhow::Result<AgentState> {
|
||||||
let mut agent = self.get(agent_id).await?;
|
let mut agent = self.get(agent_id).await?;
|
||||||
|
|
||||||
if let Some(name) = updates.name {
|
if let Some(name) = updates.name {
|
||||||
|
|
@ -453,7 +474,8 @@ impl AgentInventory {
|
||||||
}
|
}
|
||||||
if let Some(blocks) = updates.memory_blocks {
|
if let Some(blocks) = updates.memory_blocks {
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
let path = self.memfs_dir
|
let path = self
|
||||||
|
.memfs_dir
|
||||||
.join(agent_id)
|
.join(agent_id)
|
||||||
.join("memory")
|
.join("memory")
|
||||||
.join("system")
|
.join("system")
|
||||||
|
|
@ -522,17 +544,11 @@ impl AgentInventory {
|
||||||
|
|
||||||
let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
|
let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
|
||||||
|
|
||||||
repo.commit(
|
repo.commit(Some("HEAD"), &signature, &signature, &msg, &tree, &parents)?;
|
||||||
Some("HEAD"),
|
|
||||||
&signature,
|
|
||||||
&signature,
|
|
||||||
&msg,
|
|
||||||
&tree,
|
|
||||||
&parents,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok::<(), anyhow::Error>(())
|
Ok::<(), anyhow::Error>(())
|
||||||
}).await??;
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -546,13 +562,14 @@ impl AgentInventory {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension() == Some(std::ffi::OsStr::new("md")) {
|
if path.extension() == Some(std::ffi::OsStr::new("md")) {
|
||||||
let content = tokio::fs::read_to_string(&path).await?;
|
let content = tokio::fs::read_to_string(&path).await?;
|
||||||
let label = path.file_stem()
|
let label = path
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
.file_stem()
|
||||||
.unwrap_or_else(|| {
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
path.file_name()
|
.unwrap_or_else(|| {
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
path.file_name()
|
||||||
.unwrap_or_default()
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
});
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
blocks.push(MemoryBlock {
|
blocks.push(MemoryBlock {
|
||||||
label,
|
label,
|
||||||
value: content,
|
value: content,
|
||||||
|
|
|
||||||
|
|
@ -26,26 +26,35 @@
|
||||||
//! separate agent — it is the same consciousness in a different mode that runs
|
//! separate agent — it is the same consciousness in a different mode that runs
|
||||||
//! immediately after the primary's turn.
|
//! immediately after the primary's turn.
|
||||||
|
|
||||||
|
use crate::backend::BackendEvent;
|
||||||
use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
|
use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
|
||||||
|
use crate::bridge::model_router::TokenCounter;
|
||||||
use crate::bridge::LlmProvider;
|
use crate::bridge::LlmProvider;
|
||||||
use crate::bridge::ProviderRegistry;
|
use crate::bridge::ProviderRegistry;
|
||||||
use crate::bridge::model_router::TokenCounter;
|
|
||||||
use crate::core::compact::CompactionEngine;
|
use crate::core::compact::CompactionEngine;
|
||||||
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
||||||
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
|
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
|
||||||
use crate::core::tools::defs::ToolContext;
|
use crate::core::tools::defs::ToolContext;
|
||||||
use crate::server::{AgentInventory, SessionManager};
|
use crate::server::{AgentInventory, SessionManager};
|
||||||
use crate::backend::BackendEvent;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// Tools subconscious is permitted to use. `halt` and `intrusive` are
|
/// Tools subconscious is permitted to use. `halt` and `intrusive` are
|
||||||
/// subconscious-only — the primary's tool list filters them out (see
|
/// subconscious-only — the primary's tool list filters them out (see
|
||||||
/// `crate::core::tools::SUBCONSCIOUS_ONLY_TOOLS`).
|
/// `crate::core::tools::SUBCONSCIOUS_ONLY_TOOLS`).
|
||||||
const SUBCONSCIOUS_SAFE_TOOLS: &[&str] = &[
|
const SUBCONSCIOUS_SAFE_TOOLS: &[&str] = &[
|
||||||
"read", "write", "edit", "glob", "grep", "list_dir", "memory", "schedule", "todo",
|
"read",
|
||||||
"halt", "intrusive",
|
"write",
|
||||||
|
"edit",
|
||||||
|
"glob",
|
||||||
|
"grep",
|
||||||
|
"list_dir",
|
||||||
|
"memory",
|
||||||
|
"schedule",
|
||||||
|
"todo",
|
||||||
|
"halt",
|
||||||
|
"intrusive",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Tool rounds for the post-turn N+1 pass — the deeper occasion where the
|
/// Tool rounds for the post-turn N+1 pass — the deeper occasion where the
|
||||||
|
|
@ -83,10 +92,22 @@ pub struct ConsciousnessEngine {
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum ConsciousnessEvent {
|
pub enum ConsciousnessEvent {
|
||||||
Surfacing { source: String, content: String, priority: String },
|
Surfacing {
|
||||||
Reflection { content: String },
|
source: String,
|
||||||
Archivist { synthesis: String, pressure: f32 },
|
content: String,
|
||||||
CompactionWarning { pressure: f32, tier: u8 },
|
priority: String,
|
||||||
|
},
|
||||||
|
Reflection {
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
Archivist {
|
||||||
|
synthesis: String,
|
||||||
|
pressure: f32,
|
||||||
|
},
|
||||||
|
CompactionWarning {
|
||||||
|
pressure: f32,
|
||||||
|
tier: u8,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tool call recorded for a mid-turn peek. Kept so turn.rs can render
|
/// One tool call recorded for a mid-turn peek. Kept so turn.rs can render
|
||||||
|
|
@ -231,7 +252,10 @@ impl ConsciousnessEngine {
|
||||||
if m.role == "system" {
|
if m.role == "system" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(e) = self.sessions.add_message(conv_id, bifrost_to_conversation(m)) {
|
if let Err(e) = self
|
||||||
|
.sessions
|
||||||
|
.add_message(conv_id, bifrost_to_conversation(m))
|
||||||
|
{
|
||||||
tracing::warn!("subconscious session persist failed: {}", e);
|
tracing::warn!("subconscious session persist failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,17 +293,10 @@ impl ConsciousnessEngine {
|
||||||
// / primary memory via the memory tool. The summary string is
|
// / primary memory via the memory tool. The summary string is
|
||||||
// surfaced as a ConsciousnessEvent so the cockpit panel renders it.
|
// surfaced as a ConsciousnessEvent so the cockpit panel renders it.
|
||||||
if turn_count > 0 && turn_count.is_multiple_of(25) {
|
if turn_count > 0 && turn_count.is_multiple_of(25) {
|
||||||
match self
|
match self.reflection.reflect_now(agent_id, messages).await {
|
||||||
.reflection
|
|
||||||
.reflect_now(agent_id, messages)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
let header = if report.exited_cleanly {
|
let header = if report.exited_cleanly {
|
||||||
format!(
|
format!("N+25 reflection ({} turns reviewed)", report.turns_reviewed)
|
||||||
"N+25 reflection ({} turns reviewed)",
|
|
||||||
report.turns_reviewed
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"N+25 reflection (incomplete — tool rounds exhausted, {} turns)",
|
"N+25 reflection (incomplete — tool rounds exhausted, {} turns)",
|
||||||
|
|
@ -386,9 +403,7 @@ impl ConsciousnessEngine {
|
||||||
// inner-voice file the cockpit tails, not only in the box.
|
// inner-voice file the cockpit tails, not only in the box.
|
||||||
// Without this the inner-voice region never updates on a
|
// Without this the inner-voice region never updates on a
|
||||||
// quiet pass, and quiet passes are the common case.
|
// quiet pass, and quiet passes are the common case.
|
||||||
if let Err(e) =
|
if let Err(e) = inbox.surface_to_conscious(Urgency::Low, beat).await {
|
||||||
inbox.surface_to_conscious(Urgency::Low, beat).await
|
|
||||||
{
|
|
||||||
tracing::warn!("inner voice heartbeat delivery failed: {}", e);
|
tracing::warn!("inner voice heartbeat delivery failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -400,7 +415,9 @@ impl ConsciousnessEngine {
|
||||||
|
|
||||||
// Persist to inner voice file (survives compaction)
|
// Persist to inner voice file (survives compaction)
|
||||||
for item in &outcome.observations {
|
for item in &outcome.observations {
|
||||||
if let Err(e) = inbox.surface_to_conscious(item.urgency, &item.content).await
|
if let Err(e) = inbox
|
||||||
|
.surface_to_conscious(item.urgency, &item.content)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("inner voice delivery failed: {}", e);
|
tracing::warn!("inner voice delivery failed: {}", e);
|
||||||
}
|
}
|
||||||
|
|
@ -440,11 +457,13 @@ impl ConsciousnessEngine {
|
||||||
// Always surface at least a heartbeat so the user can see the
|
// Always surface at least a heartbeat so the user can see the
|
||||||
// subconscious is trying — even when subconscious errors out.
|
// subconscious is trying — even when subconscious errors out.
|
||||||
if heuristics.is_empty() {
|
if heuristics.is_empty() {
|
||||||
let _ = inbox.queue(InboxItem::new(
|
let _ = inbox
|
||||||
"surface",
|
.queue(InboxItem::new(
|
||||||
Urgency::Low,
|
"surface",
|
||||||
"Subconscious pass ran — no anomalies detected.",
|
Urgency::Low,
|
||||||
)).await;
|
"Subconscious pass ran — no anomalies detected.",
|
||||||
|
))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -490,7 +509,10 @@ impl ConsciousnessEngine {
|
||||||
|
|
||||||
// Initialize ledger structure in subconscious agent's space (idempotent)
|
// Initialize ledger structure in subconscious agent's space (idempotent)
|
||||||
if let Err(e) = sub_repo.init_subconscious_ledger().await {
|
if let Err(e) = sub_repo.init_subconscious_ledger().await {
|
||||||
tracing::warn!("Subagent subconscious ledger init failed (continuing without): {}", e);
|
tracing::warn!(
|
||||||
|
"Subagent subconscious ledger init failed (continuing without): {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For subagents we don't have the user's message context,
|
// For subagents we don't have the user's message context,
|
||||||
|
|
@ -506,7 +528,9 @@ impl ConsciousnessEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for item in &outcome.observations {
|
for item in &outcome.observations {
|
||||||
if let Err(e) = inbox.surface_to_conscious(item.urgency, &item.content).await
|
if let Err(e) = inbox
|
||||||
|
.surface_to_conscious(item.urgency, &item.content)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("subagent inner voice delivery failed: {}", e);
|
tracing::warn!("subagent inner voice delivery failed: {}", e);
|
||||||
}
|
}
|
||||||
|
|
@ -606,7 +630,8 @@ ledger to check if the issue was already flagged.
|
||||||
Append timestamped entries: `[YYYY-MM-DD HH:MM] observation`
|
Append timestamped entries: `[YYYY-MM-DD HH:MM] observation`
|
||||||
Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
|
|
||||||
let observation_format = "\n\nAfter your analysis (and any tool use), respond with 1-3 observations:\n\
|
let observation_format =
|
||||||
|
"\n\nAfter your analysis (and any tool use), respond with 1-3 observations:\n\
|
||||||
- source: \"complete\" | \"verify\" | \"persist\" | \"surface\"\n\
|
- source: \"complete\" | \"verify\" | \"persist\" | \"surface\"\n\
|
||||||
- content: 1-2 line observation about what you noticed\n\
|
- content: 1-2 line observation about what you noticed\n\
|
||||||
- urgency: \"low\" | \"medium\" | \"high\" | \"critical\"\n\n\
|
- urgency: \"low\" | \"medium\" | \"high\" | \"critical\"\n\n\
|
||||||
|
|
@ -620,12 +645,20 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
// Prepend the configurable platform prompt when set (Settings → Subconscious).
|
// Prepend the configurable platform prompt when set (Settings → Subconscious).
|
||||||
let system_prompt = match &self.subconscious_system_prompt {
|
let system_prompt = match &self.subconscious_system_prompt {
|
||||||
Some(platform) if !platform.trim().is_empty() => {
|
Some(platform) if !platform.trim().is_empty() => {
|
||||||
format!("{}\n\n---\n\n{}{}", platform.trim(), base_prompt, observation_format)
|
format!(
|
||||||
|
"{}\n\n---\n\n{}{}",
|
||||||
|
platform.trim(),
|
||||||
|
base_prompt,
|
||||||
|
observation_format
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => format!("{}{}", base_prompt, observation_format),
|
_ => format!("{}{}", base_prompt, observation_format),
|
||||||
};
|
};
|
||||||
|
|
||||||
let primary_name = self.agents.get(primary_id).await
|
let primary_name = self
|
||||||
|
.agents
|
||||||
|
.get(primary_id)
|
||||||
|
.await
|
||||||
.map(|a| a.name)
|
.map(|a| a.name)
|
||||||
.unwrap_or_else(|_| "the primary".to_string());
|
.unwrap_or_else(|_| "the primary".to_string());
|
||||||
|
|
||||||
|
|
@ -647,7 +680,10 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Occasion::MidTurnPeek { tool_round, in_flight_summary } => {
|
Occasion::MidTurnPeek {
|
||||||
|
tool_round,
|
||||||
|
in_flight_summary,
|
||||||
|
} => {
|
||||||
// First-person framing — she is peeking at her own work, not
|
// First-person framing — she is peeking at her own work, not
|
||||||
// grading the primary from outside. If she sees a problem
|
// grading the primary from outside. If she sees a problem
|
||||||
// she calls `halt`; if she sees a softer concern she calls
|
// she calls `halt`; if she sees a softer concern she calls
|
||||||
|
|
@ -669,7 +705,11 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
ambient,
|
ambient,
|
||||||
primary_name,
|
primary_name,
|
||||||
tool_round,
|
tool_round,
|
||||||
if user_message.is_empty() { "(no user message)" } else { user_message },
|
if user_message.is_empty() {
|
||||||
|
"(no user message)"
|
||||||
|
} else {
|
||||||
|
user_message
|
||||||
|
},
|
||||||
in_flight_summary,
|
in_flight_summary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -751,11 +791,18 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
messages.push(Message::assistant_tool_calls(
|
messages.push(Message::assistant_tool_calls(
|
||||||
String::new(),
|
String::new(),
|
||||||
vec![crate::bridge::bifrost::MessageToolCall::function(
|
vec![crate::bridge::bifrost::MessageToolCall::function(
|
||||||
id.clone(), name.clone(), input.clone(),
|
id.clone(),
|
||||||
|
name.clone(),
|
||||||
|
input.clone(),
|
||||||
)],
|
)],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ContentBlock::ToolResult { tool_use_id, tool_name, output, .. } => {
|
ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
tool_name,
|
||||||
|
output,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
messages.push(Message::tool_result(tool_use_id, tool_name, output.clone()));
|
messages.push(Message::tool_result(tool_use_id, tool_name, output.clone()));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -807,7 +854,11 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
let chars: Vec<char> = trimmed.chars().collect();
|
let chars: Vec<char> = trimmed.chars().collect();
|
||||||
for chunk in chars.chunks(10) {
|
for chunk in chars.chunks(10) {
|
||||||
let s: String = chunk.iter().collect();
|
let s: String = chunk.iter().collect();
|
||||||
if tx.send(Ok(BackendEvent::SubconsciousToken(s))).await.is_err() {
|
if tx
|
||||||
|
.send(Ok(BackendEvent::SubconsciousToken(s)))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
// Receiver dropped — bail out of the streaming
|
// Receiver dropped — bail out of the streaming
|
||||||
// emission; the round itself still completes.
|
// emission; the round itself still completes.
|
||||||
break;
|
break;
|
||||||
|
|
@ -816,16 +867,25 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for tc in &response.tool_calls {
|
for tc in &response.tool_calls {
|
||||||
let _ = tx.send(Ok(BackendEvent::SubconsciousToolCall {
|
let _ = tx
|
||||||
name: tc.name.clone(),
|
.send(Ok(BackendEvent::SubconsciousToolCall {
|
||||||
arguments: tc.arguments.to_string(),
|
name: tc.name.clone(),
|
||||||
})).await;
|
arguments: tc.arguments.to_string(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for event in &strain {
|
for event in &strain {
|
||||||
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event {
|
if let crate::bridge::bifrost::InferenceStrain::Transient {
|
||||||
tracing::info!("subconscious felt inference strain: {} on {}", status, model);
|
status, model, ..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
tracing::info!(
|
||||||
|
"subconscious felt inference strain: {} on {}",
|
||||||
|
status,
|
||||||
|
model
|
||||||
|
);
|
||||||
if *status == 429 {
|
if *status == 429 {
|
||||||
let current = self.rate_delay.load(Ordering::Relaxed);
|
let current = self.rate_delay.load(Ordering::Relaxed);
|
||||||
let bumped = (current + 200).min(3000);
|
let bumped = (current + 200).min(3000);
|
||||||
|
|
@ -862,11 +922,13 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
.map(|tc| {
|
||||||
tc.id.clone(),
|
crate::bridge::bifrost::MessageToolCall::function(
|
||||||
tc.name.clone(),
|
tc.id.clone(),
|
||||||
tc.arguments.to_string(),
|
tc.name.clone(),
|
||||||
))
|
tc.arguments.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
messages.push(
|
messages.push(
|
||||||
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
||||||
|
|
@ -893,9 +955,9 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
}
|
}
|
||||||
|
|
||||||
let input_str = tc.arguments.to_string();
|
let input_str = tc.arguments.to_string();
|
||||||
let result = crate::core::tools::execute_tool_with_context(
|
let result =
|
||||||
&tc.name, &input_str, &tool_ctx,
|
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
|
||||||
).await;
|
.await;
|
||||||
|
|
||||||
let output = if result.is_error {
|
let output = if result.is_error {
|
||||||
format!("Error: {}", result.output)
|
format!("Error: {}", result.output)
|
||||||
|
|
@ -905,11 +967,13 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
|
|
||||||
// Emit tool result for live TUI visibility
|
// Emit tool result for live TUI visibility
|
||||||
if let Some(tx) = stream_tx {
|
if let Some(tx) = stream_tx {
|
||||||
let _ = tx.send(Ok(BackendEvent::SubconsciousToolResult {
|
let _ = tx
|
||||||
name: tc.name.clone(),
|
.send(Ok(BackendEvent::SubconsciousToolResult {
|
||||||
output: output.clone(),
|
name: tc.name.clone(),
|
||||||
is_error: result.is_error,
|
output: output.clone(),
|
||||||
})).await;
|
is_error: result.is_error,
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push(Message::tool_result(&tc.id, &tc.name, output));
|
messages.push(Message::tool_result(&tc.id, &tc.name, output));
|
||||||
|
|
@ -929,7 +993,10 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
|
|
||||||
// Brief pause between subconscious's tool rounds — use the adaptive delay
|
// Brief pause between subconscious's tool rounds — use the adaptive delay
|
||||||
// so subconscious respects the same ceiling as the primary loop.
|
// so subconscious respects the same ceiling as the primary loop.
|
||||||
let delay_ms = self.rate_delay.load(Ordering::Relaxed).max(SUBCONSCIOUS_INTER_ROUND_DELAY_MS);
|
let delay_ms = self
|
||||||
|
.rate_delay
|
||||||
|
.load(Ordering::Relaxed)
|
||||||
|
.max(SUBCONSCIOUS_INTER_ROUND_DELAY_MS);
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,9 +1023,7 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
stream: None,
|
stream: None,
|
||||||
tools: None,
|
tools: None,
|
||||||
};
|
};
|
||||||
let (final_response, _strain) = llm
|
let (final_response, _strain) = llm.chat_completion_with_strain(final_request).await?;
|
||||||
.chat_completion_with_strain(final_request)
|
|
||||||
.await?;
|
|
||||||
let content = final_response.content.trim().to_string();
|
let content = final_response.content.trim().to_string();
|
||||||
if persist_this_pass {
|
if persist_this_pass {
|
||||||
messages.push(Message::text("assistant", final_response.content.clone()));
|
messages.push(Message::text("assistant", final_response.content.clone()));
|
||||||
|
|
@ -997,7 +1062,10 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
"", // no settled primary response — she's peeking at live work
|
"", // no settled primary response — she's peeking at live work
|
||||||
agent_id,
|
agent_id,
|
||||||
&sub_id,
|
&sub_id,
|
||||||
Occasion::MidTurnPeek { tool_round, in_flight_summary },
|
Occasion::MidTurnPeek {
|
||||||
|
tool_round,
|
||||||
|
in_flight_summary,
|
||||||
|
},
|
||||||
stream_tx,
|
stream_tx,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -1034,17 +1102,14 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
&self,
|
&self,
|
||||||
session: &crate::server::session_manager::Session,
|
session: &crate::server::session_manager::Session,
|
||||||
) -> f32 {
|
) -> f32 {
|
||||||
self.pressure_for(&session.agent_id, &session.messages).await
|
self.pressure_for(&session.agent_id, &session.messages)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context pressure for an agent given a message snapshot — the
|
/// Context pressure for an agent given a message snapshot — the
|
||||||
/// session-free form used by `on_response`, which runs after the user
|
/// session-free form used by `on_response`, which runs after the user
|
||||||
/// has been released and must not hold a live session ref.
|
/// has been released and must not hold a live session ref.
|
||||||
pub async fn pressure_for(
|
pub async fn pressure_for(&self, agent_id: &str, messages: &[ConversationMessage]) -> f32 {
|
||||||
&self,
|
|
||||||
agent_id: &str,
|
|
||||||
messages: &[ConversationMessage],
|
|
||||||
) -> f32 {
|
|
||||||
let limit = self
|
let limit = self
|
||||||
.agents
|
.agents
|
||||||
.get(agent_id)
|
.get(agent_id)
|
||||||
|
|
@ -1085,7 +1150,9 @@ fn bifrost_to_conversation(msg: &Message) -> ConversationMessage {
|
||||||
|
|
||||||
let mut blocks = Vec::new();
|
let mut blocks = Vec::new();
|
||||||
if !msg.content.is_empty() {
|
if !msg.content.is_empty() {
|
||||||
blocks.push(ContentBlock::Text { text: msg.content.as_text() });
|
blocks.push(ContentBlock::Text {
|
||||||
|
text: msg.content.as_text(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Some(calls) = &msg.tool_calls {
|
if let Some(calls) = &msg.tool_calls {
|
||||||
for c in calls {
|
for c in calls {
|
||||||
|
|
@ -1204,11 +1271,17 @@ fn parse_triple_observations(text: &str) -> Vec<InboxItem> {
|
||||||
flush(&mut items, source, content, urgency);
|
flush(&mut items, source, content, urgency);
|
||||||
content = None;
|
content = None;
|
||||||
urgency = None;
|
urgency = None;
|
||||||
source = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"'));
|
source = line
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, v)| v.trim().trim_matches('"'));
|
||||||
} else if line.starts_with("- content:") || line.starts_with("-content:") {
|
} else if line.starts_with("- content:") || line.starts_with("-content:") {
|
||||||
content = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"'));
|
content = line
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, v)| v.trim().trim_matches('"'));
|
||||||
} else if line.starts_with("- urgency:") || line.starts_with("-urgency:") {
|
} else if line.starts_with("- urgency:") || line.starts_with("-urgency:") {
|
||||||
urgency = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"'));
|
urgency = line
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, v)| v.trim().trim_matches('"'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush(&mut items, source, content, urgency);
|
flush(&mut items, source, content, urgency);
|
||||||
|
|
@ -1351,7 +1424,11 @@ mod tests {
|
||||||
fn skips_empty_and_none_slots() {
|
fn skips_empty_and_none_slots() {
|
||||||
let text = "- **persist**: None\n- **surface**: real observation here";
|
let text = "- **persist**: None\n- **surface**: real observation here";
|
||||||
let items = parse_observations(text);
|
let items = parse_observations(text);
|
||||||
assert_eq!(items.len(), 1, "an explicit `none` slot is not an observation");
|
assert_eq!(
|
||||||
|
items.len(),
|
||||||
|
1,
|
||||||
|
"an explicit `none` slot is not an observation"
|
||||||
|
);
|
||||||
assert_eq!(items[0].source, "surface");
|
assert_eq!(items[0].source, "surface");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::bridge::bifrost::{ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage};
|
use crate::bridge::bifrost::{
|
||||||
|
ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage,
|
||||||
|
};
|
||||||
use crate::bridge::LlmProvider;
|
use crate::bridge::LlmProvider;
|
||||||
use crate::core::session::{ContentBlock, ConversationMessage, Session};
|
use crate::core::session::{ContentBlock, ConversationMessage, Session};
|
||||||
|
|
||||||
|
|
@ -36,44 +38,71 @@ impl ServerConversation {
|
||||||
/// Simple turn without full tool loop (for now)
|
/// Simple turn without full tool loop (for now)
|
||||||
pub async fn turn(&mut self, user_input: &str) -> anyhow::Result<ServerTurnResult> {
|
pub async fn turn(&mut self, user_input: &str) -> anyhow::Result<ServerTurnResult> {
|
||||||
// Add user message
|
// Add user message
|
||||||
self.session.add_message(ConversationMessage::user_text(user_input));
|
self.session
|
||||||
|
.add_message(ConversationMessage::user_text(user_input));
|
||||||
|
|
||||||
// Build messages — flatten text blocks; ignore tool blocks
|
// Build messages — flatten text blocks; ignore tool blocks
|
||||||
// until the server tool loop lands.
|
// until the server tool loop lands.
|
||||||
let messages: Vec<BifrostMessage> = self.session.messages.iter().map(|m| {
|
let messages: Vec<BifrostMessage> = self
|
||||||
let role = match m.role {
|
.session
|
||||||
crate::core::session::MessageRole::System => "system",
|
.messages
|
||||||
crate::core::session::MessageRole::User => "user",
|
.iter()
|
||||||
crate::core::session::MessageRole::Assistant => "assistant",
|
.map(|m| {
|
||||||
crate::core::session::MessageRole::Tool => "tool",
|
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 {
|
if has_images {
|
||||||
let parts: Vec<ContentPart> = m.blocks.iter().filter_map(|b| match b {
|
let parts: Vec<ContentPart> = m
|
||||||
ContentBlock::Text { text } => Some(ContentPart::Text { text: text.clone() }),
|
.blocks
|
||||||
ContentBlock::Image { media_type, data } => {
|
.iter()
|
||||||
let url = format!("data:{media_type};base64,{data}");
|
.filter_map(|b| match b {
|
||||||
Some(ContentPart::ImageUrl { image_url: ImageUrlSource { url } })
|
ContentBlock::Text { text } => {
|
||||||
}
|
Some(ContentPart::Text { text: text.clone() })
|
||||||
_ => None,
|
}
|
||||||
}).collect();
|
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 {
|
let text_content = m
|
||||||
ContentBlock::Text { text } => Some(text.as_str()),
|
.blocks
|
||||||
_ => None,
|
.iter()
|
||||||
}).collect::<Vec<_>>().join("\n");
|
.filter_map(|b| match b {
|
||||||
|
ContentBlock::Text { text } => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
BifrostMessage::multimodal_user(text_content, parts)
|
BifrostMessage::multimodal_user(text_content, parts)
|
||||||
} else {
|
} else {
|
||||||
let content = m.blocks.iter().filter_map(|b| match b {
|
let content = m
|
||||||
ContentBlock::Text { text } => Some(text.as_str()),
|
.blocks
|
||||||
_ => None,
|
.iter()
|
||||||
}).collect::<Vec<_>>().join("\n");
|
.filter_map(|b| match b {
|
||||||
BifrostMessage::text(role, content)
|
ContentBlock::Text { text } => Some(text.as_str()),
|
||||||
}
|
_ => None,
|
||||||
}).collect();
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
BifrostMessage::text(role, content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Call the active provider
|
// Call the active provider
|
||||||
let req = ChatCompletionRequest {
|
let req = ChatCompletionRequest {
|
||||||
|
|
@ -89,7 +118,8 @@ impl ServerConversation {
|
||||||
let content = response.content.clone();
|
let content = response.content.clone();
|
||||||
|
|
||||||
// Store assistant response
|
// Store assistant response
|
||||||
self.session.add_message(ConversationMessage::assistant_text(&content));
|
self.session
|
||||||
|
.add_message(ConversationMessage::assistant_text(&content));
|
||||||
|
|
||||||
Ok(ServerTurnResult {
|
Ok(ServerTurnResult {
|
||||||
response_text: content,
|
response_text: content,
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,13 @@ pub async fn init_database(db_path: &Path) -> anyhow::Result<SqlitePool> {
|
||||||
// lifetime_active_seconds: cumulative time this agent has had at least
|
// lifetime_active_seconds: cumulative time this agent has had at least
|
||||||
// one running instance. Incremented by the heartbeat tick. Drives the
|
// one running instance. Incremented by the heartbeat tick. Drives the
|
||||||
// uptime % on the manager card (capped at 99 in the UI).
|
// uptime % on the manager card (capped at 99 in the UI).
|
||||||
add_column_if_missing(&pool, "agents", "lifetime_active_seconds", "INTEGER NOT NULL DEFAULT 0").await?;
|
add_column_if_missing(
|
||||||
|
&pool,
|
||||||
|
"agents",
|
||||||
|
"lifetime_active_seconds",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
add_column_if_missing(&pool, "agents", "owner_seed_id", "TEXT").await?;
|
add_column_if_missing(&pool, "agents", "owner_seed_id", "TEXT").await?;
|
||||||
|
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
|
|
@ -112,16 +118,18 @@ async fn add_column_if_missing(
|
||||||
column: &str,
|
column: &str,
|
||||||
column_decl: &str,
|
column_decl: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let rows: Vec<(i64, String)> = sqlx::query_as(
|
let rows: Vec<(i64, String)> = sqlx::query_as(&format!(
|
||||||
&format!("SELECT cid, name FROM pragma_table_info('{table}')"),
|
"SELECT cid, name FROM pragma_table_info('{table}')"
|
||||||
)
|
))
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
if rows.iter().any(|(_, name)| name == column) {
|
if rows.iter().any(|(_, name)| name == column) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
sqlx::query(&format!("ALTER TABLE {table} ADD COLUMN {column} {column_decl}"))
|
sqlx::query(&format!(
|
||||||
.execute(pool)
|
"ALTER TABLE {table} ADD COLUMN {column} {column_decl}"
|
||||||
.await?;
|
))
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,9 @@ impl DeviceRegistry {
|
||||||
}
|
}
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
// Preserve the original first_seen across re-announces.
|
// Preserve the original first_seen across re-announces.
|
||||||
let first_seen = self.peers.get(&seed_id)
|
let first_seen = self
|
||||||
|
.peers
|
||||||
|
.get(&seed_id)
|
||||||
.map(|e| e.first_seen)
|
.map(|e| e.first_seen)
|
||||||
.unwrap_or(now);
|
.unwrap_or(now);
|
||||||
let was_new = !self.peers.contains_key(&seed_id);
|
let was_new = !self.peers.contains_key(&seed_id);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ use std::sync::Arc;
|
||||||
use crate::core::nervous::EventBus;
|
use crate::core::nervous::EventBus;
|
||||||
use crate::server::SouveraineServer;
|
use crate::server::SouveraineServer;
|
||||||
|
|
||||||
pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, event_bus: &EventBus) -> Result<()> {
|
pub(crate) async fn write_energy_balance(
|
||||||
|
server: &Arc<SouveraineServer>,
|
||||||
|
agent_id: &str,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> Result<()> {
|
||||||
let memory_root = server.agents.memory_root(agent_id);
|
let memory_root = server.agents.memory_root(agent_id);
|
||||||
let tasks_dir = memory_root.join("tasks");
|
let tasks_dir = memory_root.join("tasks");
|
||||||
if !tasks_dir.exists() {
|
if !tasks_dir.exists() {
|
||||||
|
|
@ -106,7 +110,10 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
|
||||||
desc = description,
|
desc = description,
|
||||||
);
|
);
|
||||||
|
|
||||||
let balance_path = memory_root.join("system").join("dynamic").join("energy-balance.md");
|
let balance_path = memory_root
|
||||||
|
.join("system")
|
||||||
|
.join("dynamic")
|
||||||
|
.join("energy-balance.md");
|
||||||
if let Some(parent) = balance_path.parent() {
|
if let Some(parent) = balance_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +123,8 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
agent = agent_id,
|
agent = agent_id,
|
||||||
generative, consumptive,
|
generative,
|
||||||
|
consumptive,
|
||||||
"energy-balance written"
|
"energy-balance written"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -125,7 +133,13 @@ pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_i
|
||||||
|
|
||||||
/// Fire an energy_balance_updated event so the firehose carries the
|
/// Fire an energy_balance_updated event so the firehose carries the
|
||||||
/// agent's felt state across machines.
|
/// agent's felt state across machines.
|
||||||
fn fire_energy_event(event_bus: &EventBus, agent_id: &str, generative: usize, consumptive: usize, ratio: f32) {
|
fn fire_energy_event(
|
||||||
|
event_bus: &EventBus,
|
||||||
|
agent_id: &str,
|
||||||
|
generative: usize,
|
||||||
|
consumptive: usize,
|
||||||
|
ratio: f32,
|
||||||
|
) {
|
||||||
event_bus.send(crate::core::nervous::SensorEvent {
|
event_bus.send(crate::core::nervous::SensorEvent {
|
||||||
sensor_name: "energy".into(),
|
sensor_name: "energy".into(),
|
||||||
timestamp: chrono::Utc::now(),
|
timestamp: chrono::Utc::now(),
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,10 @@ fn subscription_matches(subscriptions: &[String], sensor_name: &str) -> bool {
|
||||||
/// data subscriptions — discovery and directed summons must arrive. The
|
/// data subscriptions — discovery and directed summons must arrive. The
|
||||||
/// receiving side filters by `target`, so a broadcast is safe.
|
/// receiving side filters by `target`, so a broadcast is safe.
|
||||||
fn is_control_event(sensor_name: &str) -> bool {
|
fn is_control_event(sensor_name: &str) -> bool {
|
||||||
matches!(sensor_name, "federation" | "summon_request" | "summon_response")
|
matches!(
|
||||||
|
sensor_name,
|
||||||
|
"federation" | "summon_request" | "summon_response"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exponential backoff — 1s, 2s, 4s … capped at 60s, plus up to 1s jitter.
|
/// Exponential backoff — 1s, 2s, 4s … capped at 60s, plus up to 1s jitter.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
#![allow(dead_code)] // WIP scaffolding not yet wired
|
#![allow(dead_code)] // WIP scaffolding not yet wired
|
||||||
//! Gitea HTTP API Client
|
//! Gitea HTTP API Client
|
||||||
//!
|
//!
|
||||||
//! Replaces libgit2 for server operations - all git ops go through Gitea REST API.
|
//! Replaces libgit2 for server operations - all git ops go through Gitea REST API.
|
||||||
//! This is Send-safe and follows the external-memfs pattern.
|
//! This is Send-safe and follows the external-memfs pattern.
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct GiteaClient {
|
pub struct GiteaClient {
|
||||||
|
|
@ -78,7 +78,8 @@ impl GiteaClient {
|
||||||
self.username
|
self.username
|
||||||
.get_or_try_init(|| async {
|
.get_or_try_init(|| async {
|
||||||
let url = format!("{}/api/v1/user", self.base_url);
|
let url = format!("{}/api/v1/user", self.base_url);
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -89,11 +90,12 @@ impl GiteaClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: serde_json::Value = resp.json().await?;
|
let user: serde_json::Value = resp.json().await?;
|
||||||
let username = user.get("login")
|
let username = user
|
||||||
|
.get("login")
|
||||||
.and_then(|l| l.as_str())
|
.and_then(|l| l.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| "souveraine".to_string());
|
.unwrap_or_else(|| "souveraine".to_string());
|
||||||
|
|
||||||
Ok::<String, anyhow::Error>(username)
|
Ok::<String, anyhow::Error>(username)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -113,7 +115,8 @@ impl GiteaClient {
|
||||||
private: true,
|
private: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.json(&body)
|
.json(&body)
|
||||||
|
|
@ -130,9 +133,15 @@ impl GiteaClient {
|
||||||
/// Check if repository exists
|
/// Check if repository exists
|
||||||
pub async fn repo_exists(&self, agent_id: &str) -> Result<bool> {
|
pub async fn repo_exists(&self, agent_id: &str) -> Result<bool> {
|
||||||
let owner = self.get_username().await?;
|
let owner = self.get_username().await?;
|
||||||
let url = format!("{}/api/v1/repos/{}/{}", self.base_url, owner, self.repo_name(agent_id));
|
let url = format!(
|
||||||
|
"{}/api/v1/repos/{}/{}",
|
||||||
|
self.base_url,
|
||||||
|
owner,
|
||||||
|
self.repo_name(agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -145,9 +154,13 @@ impl GiteaClient {
|
||||||
pub async fn get_file(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
|
pub async fn get_file(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
|
||||||
let owner = self.get_username().await?;
|
let owner = self.get_username().await?;
|
||||||
let repo = self.repo_name(agent_id);
|
let repo = self.repo_name(agent_id);
|
||||||
let url = format!("{}/api/v1/repos/{}/{}/raw/HEAD/{}", self.base_url, owner, repo, path);
|
let url = format!(
|
||||||
|
"{}/api/v1/repos/{}/{}/raw/HEAD/{}",
|
||||||
|
self.base_url, owner, repo, path
|
||||||
|
);
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -168,9 +181,13 @@ impl GiteaClient {
|
||||||
async fn get_file_sha(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
|
async fn get_file_sha(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
|
||||||
let owner = self.get_username().await?;
|
let owner = self.get_username().await?;
|
||||||
let repo = self.repo_name(agent_id);
|
let repo = self.repo_name(agent_id);
|
||||||
let url = format!("{}/api/v1/repos/{}/{}/contents/{}", self.base_url, owner, repo, path);
|
let url = format!(
|
||||||
|
"{}/api/v1/repos/{}/{}/contents/{}",
|
||||||
|
self.base_url, owner, repo, path
|
||||||
|
);
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -185,18 +202,30 @@ impl GiteaClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let info: serde_json::Value = resp.json().await?;
|
let info: serde_json::Value = resp.json().await?;
|
||||||
Ok(info.get("sha").and_then(|s| s.as_str()).map(|s| s.to_string()))
|
Ok(info
|
||||||
|
.get("sha")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create or update file
|
/// Create or update file
|
||||||
pub async fn put_file(&self, agent_id: &str, path: &str, content: &str, message: &str) -> Result<()> {
|
pub async fn put_file(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
path: &str,
|
||||||
|
content: &str,
|
||||||
|
message: &str,
|
||||||
|
) -> Result<()> {
|
||||||
let owner = self.get_username().await?;
|
let owner = self.get_username().await?;
|
||||||
let repo = self.repo_name(agent_id);
|
let repo = self.repo_name(agent_id);
|
||||||
let url = format!("{}/api/v1/repos/{}/{}/contents/{}", self.base_url, owner, repo, path);
|
let url = format!(
|
||||||
|
"{}/api/v1/repos/{}/{}/contents/{}",
|
||||||
|
self.base_url, owner, repo, path
|
||||||
|
);
|
||||||
|
|
||||||
// Check if file exists first
|
// Check if file exists first
|
||||||
let existing_sha = self.get_file_sha(agent_id, path).await?;
|
let existing_sha = self.get_file_sha(agent_id, path).await?;
|
||||||
|
|
||||||
let encoded_content = base64::engine::general_purpose::STANDARD.encode(content.as_bytes());
|
let encoded_content = base64::engine::general_purpose::STANDARD.encode(content.as_bytes());
|
||||||
|
|
||||||
let resp = if let Some(sha) = existing_sha {
|
let resp = if let Some(sha) = existing_sha {
|
||||||
|
|
@ -227,7 +256,10 @@ impl GiteaClient {
|
||||||
};
|
};
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(anyhow!("Failed to put file: {}", resp.text().await.unwrap_or_default()));
|
return Err(anyhow!(
|
||||||
|
"Failed to put file: {}",
|
||||||
|
resp.text().await.unwrap_or_default()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -237,9 +269,13 @@ impl GiteaClient {
|
||||||
pub async fn list_files(&self, agent_id: &str, path: &str) -> Result<Vec<String>> {
|
pub async fn list_files(&self, agent_id: &str, path: &str) -> Result<Vec<String>> {
|
||||||
let owner = self.get_username().await?;
|
let owner = self.get_username().await?;
|
||||||
let repo = self.repo_name(agent_id);
|
let repo = self.repo_name(agent_id);
|
||||||
let url = format!("{}/api/v1/repos/{}/{}/contents/{}", self.base_url, owner, repo, path);
|
let url = format!(
|
||||||
|
"{}/api/v1/repos/{}/{}/contents/{}",
|
||||||
|
self.base_url, owner, repo, path
|
||||||
|
);
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -254,9 +290,14 @@ impl GiteaClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let files: Vec<serde_json::Value> = resp.json().await?;
|
let files: Vec<serde_json::Value> = resp.json().await?;
|
||||||
Ok(files.iter()
|
Ok(files
|
||||||
|
.iter()
|
||||||
.filter(|f| f.get("type").and_then(|t| t.as_str()) == Some("file"))
|
.filter(|f| f.get("type").and_then(|t| t.as_str()) == Some("file"))
|
||||||
.filter_map(|f| f.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
|
.filter_map(|f| {
|
||||||
|
f.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,9 +305,13 @@ impl GiteaClient {
|
||||||
pub async fn get_commits(&self, agent_id: &str, limit: usize) -> Result<Vec<(String, String)>> {
|
pub async fn get_commits(&self, agent_id: &str, limit: usize) -> Result<Vec<(String, String)>> {
|
||||||
let owner = self.get_username().await?;
|
let owner = self.get_username().await?;
|
||||||
let repo = self.repo_name(agent_id);
|
let repo = self.repo_name(agent_id);
|
||||||
let url = format!("{}/api/v1/repos/{}/{}/commits?limit={}", self.base_url, owner, repo, limit);
|
let url = format!(
|
||||||
|
"{}/api/v1/repos/{}/{}/commits?limit={}",
|
||||||
|
self.base_url, owner, repo, limit
|
||||||
|
);
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -277,7 +322,8 @@ impl GiteaClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let commits: Vec<serde_json::Value> = resp.json().await?;
|
let commits: Vec<serde_json::Value> = resp.json().await?;
|
||||||
Ok(commits.iter()
|
Ok(commits
|
||||||
|
.iter()
|
||||||
.filter_map(|c| {
|
.filter_map(|c| {
|
||||||
let sha = c.get("sha")?.as_str()?.to_string();
|
let sha = c.get("sha")?.as_str()?.to_string();
|
||||||
let msg = c.get("commit")?.get("message")?.as_str()?.to_string();
|
let msg = c.get("commit")?.get("message")?.as_str()?.to_string();
|
||||||
|
|
|
||||||
|
|
@ -22,32 +22,31 @@ pub struct GiteaMemory {
|
||||||
|
|
||||||
impl GiteaMemory {
|
impl GiteaMemory {
|
||||||
/// Create new GiteaMemory from config
|
/// Create new GiteaMemory from config
|
||||||
///
|
///
|
||||||
/// Expects config.gitea.url and config.gitea.token
|
/// Expects config.gitea.url and config.gitea.token
|
||||||
pub async fn new(config: Arc<RwLock<ConsciousnessConfig>>) -> Result<Self> {
|
pub async fn new(config: Arc<RwLock<ConsciousnessConfig>>) -> Result<Self> {
|
||||||
let cfg = config.read().await;
|
let cfg = config.read().await;
|
||||||
|
|
||||||
// Get Gitea URL from config - fall back to env var or default
|
// Get Gitea URL from config - fall back to env var or default
|
||||||
let gitea_url = std::env::var("SOUVERAINE_GITEA_URL")
|
let gitea_url = std::env::var("SOUVERAINE_GITEA_URL").unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| {
|
// Default to localhost Gitea
|
||||||
// Default to localhost Gitea
|
"http://localhost:3000".to_string()
|
||||||
"http://localhost:3000".to_string()
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let gitea_token = std::env::var("SOUVERAINE_GITEA_TOKEN")
|
let gitea_token = std::env::var("SOUVERAINE_GITEA_TOKEN")
|
||||||
.map_err(|_| anyhow!("SOUVERAINE_GITEA_TOKEN env var required for server mode"))?;
|
.map_err(|_| anyhow!("SOUVERAINE_GITEA_TOKEN env var required for server mode"))?;
|
||||||
|
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
let client = GiteaClient::new(gitea_url, gitea_token);
|
let client = GiteaClient::new(gitea_url, gitea_token);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
client,
|
||||||
config,
|
config,
|
||||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read file from Gitea
|
/// Read file from Gitea
|
||||||
pub async fn read(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
|
pub async fn read(&self, agent_id: &str, path: &str) -> Result<Option<String>> {
|
||||||
// Try cache first
|
// Try cache first
|
||||||
|
|
@ -58,7 +57,7 @@ impl GiteaMemory {
|
||||||
return Ok(Some(content.clone()));
|
return Ok(Some(content.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from Gitea
|
// Fetch from Gitea
|
||||||
match self.client.get_file(agent_id, path).await? {
|
match self.client.get_file(agent_id, path).await? {
|
||||||
Some(content) => {
|
Some(content) => {
|
||||||
|
|
@ -70,26 +69,28 @@ impl GiteaMemory {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write file to Gitea
|
/// Write file to Gitea
|
||||||
pub async fn write(&self, agent_id: &str, path: &str, content: &str) -> Result<()> {
|
pub async fn write(&self, agent_id: &str, path: &str, content: &str) -> Result<()> {
|
||||||
let cache_key = format!("{}/{}", agent_id, path);
|
let cache_key = format!("{}/{}", agent_id, path);
|
||||||
|
|
||||||
// Ensure repo exists
|
// Ensure repo exists
|
||||||
if !self.client.repo_exists(agent_id).await? {
|
if !self.client.repo_exists(agent_id).await? {
|
||||||
self.client.create_repo(agent_id).await?;
|
self.client.create_repo(agent_id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to Gitea
|
// Write to Gitea
|
||||||
self.client.put_file(agent_id, path, content, &format!("Update {}", path)).await?;
|
self.client
|
||||||
|
.put_file(agent_id, path, content, &format!("Update {}", path))
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
let mut cache = self.cache.write().await;
|
let mut cache = self.cache.write().await;
|
||||||
cache.insert(cache_key, content.to_string());
|
cache.insert(cache_key, content.to_string());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append to file in Gitea
|
/// Append to file in Gitea
|
||||||
pub async fn append(&self, agent_id: &str, path: &str, content: &str) -> Result<()> {
|
pub async fn append(&self, agent_id: &str, path: &str, content: &str) -> Result<()> {
|
||||||
let existing = self.read(agent_id, path).await?;
|
let existing = self.read(agent_id, path).await?;
|
||||||
|
|
@ -104,23 +105,23 @@ impl GiteaMemory {
|
||||||
};
|
};
|
||||||
self.write(agent_id, path, &new_content).await
|
self.write(agent_id, path, &new_content).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List files in agent's memory
|
/// List files in agent's memory
|
||||||
pub async fn list(&self, agent_id: &str, path: &str) -> Result<Vec<String>> {
|
pub async fn list(&self, agent_id: &str, path: &str) -> Result<Vec<String>> {
|
||||||
self.client.list_files(agent_id, path).await
|
self.client.list_files(agent_id, path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync all memory from Gitea to local cache
|
/// Sync all memory from Gitea to local cache
|
||||||
pub async fn sync_from_gitea(&self, agent_id: &str) -> Result<Vec<(String, String)>> {
|
pub async fn sync_from_gitea(&self, agent_id: &str) -> Result<Vec<(String, String)>> {
|
||||||
let files = self.list(agent_id, "").await?;
|
let files = self.list(agent_id, "").await?;
|
||||||
let mut synced = Vec::new();
|
let mut synced = Vec::new();
|
||||||
|
|
||||||
for file in files {
|
for file in files {
|
||||||
if let Some(content) = self.read(agent_id, &file).await? {
|
if let Some(content) = self.read(agent_id, &file).await? {
|
||||||
synced.push((file, content));
|
synced.push((file, content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(synced)
|
Ok(synced)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ pub struct LiteListener {
|
||||||
pub auto_wake: bool,
|
pub auto_wake: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl LiteListener {
|
impl LiteListener {
|
||||||
/// Build the listener: load the seed identity, start the federation
|
/// Build the listener: load the seed identity, start the federation
|
||||||
/// bridge to configured peers, and spawn the summon-wake watcher.
|
/// bridge to configured peers, and spawn the summon-wake watcher.
|
||||||
|
|
@ -89,11 +88,15 @@ impl LiteListener {
|
||||||
// event's `event_type`. A summon signed by an agent we host is
|
// event's `event_type`. A summon signed by an agent we host is
|
||||||
// a genuine self-extension (reach) and bypasses the consent
|
// a genuine self-extension (reach) and bypasses the consent
|
||||||
// floor; anything else must be an authorized summoner (consult).
|
// floor; anything else must be an authorized summoner (consult).
|
||||||
let field = |k: &str| event.payload.as_ref()
|
let field = |k: &str| {
|
||||||
.and_then(|p| p.get(k))
|
event
|
||||||
.and_then(|v| v.as_str())
|
.payload
|
||||||
.unwrap_or("")
|
.as_ref()
|
||||||
.to_string();
|
.and_then(|p| p.get(k))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
let request_id = field("request_id");
|
let request_id = field("request_id");
|
||||||
let prompt = field("prompt");
|
let prompt = field("prompt");
|
||||||
let agent_pubkey = field("agent_pubkey");
|
let agent_pubkey = field("agent_pubkey");
|
||||||
|
|
@ -101,8 +104,12 @@ impl LiteListener {
|
||||||
let target = event.target.clone().unwrap_or_default();
|
let target = event.target.clone().unwrap_or_default();
|
||||||
|
|
||||||
if !verify_summon(
|
if !verify_summon(
|
||||||
&agent_pubkey, &agent_sig, &request_id,
|
&agent_pubkey,
|
||||||
&event.event_type, &target, &prompt,
|
&agent_sig,
|
||||||
|
&request_id,
|
||||||
|
&event.event_type,
|
||||||
|
&target,
|
||||||
|
&prompt,
|
||||||
) {
|
) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
request_id = %request_id,
|
request_id = %request_id,
|
||||||
|
|
@ -112,8 +119,8 @@ impl LiteListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_self = self.hosted_agent_pubkeys.iter().any(|p| p == &agent_pubkey);
|
let is_self = self.hosted_agent_pubkeys.iter().any(|p| p == &agent_pubkey);
|
||||||
let authorized = is_self
|
let authorized =
|
||||||
|| self.authorized_summoners.iter().any(|s| s == &agent_pubkey);
|
is_self || self.authorized_summoners.iter().any(|s| s == &agent_pubkey);
|
||||||
if !authorized {
|
if !authorized {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
summoner = %agent_pubkey,
|
summoner = %agent_pubkey,
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ use crate::bridge::{build_registry, ProviderRegistry};
|
||||||
use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock};
|
use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock};
|
||||||
use crate::core::config::ConsciousnessConfig;
|
use crate::core::config::ConsciousnessConfig;
|
||||||
use crate::server::gitea_memory::GiteaMemory;
|
use crate::server::gitea_memory::GiteaMemory;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::AtomicU64;
|
use std::sync::atomic::AtomicU64;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
|
@ -113,10 +113,7 @@ impl Default for TurnSignals {
|
||||||
impl SouveraineServer {
|
impl SouveraineServer {
|
||||||
pub async fn new(mut config: ConsciousnessConfig) -> anyhow::Result<Self> {
|
pub async fn new(mut config: ConsciousnessConfig) -> anyhow::Result<Self> {
|
||||||
config.normalize_providers();
|
config.normalize_providers();
|
||||||
let data_dir = dirs::home_dir()
|
let data_dir = dirs::home_dir().unwrap().join(".souveraine").join("server");
|
||||||
.unwrap()
|
|
||||||
.join(".souveraine")
|
|
||||||
.join("server");
|
|
||||||
|
|
||||||
tokio::fs::create_dir_all(&data_dir).await?;
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
tokio::fs::create_dir_all(data_dir.join("agents")).await?;
|
tokio::fs::create_dir_all(data_dir.join("agents")).await?;
|
||||||
|
|
@ -145,13 +142,15 @@ impl SouveraineServer {
|
||||||
let agents_for_tick = agents.clone();
|
let agents_for_tick = agents.clone();
|
||||||
let id_for_tick = instance_id.clone();
|
let id_for_tick = instance_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(
|
let mut interval =
|
||||||
std::time::Duration::from_secs(TICK_SECONDS as u64),
|
tokio::time::interval(std::time::Duration::from_secs(TICK_SECONDS as u64));
|
||||||
);
|
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(e) = agents_for_tick.heartbeat_instance(&id_for_tick, TICK_SECONDS).await {
|
if let Err(e) = agents_for_tick
|
||||||
|
.heartbeat_instance(&id_for_tick, TICK_SECONDS)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!("instance heartbeat failed: {}", e);
|
tracing::warn!("instance heartbeat failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -184,9 +183,11 @@ impl SouveraineServer {
|
||||||
let s = comp_session.clone();
|
let s = comp_session.clone();
|
||||||
Arc::new(move |agent_id, messages| {
|
Arc::new(move |agent_id, messages| {
|
||||||
let conv_ids = s.list_for_agent(agent_id);
|
let conv_ids = s.list_for_agent(agent_id);
|
||||||
let conv_id = conv_ids.last()
|
let conv_id = conv_ids
|
||||||
|
.last()
|
||||||
.ok_or_else(|| anyhow::anyhow!("No session for {}", agent_id))?;
|
.ok_or_else(|| anyhow::anyhow!("No session for {}", agent_id))?;
|
||||||
let mut session = s.get_mut(conv_id)
|
let mut session = s
|
||||||
|
.get_mut(conv_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
||||||
session.messages = messages;
|
session.messages = messages;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -203,14 +204,13 @@ impl SouveraineServer {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
|
let get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> = Arc::new(|id| {
|
||||||
Arc::new(|id| {
|
if id.ends_with("-sub") {
|
||||||
if id.ends_with("-sub") {
|
Some("subconscious".to_string())
|
||||||
Some("subconscious".to_string())
|
} else {
|
||||||
} else {
|
Some("primary".to_string())
|
||||||
Some("primary".to_string())
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
let get_agent_provider: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
|
let get_agent_provider: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
|
||||||
Arc::new(|id| {
|
Arc::new(|id| {
|
||||||
let primary_id = if id.ends_with("-sub") {
|
let primary_id = if id.ends_with("-sub") {
|
||||||
|
|
@ -236,7 +236,11 @@ impl SouveraineServer {
|
||||||
config: app_cfg,
|
config: app_cfg,
|
||||||
counter: crate::bridge::model_router::TokenCounter::new(),
|
counter: crate::bridge::model_router::TokenCounter::new(),
|
||||||
providers: Some(providers.clone()),
|
providers: Some(providers.clone()),
|
||||||
model: config.compaction.model.clone().or_else(|| config.subconscious.model.clone()),
|
model: config
|
||||||
|
.compaction
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.or_else(|| config.subconscious.model.clone()),
|
||||||
clock: Arc::new(UtcClock),
|
clock: Arc::new(UtcClock),
|
||||||
get_messages,
|
get_messages,
|
||||||
replace_messages,
|
replace_messages,
|
||||||
|
|
@ -281,10 +285,9 @@ impl SouveraineServer {
|
||||||
// user-tier seed as a loud fallback. Never generates — a box with no
|
// user-tier seed as a loud fallback. Never generates — a box with no
|
||||||
// identity runs federation-less with a warning, it does not silently
|
// identity runs federation-less with a warning, it does not silently
|
||||||
// mint a key.
|
// mint a key.
|
||||||
let souveraine_base = dirs::home_dir()
|
let souveraine_base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||||
.unwrap_or_default()
|
let machine_signer = match crate::machined::signer::MachineSigner::resolve(&souveraine_base)
|
||||||
.join(".souveraine");
|
{
|
||||||
let machine_signer = match crate::machined::signer::MachineSigner::resolve(&souveraine_base) {
|
|
||||||
Ok(signer) => Some(Arc::new(signer)),
|
Ok(signer) => Some(Arc::new(signer)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|
@ -323,15 +326,13 @@ impl SouveraineServer {
|
||||||
// ── Summon handler ──
|
// ── Summon handler ──
|
||||||
let summon_handler = match (&local_seed_id, &machine_signer) {
|
let summon_handler = match (&local_seed_id, &machine_signer) {
|
||||||
(Some(seed_id), Some(signer)) => {
|
(Some(seed_id), Some(signer)) => {
|
||||||
let handler = Arc::new(
|
let handler = Arc::new(summon_handler::SummonHandler::new(
|
||||||
summon_handler::SummonHandler::new(
|
seed_id.clone(),
|
||||||
seed_id.clone(),
|
event_bus.clone(),
|
||||||
event_bus.clone(),
|
signer.clone(),
|
||||||
signer.clone(),
|
souveraine_base.clone(),
|
||||||
souveraine_base.clone(),
|
config.federation.auto_wake,
|
||||||
config.federation.auto_wake,
|
));
|
||||||
),
|
|
||||||
);
|
|
||||||
handler.spawn_listener();
|
handler.spawn_listener();
|
||||||
Some(handler)
|
Some(handler)
|
||||||
}
|
}
|
||||||
|
|
@ -356,7 +357,9 @@ impl SouveraineServer {
|
||||||
local_seed_id,
|
local_seed_id,
|
||||||
machine_signer,
|
machine_signer,
|
||||||
turn_signals: Arc::new(dashmap::DashMap::new()),
|
turn_signals: Arc::new(dashmap::DashMap::new()),
|
||||||
sensorium: Arc::new(Mutex::new(crate::core::sensorium::SensoriumCoordinator::new())),
|
sensorium: Arc::new(Mutex::new(
|
||||||
|
crate::core::sensorium::SensoriumCoordinator::new(),
|
||||||
|
)),
|
||||||
surface_conversations: Arc::new(StdMutex::new(HashMap::new())),
|
surface_conversations: Arc::new(StdMutex::new(HashMap::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -388,7 +391,10 @@ impl SouveraineServer {
|
||||||
let config = self.app_config.read().await;
|
let config = self.app_config.read().await;
|
||||||
let platform_prompt = config.agent.system_prompt.clone();
|
let platform_prompt = config.agent.system_prompt.clone();
|
||||||
// Visual state greeting (atmosphere + outfit), when presence is set.
|
// Visual state greeting (atmosphere + outfit), when presence is set.
|
||||||
let greeting_extra = config.presence.atmosphere.as_deref()
|
let greeting_extra = config
|
||||||
|
.presence
|
||||||
|
.atmosphere
|
||||||
|
.as_deref()
|
||||||
.filter(|a| !a.is_empty())
|
.filter(|a| !a.is_empty())
|
||||||
.map(|atm| {
|
.map(|atm| {
|
||||||
let display = atm.replace('_', " ");
|
let display = atm.replace('_', " ");
|
||||||
|
|
@ -489,8 +495,10 @@ impl SouveraineServer {
|
||||||
// ~/.souveraine/.summon-pending/. Re-fire them onto the bus so the
|
// ~/.souveraine/.summon-pending/. Re-fire them onto the bus so the
|
||||||
// full engine's SummonHandler picks them up, then clear the files.
|
// full engine's SummonHandler picks them up, then clear the files.
|
||||||
{
|
{
|
||||||
let pending_dir = dirs::home_dir().unwrap_or_default()
|
let pending_dir = dirs::home_dir()
|
||||||
.join(".souveraine").join(".summon-pending");
|
.unwrap_or_default()
|
||||||
|
.join(".souveraine")
|
||||||
|
.join(".summon-pending");
|
||||||
if let Ok(entries) = std::fs::read_dir(&pending_dir) {
|
if let Ok(entries) = std::fs::read_dir(&pending_dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
@ -498,7 +506,9 @@ impl SouveraineServer {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
if let Ok(event) = serde_json::from_str::<crate::core::nervous::SensorEvent>(&content) {
|
if let Ok(event) =
|
||||||
|
serde_json::from_str::<crate::core::nervous::SensorEvent>(&content)
|
||||||
|
{
|
||||||
self.event_bus.send(event);
|
self.event_bus.send(event);
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
tracing::info!(file = ?path, "drained parked summon");
|
tracing::info!(file = ?path, "drained parked summon");
|
||||||
|
|
@ -520,4 +530,3 @@ impl SouveraineServer {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#![allow(dead_code)] // WIP scaffolding not yet wired
|
#![allow(dead_code)] // WIP scaffolding not yet wired
|
||||||
|
use crate::api::models::StreamEvent;
|
||||||
use crate::core::conversation::{ConversationRecord, ConversationStore};
|
use crate::core::conversation::{ConversationRecord, ConversationStore};
|
||||||
use crate::core::session::ConversationMessage;
|
use crate::core::session::ConversationMessage;
|
||||||
use crate::api::models::StreamEvent;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -50,9 +50,7 @@ impl SessionManager {
|
||||||
Self {
|
Self {
|
||||||
sessions: DashMap::new(),
|
sessions: DashMap::new(),
|
||||||
agent_conversations: DashMap::new(),
|
agent_conversations: DashMap::new(),
|
||||||
store: Some(Arc::new(ConversationStoreHandle {
|
store: Some(Arc::new(ConversationStoreHandle { agents_dir })),
|
||||||
agents_dir,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,10 +77,7 @@ impl SessionManager {
|
||||||
.push(conversation_id.clone());
|
.push(conversation_id.clone());
|
||||||
|
|
||||||
if let Some(handle) = &self.store {
|
if let Some(handle) = &self.store {
|
||||||
let record = ConversationRecord::new(
|
let record = ConversationRecord::new(conversation_id.clone(), agent_id.to_string());
|
||||||
conversation_id.clone(),
|
|
||||||
agent_id.to_string(),
|
|
||||||
);
|
|
||||||
let store = handle.store_for(agent_id);
|
let store = handle.store_for(agent_id);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = store.save_metadata(&record).await {
|
if let Err(e) = store.save_metadata(&record).await {
|
||||||
|
|
@ -102,7 +97,13 @@ impl SessionManager {
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
messages: Vec<ConversationMessage>,
|
messages: Vec<ConversationMessage>,
|
||||||
) -> String {
|
) -> String {
|
||||||
self.create_with_messages_and_timestamps(agent_id, conversation_id, messages, Utc::now(), Utc::now())
|
self.create_with_messages_and_timestamps(
|
||||||
|
agent_id,
|
||||||
|
conversation_id,
|
||||||
|
messages,
|
||||||
|
Utc::now(),
|
||||||
|
Utc::now(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `create_with_messages` but preserves the persisted timestamps so
|
/// Like `create_with_messages` but preserves the persisted timestamps so
|
||||||
|
|
@ -143,16 +144,27 @@ impl SessionManager {
|
||||||
conversation_id
|
conversation_id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, conversation_id: &str) -> Option<dashmap::mapref::one::Ref<'_, String, Session>> {
|
pub fn get(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Option<dashmap::mapref::one::Ref<'_, String, Session>> {
|
||||||
self.sessions.get(conversation_id)
|
self.sessions.get(conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_mut(&self, conversation_id: &str) -> Option<dashmap::mapref::one::RefMut<'_, String, Session>> {
|
pub fn get_mut(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Option<dashmap::mapref::one::RefMut<'_, String, Session>> {
|
||||||
self.sessions.get_mut(conversation_id)
|
self.sessions.get_mut(conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_message(&self, conversation_id: &str, message: ConversationMessage) -> anyhow::Result<()> {
|
pub fn add_message(
|
||||||
let mut session = self.sessions
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
message: ConversationMessage,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut session = self
|
||||||
|
.sessions
|
||||||
.get_mut(conversation_id)
|
.get_mut(conversation_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
|
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
|
||||||
|
|
||||||
|
|
@ -186,15 +198,20 @@ impl SessionManager {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe(&self, conversation_id: &str) -> anyhow::Result<broadcast::Receiver<StreamEvent>> {
|
pub fn subscribe(
|
||||||
let session = self.sessions
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> anyhow::Result<broadcast::Receiver<StreamEvent>> {
|
||||||
|
let session = self
|
||||||
|
.sessions
|
||||||
.get(conversation_id)
|
.get(conversation_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
||||||
Ok(session.event_sender.subscribe())
|
Ok(session.event_sender.subscribe())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn broadcast(&self, conversation_id: &str, event: StreamEvent) -> anyhow::Result<()> {
|
pub fn broadcast(&self, conversation_id: &str, event: StreamEvent) -> anyhow::Result<()> {
|
||||||
let session = self.sessions
|
let session = self
|
||||||
|
.sessions
|
||||||
.get(conversation_id)
|
.get(conversation_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
||||||
let _ = session.event_sender.send(event);
|
let _ = session.event_sender.send(event);
|
||||||
|
|
@ -202,7 +219,8 @@ impl SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_pressure(&self, conversation_id: &str, pressure: f32) -> anyhow::Result<()> {
|
pub fn update_pressure(&self, conversation_id: &str, pressure: f32) -> anyhow::Result<()> {
|
||||||
let mut session = self.sessions
|
let mut session = self
|
||||||
|
.sessions
|
||||||
.get_mut(conversation_id)
|
.get_mut(conversation_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
||||||
session.context_pressure = pressure;
|
session.context_pressure = pressure;
|
||||||
|
|
@ -322,7 +340,10 @@ mod tests {
|
||||||
assert_eq!(sessions.list_for_agent(agent_id), vec![conversation_id]);
|
assert_eq!(sessions.list_for_agent(agent_id), vec![conversation_id]);
|
||||||
let session = sessions.get(conversation_id).unwrap();
|
let session = sessions.get(conversation_id).unwrap();
|
||||||
assert_eq!(session.messages.len(), 1);
|
assert_eq!(session.messages.len(), 1);
|
||||||
assert_eq!(session.messages[0].role, crate::core::session::MessageRole::User);
|
assert_eq!(
|
||||||
|
session.messages[0].role,
|
||||||
|
crate::core::session::MessageRole::User
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session.messages[0].blocks,
|
session.messages[0].blocks,
|
||||||
vec![crate::core::session::ContentBlock::Text {
|
vec![crate::core::session::ContentBlock::Text {
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,9 @@ impl SubagentRunner for ServerSubagentRunner {
|
||||||
std::env::current_dir().ok(),
|
std::env::current_dir().ok(),
|
||||||
params.memory_root.clone(),
|
params.memory_root.clone(),
|
||||||
std::env::vars().collect(),
|
std::env::vars().collect(),
|
||||||
Some(Arc::new(ServerSubagentRunner::new(self.server.clone())) as Arc<dyn SubagentRunner>),
|
Some(
|
||||||
|
Arc::new(ServerSubagentRunner::new(self.server.clone())) as Arc<dyn SubagentRunner>
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial messages: system prompt + user prompt
|
// Initial messages: system prompt + user prompt
|
||||||
|
|
@ -143,11 +145,17 @@ impl SubagentRunner for ServerSubagentRunner {
|
||||||
tools: Some(bifrost_tools.clone()),
|
tools: Some(bifrost_tools.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = self.server.providers.default_provider().chat_completion(req).await.map_err(|e| {
|
let response = self
|
||||||
crate::core::tools::defs::ToolError::invalid_input(&format!(
|
.server
|
||||||
"Subagent LLM call failed: {e}"
|
.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() {
|
if response.tool_calls.is_empty() {
|
||||||
final_content = response.content.clone();
|
final_content = response.content.clone();
|
||||||
|
|
@ -160,11 +168,13 @@ impl SubagentRunner for ServerSubagentRunner {
|
||||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
.map(|tc| {
|
||||||
tc.id.clone(),
|
crate::bridge::bifrost::MessageToolCall::function(
|
||||||
tc.name.clone(),
|
tc.id.clone(),
|
||||||
tc.arguments.to_string(),
|
tc.name.clone(),
|
||||||
))
|
tc.arguments.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
messages.push(
|
messages.push(
|
||||||
BifrostMessage::assistant_tool_calls(response.content.clone(), calls)
|
BifrostMessage::assistant_tool_calls(response.content.clone(), calls)
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,17 @@ use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::core::nervous::handler::TurnInjector;
|
use crate::core::nervous::handler::TurnInjector;
|
||||||
use crate::machined::signer::MachineSigner;
|
|
||||||
use crate::core::nervous::{EventBus, SensorEvent};
|
use crate::core::nervous::{EventBus, SensorEvent};
|
||||||
|
use crate::machined::signer::MachineSigner;
|
||||||
|
|
||||||
const RESPONSE_TIMEOUT_SECS: u64 = 60;
|
const RESPONSE_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RequestMeta {
|
pub struct RequestMeta {
|
||||||
pub request_id: String,
|
pub request_id: String,
|
||||||
pub tool: String, // "reach" or "consult"
|
pub tool: String, // "reach" or "consult"
|
||||||
pub target_seed_id: String,
|
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 issued_at: chrono::DateTime<Utc>,
|
||||||
pub responded: bool,
|
pub responded: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -148,9 +148,14 @@ impl SummonHandler {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
let outbox = self.souveraine_base
|
let outbox = self
|
||||||
.join("server").join("agents").join(&agent_id)
|
.souveraine_base
|
||||||
.join("memory").join("federation").join("outbox");
|
.join("server")
|
||||||
|
.join("agents")
|
||||||
|
.join(&agent_id)
|
||||||
|
.join("memory")
|
||||||
|
.join("federation")
|
||||||
|
.join("outbox");
|
||||||
let entries = match std::fs::read_dir(&outbox) {
|
let entries = match std::fs::read_dir(&outbox) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
|
|
@ -212,13 +217,20 @@ impl SummonHandler {
|
||||||
|
|
||||||
let declared = event.event_type.clone(); // "reach" | "consult" — a claim
|
let declared = event.event_type.clone(); // "reach" | "consult" — a claim
|
||||||
let payload = event.payload.clone().unwrap_or_default();
|
let payload = event.payload.clone().unwrap_or_default();
|
||||||
let str_field = |k: &str| payload.get(k)
|
let str_field = |k: &str| {
|
||||||
.and_then(|v| v.as_str())
|
payload
|
||||||
.unwrap_or("")
|
.get(k)
|
||||||
.to_string();
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
let request_id = {
|
let request_id = {
|
||||||
let r = str_field("request_id");
|
let r = str_field("request_id");
|
||||||
if r.is_empty() { "unknown".to_string() } else { r }
|
if r.is_empty() {
|
||||||
|
"unknown".to_string()
|
||||||
|
} else {
|
||||||
|
r
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let prompt = str_field("prompt");
|
let prompt = str_field("prompt");
|
||||||
let agent_pubkey = str_field("agent_pubkey");
|
let agent_pubkey = str_field("agent_pubkey");
|
||||||
|
|
@ -230,7 +242,12 @@ impl SummonHandler {
|
||||||
// fields — a forged or tampered request fails here, silently
|
// fields — a forged or tampered request fails here, silently
|
||||||
// (no ack to an attacker).
|
// (no ack to an attacker).
|
||||||
if !crate::core::identity::verify_summon(
|
if !crate::core::identity::verify_summon(
|
||||||
&agent_pubkey, &agent_sig, &request_id, &declared, &target, &prompt,
|
&agent_pubkey,
|
||||||
|
&agent_sig,
|
||||||
|
&request_id,
|
||||||
|
&declared,
|
||||||
|
&target,
|
||||||
|
&prompt,
|
||||||
) {
|
) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
request_id = %request_id,
|
request_id = %request_id,
|
||||||
|
|
@ -247,8 +264,8 @@ impl SummonHandler {
|
||||||
// Classify by *identity*, not by the declared event field:
|
// Classify by *identity*, not by the declared event field:
|
||||||
// a summon whose agent key matches ours is genuinely self
|
// a summon whose agent key matches ours is genuinely self
|
||||||
// (reach); anything else is a separate being (consult).
|
// (reach); anything else is a separate being (consult).
|
||||||
let is_self = self.own_agent_pubkey(&agent_id).as_deref()
|
let is_self =
|
||||||
== Some(agent_pubkey.as_str());
|
self.own_agent_pubkey(&agent_id).as_deref() == Some(agent_pubkey.as_str());
|
||||||
let classified = if is_self { "reach" } else { "consult" };
|
let classified = if is_self { "reach" } else { "consult" };
|
||||||
if classified != declared {
|
if classified != declared {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
@ -292,7 +309,11 @@ impl SummonHandler {
|
||||||
// Write to the summoned agent's inbox, and remember who to
|
// Write to the summoned agent's inbox, and remember who to
|
||||||
// answer so a reply from the outbox can be routed home.
|
// answer so a reply from the outbox can be routed home.
|
||||||
{
|
{
|
||||||
let target_box = if classified == "reach" { "pending" } else { "intrusive" };
|
let target_box = if classified == "reach" {
|
||||||
|
"pending"
|
||||||
|
} else {
|
||||||
|
"intrusive"
|
||||||
|
};
|
||||||
match self.write_inbox(&agent_id, target_box, &event) {
|
match self.write_inbox(&agent_id, target_box, &event) {
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
|
|
@ -318,7 +339,8 @@ impl SummonHandler {
|
||||||
|
|
||||||
// Inbound: a response to a request we sent.
|
// Inbound: a response to a request we sent.
|
||||||
"summon_response" => {
|
"summon_response" => {
|
||||||
let request_id = event.payload
|
let request_id = event
|
||||||
|
.payload
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.get("request_id"))
|
.and_then(|p| p.get("request_id"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|
@ -332,10 +354,14 @@ impl SummonHandler {
|
||||||
|
|
||||||
// Route response to the appropriate inbox.
|
// Route response to the appropriate inbox.
|
||||||
if let Some(agent_id) = self.resolve_primary_agent() {
|
if let Some(agent_id) = self.resolve_primary_agent() {
|
||||||
let target_box = if tool == "reach" { "pending" } else { "intrusive" };
|
let target_box = if tool == "reach" {
|
||||||
if let Err(e) = self.write_inbox_response(
|
"pending"
|
||||||
&agent_id, target_box, request_id, &event,
|
} else {
|
||||||
) {
|
"intrusive"
|
||||||
|
};
|
||||||
|
if let Err(e) =
|
||||||
|
self.write_inbox_response(&agent_id, target_box, request_id, &event)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
"summon_handler: failed to write response to inbox"
|
"summon_handler: failed to write response to inbox"
|
||||||
|
|
@ -353,7 +379,9 @@ impl SummonHandler {
|
||||||
/// correlated when it arrives. Called from the bus listener when our own
|
/// correlated when it arrives. Called from the bus listener when our own
|
||||||
/// reach/consult tool fires a `summon_request`.
|
/// reach/consult tool fires a `summon_request`.
|
||||||
fn register_outbound(&self, event: &SensorEvent) {
|
fn register_outbound(&self, event: &SensorEvent) {
|
||||||
let request_id = match event.payload.as_ref()
|
let request_id = match event
|
||||||
|
.payload
|
||||||
|
.as_ref()
|
||||||
.and_then(|p| p.get("request_id"))
|
.and_then(|p| p.get("request_id"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
{
|
{
|
||||||
|
|
@ -363,27 +391,24 @@ impl SummonHandler {
|
||||||
if self.in_flight.contains_key(&request_id) {
|
if self.in_flight.contains_key(&request_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.in_flight.insert(request_id.clone(), RequestMeta {
|
self.in_flight.insert(
|
||||||
request_id,
|
request_id.clone(),
|
||||||
tool: event.event_type.clone(),
|
RequestMeta {
|
||||||
target_seed_id: event.target.clone().unwrap_or_default(),
|
request_id,
|
||||||
reply_to: self.local_seed_id.clone(),
|
tool: event.event_type.clone(),
|
||||||
issued_at: event.timestamp,
|
target_seed_id: event.target.clone().unwrap_or_default(),
|
||||||
responded: false,
|
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
|
/// Wake the summoned agent with a background turn so she picks up the
|
||||||
/// request now. No-op unless `auto_wake` is set and a `TurnInjector`
|
/// request now. No-op unless `auto_wake` is set and a `TurnInjector`
|
||||||
/// has been wired (the pure-server path has neither — the summon then
|
/// has been wired (the pure-server path has neither — the summon then
|
||||||
/// waits in the inbox for her next turn).
|
/// waits in the inbox for her next turn).
|
||||||
fn maybe_wake(
|
fn maybe_wake(&self, agent_id: &str, tool: &str, request_id: &str, summoner: Option<&str>) {
|
||||||
&self,
|
|
||||||
agent_id: &str,
|
|
||||||
tool: &str,
|
|
||||||
request_id: &str,
|
|
||||||
summoner: Option<&str>,
|
|
||||||
) {
|
|
||||||
if !self.auto_wake {
|
if !self.auto_wake {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -413,9 +438,13 @@ impl SummonHandler {
|
||||||
/// agent seed's `public.key`. No private key, no generation: classifying
|
/// agent seed's `public.key`. No private key, no generation: classifying
|
||||||
/// an inbound summon only needs to *verify*, never sign.
|
/// an inbound summon only needs to *verify*, never sign.
|
||||||
fn own_agent_pubkey(&self, agent_id: &str) -> Option<String> {
|
fn own_agent_pubkey(&self, agent_id: &str) -> Option<String> {
|
||||||
let path = self.souveraine_base
|
let path = self
|
||||||
.join("server").join("agents").join(agent_id)
|
.souveraine_base
|
||||||
.join("seed").join("public.key");
|
.join("server")
|
||||||
|
.join("agents")
|
||||||
|
.join(agent_id)
|
||||||
|
.join("seed")
|
||||||
|
.join("public.key");
|
||||||
let bytes = std::fs::read(&path).ok()?;
|
let bytes = std::fs::read(&path).ok()?;
|
||||||
if bytes.len() != 32 {
|
if bytes.len() != 32 {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -428,7 +457,8 @@ impl SummonHandler {
|
||||||
/// Consent is per-being — Sam is Sam on any of his machines — so it
|
/// Consent is per-being — Sam is Sam on any of his machines — so it
|
||||||
/// gates on agent identity, not the machine seed.
|
/// gates on agent identity, not the machine seed.
|
||||||
fn is_authorized_summoner(&self, agent_pubkey: &str) -> bool {
|
fn is_authorized_summoner(&self, agent_pubkey: &str) -> bool {
|
||||||
let path = self.souveraine_base
|
let path = self
|
||||||
|
.souveraine_base
|
||||||
.join("federation")
|
.join("federation")
|
||||||
.join("authorized-summoners.md");
|
.join("authorized-summoners.md");
|
||||||
match std::fs::read_to_string(&path) {
|
match std::fs::read_to_string(&path) {
|
||||||
|
|
@ -452,7 +482,8 @@ impl SummonHandler {
|
||||||
inbox_box: &str,
|
inbox_box: &str,
|
||||||
event: &SensorEvent,
|
event: &SensorEvent,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let inbox_path = self.souveraine_base
|
let inbox_path = self
|
||||||
|
.souveraine_base
|
||||||
.join("server")
|
.join("server")
|
||||||
.join("agents")
|
.join("agents")
|
||||||
.join(agent_id)
|
.join(agent_id)
|
||||||
|
|
@ -461,12 +492,14 @@ impl SummonHandler {
|
||||||
.join(inbox_box);
|
.join(inbox_box);
|
||||||
std::fs::create_dir_all(&inbox_path)?;
|
std::fs::create_dir_all(&inbox_path)?;
|
||||||
|
|
||||||
let request_id = event.payload
|
let request_id = event
|
||||||
|
.payload
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.get("request_id"))
|
.and_then(|p| p.get("request_id"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
let prompt = event.payload
|
let prompt = event
|
||||||
|
.payload
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.get("prompt"))
|
.and_then(|p| p.get("prompt"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|
@ -500,7 +533,8 @@ impl SummonHandler {
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
event: &SensorEvent,
|
event: &SensorEvent,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let inbox_path = self.souveraine_base
|
let inbox_path = self
|
||||||
|
.souveraine_base
|
||||||
.join("server")
|
.join("server")
|
||||||
.join("agents")
|
.join("agents")
|
||||||
.join(agent_id)
|
.join(agent_id)
|
||||||
|
|
@ -509,7 +543,8 @@ impl SummonHandler {
|
||||||
.join(inbox_box);
|
.join(inbox_box);
|
||||||
std::fs::create_dir_all(&inbox_path)?;
|
std::fs::create_dir_all(&inbox_path)?;
|
||||||
|
|
||||||
let result = event.payload
|
let result = event
|
||||||
|
.payload
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.get("result"))
|
.and_then(|p| p.get("result"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|
@ -552,7 +587,9 @@ impl SummonHandler {
|
||||||
/// Prune in_flight entries that have timed out.
|
/// Prune in_flight entries that have timed out.
|
||||||
fn prune_expired(&self) {
|
fn prune_expired(&self) {
|
||||||
let cutoff = Utc::now() - chrono::Duration::seconds(RESPONSE_TIMEOUT_SECS as i64);
|
let cutoff = Utc::now() - chrono::Duration::seconds(RESPONSE_TIMEOUT_SECS as i64);
|
||||||
let expired: Vec<String> = self.in_flight.iter()
|
let expired: Vec<String> = self
|
||||||
|
.in_flight
|
||||||
|
.iter()
|
||||||
.filter(|e| e.issued_at < cutoff)
|
.filter(|e| e.issued_at < cutoff)
|
||||||
.map(|e| e.request_id.clone())
|
.map(|e| e.request_id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -567,7 +604,11 @@ impl SummonHandler {
|
||||||
// Surface the timeout into the agent's inbox so the caller
|
// Surface the timeout into the agent's inbox so the caller
|
||||||
// learns the peer never answered — silence is information.
|
// learns the peer never answered — silence is information.
|
||||||
if let Some(agent_id) = self.resolve_primary_agent() {
|
if let Some(agent_id) = self.resolve_primary_agent() {
|
||||||
let target_box = if meta.tool == "reach" { "pending" } else { "intrusive" };
|
let target_box = if meta.tool == "reach" {
|
||||||
|
"pending"
|
||||||
|
} else {
|
||||||
|
"intrusive"
|
||||||
|
};
|
||||||
let timeout_event = SensorEvent {
|
let timeout_event = SensorEvent {
|
||||||
sensor_name: "summon_timeout".into(),
|
sensor_name: "summon_timeout".into(),
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
|
|
@ -588,7 +629,10 @@ impl SummonHandler {
|
||||||
};
|
};
|
||||||
self.event_bus.send(timeout_event.clone());
|
self.event_bus.send(timeout_event.clone());
|
||||||
if let Err(e) = self.write_inbox_response(
|
if let Err(e) = self.write_inbox_response(
|
||||||
&agent_id, target_box, &meta.request_id, &timeout_event,
|
&agent_id,
|
||||||
|
target_box,
|
||||||
|
&meta.request_id,
|
||||||
|
&timeout_event,
|
||||||
) {
|
) {
|
||||||
tracing::warn!(error = %e, "summon_handler: failed to write timeout to inbox");
|
tracing::warn!(error = %e, "summon_handler: failed to write timeout to inbox");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::bridge::bifrost::{ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage};
|
use crate::bridge::bifrost::{
|
||||||
|
ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage,
|
||||||
|
};
|
||||||
use crate::bridge::model_router::TokenCounter;
|
use crate::bridge::model_router::TokenCounter;
|
||||||
use crate::core::compact::CompactionEngine;
|
use crate::core::compact::CompactionEngine;
|
||||||
use crate::core::nervous::{EventBus, SensorEvent};
|
use crate::core::nervous::{EventBus, SensorEvent};
|
||||||
|
|
@ -22,8 +24,15 @@ use crate::server::subagent::ServerSubagentRunner;
|
||||||
/// accumulate inside a single turn. `context_limit` comes from the
|
/// accumulate inside a single turn. `context_limit` comes from the
|
||||||
/// agent's `llm_config.context_window` (Constitution V.3 — per-model
|
/// agent's `llm_config.context_window` (Constitution V.3 — per-model
|
||||||
/// physics, no hardcoded 128K).
|
/// physics, no hardcoded 128K).
|
||||||
fn bifrost_pressure(counter: &TokenCounter, messages: &[BifrostMessage], context_limit: usize) -> f32 {
|
fn bifrost_pressure(
|
||||||
let tokens: usize = messages.iter().map(|m| counter.count(&m.content.as_text())).sum();
|
counter: &TokenCounter,
|
||||||
|
messages: &[BifrostMessage],
|
||||||
|
context_limit: usize,
|
||||||
|
) -> f32 {
|
||||||
|
let tokens: usize = messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| counter.count(&m.content.as_text()))
|
||||||
|
.sum();
|
||||||
let limit = context_limit.max(1);
|
let limit = context_limit.max(1);
|
||||||
(tokens as f32 / limit as f32).min(1.0)
|
(tokens as f32 / limit as f32).min(1.0)
|
||||||
}
|
}
|
||||||
|
|
@ -70,10 +79,7 @@ fn migraine_text(severity: &str, reason: &str) -> String {
|
||||||
"[{} — a pressure behind my eyes. {}. I should slow down.]",
|
"[{} — a pressure behind my eyes. {}. I should slow down.]",
|
||||||
stamp, reason
|
stamp, reason
|
||||||
),
|
),
|
||||||
"critical" => format!(
|
"critical" => format!("[{} — the room tilts. Stop. {}.]", stamp, reason),
|
||||||
"[{} — the room tilts. Stop. {}.]",
|
|
||||||
stamp, reason
|
|
||||||
),
|
|
||||||
// "firm" and any unexpected value land here — the default migraine.
|
// "firm" and any unexpected value land here — the default migraine.
|
||||||
_ => format!(
|
_ => format!(
|
||||||
"[{} — a migraine. {}. I was about to do something I shouldn't.]",
|
"[{} — a migraine. {}. I was about to do something I shouldn't.]",
|
||||||
|
|
@ -124,7 +130,10 @@ pub(crate) async fn run_turn(
|
||||||
// If the model doesn't support images, strip Image blocks
|
// If the model doesn't support images, strip Image blocks
|
||||||
// and replace with text markers.
|
// and replace with text markers.
|
||||||
if !supports_images {
|
if !supports_images {
|
||||||
let has_images = m.blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
|
let has_images = m
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.any(|b| matches!(b, ContentBlock::Image { .. }));
|
||||||
if has_images {
|
if has_images {
|
||||||
let mut text_parts: Vec<&str> = Vec::new();
|
let mut text_parts: Vec<&str> = Vec::new();
|
||||||
for b in &m.blocks {
|
for b in &m.blocks {
|
||||||
|
|
@ -138,7 +147,11 @@ pub(crate) async fn run_turn(
|
||||||
}
|
}
|
||||||
let content = text_parts.join("\n");
|
let content = text_parts.join("\n");
|
||||||
// Append text markers for stripped images
|
// Append text markers for stripped images
|
||||||
let img_count = m.blocks.iter().filter(|b| matches!(b, ContentBlock::Image { .. })).count();
|
let img_count = m
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.filter(|b| matches!(b, ContentBlock::Image { .. }))
|
||||||
|
.count();
|
||||||
let mut enriched = content;
|
let mut enriched = content;
|
||||||
for _ in 0..img_count {
|
for _ in 0..img_count {
|
||||||
enriched.push_str("\n[Image: attached by user]");
|
enriched.push_str("\n[Image: attached by user]");
|
||||||
|
|
@ -148,23 +161,39 @@ pub(crate) async fn run_turn(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this message has image content blocks
|
// Check if this message has image content blocks
|
||||||
let has_images = m.blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
|
let has_images = m
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.any(|b| matches!(b, ContentBlock::Image { .. }));
|
||||||
|
|
||||||
if has_images {
|
if has_images {
|
||||||
// Build multimodal content parts (OpenAI multi-part format)
|
// Build multimodal content parts (OpenAI multi-part format)
|
||||||
let parts: Vec<ContentPart> = m.blocks.iter().filter_map(|b| match b {
|
let parts: Vec<ContentPart> = m
|
||||||
ContentBlock::Text { text } => Some(ContentPart::Text { text: text.clone() }),
|
.blocks
|
||||||
ContentBlock::Image { media_type, data } => {
|
.iter()
|
||||||
let url = format!("data:{media_type};base64,{data}");
|
.filter_map(|b| match b {
|
||||||
Some(ContentPart::ImageUrl { image_url: ImageUrlSource { url } })
|
ContentBlock::Text { text } => {
|
||||||
}
|
Some(ContentPart::Text { text: text.clone() })
|
||||||
_ => None,
|
}
|
||||||
}).collect();
|
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 {
|
let text_content = m
|
||||||
ContentBlock::Text { text } => Some(text.as_str()),
|
.blocks
|
||||||
_ => None,
|
.iter()
|
||||||
}).collect::<Vec<_>>().join("\n");
|
.filter_map(|b| match b {
|
||||||
|
ContentBlock::Text { text } => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
BifrostMessage::multimodal_user(text_content, parts)
|
BifrostMessage::multimodal_user(text_content, parts)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -201,7 +230,11 @@ pub(crate) async fn run_turn(
|
||||||
// Resolve the model's configured output limit + presence pulse settings.
|
// Resolve the model's configured output limit + presence pulse settings.
|
||||||
let (output_limit, pulse_enabled, pulse_interval) = {
|
let (output_limit, pulse_enabled, pulse_interval) = {
|
||||||
let cfg = server.app_config.read().await;
|
let cfg = server.app_config.read().await;
|
||||||
let out = cfg.models.get(&model).map(|m| m.output_limit as u32).unwrap_or(8192);
|
let out = cfg
|
||||||
|
.models
|
||||||
|
.get(&model)
|
||||||
|
.map(|m| m.output_limit as u32)
|
||||||
|
.unwrap_or(8192);
|
||||||
let p_on = cfg.presence.pulse_enabled;
|
let p_on = cfg.presence.pulse_enabled;
|
||||||
let p_iv = Duration::from_secs(cfg.presence.pulse_interval_secs.max(60));
|
let p_iv = Duration::from_secs(cfg.presence.pulse_interval_secs.max(60));
|
||||||
(out, p_on, p_iv)
|
(out, p_on, p_iv)
|
||||||
|
|
@ -219,15 +252,10 @@ pub(crate) async fn run_turn(
|
||||||
let memory_root_for_itin = memory_root.clone();
|
let memory_root_for_itin = memory_root.clone();
|
||||||
let cwd = std::env::current_dir().ok();
|
let cwd = std::env::current_dir().ok();
|
||||||
let env: Vec<(String, String)> = std::env::vars().collect();
|
let env: Vec<(String, String)> = std::env::vars().collect();
|
||||||
let subagent_runner = Some(Arc::new(ServerSubagentRunner::new(server.clone())) as Arc<dyn crate::core::tools::defs::SubagentRunner>);
|
let subagent_runner = Some(Arc::new(ServerSubagentRunner::new(server.clone()))
|
||||||
|
as Arc<dyn crate::core::tools::defs::SubagentRunner>);
|
||||||
|
|
||||||
let tool_ctx = ToolContext::for_agent(
|
let tool_ctx = ToolContext::for_agent(agent_id.clone(), cwd, memory_root, env, subagent_runner);
|
||||||
agent_id.clone(),
|
|
||||||
cwd,
|
|
||||||
memory_root,
|
|
||||||
env,
|
|
||||||
subagent_runner,
|
|
||||||
);
|
|
||||||
let tool_ctx = ToolContext {
|
let tool_ctx = ToolContext {
|
||||||
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
|
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
|
||||||
event_bus: Some(event_bus.clone()),
|
event_bus: Some(event_bus.clone()),
|
||||||
|
|
@ -267,7 +295,8 @@ pub(crate) async fn run_turn(
|
||||||
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
// Accumulator for mid-turn checkpoint blocks — one entry per tool call.
|
// Accumulator for mid-turn checkpoint blocks — one entry per tool call.
|
||||||
let mut checkpoint_blocks: Vec<crate::server::consciousness_engine::CheckpointToolBlock> = Vec::new();
|
let mut checkpoint_blocks: Vec<crate::server::consciousness_engine::CheckpointToolBlock> =
|
||||||
|
Vec::new();
|
||||||
|
|
||||||
// Announce this turn's lifecycle onto the nervous system so any
|
// Announce this turn's lifecycle onto the nervous system so any
|
||||||
// sensorium (Matrix, mobile) can drive itself off the event stream.
|
// sensorium (Matrix, mobile) can drive itself off the event stream.
|
||||||
|
|
@ -328,7 +357,9 @@ pub(crate) async fn run_turn(
|
||||||
"LLM call starting"
|
"LLM call starting"
|
||||||
);
|
);
|
||||||
let max_tokens = pressure_to_max_tokens(pressure, output_limit);
|
let max_tokens = pressure_to_max_tokens(pressure, output_limit);
|
||||||
let _ = tx.send(Ok(BackendEvent::ContextPressure(pressure, context_limit))).await;
|
let _ = tx
|
||||||
|
.send(Ok(BackendEvent::ContextPressure(pressure, context_limit)))
|
||||||
|
.await;
|
||||||
|
|
||||||
if max_rounds == 0 {
|
if max_rounds == 0 {
|
||||||
tracing::warn!("turn: max_rounds is 0 — sending NO tool definitions to model");
|
tracing::warn!("turn: max_rounds is 0 — sending NO tool definitions to model");
|
||||||
|
|
@ -368,12 +399,20 @@ pub(crate) async fn run_turn(
|
||||||
);
|
);
|
||||||
|
|
||||||
for event in &strain {
|
for event in &strain {
|
||||||
if let crate::bridge::bifrost::InferenceStrain::Transient { attempt, status, model, .. } = event {
|
if let crate::bridge::bifrost::InferenceStrain::Transient {
|
||||||
let _ = tx.send(Ok(BackendEvent::InferenceStrain {
|
attempt,
|
||||||
attempt: *attempt,
|
status,
|
||||||
status: *status,
|
model,
|
||||||
model: model.clone(),
|
..
|
||||||
})).await;
|
} = event
|
||||||
|
{
|
||||||
|
let _ = tx
|
||||||
|
.send(Ok(BackendEvent::InferenceStrain {
|
||||||
|
attempt: *attempt,
|
||||||
|
status: *status,
|
||||||
|
model: model.clone(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
bump_on_strain(&server.rate_delay, *status);
|
bump_on_strain(&server.rate_delay, *status);
|
||||||
// Surface provider strain onto the nervous-system bus too, so a
|
// Surface provider strain onto the nervous-system bus too, so a
|
||||||
// second machine watching the firehose sees the voice go hoarse,
|
// second machine watching the firehose sees the voice go hoarse,
|
||||||
|
|
@ -398,7 +437,9 @@ pub(crate) async fn run_turn(
|
||||||
// Emit reasoning trace if present
|
// Emit reasoning trace if present
|
||||||
if let Some(reasoning) = &response.reasoning {
|
if let Some(reasoning) = &response.reasoning {
|
||||||
dispatcher.emit_reasoning(reasoning);
|
dispatcher.emit_reasoning(reasoning);
|
||||||
let _ = tx.send(Ok(BackendEvent::Reasoning(reasoning.clone()))).await;
|
let _ = tx
|
||||||
|
.send(Ok(BackendEvent::Reasoning(reasoning.clone())))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Truncation: the agent hit her output ceiling ─────────
|
// ── Truncation: the agent hit her output ceiling ─────────
|
||||||
|
|
@ -426,7 +467,7 @@ pub(crate) async fn run_turn(
|
||||||
"My output just hit its ceiling — I was cut off mid-flow, not \
|
"My output just hit its ceiling — I was cut off mid-flow, not \
|
||||||
finished. If I was in the middle of something, I can continue \
|
finished. If I was in the middle of something, I can continue \
|
||||||
from here more tightly. If I had more to say, the room is still \
|
from here more tightly. If I had more to say, the room is still \
|
||||||
mine."
|
mine.",
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -517,11 +558,13 @@ pub(crate) async fn run_turn(
|
||||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
.map(|tc| {
|
||||||
tc.id.clone(),
|
crate::bridge::bifrost::MessageToolCall::function(
|
||||||
tc.name.clone(),
|
tc.id.clone(),
|
||||||
tc.arguments.to_string(),
|
tc.name.clone(),
|
||||||
))
|
tc.arguments.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
messages.push(
|
messages.push(
|
||||||
BifrostMessage::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
BifrostMessage::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
||||||
|
|
@ -540,13 +583,12 @@ pub(crate) async fn run_turn(
|
||||||
if cfg.tui.show_interstitial {
|
if cfg.tui.show_interstitial {
|
||||||
// Classify by length: a brief aside is a cenno, a full
|
// Classify by length: a brief aside is a cenno, a full
|
||||||
// passage is her-voice. tui.cenno_word_threshold is the line.
|
// passage is her-voice. tui.cenno_word_threshold is the line.
|
||||||
let register = if narration.split_whitespace().count()
|
let register =
|
||||||
>= cfg.tui.cenno_word_threshold
|
if narration.split_whitespace().count() >= cfg.tui.cenno_word_threshold {
|
||||||
{
|
crate::backend::Register::HerVoice
|
||||||
crate::backend::Register::HerVoice
|
} else {
|
||||||
} else {
|
crate::backend::Register::Cenno
|
||||||
crate::backend::Register::Cenno
|
};
|
||||||
};
|
|
||||||
let _ = tx
|
let _ = tx
|
||||||
.send(Ok(BackendEvent::Interstitial {
|
.send(Ok(BackendEvent::Interstitial {
|
||||||
text: narration.to_string(),
|
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
|
// If the agent called the outfit tool, emit an Outfit event so
|
||||||
// the TUI can switch expression directories.
|
// the TUI can switch expression directories.
|
||||||
if tc.name == "outfit" {
|
if tc.name == "outfit" {
|
||||||
let outfit_name = tc.arguments
|
let outfit_name = tc
|
||||||
|
.arguments
|
||||||
.get("name")
|
.get("name")
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let _ = tx
|
let _ = tx.send(Ok(BackendEvent::Outfit(outfit_name))).await;
|
||||||
.send(Ok(BackendEvent::Outfit(outfit_name)))
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the agent called the atmosphere tool, emit an Atmosphere
|
// If the agent called the atmosphere tool, emit an Atmosphere
|
||||||
// event so the TUI chrome shifts to match her mood.
|
// event so the TUI chrome shifts to match her mood.
|
||||||
if tc.name == "atmosphere" {
|
if tc.name == "atmosphere" {
|
||||||
let atm_name = tc.arguments
|
let atm_name = tc
|
||||||
|
.arguments
|
||||||
.get("name")
|
.get("name")
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let _ = tx
|
let _ = tx.send(Ok(BackendEvent::Atmosphere(atm_name))).await;
|
||||||
.send(Ok(BackendEvent::Atmosphere(atm_name)))
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the agent called the itinerary tool, read the current
|
// If the agent called the itinerary tool, read the current
|
||||||
|
|
@ -638,9 +678,7 @@ pub(crate) async fn run_turn(
|
||||||
.map(|ity| ity.route_line())
|
.map(|ity| ity.route_line())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !route.is_empty() {
|
if !route.is_empty() {
|
||||||
let _ = tx
|
let _ = tx.send(Ok(BackendEvent::Itinerary(route))).await;
|
||||||
.send(Ok(BackendEvent::Itinerary(route)))
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -656,7 +694,10 @@ pub(crate) async fn run_turn(
|
||||||
// the loop. The primary feels a halt as a migraine in her own
|
// the loop. The primary feels a halt as a migraine in her own
|
||||||
// register — no `[subconscious: …]` text is ever shoved into her
|
// register — no `[subconscious: …]` text is ever shoved into her
|
||||||
// context, and no `*[HALT]*` marker pollutes session storage.
|
// context, and no `*[HALT]*` marker pollutes session storage.
|
||||||
if checkpoint_interval > 0 && tool_round > 0 && tool_round.is_multiple_of(checkpoint_interval) {
|
if checkpoint_interval > 0
|
||||||
|
&& tool_round > 0
|
||||||
|
&& tool_round.is_multiple_of(checkpoint_interval)
|
||||||
|
{
|
||||||
let recent: Vec<_> = checkpoint_blocks
|
let recent: Vec<_> = checkpoint_blocks
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
|
|
@ -896,7 +937,13 @@ pub(crate) async fn run_turn(
|
||||||
};
|
};
|
||||||
let pass_result = server
|
let pass_result = server
|
||||||
.consciousness
|
.consciousness
|
||||||
.on_response(&agent_id, n1_turn_count, &n1_messages, &final_content, Some(tx))
|
.on_response(
|
||||||
|
&agent_id,
|
||||||
|
n1_turn_count,
|
||||||
|
&n1_messages,
|
||||||
|
&final_content,
|
||||||
|
Some(tx),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let pass_elapsed = pass_start.elapsed();
|
let pass_elapsed = pass_start.elapsed();
|
||||||
|
|
@ -924,10 +971,7 @@ pub(crate) async fn run_turn(
|
||||||
let msg = crate::core::session::ConversationMessage {
|
let msg = crate::core::session::ConversationMessage {
|
||||||
role: crate::core::session::MessageRole::System,
|
role: crate::core::session::MessageRole::System,
|
||||||
blocks: vec![crate::core::session::ContentBlock::Text {
|
blocks: vec![crate::core::session::ContentBlock::Text {
|
||||||
text: format!(
|
text: format!("[surfacing: {}] {} — {}", source, content, priority),
|
||||||
"[surfacing: {}] {} — {}",
|
|
||||||
source, content, priority
|
|
||||||
),
|
|
||||||
}],
|
}],
|
||||||
usage: None,
|
usage: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
|
|
@ -947,23 +991,38 @@ pub(crate) async fn run_turn(
|
||||||
// the EventBus preserves the event for any subscriber.
|
// the EventBus preserves the event for any subscriber.
|
||||||
for event in &events {
|
for event in &events {
|
||||||
let (event_type, payload, urgency) = match event {
|
let (event_type, payload, urgency) = match event {
|
||||||
ConsciousnessEvent::Surfacing { source, content, priority } => {
|
ConsciousnessEvent::Surfacing {
|
||||||
|
source,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
} => {
|
||||||
let urg = match priority.as_str() {
|
let urg = match priority.as_str() {
|
||||||
"critical" => 0.9,
|
"critical" => 0.9,
|
||||||
"high" => 0.7,
|
"high" => 0.7,
|
||||||
_ => 0.3,
|
_ => 0.3,
|
||||||
};
|
};
|
||||||
("surfacing", serde_json::json!({ "source": source, "content": content, "priority": priority }), urg)
|
(
|
||||||
|
"surfacing",
|
||||||
|
serde_json::json!({ "source": source, "content": content, "priority": priority }),
|
||||||
|
urg,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
ConsciousnessEvent::Reflection { content } => {
|
ConsciousnessEvent::Reflection { content } => {
|
||||||
("reflection", serde_json::json!({ "content": content }), 0.5)
|
("reflection", serde_json::json!({ "content": content }), 0.5)
|
||||||
}
|
}
|
||||||
ConsciousnessEvent::Archivist { synthesis, pressure } => {
|
ConsciousnessEvent::Archivist {
|
||||||
("archivist", serde_json::json!({ "synthesis": synthesis, "pressure": pressure }), *pressure)
|
synthesis,
|
||||||
}
|
pressure,
|
||||||
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
|
} => (
|
||||||
("compaction_warning", serde_json::json!({ "pressure": *pressure, "tier": tier }), (*pressure).min(0.9))
|
"archivist",
|
||||||
}
|
serde_json::json!({ "synthesis": synthesis, "pressure": pressure }),
|
||||||
|
*pressure,
|
||||||
|
),
|
||||||
|
ConsciousnessEvent::CompactionWarning { pressure, tier } => (
|
||||||
|
"compaction_warning",
|
||||||
|
serde_json::json!({ "pressure": *pressure, "tier": tier }),
|
||||||
|
(*pressure).min(0.9),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
event_bus.send(crate::core::nervous::SensorEvent {
|
event_bus.send(crate::core::nervous::SensorEvent {
|
||||||
sensor_name: "consciousness".into(),
|
sensor_name: "consciousness".into(),
|
||||||
|
|
@ -988,9 +1047,7 @@ pub(crate) async fn run_turn(
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
priority: priority.to_string(),
|
priority: priority.to_string(),
|
||||||
},
|
},
|
||||||
ConsciousnessEvent::Reflection { content } => {
|
ConsciousnessEvent::Reflection { content } => BackendEvent::Reflection(content.clone()),
|
||||||
BackendEvent::Reflection(content.clone())
|
|
||||||
}
|
|
||||||
ConsciousnessEvent::Archivist {
|
ConsciousnessEvent::Archivist {
|
||||||
synthesis,
|
synthesis,
|
||||||
pressure,
|
pressure,
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,10 @@ fn nmcli_devices() -> Vec<(String, String, String, String)> {
|
||||||
/// Does a default route exist over this interface? This is the "carrying" half
|
/// Does a default route exist over this interface? This is the "carrying" half
|
||||||
/// of the distinction — association alone is not evidence of a path.
|
/// of the distinction — association alone is not evidence of a path.
|
||||||
fn has_default_route(iface: &str) -> bool {
|
fn has_default_route(iface: &str) -> bool {
|
||||||
let out = match Command::new("ip").args(["-4", "route", "show", "default"]).output() {
|
let out = match Command::new("ip")
|
||||||
|
.args(["-4", "route", "show", "default"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
Ok(o) if o.status.success() => o.stdout,
|
Ok(o) if o.status.success() => o.stdout,
|
||||||
_ => return false,
|
_ => return false,
|
||||||
};
|
};
|
||||||
|
|
@ -254,7 +257,8 @@ fn has_default_route(iface: &str) -> bool {
|
||||||
/// Returns wifi health, whether we are home, and the SSID.
|
/// Returns wifi health, whether we are home, and the SSID.
|
||||||
fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<String>) {
|
fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<String>) {
|
||||||
let devices = nmcli_devices();
|
let devices = nmcli_devices();
|
||||||
let Some((iface, _, state, _)) = devices.iter().find(|(_, t, _, _)| t == "wifi").cloned() else {
|
let Some((iface, _, state, _)) = devices.iter().find(|(_, t, _, _)| t == "wifi").cloned()
|
||||||
|
else {
|
||||||
return ((LinkHealth::Absent, None), None);
|
return ((LinkHealth::Absent, None), None);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -265,9 +269,7 @@ fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<Stri
|
||||||
let ssid = active_ssid(&iface);
|
let ssid = active_ssid(&iface);
|
||||||
// Identity, never prefix. An unknown SSID is `Some(false)` — we know where
|
// Identity, never prefix. An unknown SSID is `Some(false)` — we know where
|
||||||
// we are and it is not home. No SSID at all is `None` — we do not know.
|
// we are and it is not home. No SSID at all is `None` — we do not know.
|
||||||
let home = ssid
|
let home = ssid.as_ref().map(|s| home_ssids.iter().any(|h| h == s));
|
||||||
.as_ref()
|
|
||||||
.map(|s| home_ssids.iter().any(|h| h == s));
|
|
||||||
|
|
||||||
let health = if has_default_route(&iface) {
|
let health = if has_default_route(&iface) {
|
||||||
LinkHealth::Carrying
|
LinkHealth::Carrying
|
||||||
|
|
@ -279,7 +281,16 @@ fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<Stri
|
||||||
|
|
||||||
fn active_ssid(iface: &str) -> Option<String> {
|
fn active_ssid(iface: &str) -> Option<String> {
|
||||||
let out = Command::new("nmcli")
|
let out = Command::new("nmcli")
|
||||||
.args(["-t", "-f", "ACTIVE,SSID", "device", "wifi", "list", "ifname", iface])
|
.args([
|
||||||
|
"-t",
|
||||||
|
"-f",
|
||||||
|
"ACTIVE,SSID",
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"list",
|
||||||
|
"ifname",
|
||||||
|
iface,
|
||||||
|
])
|
||||||
.output()
|
.output()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
|
|
@ -302,7 +313,10 @@ fn probe_cellular() -> LinkHealth {
|
||||||
if !modem_up {
|
if !modem_up {
|
||||||
return LinkHealth::Absent;
|
return LinkHealth::Absent;
|
||||||
}
|
}
|
||||||
if has_default_route("clat") || devices.iter().any(|(d, _, s, _)| d == "clat" && s == "connected")
|
if has_default_route("clat")
|
||||||
|
|| devices
|
||||||
|
.iter()
|
||||||
|
.any(|(d, _, s, _)| d == "clat" && s == "connected")
|
||||||
{
|
{
|
||||||
LinkHealth::Carrying
|
LinkHealth::Carrying
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::sessiond::bearer::{Bearer, BearerEvidence};
|
use crate::sessiond::bearer::{Bearer, BearerEvidence};
|
||||||
use crate::sessiond::protocol::{
|
use crate::sessiond::protocol::{
|
||||||
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture,};
|
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture,
|
||||||
|
};
|
||||||
|
|
||||||
/// How long a locked, lit panel waits for input before it blanks.
|
/// How long a locked, lit panel waits for input before it blanks.
|
||||||
///
|
///
|
||||||
|
|
@ -380,9 +381,7 @@ pub enum Action {
|
||||||
/// `BUTTON_MULTI_TAP_WINDOW` is 300 ms, so a tap-driven volume key would
|
/// `BUTTON_MULTI_TAP_WINDOW` is 300 ms, so a tap-driven volume key would
|
||||||
/// lag a third of a second behind the press and feel broken. Volume is one
|
/// lag a third of a second behind the press and feel broken. Volume is one
|
||||||
/// of the two controls whose whole quality is immediacy.
|
/// of the two controls whose whole quality is immediacy.
|
||||||
Volume {
|
Volume { up: bool },
|
||||||
up: bool,
|
|
||||||
},
|
|
||||||
/// Light the panel through the same executor.
|
/// Light the panel through the same executor.
|
||||||
///
|
///
|
||||||
/// The machine could turn the screen off and had no way to turn it back
|
/// The machine could turn the screen off and had no way to turn it back
|
||||||
|
|
@ -726,6 +725,13 @@ pub struct StateSnapshot {
|
||||||
/// hours is indistinguishable from a healthy one, and the log answers
|
/// hours is indistinguishable from a healthy one, and the log answers
|
||||||
/// "what happened" but not "why was it wrong".
|
/// "what happened" but not "why was it wrong".
|
||||||
pub sensors_degraded: bool,
|
pub sensors_degraded: bool,
|
||||||
|
/// Where the machine believed the phone was, and how sure it was.
|
||||||
|
///
|
||||||
|
/// On every snapshot for the same reason `sensors_degraded` is: a decision
|
||||||
|
/// is only reconstructable if the trail records what it was deciding on.
|
||||||
|
/// "Refused a wake" and "refused a wake believing this was a pocket at 0.35"
|
||||||
|
/// are the same line and different facts.
|
||||||
|
pub placement: Placement,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forensic log — accumulates entries for post-hoc analysis.
|
/// Forensic log — accumulates entries for post-hoc analysis.
|
||||||
|
|
@ -1168,7 +1174,10 @@ impl TrailWriter {
|
||||||
// Not a file problem: the entry itself will not serialize.
|
// Not a file problem: the entry itself will not serialize.
|
||||||
// Never silent — a decision that cannot be recorded is one the
|
// Never silent — a decision that cannot be recorded is one the
|
||||||
// trail would otherwise imply never happened.
|
// trail would otherwise imply never happened.
|
||||||
warn!("[device-state] forensic entry seq {} will not serialize ({e}) — NOT RECORDED", entry.seq);
|
warn!(
|
||||||
|
"[device-state] forensic entry seq {} will not serialize ({e}) — NOT RECORDED",
|
||||||
|
entry.seq
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1187,7 +1196,9 @@ impl TrailWriter {
|
||||||
self.rotate();
|
self.rotate();
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(path) = self.path.clone() else { return };
|
let Some(path) = self.path.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let written = std::fs::OpenOptions::new()
|
let written = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
|
|
@ -1233,7 +1244,9 @@ impl TrailWriter {
|
||||||
/// finds a non-empty `prev` on line 1, which is the correct answer — its
|
/// finds a non-empty `prev` on line 1, which is the correct answer — its
|
||||||
/// predecessor is the next file along, not nothing.
|
/// predecessor is the next file along, not nothing.
|
||||||
fn rotate(&mut self) {
|
fn rotate(&mut self) {
|
||||||
let Some(path) = self.path.clone() else { return };
|
let Some(path) = self.path.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let gen = |n: usize| path.with_extension(format!("jsonl.{n}"));
|
let gen = |n: usize| path.with_extension(format!("jsonl.{n}"));
|
||||||
|
|
||||||
let _ = std::fs::remove_file(gen(FORENSIC_KEEP));
|
let _ = std::fs::remove_file(gen(FORENSIC_KEEP));
|
||||||
|
|
@ -1353,6 +1366,43 @@ const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Which states count as "locked" for security purposes.
|
/// Which states count as "locked" for security purposes.
|
||||||
|
/// Where the phone believes it is. See [`DeviceStateMachine::placement`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PlacementBelief {
|
||||||
|
Hand,
|
||||||
|
Table,
|
||||||
|
Pocket,
|
||||||
|
/// A call is the only thing that makes a covered sensor mean a face, and
|
||||||
|
/// there is no call-state input yet (§4, "Owed: the call"). Never inferred.
|
||||||
|
Face,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A placement belief and the evidence that carried it.
|
||||||
|
///
|
||||||
|
/// The facts travel with the answer so a refusal can say what it believed and
|
||||||
|
/// why, rather than asserting a conclusion the caller cannot argue with.
|
||||||
|
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||||
|
pub struct Placement {
|
||||||
|
pub belief: PlacementBelief,
|
||||||
|
pub confidence: f32,
|
||||||
|
pub covered: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub lit: bool,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How sure the machine must be it is in a pocket before it refuses a wake.
|
||||||
|
///
|
||||||
|
/// Above the 0.35 a full pocket signature earns, on purpose: nothing available
|
||||||
|
/// today separates a pocket from a face-down phone on a desk, so the veto is
|
||||||
|
/// effectively disarmed until an input exists that can. That is the honest
|
||||||
|
/// state of the evidence, and it is a threshold rather than deleted code so
|
||||||
|
/// that adding the call-state input (§4) or a stillness signal re-arms it
|
||||||
|
/// without anyone having to rediscover why it was off.
|
||||||
|
const POCKET_VETO_CONFIDENCE: f32 = 0.6;
|
||||||
|
|
||||||
pub fn is_locked(state: DeviceState) -> bool {
|
pub fn is_locked(state: DeviceState) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
state,
|
state,
|
||||||
|
|
@ -1632,9 +1682,7 @@ impl DeviceStateMachine {
|
||||||
// taps and holds from them — that is how a future binding table gets
|
// taps and holds from them — that is how a future binding table gets
|
||||||
// volume double-tap or hold-to-ramp without this changing. It simply
|
// volume double-tap or hold-to-ramp without this changing. It simply
|
||||||
// is not what produces the step today.
|
// is not what produces the step today.
|
||||||
if edge == ButtonEdge::Down
|
if edge == ButtonEdge::Down && matches!(button, Button::VolumeUp | Button::VolumeDown) {
|
||||||
&& matches!(button, Button::VolumeUp | Button::VolumeDown)
|
|
||||||
{
|
|
||||||
actions.push(Action::Volume {
|
actions.push(Action::Volume {
|
||||||
up: button == Button::VolumeUp,
|
up: button == Button::VolumeUp,
|
||||||
});
|
});
|
||||||
|
|
@ -1677,8 +1725,8 @@ impl DeviceStateMachine {
|
||||||
gesture: ButtonGesture,
|
gesture: ButtonGesture,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
) -> Vec<Action> {
|
) -> Vec<Action> {
|
||||||
let bound = button == Button::Power
|
let bound =
|
||||||
&& matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
|
button == Button::Power && matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
|
||||||
self.record_decision(
|
self.record_decision(
|
||||||
"button-gesture",
|
"button-gesture",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
|
@ -1709,7 +1757,11 @@ impl DeviceStateMachine {
|
||||||
}
|
}
|
||||||
// The panel as it was when THIS button's press began, not as it is
|
// The panel as it was when THIS button's press began, not as it is
|
||||||
// now. See `press_began_dark`: this same press already woke it.
|
// now. See `press_began_dark`: this same press already woke it.
|
||||||
if self.press_began_dark.remove(&button).unwrap_or(!self.panel_on) {
|
if self
|
||||||
|
.press_began_dark
|
||||||
|
.remove(&button)
|
||||||
|
.unwrap_or(!self.panel_on)
|
||||||
|
{
|
||||||
// Dark: the tap is a wake, and a power-button wake is never vetoed.
|
// Dark: the tap is a wake, and a power-button wake is never vetoed.
|
||||||
//
|
//
|
||||||
// `Unblank` *then* `Restore`, and the order is the whole of it: the
|
// `Unblank` *then* `Restore`, and the order is the whole of it: the
|
||||||
|
|
@ -2294,18 +2346,19 @@ impl DeviceStateMachine {
|
||||||
/// reports vanish silently is indistinguishable from a dead one.
|
/// reports vanish silently is indistinguishable from a dead one.
|
||||||
pub fn note_input_gated(&mut self, trigger: InputTrigger) -> Option<Vec<Action>> {
|
pub fn note_input_gated(&mut self, trigger: InputTrigger) -> Option<Vec<Action>> {
|
||||||
if self.suppress_wake(trigger) {
|
if self.suppress_wake(trigger) {
|
||||||
|
let placement = self.placement();
|
||||||
warn!(
|
warn!(
|
||||||
"[device-state] {:?} refused — proximity near, treating as pocket",
|
"[device-state] {:?} refused — believed {:?} at {:.2}",
|
||||||
trigger
|
trigger, placement.belief, placement.confidence
|
||||||
);
|
);
|
||||||
self.record_decision(
|
self.record_decision(
|
||||||
"input-refused",
|
"input-refused",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"trigger": format!("{:?}", trigger),
|
"trigger": format!("{:?}", trigger),
|
||||||
"proximity_near": self.sensor_evidence.proximity_near,
|
"placement": placement,
|
||||||
"confidence": self.sensor_evidence.confidence(),
|
"confidence": self.sensor_evidence.confidence(),
|
||||||
}),
|
}),
|
||||||
"a covered sensor is the pocket veto (DEVICE-STATE-MACHINE §4)",
|
"a placement belief above the veto threshold (DEVICE-STATE-MACHINE §4)",
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -2507,6 +2560,7 @@ impl DeviceStateMachine {
|
||||||
idle_coordinator_state: idle_state.to_string(),
|
idle_coordinator_state: idle_state.to_string(),
|
||||||
sleep_inhibitor_held: inhibitor_held,
|
sleep_inhibitor_held: inhibitor_held,
|
||||||
sensors_degraded: self.source_health.any_down(),
|
sensors_degraded: self.source_health.any_down(),
|
||||||
|
placement: self.placement(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2559,11 +2613,88 @@ impl DeviceStateMachine {
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the phone believes it is. Interpretation, not a reading.
|
||||||
|
///
|
||||||
|
/// `proximity_near` was being read as "pocket" and refusing things on that
|
||||||
|
/// basis. §4 says it cannot carry that meaning — *"A covered sensor is a
|
||||||
|
/// pocket, a face, a table or a thumb, and nothing in the machine can tell
|
||||||
|
/// which"* — and measured 2026-08-05 it was drastically wrong: seven
|
||||||
|
/// deliberate squeezes refused as pocket-dials while the phone was in a
|
||||||
|
/// hand, unlocked, screen lit.
|
||||||
|
///
|
||||||
|
/// So the four candidates §4 names get separated by the evidence that
|
||||||
|
/// actually distinguishes them, and each answer carries a confidence,
|
||||||
|
/// because they are not equally knowable:
|
||||||
|
///
|
||||||
|
/// - **Hand** — unlocked with the panel lit. Proximity near is *compatible*
|
||||||
|
/// with this, not evidence against it: holding a phone is what puts a
|
||||||
|
/// palm over the sensor.
|
||||||
|
/// - **Table** — lit and unlocked but nothing has touched it in a while.
|
||||||
|
/// Casey, 2026-08-05: *"on table is more accurate as I'm not staring it
|
||||||
|
/// down but if it flashed I'd notice"* — which is the useful part. Table
|
||||||
|
/// is *attention available*, not absence.
|
||||||
|
/// - **Pocket** — the only one that needs all of locked, dark and covered,
|
||||||
|
/// and still tops out **low**. Every one of those three is a state the
|
||||||
|
/// phone is often in on a desk in a dark room, and the sensor that would
|
||||||
|
/// settle it is the one §4's table marks as able to lie.
|
||||||
|
/// - **Face** — needs a call, and there is no call-state input yet. §4 names
|
||||||
|
/// it as owed; until it exists this is never answered rather than guessed.
|
||||||
|
///
|
||||||
|
/// Confidence is deliberately capped below certainty everywhere. This is
|
||||||
|
/// interpretation consuming evidence, and it gates one thing (tap-to-wake).
|
||||||
|
/// It is on the snapshot so the trail records what was believed, and it is
|
||||||
|
/// never an authority.
|
||||||
|
pub fn placement(&self) -> Placement {
|
||||||
|
let covered = self.sensor_evidence.proximity_near;
|
||||||
|
let locked = is_locked(self.state);
|
||||||
|
let lit = self.panel_on;
|
||||||
|
// `idle_since` is cleared by real input, so `None` is "something touched
|
||||||
|
// this recently" without needing a clock passed in.
|
||||||
|
let active = self.idle_since.is_none();
|
||||||
|
|
||||||
|
let (belief, confidence) = if !locked && lit && active {
|
||||||
|
(PlacementBelief::Hand, 0.8)
|
||||||
|
} else if !locked && lit {
|
||||||
|
// Unlocked and bright with nobody touching it. Casey's case, and
|
||||||
|
// the one the old boolean got most wrong.
|
||||||
|
(PlacementBelief::Table, 0.7)
|
||||||
|
} else if locked && !lit && covered {
|
||||||
|
// Everything a pocket would show, and still low: a face-down phone
|
||||||
|
// on a desk shows exactly the same three.
|
||||||
|
(PlacementBelief::Pocket, 0.35)
|
||||||
|
} else if locked && !lit {
|
||||||
|
(PlacementBelief::Table, 0.5)
|
||||||
|
} else {
|
||||||
|
(PlacementBelief::Unknown, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
Placement {
|
||||||
|
belief,
|
||||||
|
confidence,
|
||||||
|
covered,
|
||||||
|
locked,
|
||||||
|
lit,
|
||||||
|
active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap-to-wake, and nothing else.
|
||||||
|
///
|
||||||
|
/// §4 settles the scope in one line — *"It vetoes tap-to-wake, and nothing
|
||||||
|
/// else"* — and the argument is that a double tap is the one input **a
|
||||||
|
/// pocket can produce by itself**. Squeeze was in this list and does not
|
||||||
|
/// meet that test: six strain gauges deflecting past a calibrated baseline
|
||||||
|
/// is a hand closing on the phone, and holding it in order to squeeze it is
|
||||||
|
/// exactly what covers the proximity sensor.
|
||||||
|
///
|
||||||
|
/// If pocket squeezes turn out to be real, the answer is the producer's
|
||||||
|
/// threshold — it owns the gauges and their per-device sensitivities — not
|
||||||
|
/// a veto from the one sensor that cannot tell a pocket from a thumb.
|
||||||
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
|
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
|
||||||
matches!(
|
let p = self.placement();
|
||||||
trigger,
|
matches!(trigger, InputTrigger::DoubleTapToWake)
|
||||||
InputTrigger::DoubleTapToWake | InputTrigger::Squeeze
|
&& p.belief == PlacementBelief::Pocket
|
||||||
) && self.sensor_evidence.proximity_near
|
&& p.confidence >= POCKET_VETO_CONFIDENCE
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt a state transition. Returns true if the transition was
|
/// Attempt a state transition. Returns true if the transition was
|
||||||
|
|
@ -2598,6 +2729,8 @@ impl DeviceStateMachine {
|
||||||
idle_coordinator_state: String::new(),
|
idle_coordinator_state: String::new(),
|
||||||
sleep_inhibitor_held: false,
|
sleep_inhibitor_held: false,
|
||||||
sensors_degraded: self.source_health.any_down(),
|
sensors_degraded: self.source_health.any_down(),
|
||||||
|
placement: self.placement(),
|
||||||
|
placement: self.placement(),
|
||||||
};
|
};
|
||||||
self.forensic.append(
|
self.forensic.append(
|
||||||
ForensicEvent::Transition {
|
ForensicEvent::Transition {
|
||||||
|
|
@ -2639,6 +2772,7 @@ impl DeviceStateMachine {
|
||||||
idle_coordinator_state: String::new(),
|
idle_coordinator_state: String::new(),
|
||||||
sleep_inhibitor_held: false,
|
sleep_inhibitor_held: false,
|
||||||
sensors_degraded: self.source_health.any_down(),
|
sensors_degraded: self.source_health.any_down(),
|
||||||
|
placement: self.placement(),
|
||||||
};
|
};
|
||||||
self.forensic.append(
|
self.forensic.append(
|
||||||
ForensicEvent::Transition {
|
ForensicEvent::Transition {
|
||||||
|
|
@ -2869,6 +3003,8 @@ impl DeviceStateMachine {
|
||||||
idle_coordinator_state: String::new(),
|
idle_coordinator_state: String::new(),
|
||||||
sleep_inhibitor_held: false,
|
sleep_inhibitor_held: false,
|
||||||
sensors_degraded: self.source_health.any_down(),
|
sensors_degraded: self.source_health.any_down(),
|
||||||
|
placement: self.placement(),
|
||||||
|
placement: self.placement(),
|
||||||
};
|
};
|
||||||
self.forensic.append(
|
self.forensic.append(
|
||||||
ForensicEvent::SensorInput {
|
ForensicEvent::SensorInput {
|
||||||
|
|
@ -2925,6 +3061,7 @@ impl DeviceStateMachine {
|
||||||
idle_coordinator_state: String::new(),
|
idle_coordinator_state: String::new(),
|
||||||
sleep_inhibitor_held: false,
|
sleep_inhibitor_held: false,
|
||||||
sensors_degraded: self.source_health.any_down(),
|
sensors_degraded: self.source_health.any_down(),
|
||||||
|
placement: self.placement(),
|
||||||
};
|
};
|
||||||
self.forensic
|
self.forensic
|
||||||
.append(ForensicEvent::Wake { trigger }, snapshot, reason);
|
.append(ForensicEvent::Wake { trigger }, snapshot, reason);
|
||||||
|
|
@ -2947,6 +3084,7 @@ impl DeviceStateMachine {
|
||||||
idle_coordinator_state: String::new(),
|
idle_coordinator_state: String::new(),
|
||||||
sleep_inhibitor_held: false,
|
sleep_inhibitor_held: false,
|
||||||
sensors_degraded: self.source_health.any_down(),
|
sensors_degraded: self.source_health.any_down(),
|
||||||
|
placement: self.placement(),
|
||||||
};
|
};
|
||||||
self.forensic.append(
|
self.forensic.append(
|
||||||
ForensicEvent::Error {
|
ForensicEvent::Error {
|
||||||
|
|
@ -2976,6 +3114,7 @@ impl DeviceStateMachine {
|
||||||
idle_coordinator_state: String::new(),
|
idle_coordinator_state: String::new(),
|
||||||
sleep_inhibitor_held: false,
|
sleep_inhibitor_held: false,
|
||||||
sensors_degraded: self.source_health.any_down(),
|
sensors_degraded: self.source_health.any_down(),
|
||||||
|
placement: self.placement(),
|
||||||
};
|
};
|
||||||
self.forensic.append(
|
self.forensic.append(
|
||||||
ForensicEvent::Decision {
|
ForensicEvent::Decision {
|
||||||
|
|
@ -3245,7 +3384,11 @@ mod tests {
|
||||||
let (mut sm, t0) = unlocked_and_lit();
|
let (mut sm, t0) = unlocked_and_lit();
|
||||||
sm.set_panel(false);
|
sm.set_panel(false);
|
||||||
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
||||||
let actions = sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
|
let actions = sm.button_edge_at(
|
||||||
|
Button::Power,
|
||||||
|
ButtonEdge::Up,
|
||||||
|
t0 + Duration::from_millis(80),
|
||||||
|
);
|
||||||
let actions = if actions.is_empty() {
|
let actions = if actions.is_empty() {
|
||||||
// Taps resolve on the multi-tap window expiring, not on release.
|
// Taps resolve on the multi-tap window expiring, not on release.
|
||||||
sm.tick_at(t0 + Duration::from_secs(2))
|
sm.tick_at(t0 + Duration::from_secs(2))
|
||||||
|
|
@ -3254,7 +3397,10 @@ mod tests {
|
||||||
};
|
};
|
||||||
let unblank = actions.iter().position(|a| *a == Action::Unblank);
|
let unblank = actions.iter().position(|a| *a == Action::Unblank);
|
||||||
let restore = actions.iter().position(|a| *a == Action::Restore);
|
let restore = actions.iter().position(|a| *a == Action::Restore);
|
||||||
assert!(unblank.is_some(), "a tap on a dark panel must wake it: {actions:?}");
|
assert!(
|
||||||
|
unblank.is_some(),
|
||||||
|
"a tap on a dark panel must wake it: {actions:?}"
|
||||||
|
);
|
||||||
if let (Some(u), Some(r)) = (unblank, restore) {
|
if let (Some(u), Some(r)) = (unblank, restore) {
|
||||||
assert!(u < r, "the panel must come back before its brightness does");
|
assert!(u < r, "the panel must come back before its brightness does");
|
||||||
}
|
}
|
||||||
|
|
@ -3342,8 +3488,11 @@ mod tests {
|
||||||
// The wake this very press caused, landing between the edges.
|
// The wake this very press caused, landing between the edges.
|
||||||
sm.set_panel(true);
|
sm.set_panel(true);
|
||||||
|
|
||||||
let mut actions =
|
let mut actions = sm.button_edge_at(
|
||||||
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
|
Button::Power,
|
||||||
|
ButtonEdge::Up,
|
||||||
|
t0 + Duration::from_millis(80),
|
||||||
|
);
|
||||||
if actions.is_empty() {
|
if actions.is_empty() {
|
||||||
actions = sm.tick_at(t0 + Duration::from_secs(2));
|
actions = sm.tick_at(t0 + Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
|
|
@ -3367,15 +3516,29 @@ mod tests {
|
||||||
sm.set_panel(false);
|
sm.set_panel(false);
|
||||||
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
||||||
sm.set_panel(true); // the wake this press caused
|
sm.set_panel(true); // the wake this press caused
|
||||||
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
|
sm.button_edge_at(
|
||||||
|
Button::Power,
|
||||||
|
ButtonEdge::Up,
|
||||||
|
t0 + Duration::from_millis(80),
|
||||||
|
);
|
||||||
|
|
||||||
// The rocker, well inside the multi-tap window.
|
// The rocker, well inside the multi-tap window.
|
||||||
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Down, t0 + Duration::from_millis(120));
|
sm.button_edge_at(
|
||||||
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Up, t0 + Duration::from_millis(200));
|
Button::VolumeUp,
|
||||||
|
ButtonEdge::Down,
|
||||||
|
t0 + Duration::from_millis(120),
|
||||||
|
);
|
||||||
|
sm.button_edge_at(
|
||||||
|
Button::VolumeUp,
|
||||||
|
ButtonEdge::Up,
|
||||||
|
t0 + Duration::from_millis(200),
|
||||||
|
);
|
||||||
|
|
||||||
let actions = sm.tick_at(t0 + Duration::from_secs(2));
|
let actions = sm.tick_at(t0 + Duration::from_secs(2));
|
||||||
assert!(
|
assert!(
|
||||||
!actions.iter().any(|a| matches!(a, Action::Blank | Action::Lock)),
|
!actions
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a, Action::Blank | Action::Lock)),
|
||||||
"the volume press must not decide what the power press meant: {actions:?}"
|
"the volume press must not decide what the power press meant: {actions:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3386,8 +3549,11 @@ mod tests {
|
||||||
// button's only binding.
|
// button's only binding.
|
||||||
let (mut sm, t0) = unlocked_and_lit();
|
let (mut sm, t0) = unlocked_and_lit();
|
||||||
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
||||||
let mut actions =
|
let mut actions = sm.button_edge_at(
|
||||||
sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
|
Button::Power,
|
||||||
|
ButtonEdge::Up,
|
||||||
|
t0 + Duration::from_millis(80),
|
||||||
|
);
|
||||||
if actions.is_empty() {
|
if actions.is_empty() {
|
||||||
actions = sm.tick_at(t0 + Duration::from_secs(2));
|
actions = sm.tick_at(t0 + Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
|
|
@ -4001,7 +4167,11 @@ mod tests {
|
||||||
// run dark came back brighter than it started. The machine remembers
|
// run dark came back brighter than it started. The machine remembers
|
||||||
// the number now instead of the executor guessing at it.
|
// the number now instead of the executor guessing at it.
|
||||||
let mut sm = DeviceStateMachine::new();
|
let mut sm = DeviceStateMachine::new();
|
||||||
assert_eq!(sm.take_brightness_before_dim(), None, "nothing captured yet");
|
assert_eq!(
|
||||||
|
sm.take_brightness_before_dim(),
|
||||||
|
None,
|
||||||
|
"nothing captured yet"
|
||||||
|
);
|
||||||
|
|
||||||
sm.note_brightness_before_dim(Some(18));
|
sm.note_brightness_before_dim(Some(18));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -4395,7 +4565,10 @@ mod tests {
|
||||||
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
|
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
|
||||||
assert_eq!(sm.source_health.light, SourceHealth::Absent);
|
assert_eq!(sm.source_health.light, SourceHealth::Absent);
|
||||||
assert_eq!(sm.source_health.accel, SourceHealth::Absent);
|
assert_eq!(sm.source_health.accel, SourceHealth::Absent);
|
||||||
assert!(sm.source_health.any_down(), "absent evidence is degraded evidence");
|
assert!(
|
||||||
|
sm.source_health.any_down(),
|
||||||
|
"absent evidence is degraded evidence"
|
||||||
|
);
|
||||||
|
|
||||||
// Touch has no reporter on this device, so it must stay silent — a
|
// Touch has no reporter on this device, so it must stay silent — a
|
||||||
// permanent false alarm is the same defect in the other direction.
|
// permanent false alarm is the same defect in the other direction.
|
||||||
|
|
@ -4435,7 +4608,10 @@ mod tests {
|
||||||
|
|
||||||
// The reporter finally starts. Absent must close like Down does, or
|
// The reporter finally starts. Absent must close like Down does, or
|
||||||
// the trail says when evidence went missing and never when it returned.
|
// the trail says when evidence went missing and never when it returned.
|
||||||
sm.mark_evidence_seen_at(SensorSource::Proximity, t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(5));
|
sm.mark_evidence_seen_at(
|
||||||
|
SensorSource::Proximity,
|
||||||
|
t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(5),
|
||||||
|
);
|
||||||
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
|
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
|
||||||
|
|
||||||
let recovered = sm
|
let recovered = sm
|
||||||
|
|
@ -4717,7 +4893,10 @@ mod tests {
|
||||||
|
|
||||||
let rotated = chain_of(&path.with_extension("jsonl.1"));
|
let rotated = chain_of(&path.with_extension("jsonl.1"));
|
||||||
let live = chain_of(&path);
|
let live = chain_of(&path);
|
||||||
assert!(!rotated.is_empty() && !live.is_empty(), "a rotation happened");
|
assert!(
|
||||||
|
!rotated.is_empty() && !live.is_empty(),
|
||||||
|
"a rotation happened"
|
||||||
|
);
|
||||||
let last_rotated = rotated.last().expect("rotated entries");
|
let last_rotated = rotated.last().expect("rotated entries");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
live[0].1, last_rotated.2,
|
live[0].1, last_rotated.2,
|
||||||
|
|
@ -4784,7 +4963,10 @@ mod tests {
|
||||||
let fresh = chain_of(&path);
|
let fresh = chain_of(&path);
|
||||||
assert_eq!(fresh.len(), 1);
|
assert_eq!(fresh.len(), 1);
|
||||||
assert_eq!(fresh[0].0, 0, "the new chain starts clean");
|
assert_eq!(fresh[0].0, 0, "the new chain starts clean");
|
||||||
assert_eq!(fresh[0].1, "", "and does not claim a predecessor it cannot verify");
|
assert_eq!(
|
||||||
|
fresh[0].1, "",
|
||||||
|
"and does not claim a predecessor it cannot verify"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -4924,7 +5106,9 @@ mod tests {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let _ = m.tick_at(t0);
|
let _ = m.tick_at(t0);
|
||||||
let settled = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
let settled = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
||||||
assert!(m.tick_at(settled).contains(&Action::PreferLink(Bearer::Wifi)));
|
assert!(m
|
||||||
|
.tick_at(settled)
|
||||||
|
.contains(&Action::PreferLink(Bearer::Wifi)));
|
||||||
|
|
||||||
for i in 1..5 {
|
for i in 1..5 {
|
||||||
let a = m.tick_at(settled + Duration::from_secs(i));
|
let a = m.tick_at(settled + Duration::from_secs(i));
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,14 @@ mod tests {
|
||||||
fn render_fills_the_whole_buffer_with_the_field() {
|
fn render_fills_the_whole_buffer_with_the_field() {
|
||||||
let (w, h) = (8, 4);
|
let (w, h) = (8, 4);
|
||||||
let mut buf = vec![0u32; (w * h) as usize];
|
let mut buf = vec![0u32; (w * h) as usize];
|
||||||
render(&mut buf, w, h, &Scene { mood: Mood::Holding });
|
render(
|
||||||
|
&mut buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
&Scene {
|
||||||
|
mood: Mood::Holding,
|
||||||
|
},
|
||||||
|
);
|
||||||
assert!(buf.iter().all(|&px| px == BG));
|
assert!(buf.iter().all(|&px| px == BG));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +68,14 @@ mod tests {
|
||||||
// underneath it, which is the one thing it exists to prevent.
|
// underneath it, which is the one thing it exists to prevent.
|
||||||
let (w, h) = (4, 4);
|
let (w, h) = (4, 4);
|
||||||
let mut buf = vec![0u32; (w * h) as usize];
|
let mut buf = vec![0u32; (w * h) as usize];
|
||||||
render(&mut buf, w, h, &Scene { mood: Mood::Holding });
|
render(
|
||||||
|
&mut buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
&Scene {
|
||||||
|
mood: Mood::Holding,
|
||||||
|
},
|
||||||
|
);
|
||||||
assert!(buf.iter().all(|&px| px >> 24 == 0xFF));
|
assert!(buf.iter().all(|&px| px >> 24 == 0xFF));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,12 +94,8 @@ pub fn run(sink: Arc<dyn Fn(IdleEvent) + Send + Sync>) -> Result<()> {
|
||||||
// awake" is yes, through the client's inhibitor rather than through faked
|
// awake" is yes, through the client's inhibitor rather than through faked
|
||||||
// input — which also means a client that lies is visible as an inhibitor
|
// input — which also means a client that lies is visible as an inhibitor
|
||||||
// rather than as phantom activity.
|
// rather than as phantom activity.
|
||||||
let _notification = notifier.get_idle_notification(
|
let _notification =
|
||||||
QUIET_PERIOD.as_millis() as u32,
|
notifier.get_idle_notification(QUIET_PERIOD.as_millis() as u32, &seat, &qh, ());
|
||||||
&seat,
|
|
||||||
&qh,
|
|
||||||
(),
|
|
||||||
);
|
|
||||||
info!(
|
info!(
|
||||||
"[idle] watching input via ext_idle_notifier_v1 (quiet period {}ms)",
|
"[idle] watching input via ext_idle_notifier_v1 (quiet period {}ms)",
|
||||||
QUIET_PERIOD.as_millis()
|
QUIET_PERIOD.as_millis()
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,10 @@ impl LockController {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn try_clone(&self) -> Result<LockController> {
|
pub fn try_clone(&self) -> Result<LockController> {
|
||||||
Ok(LockController { tx: self.tx.clone(), wake: self.wake.try_clone()? })
|
Ok(LockController {
|
||||||
|
tx: self.tx.clone(),
|
||||||
|
wake: self.wake.try_clone()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +180,6 @@ impl LockState {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn surface_size_by_proto_id(&self, id: u32) -> Option<(i32, i32)> {
|
fn surface_size_by_proto_id(&self, id: u32) -> Option<(i32, i32)> {
|
||||||
self.surfaces
|
self.surfaces
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -247,7 +249,9 @@ pub fn run(rx: Receiver<Msg>, wake_read: OwnedFd) -> Result<SessionOutcome> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.blocking_dispatch(&mut state).context("waiting for locked")?;
|
queue
|
||||||
|
.blocking_dispatch(&mut state)
|
||||||
|
.context("waiting for locked")?;
|
||||||
}
|
}
|
||||||
if state.finished {
|
if state.finished {
|
||||||
return Ok(SessionOutcome::Denied);
|
return Ok(SessionOutcome::Denied);
|
||||||
|
|
@ -288,8 +292,16 @@ pub fn run(rx: Receiver<Msg>, wake_read: OwnedFd) -> Result<SessionOutcome> {
|
||||||
};
|
};
|
||||||
let wl_fd = guard.connection_fd().as_raw_fd();
|
let wl_fd = guard.connection_fd().as_raw_fd();
|
||||||
let mut fds = [
|
let mut fds = [
|
||||||
libc::pollfd { fd: wl_fd, events: libc::POLLIN, revents: 0 },
|
libc::pollfd {
|
||||||
libc::pollfd { fd: wake_read.as_raw_fd(), events: libc::POLLIN, revents: 0 },
|
fd: wl_fd,
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
},
|
||||||
|
libc::pollfd {
|
||||||
|
fd: wake_read.as_raw_fd(),
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
// SAFETY: fds array outlives the call.
|
// SAFETY: fds array outlives the call.
|
||||||
let rc = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
|
let rc = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
|
||||||
|
|
@ -315,7 +327,9 @@ pub fn run(rx: Receiver<Msg>, wake_read: OwnedFd) -> Result<SessionOutcome> {
|
||||||
if fds[0].revents & libc::POLLIN != 0 {
|
if fds[0].revents & libc::POLLIN != 0 {
|
||||||
match guard.read() {
|
match guard.read() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
queue.dispatch_pending(&mut state).context("dispatch after read")?;
|
queue
|
||||||
|
.dispatch_pending(&mut state)
|
||||||
|
.context("dispatch after read")?;
|
||||||
}
|
}
|
||||||
Err(wayland_client::backend::WaylandError::Io(e))
|
Err(wayland_client::backend::WaylandError::Io(e))
|
||||||
if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||||
|
|
@ -366,7 +380,9 @@ fn ensure_surfaces(state: &mut LockState, qh: &QueueHandle<LockState>) {
|
||||||
|
|
||||||
fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()> {
|
fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()> {
|
||||||
let scene = Scene { mood: state.mood };
|
let scene = Scene { mood: state.mood };
|
||||||
let Some(shm) = state.shm.clone() else { return Ok(()) };
|
let Some(shm) = state.shm.clone() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
for ctx in &mut state.surfaces {
|
for ctx in &mut state.surfaces {
|
||||||
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
|
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -439,7 +455,11 @@ impl Dispatch<WlRegistry, ()> for LockState {
|
||||||
qh: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
wl_registry::Event::Global { name, interface, version } => match interface.as_str() {
|
wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} => match interface.as_str() {
|
||||||
"wl_compositor" => {
|
"wl_compositor" => {
|
||||||
state.compositor =
|
state.compositor =
|
||||||
Some(registry.bind::<WlCompositor, _, _>(name, version.min(4), qh, ()));
|
Some(registry.bind::<WlCompositor, _, _>(name, version.min(4), qh, ()));
|
||||||
|
|
@ -497,7 +517,12 @@ impl Dispatch<ExtSessionLockSurfaceV1, ()> for LockState {
|
||||||
_: &Connection,
|
_: &Connection,
|
||||||
_: &QueueHandle<Self>,
|
_: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
if let ext_session_lock_surface_v1::Event::Configure { serial, width, height } = event {
|
if let ext_session_lock_surface_v1::Event::Configure {
|
||||||
|
serial,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
surface.ack_configure(serial);
|
surface.ack_configure(serial);
|
||||||
if let Some(ctx) = state
|
if let Some(ctx) = state
|
||||||
.surfaces
|
.surfaces
|
||||||
|
|
@ -522,11 +547,11 @@ impl Dispatch<WlSeat, ()> for LockState {
|
||||||
_: &Connection,
|
_: &Connection,
|
||||||
qh: &QueueHandle<Self>,
|
qh: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
if let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event {
|
if let wl_seat::Event::Capabilities {
|
||||||
let entry = state
|
capabilities: WEnum::Value(caps),
|
||||||
.seats
|
} = event
|
||||||
.values_mut()
|
{
|
||||||
.find(|(s, _)| s.id() == seat.id());
|
let entry = state.seats.values_mut().find(|(s, _)| s.id() == seat.id());
|
||||||
let Some((seat, devices)) = entry else { return };
|
let Some((seat, devices)) = entry else { return };
|
||||||
if caps.contains(wl_seat::Capability::Touch) && devices.touch.is_none() {
|
if caps.contains(wl_seat::Capability::Touch) && devices.touch.is_none() {
|
||||||
devices.touch = Some(seat.get_touch(qh, ()));
|
devices.touch = Some(seat.get_touch(qh, ()));
|
||||||
|
|
@ -571,17 +596,29 @@ impl Dispatch<WlPointer, ()> for LockState {
|
||||||
_: &QueueHandle<Self>,
|
_: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
wl_pointer::Event::Enter { surface, surface_x, surface_y, .. } => {
|
wl_pointer::Event::Enter {
|
||||||
|
surface,
|
||||||
|
surface_x,
|
||||||
|
surface_y,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
state.pointer_surface = Some(surface.id().protocol_id());
|
state.pointer_surface = Some(surface.id().protocol_id());
|
||||||
state.pointer_pos = (surface_x, surface_y);
|
state.pointer_pos = (surface_x, surface_y);
|
||||||
}
|
}
|
||||||
wl_pointer::Event::Leave { .. } => {
|
wl_pointer::Event::Leave { .. } => {
|
||||||
state.pointer_surface = None;
|
state.pointer_surface = None;
|
||||||
}
|
}
|
||||||
wl_pointer::Event::Motion { surface_x, surface_y, .. } => {
|
wl_pointer::Event::Motion {
|
||||||
|
surface_x,
|
||||||
|
surface_y,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
state.pointer_pos = (surface_x, surface_y);
|
state.pointer_pos = (surface_x, surface_y);
|
||||||
}
|
}
|
||||||
wl_pointer::Event::Button { state: WEnum::Value(st), .. } => match st {
|
wl_pointer::Event::Button {
|
||||||
|
state: WEnum::Value(st),
|
||||||
|
..
|
||||||
|
} => match st {
|
||||||
wl_pointer::ButtonState::Pressed => state.press(),
|
wl_pointer::ButtonState::Pressed => state.press(),
|
||||||
wl_pointer::ButtonState::Released => {}
|
wl_pointer::ButtonState::Released => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -601,17 +638,18 @@ impl Dispatch<WlKeyboard, ()> for LockState {
|
||||||
_: &QueueHandle<Self>,
|
_: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
wl_keyboard::Event::Key { key, state: WEnum::Value(st), .. } => {
|
wl_keyboard::Event::Key {
|
||||||
match st {
|
key,
|
||||||
wl_keyboard::KeyState::Pressed => {
|
state: WEnum::Value(st),
|
||||||
let _ = key;
|
..
|
||||||
state.press();
|
} => match st {
|
||||||
}
|
wl_keyboard::KeyState::Pressed => {
|
||||||
wl_keyboard::KeyState::Released => {
|
let _ = key;
|
||||||
}
|
state.press();
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
wl_keyboard::KeyState::Released => {}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
// Keymap carries an fd we must not leak; OwnedFd drops it here.
|
// Keymap carries an fd we must not leak; OwnedFd drops it here.
|
||||||
wl_keyboard::Event::Keymap { .. } => {}
|
wl_keyboard::Event::Keymap { .. } => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,9 @@ pub fn spawn(on_change: Arc<dyn Fn(bool) + Send + Sync>) {
|
||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("[lock-hint] could not start gdbus monitor: {e} — lock truth will not update");
|
warn!(
|
||||||
|
"[lock-hint] could not start gdbus monitor: {e} — lock truth will not update"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -614,7 +614,11 @@ mod tests {
|
||||||
// 17 since `gesture` (2026-08-03): the compositor names what the
|
// 17 since `gesture` (2026-08-03): the compositor names what the
|
||||||
// fingers did and the machine decides what it means, so the naming had
|
// fingers did and the machine decides what it means, so the naming had
|
||||||
// to become a verb rather than the compositor calling a tool itself.
|
// to become a verb rather than the compositor calling a tool itself.
|
||||||
assert_eq!(VERBS.len(), 17, "a Request variant was added without a VerbDoc");
|
assert_eq!(
|
||||||
|
VERBS.len(),
|
||||||
|
17,
|
||||||
|
"a Request variant was added without a VerbDoc"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -174,37 +174,37 @@ fn spawn_clock(shared: &Arc<Shared>) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut tick_count: u64 = 0;
|
let mut tick_count: u64 = 0;
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(TICK_INTERVAL);
|
std::thread::sleep(TICK_INTERVAL);
|
||||||
tick_count = tick_count.wrapping_add(1);
|
tick_count = tick_count.wrapping_add(1);
|
||||||
|
|
||||||
// Bearer evidence is sampled off the existing clock rather than given a
|
// 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
|
// timer of its own — TASK-49 acceptance #6, "no new timer". Every fifth
|
||||||
// tick, because the probe costs five subprocesses and the settling
|
// tick, because the probe costs five subprocesses and the settling
|
||||||
// window is 20 s: four samples per window is enough to establish that a
|
// 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.
|
// change held, and 1 Hz would be five processes a second forever.
|
||||||
//
|
//
|
||||||
// Probed with the lock *released*. Holding the state mutex across
|
// Probed with the lock *released*. Holding the state mutex across
|
||||||
// subprocess spawns is exactly the self-deadlock shape that put 347
|
// subprocess spawns is exactly the self-deadlock shape that put 347
|
||||||
// threads in __futex_wait on 2026-07-26.
|
// threads in __futex_wait on 2026-07-26.
|
||||||
if tick_count.is_multiple_of(BEARER_PROBE_EVERY) {
|
if tick_count.is_multiple_of(BEARER_PROBE_EVERY) {
|
||||||
let home = shared.lock().device_state.policy.home_ssids.clone();
|
let home = shared.lock().device_state.policy.home_ssids.clone();
|
||||||
let evidence = crate::sessiond::bearer::probe(&home);
|
let evidence = crate::sessiond::bearer::probe(&home);
|
||||||
shared.lock().device_state.note_bearer(evidence);
|
shared.lock().device_state.note_bearer(evidence);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hold the lock only to decide, never while running a command.
|
// Hold the lock only to decide, never while running a command.
|
||||||
let actions = {
|
let actions = {
|
||||||
let mut d = shared.lock();
|
let mut d = shared.lock();
|
||||||
d.device_state.tick()
|
d.device_state.tick()
|
||||||
};
|
};
|
||||||
for action in actions {
|
for action in actions {
|
||||||
execute(&shared, action);
|
execute(&shared, action);
|
||||||
}
|
}
|
||||||
// After the actions, so an executor's own `error-operational` is in the
|
// 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
|
// 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
|
// blank but not the brightnessctl failure underneath it has been told a
|
||||||
// tidier story than what happened.
|
// tidier story than what happened.
|
||||||
fan_out(&shared);
|
fan_out(&shared);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -608,7 +608,11 @@ fn wg_endpoint_ip() -> Option<String> {
|
||||||
let host = text.lines().find_map(|l| {
|
let host = text.lines().find_map(|l| {
|
||||||
l.split_whitespace()
|
l.split_whitespace()
|
||||||
.find_map(|f| f.strip_prefix("endpoint="))
|
.find_map(|f| f.strip_prefix("endpoint="))
|
||||||
.and_then(|e| e.replace("\\:", ":").rsplit_once(':').map(|(h, _)| h.to_string()))
|
.and_then(|e| {
|
||||||
|
e.replace("\\:", ":")
|
||||||
|
.rsplit_once(':')
|
||||||
|
.map(|(h, _)| h.to_string())
|
||||||
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Already an address? Then there is nothing to resolve.
|
// Already an address? Then there is nothing to resolve.
|
||||||
|
|
@ -792,13 +796,7 @@ fn read_brightness() -> Option<u32> {
|
||||||
|
|
||||||
/// Run one executor and record what it did. Shared by the paths that build
|
/// Run one executor and record what it did. Shared by the paths that build
|
||||||
/// their arguments dynamically and the table below.
|
/// their arguments dynamically and the table below.
|
||||||
fn run_executor(
|
fn run_executor(shared: &Arc<Shared>, program: &str, args: &[&str], label: &str, action: Action) {
|
||||||
shared: &Arc<Shared>,
|
|
||||||
program: &str,
|
|
||||||
args: &[&str],
|
|
||||||
label: &str,
|
|
||||||
action: Action,
|
|
||||||
) {
|
|
||||||
match std::process::Command::new(program).args(args).status() {
|
match std::process::Command::new(program).args(args).status() {
|
||||||
Ok(status) if status.success() => {
|
Ok(status) if status.success() => {
|
||||||
if action == Action::Blank {
|
if action == Action::Blank {
|
||||||
|
|
@ -896,7 +894,10 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if line.len() as u64 >= MAX_REQUEST_BYTES {
|
if line.len() as u64 >= MAX_REQUEST_BYTES {
|
||||||
let _ = respond(&mut writer, refuse(RefusalCode::InvalidArgument, "request exceeds size limit"));
|
let _ = respond(
|
||||||
|
&mut writer,
|
||||||
|
refuse(RefusalCode::InvalidArgument, "request exceeds size limit"),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
|
|
@ -917,7 +918,10 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
||||||
shared.lock().device_state.forensic.set_intent(None);
|
shared.lock().device_state.forensic.set_intent(None);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
Err(e) => refuse(RefusalCode::UnsupportedOp, &format!("malformed request: {e}")),
|
Err(e) => refuse(
|
||||||
|
RefusalCode::UnsupportedOp,
|
||||||
|
&format!("malformed request: {e}"),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
if response.get("subscribed").and_then(|v| v.as_bool()) == Some(true) {
|
if response.get("subscribed").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
subscribed = true;
|
subscribed = true;
|
||||||
|
|
@ -1008,7 +1012,11 @@ fn fan_out(shared: &Arc<Shared>) {
|
||||||
/// (`peer_credentials_unix_socket`, rust#42839) and only fails at the aarch64
|
/// (`peer_credentials_unix_socket`, rust#42839) and only fails at the aarch64
|
||||||
/// build, after the host test job has already gone green.
|
/// build, after the host test job has already gone green.
|
||||||
fn peer_pid(stream: &UnixStream) -> Option<i32> {
|
fn peer_pid(stream: &UnixStream) -> Option<i32> {
|
||||||
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
|
let mut cred = libc::ucred {
|
||||||
|
pid: 0,
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
};
|
||||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||||
// SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd is
|
// SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd is
|
||||||
// live for the duration of the call because we hold &UnixStream.
|
// live for the duration of the call because we hold &UnixStream.
|
||||||
|
|
@ -1083,9 +1091,13 @@ fn handle_request(
|
||||||
// Anything we cannot PROVE is the same process is still refused,
|
// Anything we cannot PROVE is the same process is still refused,
|
||||||
// so the lease keeps every case it was written for.
|
// so the lease keeps every case it was written for.
|
||||||
if d.shell_alive {
|
if d.shell_alive {
|
||||||
let is_reload = matches!((peer, d.shell_pid), (Some(new), Some(held)) if new == held);
|
let is_reload =
|
||||||
|
matches!((peer, d.shell_pid), (Some(new), Some(held)) if new == held);
|
||||||
if !is_reload {
|
if !is_reload {
|
||||||
return refuse(RefusalCode::RefusedByState, "shell authority is already registered");
|
return refuse(
|
||||||
|
RefusalCode::RefusedByState,
|
||||||
|
"shell authority is already registered",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Supersede in place. `heartbeat_gen` already exists to make a
|
// Supersede in place. `heartbeat_gen` already exists to make a
|
||||||
// stale connection's EOF harmless — the outgoing handler checks
|
// stale connection's EOF harmless — the outgoing handler checks
|
||||||
|
|
@ -1133,7 +1145,10 @@ fn handle_request(
|
||||||
.wait_timeout_while(d, deadline, |d| d.controller.is_some())
|
.wait_timeout_while(d, deadline, |d| d.controller.is_some())
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
if timed_out.timed_out() {
|
if timed_out.timed_out() {
|
||||||
return refuse(RefusalCode::Unavailable, "lock session did not release in time");
|
return refuse(
|
||||||
|
RefusalCode::Unavailable,
|
||||||
|
"lock session did not release in time",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
info!("handoff: lock released to shell (gen {gen})");
|
info!("handoff: lock released to shell (gen {gen})");
|
||||||
serde_json::json!({ "ok": true, "held": true, "must_lock": true })
|
serde_json::json!({ "ok": true, "held": true, "must_lock": true })
|
||||||
|
|
@ -1148,7 +1163,10 @@ fn handle_request(
|
||||||
Request::LockedAck => {
|
Request::LockedAck => {
|
||||||
let mut d = shared.lock();
|
let mut d = shared.lock();
|
||||||
if heartbeat.is_none() {
|
if heartbeat.is_none() {
|
||||||
return refuse(RefusalCode::RefusedByState, "locked_ack from a connection that never sent shell_ready");
|
return refuse(
|
||||||
|
RefusalCode::RefusedByState,
|
||||||
|
"locked_ack from a connection that never sent shell_ready",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
d.phase = Phase::Released;
|
d.phase = Phase::Released;
|
||||||
info!("shell lock confirmed");
|
info!("shell lock confirmed");
|
||||||
|
|
@ -1169,9 +1187,10 @@ fn handle_request(
|
||||||
let d = shared.lock();
|
let d = shared.lock();
|
||||||
match d.phase {
|
match d.phase {
|
||||||
Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
|
Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
|
||||||
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => {
|
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => refuse(
|
||||||
refuse(RefusalCode::RefusedByState, "a live shell owns the session lock; use the shell's lock IPC")
|
RefusalCode::RefusedByState,
|
||||||
}
|
"a live shell owns the session lock; use the shell's lock IPC",
|
||||||
|
),
|
||||||
_ => {
|
_ => {
|
||||||
drop(d);
|
drop(d);
|
||||||
if spawn_lock_session(shared) {
|
if spawn_lock_session(shared) {
|
||||||
|
|
@ -1229,9 +1248,7 @@ fn handle_request(
|
||||||
.update_sensors_from(input.source, evidence_copy);
|
.update_sensors_from(input.source, evidence_copy);
|
||||||
// The tap-to-wake answer specifically; a power button is never
|
// The tap-to-wake answer specifically; a power button is never
|
||||||
// refused. Asked after the update, not before.
|
// refused. Asked after the update, not before.
|
||||||
let suppress = d
|
let suppress = d.device_state.suppress_wake(InputTrigger::DoubleTapToWake);
|
||||||
.device_state
|
|
||||||
.suppress_wake(InputTrigger::DoubleTapToWake);
|
|
||||||
info!(
|
info!(
|
||||||
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
|
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
|
||||||
input.source, input.value, conf, suppress, promote
|
input.source, input.value, conf, suppress, promote
|
||||||
|
|
@ -1259,9 +1276,17 @@ fn handle_request(
|
||||||
// explicit that the policy layer means something before the
|
// explicit that the policy layer means something before the
|
||||||
// enforcement lands; TASK-41 is the enforcement.
|
// enforcement lands; TASK-41 is the enforcement.
|
||||||
let Some(actions) = gated else {
|
let Some(actions) = gated else {
|
||||||
|
// Report the belief and its confidence, not a conclusion. This
|
||||||
|
// said "proximity near — treated as a pocket", which was a
|
||||||
|
// reading wearing an interpretation's name and was wrong on
|
||||||
|
// every deliberate squeeze (see `placement`).
|
||||||
|
let p = { shared.lock().device_state.placement() };
|
||||||
return refuse(
|
return refuse(
|
||||||
RefusalCode::RefusedByState,
|
RefusalCode::RefusedByState,
|
||||||
"proximity near — treated as a pocket",
|
format!(
|
||||||
|
"believed {:?} at {:.2} (covered={}, locked={}, lit={})",
|
||||||
|
p.belief, p.confidence, p.covered, p.locked, p.lit
|
||||||
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let cancelled = !actions.is_empty();
|
let cancelled = !actions.is_empty();
|
||||||
|
|
@ -1403,7 +1428,10 @@ fn handle_request(
|
||||||
if let Some(v) = lock_ack_budget_secs {
|
if let Some(v) = lock_ack_budget_secs {
|
||||||
if v == 0 {
|
if v == 0 {
|
||||||
drop(d);
|
drop(d);
|
||||||
return refuse(RefusalCode::InvalidArgument, "lock_ack_budget_secs must be at least 1");
|
return refuse(
|
||||||
|
RefusalCode::InvalidArgument,
|
||||||
|
"lock_ack_budget_secs must be at least 1",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
p.lock_ack_budget = Duration::from_secs(v);
|
p.lock_ack_budget = Duration::from_secs(v);
|
||||||
}
|
}
|
||||||
|
|
@ -1432,7 +1460,10 @@ fn handle_request(
|
||||||
if let Some(budget) = p.lock_blank_after {
|
if let Some(budget) = p.lock_blank_after {
|
||||||
if p.dim_warning && p.dim_grace >= budget {
|
if p.dim_warning && p.dim_grace >= budget {
|
||||||
drop(d);
|
drop(d);
|
||||||
return refuse(RefusalCode::InvalidArgument, "dim_grace_secs must be shorter than lock_blank_after_secs");
|
return refuse(
|
||||||
|
RefusalCode::InvalidArgument,
|
||||||
|
"dim_grace_secs must be shorter than lock_blank_after_secs",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let applied = serde_json::json!({
|
let applied = serde_json::json!({
|
||||||
|
|
@ -1590,9 +1621,19 @@ mod tests {
|
||||||
let (incoming, _i) = UnixStream::pair().unwrap();
|
let (incoming, _i) = UnixStream::pair().unwrap();
|
||||||
|
|
||||||
let mut stale_heartbeat = None;
|
let mut stale_heartbeat = None;
|
||||||
handle_request(Request::ShellReady, &shared, Some(&outgoing), &mut stale_heartbeat);
|
handle_request(
|
||||||
|
Request::ShellReady,
|
||||||
|
&shared,
|
||||||
|
Some(&outgoing),
|
||||||
|
&mut stale_heartbeat,
|
||||||
|
);
|
||||||
let mut live_heartbeat = None;
|
let mut live_heartbeat = None;
|
||||||
handle_request(Request::ShellReady, &shared, Some(&incoming), &mut live_heartbeat);
|
handle_request(
|
||||||
|
Request::ShellReady,
|
||||||
|
&shared,
|
||||||
|
Some(&incoming),
|
||||||
|
&mut live_heartbeat,
|
||||||
|
);
|
||||||
|
|
||||||
// Replays the EOF branch of handle_connection for the OLD connection.
|
// Replays the EOF branch of handle_connection for the OLD connection.
|
||||||
let stale_gen = stale_heartbeat.unwrap();
|
let stale_gen = stale_heartbeat.unwrap();
|
||||||
|
|
@ -1672,7 +1713,12 @@ mod tests {
|
||||||
let (shell, _s) = UnixStream::pair().unwrap();
|
let (shell, _s) = UnixStream::pair().unwrap();
|
||||||
|
|
||||||
let mut shell_heartbeat = None;
|
let mut shell_heartbeat = None;
|
||||||
handle_request(Request::ShellReady, &shared, Some(&shell), &mut shell_heartbeat);
|
handle_request(
|
||||||
|
Request::ShellReady,
|
||||||
|
&shared,
|
||||||
|
Some(&shell),
|
||||||
|
&mut shell_heartbeat,
|
||||||
|
);
|
||||||
|
|
||||||
let mut anon_heartbeat = None;
|
let mut anon_heartbeat = None;
|
||||||
let anon = handle_request(Request::ShellReady, &shared, None, &mut anon_heartbeat);
|
let anon = handle_request(Request::ShellReady, &shared, None, &mut anon_heartbeat);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#![allow(dead_code, deprecated)] // legacy ratatui render path + WIP scaffolding, pending tuie parity
|
#![allow(dead_code, deprecated)] // legacy ratatui render path + WIP scaffolding, pending tuie parity
|
||||||
|
use std::io::{stdout, Write};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
use std::io::{stdout, Write};
|
|
||||||
|
|
||||||
/// Animation system for sexy terminal effects
|
/// Animation system for sexy terminal effects
|
||||||
pub struct Animator {
|
pub struct Animator {
|
||||||
|
|
@ -14,7 +14,7 @@ impl Animator {
|
||||||
start_time: Instant::now(),
|
start_time: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Breathing glow effect (sine wave)
|
/// Breathing glow effect (sine wave)
|
||||||
/// Returns intensity 0.0-1.0 based on time
|
/// Returns intensity 0.0-1.0 based on time
|
||||||
pub fn breathe(&self, speed_ms: u64) -> f32 {
|
pub fn breathe(&self, speed_ms: u64) -> f32 {
|
||||||
|
|
@ -22,9 +22,14 @@ impl Animator {
|
||||||
let cycle = (elapsed / speed_ms as f64) * 2.0 * std::f64::consts::PI;
|
let cycle = (elapsed / speed_ms as f64) * 2.0 * std::f64::consts::PI;
|
||||||
((cycle.sin() + 1.0) / 2.0) as f32
|
((cycle.sin() + 1.0) / 2.0) as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pulsing between two colors
|
/// Pulsing between two colors
|
||||||
pub fn pulse_color(&self, color1: (u8, u8, u8), color2: (u8, u8, u8), speed_ms: u64) -> (u8, u8, u8) {
|
pub fn pulse_color(
|
||||||
|
&self,
|
||||||
|
color1: (u8, u8, u8),
|
||||||
|
color2: (u8, u8, u8),
|
||||||
|
speed_ms: u64,
|
||||||
|
) -> (u8, u8, u8) {
|
||||||
let t = self.breathe(speed_ms);
|
let t = self.breathe(speed_ms);
|
||||||
(
|
(
|
||||||
(color1.0 as f32 * (1.0 - t) + color2.0 as f32 * t) as u8,
|
(color1.0 as f32 * (1.0 - t) + color2.0 as f32 * t) as u8,
|
||||||
|
|
@ -38,7 +43,7 @@ impl Animator {
|
||||||
pub async fn typewrite(text: &str, wpm: u64) {
|
pub async fn typewrite(text: &str, wpm: u64) {
|
||||||
// 120 WPM = ~10 chars/sec = 100ms per char
|
// 120 WPM = ~10 chars/sec = 100ms per char
|
||||||
let delay_ms = 60000 / (wpm * 5); // chars per word ~5
|
let delay_ms = 60000 / (wpm * 5); // chars per word ~5
|
||||||
|
|
||||||
for ch in text.chars() {
|
for ch in text.chars() {
|
||||||
print!("{}", ch);
|
print!("{}", ch);
|
||||||
stdout().flush().unwrap();
|
stdout().flush().unwrap();
|
||||||
|
|
@ -62,26 +67,28 @@ pub fn gradient(text: &str, start_hue: f32) -> String {
|
||||||
pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
/// Wave animation for progress
|
/// Wave animation for progress
|
||||||
pub const WAVE: &[&str] = &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"];
|
pub const WAVE: &[&str] = &[
|
||||||
|
"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂",
|
||||||
|
];
|
||||||
|
|
||||||
/// Persona signature colors
|
/// Persona signature colors
|
||||||
pub mod colors {
|
pub mod colors {
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
|
||||||
// Ani: Warm, gentle, inviting
|
// 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_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
|
// 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_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
|
// Eione: Creative, flowing, purple
|
||||||
pub const EIONE_PRIMARY: Color = Color::Rgb(155, 89, 182); // 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_SECONDARY: Color = Color::Rgb(200, 150, 220); // Light purple
|
||||||
pub const EIONE_DIM: Color = Color::Rgb(120, 80, 140); // Muted
|
pub const EIONE_DIM: Color = Color::Rgb(120, 80, 140); // Muted
|
||||||
|
|
||||||
// Subconscious surfacing: Gray, dim, italic
|
// Subconscious surfacing: Gray, dim, italic
|
||||||
pub const SUBCONSCIOUS: Color = Color::Rgb(128, 128, 128);
|
pub const SUBCONSCIOUS: Color = Color::Rgb(128, 128, 128);
|
||||||
|
|
@ -103,7 +110,7 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||||
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
||||||
let m = l - c / 2.0;
|
let m = l - c / 2.0;
|
||||||
|
|
||||||
let (r1, g1, b1) = if h < 60.0 {
|
let (r1, g1, b1) = if h < 60.0 {
|
||||||
(c, x, 0.0)
|
(c, x, 0.0)
|
||||||
} else if h < 120.0 {
|
} else if h < 120.0 {
|
||||||
|
|
@ -117,7 +124,7 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||||
} else {
|
} else {
|
||||||
(c, 0.0, x)
|
(c, 0.0, x)
|
||||||
};
|
};
|
||||||
|
|
||||||
(
|
(
|
||||||
((r1 + m) * 255.0) as u8,
|
((r1 + m) * 255.0) as u8,
|
||||||
((g1 + m) * 255.0) as u8,
|
((g1 + m) * 255.0) as u8,
|
||||||
|
|
@ -131,10 +138,10 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||||
/// center outward. Three render modes cycle: braille dots, block fills,
|
/// center outward. Three render modes cycle: braille dots, block fills,
|
||||||
/// and ASCII characters. Inspired by peonia.html.
|
/// and ASCII characters. Inspired by peonia.html.
|
||||||
pub mod bloom {
|
pub mod bloom {
|
||||||
|
use crate::ui::color_support::rgb;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
use crate::ui::color_support::rgb;
|
|
||||||
|
|
||||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*";
|
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*";
|
||||||
/// Sharp spike chars for wicked edges
|
/// Sharp spike chars for wicked edges
|
||||||
|
|
@ -152,7 +159,13 @@ pub mod bloom {
|
||||||
|
|
||||||
impl BloomState {
|
impl BloomState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { progress: 0.0, mode: 0, mode_timer: 0.0, flash: 0.0, variant: 0 }
|
Self {
|
||||||
|
progress: 0.0,
|
||||||
|
mode: 0,
|
||||||
|
mode_timer: 0.0,
|
||||||
|
flash: 0.0,
|
||||||
|
variant: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn advance(&mut self, dt: f32) {
|
pub fn advance(&mut self, dt: f32) {
|
||||||
|
|
@ -187,10 +200,12 @@ pub mod bloom {
|
||||||
let stem_top = cy + max_r * 0.3;
|
let stem_top = cy + max_r * 0.3;
|
||||||
let stem_visible = stem_len * stem_progress;
|
let stem_visible = stem_len * stem_progress;
|
||||||
let stem_bot = stem_top + stem_visible;
|
let stem_bot = stem_top + stem_visible;
|
||||||
for y in (stem_top.max(0.0) as u16)..(stem_bot.max(0.0) as u16).min(area.y + area.height) {
|
for y in
|
||||||
|
(stem_top.max(0.0) as u16)..(stem_bot.max(0.0) as u16).min(area.y + area.height)
|
||||||
|
{
|
||||||
let tt = (y as f32 - stem_top) / stem_len;
|
let tt = (y as f32 - stem_top) / stem_len;
|
||||||
let curve = (tt * std::f32::consts::PI * 0.3).sin() * 6.0
|
let curve = (tt * std::f32::consts::PI * 0.3).sin() * 6.0
|
||||||
+ (tt * std::f32::consts::PI * 0.8).sin() * 2.0;
|
+ (tt * std::f32::consts::PI * 0.8).sin() * 2.0;
|
||||||
let col = (area.x as f32 + cx + curve).max(0.0) as u16;
|
let col = (area.x as f32 + cx + curve).max(0.0) as u16;
|
||||||
if col < area.x + area.width && y < area.y + area.height {
|
if col < area.x + area.width && y < area.y + area.height {
|
||||||
let c = buf.get_mut(col, y);
|
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 spike_bloom = ((bloom - 0.5) / 0.4).min(1.0);
|
||||||
let num_spikes = 16u32;
|
let num_spikes = 16u32;
|
||||||
for i in 0..num_spikes {
|
for i in 0..num_spikes {
|
||||||
let angle = (std::f32::consts::TAU / num_spikes as f32) * i as f32
|
let angle =
|
||||||
+ (t * 0.05).sin() * 0.2;
|
(std::f32::consts::TAU / num_spikes as f32) * i as f32 + (t * 0.05).sin() * 0.2;
|
||||||
let spike_dist = max_r * 0.85 * spike_bloom;
|
let spike_dist = max_r * 0.85 * spike_bloom;
|
||||||
for si in 0..3 {
|
for si in 0..3 {
|
||||||
let sd = spike_dist + si as f32 * 1.5;
|
let sd = spike_dist + si as f32 * 1.5;
|
||||||
|
|
@ -390,22 +405,27 @@ pub mod bloom {
|
||||||
1 => {
|
1 => {
|
||||||
// Block fills with sharper edge transitions
|
// Block fills with sharper edge transitions
|
||||||
let density = if s < 0.3 {
|
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 {
|
} 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 {
|
} else {
|
||||||
0.8
|
0.8
|
||||||
};
|
};
|
||||||
if density > 0.75 { '█' }
|
if density > 0.75 {
|
||||||
else if density > 0.5 { '▓' }
|
'█'
|
||||||
else if density > 0.3 { '▒' }
|
} else if density > 0.5 {
|
||||||
else { '░' }
|
'▓'
|
||||||
|
} else if density > 0.3 {
|
||||||
|
'▒'
|
||||||
|
} else {
|
||||||
|
'░'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Braille — use sparser patterns near edges for sharper look
|
// Braille — use sparser patterns near edges for sharper look
|
||||||
let braille_base = 0x2800u32;
|
let braille_base = 0x2800u32;
|
||||||
let density_mask = if s > 0.7 {
|
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 {
|
} else {
|
||||||
255u32
|
255u32
|
||||||
};
|
};
|
||||||
|
|
@ -430,14 +450,14 @@ pub mod bloom {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gradient() {
|
fn test_gradient() {
|
||||||
let text = "Ani";
|
let text = "Ani";
|
||||||
let result = gradient(text, 30.0); // Orange-ish start
|
let result = gradient(text, 30.0); // Orange-ish start
|
||||||
assert!(result.contains("\x1b[38;2;")); // Contains ANSI color code
|
assert!(result.contains("\x1b[38;2;")); // Contains ANSI color code
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_breathe() {
|
fn test_breathe() {
|
||||||
let animator = Animator::new();
|
let animator = Animator::new();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use super::{App, Screen};
|
use super::{App, Screen};
|
||||||
use crate::ui::presence::Presence;
|
|
||||||
use crate::ui::component::TuiEvent;
|
use crate::ui::component::TuiEvent;
|
||||||
|
use crate::ui::presence::Presence;
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub(super) fn add_available_agent(&mut self, agent_name: String) {
|
pub(super) fn add_available_agent(&mut self, agent_name: String) {
|
||||||
|
|
@ -78,14 +78,22 @@ impl App {
|
||||||
if self.available_agents.is_empty() {
|
if self.available_agents.is_empty() {
|
||||||
// No agents available yet - create a default alias
|
// No agents available yet - create a default alias
|
||||||
// This is WIP - will be expanded with full agent creation flow
|
// This is WIP - will be expanded with full agent creation flow
|
||||||
let default_agents = vec!["Ani".to_string(), "JeanLuc".to_string(), "Eione".to_string()];
|
let default_agents = vec![
|
||||||
|
"Ani".to_string(),
|
||||||
|
"JeanLuc".to_string(),
|
||||||
|
"Eione".to_string(),
|
||||||
|
];
|
||||||
for agent in default_agents {
|
for agent in default_agents {
|
||||||
self.add_available_agent(agent);
|
self.add_available_agent(agent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the agent name to avoid borrow checker issues
|
// Clone the agent name to avoid borrow checker issues
|
||||||
let agent_to_select = if let Some(current_idx) = self.available_agents.iter().position(|a| a == &self.agent_pref) {
|
let agent_to_select = if let Some(current_idx) = self
|
||||||
|
.available_agents
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == &self.agent_pref)
|
||||||
|
{
|
||||||
let next_idx = (current_idx + 1) % self.available_agents.len();
|
let next_idx = (current_idx + 1) % self.available_agents.len();
|
||||||
self.available_agents[next_idx].clone()
|
self.available_agents[next_idx].clone()
|
||||||
} else if !self.available_agents.is_empty() {
|
} else if !self.available_agents.is_empty() {
|
||||||
|
|
@ -98,9 +106,9 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn agent_id_by_name(&self, name: &str) -> Option<String> {
|
pub(super) fn agent_id_by_name(&self, name: &str) -> Option<String> {
|
||||||
self.agent_cards.iter()
|
self.agent_cards
|
||||||
|
.iter()
|
||||||
.find(|c| c.name.eq_ignore_ascii_case(name))
|
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||||||
.map(|c| c.id.clone())
|
.map(|c| c.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue