Watch
1
0
Fork
You've already forked souveraine
0

refactor: split app.rs and local.rs into focused modules

app.rs (3540 lines) → 10 modules: mod.rs (event loop core), welcome,
dashboard, presence_screen, manager_screen, voice, settings_handler,
splash, images, agents.

local.rs (1827 lines) → 5 modules: mod.rs (Backend impl), turn
(run_turn + tool loop), consciousness (TurnInjector + surfacings),
subagent (LocalSubagentRunner), energy (balance writer + event).

No behavioral changes — same code, different files. 169 tests pass.
This commit is contained in:
Fimeg 2026-05-19 10:03:01 -04:00
commit d3d485f9e8
17 changed files with 5404 additions and 5367 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,212 @@
use anyhow::Result;
use futures::stream::StreamExt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use crate::bridge::bifrost::Message as BifrostMessage;
use crate::core::nervous::EventBus;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::server::{ConsciousnessEvent, SouveraineServer};
use crate::backend::{Backend, BackendEvent};
use super::LocalBackend;
#[async_trait::async_trait]
impl crate::core::nervous::handler::TurnInjector for LocalBackend {
/// Heartbeat-driven turn injection. The cron loop pauses while
/// `active_sessions > 0`, so by the time we get here the agent is
/// idle. We grab the most recent conversation (or create a fresh one
/// if the agent has none), append the scheduled prompt as a user
/// message, and drain the resulting stream — the turn runs silently
/// in the background. Anything subconscious surfaces lands in the inbox.
async fn inject_background_turn(
&self,
agent_id: &str,
text: &str,
) -> anyhow::Result<()> {
let conv_id = match self.server.sessions.list_for_agent(agent_id).last().cloned() {
Some(id) => id,
None => self.ensure_conversation(agent_id).await?,
};
let stream = self.send(&conv_id, text).await?;
// Drain the stream in the background — no UI is listening. But the
// subconscious's N+1 pass runs inside this turn, and what she
// surfaces (a commitment, a reflection, an archivist synthesis)
// would otherwise vanish with the drained events. Collect those and
// stash them so the next TUI/CLI session shows the human what
// happened during the autonomous cycle.
let server = self.server.clone();
let agent_id = agent_id.to_string();
tokio::spawn(async move {
use crate::core::nervous::pending::PendingSurfacing;
let mut s = stream;
let mut stashed: Vec<PendingSurfacing> = Vec::new();
while let Some(ev) = s.next().await {
match ev {
Ok(BackendEvent::Surfacing { source, content, priority }) => {
// Skip the no-op heartbeat sentinel — the subconscious
// always queues a low "pass complete, no anomalies"
// item so the UI shows the pass ran. That is noise to
// resurface on connect; only stash real observations.
if priority.eq_ignore_ascii_case("low")
&& content.contains("no anomalies detected")
{
continue;
}
stashed.push(PendingSurfacing {
kind: "surfacing".to_string(),
source,
content,
priority,
at: chrono::Utc::now(),
});
}
Ok(BackendEvent::Reflection(content)) => {
stashed.push(PendingSurfacing {
kind: "reflection".to_string(),
source: String::new(),
content,
priority: String::new(),
at: chrono::Utc::now(),
});
}
Ok(BackendEvent::Archivist { synthesis, .. }) => {
stashed.push(PendingSurfacing {
kind: "archivist".to_string(),
source: String::new(),
content: synthesis,
priority: String::new(),
at: chrono::Utc::now(),
});
}
_ => {}
}
}
if !stashed.is_empty() {
let dir = server.agents.agent_data_dir(&agent_id);
match crate::core::nervous::pending::append(&dir, &stashed).await {
Ok(()) => tracing::info!(
agent = %agent_id,
count = stashed.len(),
"stashed heartbeat surfacings for next session"
),
Err(e) => tracing::warn!(
"pending heartbeat surfacings stash failed: {}", e
),
}
}
});
Ok(())
}
/// Surface-initiated turn injection. Called by the
/// [`SensoriumInputHandler`] when a `sensorium:input` event arrives.
///
/// Unlike background turns, the turn's output events are NOT drained
/// here — `run_turn` already fires them onto the EventBus as `turn:*`
/// events (via `TurnEventDispatcher`). The originating sensorium's
/// `run` loop consumes those events for incremental rendering.
///
/// We drain the stream only to prevent backpressure on the mpsc
/// channel. The EventBus is the public event system; the stream is
/// a TUI-internal detail.
async fn inject_surface_turn(
&self,
agent_id: &str,
conversation_id: &str,
text: &str,
) -> anyhow::Result<()> {
// Resolve the surface chat ID to a Souveraine conversation ID.
// Matrix room IDs are not Souveraine conversation IDs — the
// mapping survives for the lifetime of the surface session so
// subsequent messages in the same room route to the same
// conversation. The lock scope is carefully bounded to avoid
// holding a !Send MutexGuard across the .await below.
let conv_id = {
let map = self.surface_conversations.lock().unwrap();
if let Some(id) = map.get(conversation_id) {
Some(id.clone())
} else {
None
}
};
let conv_id = match conv_id {
Some(id) => id,
None => {
let id = self.ensure_conversation(agent_id).await?;
self.surface_conversations
.lock()
.unwrap()
.insert(conversation_id.to_string(), id.clone());
id
}
};
let stream = self.send(&conv_id, text).await?;
// Drain the stream in the background — the EventBus already carries
// every `turn:*` event via TurnEventDispatcher. The sensorium
// renders from the bus. We drain here so the mpsc channel doesn't
// back up.
tokio::spawn(async move {
let mut s = stream;
while let Some(ev) = s.next().await {
if ev.is_err() {
break;
}
}
});
Ok(())
}
}
/// Drain subconscious's intrusive box for the given agent and return formatted
/// `[ surfacing: ... ]` lines ready to prepend to the user's next message.
/// Marks each drained item as delivered (moved to `sent.md`). Mirrors
/// lettabot-v017's `readSurfacingThoughts` + `clearSurfacingThoughts` pair
/// (`~/Projects/lettabot-v017/src/core/prompts.ts:64-91`) — the substrate
/// reads the channel subconscious wrote to and lets the conscious mind see it
/// before she reads Casey.
///
/// Critical urgency gets `[ surfacing — CRITICAL: ... ]`. High becomes
/// `[ surfacing — !: ... ]`. Low/none keep the bare form. The shape is a
/// gradient the agent feels, not a number she has to read.
pub(super) async fn drain_intrusive_surfacings(
server: &Arc<SouveraineServer>,
agent_id: &str,
) -> Vec<String> {
use crate::core::subconscious::{SubconsciousInbox, Urgency};
let sub_repo = server.agents.subconscious_memory_repo(agent_id);
let primary_repo = server.agents.memory_repo(agent_id);
let inbox = SubconsciousInbox::with_primary(sub_repo, primary_repo);
let items = match inbox.get_intrusive().await {
Ok(items) => items,
Err(e) => {
tracing::debug!("intrusive surfacing read failed (continuing without): {}", e);
return Vec::new();
}
};
if items.is_empty() {
return Vec::new();
}
let mut lines = Vec::with_capacity(items.len());
for item in &items {
let prefix = match item.urgency {
Urgency::Critical => "[ surfacing — CRITICAL:",
Urgency::High => "[ surfacing — !:",
Urgency::Low => "[ surfacing:",
};
let content = item.content.trim();
lines.push(format!("{} {} ]", prefix, content));
if let Err(e) = inbox.mark_delivered(&item.id).await {
tracing::debug!("mark_delivered failed for {}: {}", item.id, e);
}
}
lines
}

143
src/backend/local/energy.rs Normal file
View file

@ -0,0 +1,143 @@
use anyhow::Result;
use std::sync::Arc;
use crate::core::nervous::EventBus;
use crate::server::SouveraineServer;
pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, event_bus: &EventBus) -> Result<()> {
let memory_root = server.agents.memory_root(agent_id);
let tasks_dir = memory_root.join("tasks");
if !tasks_dir.exists() {
// No tasks directory yet — nothing to count.
return Ok(());
}
let mut generative: usize = 0;
let mut consumptive: usize = 0;
let mut hot: usize = 0;
let mut warm: usize = 0;
let mut cold: usize = 0;
if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
// Quick frontmatter parse — just the fields we need.
let body = match content.strip_prefix("---\n") {
Some(rest) => match rest.find("\n---\n") {
Some(end) => &rest[..end],
None => continue,
},
None => continue,
};
let mut status: Option<&str> = None;
let mut energy: Option<&str> = None;
let mut momentum: Option<&str> = None;
for line in body.lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"status" => status = Some(val),
"energy" => energy = Some(val),
"momentum" => momentum = Some(val),
_ => {}
}
}
}
// Only live commitments weigh on the energy balance —
// done and cancelled ones have been set down.
if matches!(status, Some("pending") | Some("in_progress")) {
match energy {
Some("generative") => generative += 1,
_ => consumptive += 1,
}
match momentum {
Some("hot") => hot += 1,
Some("warm") => warm += 1,
_ => cold += 1,
}
}
}
}
}
// Determine the top-of-mind description — shifts the tone of the one-liner
// the agent reads in context. Matches the lettabot-v017 heartbeat topology.
let ratio = if generative + consumptive > 0 {
generative as f32 / (generative + consumptive) as f32
} else {
0.5
};
let description = if generative == 0 && consumptive == 0 {
"no tasks — the space is clean".to_string()
} else if ratio < 0.2 {
"all-consumptive — the engine is running cold".to_string()
} else if ratio < 0.4 {
"mostly obligations — tending the garden".to_string()
} else if ratio > 0.8 {
"all-generative — building new things".to_string()
} else if ratio > 0.6 {
"mostly generative — restless momentum".to_string()
} else {
"balanced — generative and consumptive in rhythm".to_string()
};
let now = chrono::Utc::now();
let frontmatter = format!(
"---\nupdated: {updated}\ngenerative: {gen}\nconsumptive: {con}\nratio: {ratio:.2}\n\
hot: {hot}\nwarm: {warm}\ncold: {cold}\n---\n\n# Energy Balance\n\n\
{gen} generative, {con} consumptive ({hot} hot, {warm} warm, {cold} cold). {desc}\n",
updated = now.to_rfc3339(),
gen = generative,
con = consumptive,
ratio = ratio,
hot = hot,
warm = warm,
cold = cold,
desc = description,
);
let balance_path = memory_root.join("system").join("dynamic").join("energy-balance.md");
if let Some(parent) = balance_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&balance_path, frontmatter)?;
fire_energy_event(event_bus, agent_id, generative, consumptive, ratio);
tracing::debug!(
agent = agent_id,
generative, consumptive,
"energy-balance written"
);
Ok(())
}
/// Fire an energy_balance_updated event so the firehose carries the
/// agent's felt state across machines.
fn fire_energy_event(event_bus: &EventBus, agent_id: &str, generative: usize, consumptive: usize, ratio: f32) {
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "energy".into(),
timestamp: chrono::Utc::now(),
event_type: "energy_balance_updated".into(),
target: Some(agent_id.to_string()),
urgency: 0.1,
payload: Some(serde_json::json!({
"generative": generative,
"consumptive": consumptive,
"ratio": ratio,
})),
seed_id: None,
reply_to: None,
});
}

570
src/backend/local/mod.rs Normal file
View file

@ -0,0 +1,570 @@
//! In-process Backend impl. Same engine as the HTTP server, no socket.
//!
//! Constructed once with a `ConsciousnessConfig`; spins up an `AgentInventory`
//! (SQLite under `~/.souveraine/server/`), `SessionManager`, `BifrostClient`,
//! and `ConsciousnessEngine`. `send` mirrors the server's `stream_messages`
//! handler, but emits `BackendEvent`s directly instead of SSE frames.
//!
//! This is the "harness still works when the server is gone" path
//! (`souveraine chat --local`, or auto-fallback when the remote is down).
pub(crate) mod energy;
mod turn;
mod consciousness;
mod subagent;
pub use subagent::LocalSubagentRunner;
use anyhow::{Context, Result};
use async_trait::async_trait;
use futures::stream::{BoxStream, StreamExt};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
use crate::core::config::ConsciousnessConfig;
use crate::core::identity::SeedId;
use crate::core::nervous::EventBus;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::{SubagentParams, SubagentRunner, ToolContext};
use crate::server::{ConsciousnessEvent, SouveraineServer};
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
/// Below 95%: no cap. At 95%+: scale max_tokens so context + output
/// stays under the model's limit. The agent feels the room shrink.
fn pressure_to_max_tokens(pressure: f32, output_limit: u32) -> Option<u32> {
if pressure <= 0.95 {
return None;
}
let remaining = (1.0 - pressure) / 0.05;
let ratio = remaining.max(0.0).min(1.0);
Some((output_limit as f32 * ratio) as u32)
}
/// Mirror of `ConsciousnessEngine::calculate_pressure` for the in-loop
/// BifrostMessage shape, so we can recompute pressure as tool results
/// accumulate inside a single turn. `context_limit` comes from the
/// agent's `llm_config.context_window` (Constitution V.3 — per-model
/// physics, no hardcoded 128K).
fn bifrost_pressure(counter: &TokenCounter, messages: &[BifrostMessage], context_limit: usize) -> f32 {
let tokens: usize = messages.iter().map(|m| counter.count(&m.content)).sum();
let limit = context_limit.max(1);
(tokens as f32 / limit as f32).min(1.0)
}
/// Helper: bump adaptive delay when we hit a 429. No decay — once bumped,
/// the delay stays at that level until the app restarts.
fn bump_on_strain(delay: &AtomicU64, status: u16) {
if status == 429 {
let current = delay.load(Ordering::Relaxed);
let bumped = (current + 200).min(3000);
if bumped > current {
delay.store(bumped, Ordering::Relaxed);
tracing::info!("rate delay bumped to {}ms (429)", bumped);
}
}
}
#[derive(Clone)]
pub struct LocalBackend {
server: Arc<SouveraineServer>,
event_bus: EventBus,
seed_id: Arc<SeedId>,
active_sessions: Arc<AtomicU32>,
sensorium: Arc<tokio::sync::Mutex<crate::core::sensorium::SensoriumCoordinator>>,
surface_conversations: Arc<std::sync::Mutex<HashMap<String, String>>>,
}
impl LocalBackend {
pub async fn new(config: ConsciousnessConfig) -> Result<Self> {
let server = SouveraineServer::new(config.clone())
.await
.context("LocalBackend: SouveraineServer init")?;
let event_bus = server.event_bus.clone();
let base = config
.memory
.base_path
.clone()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".souveraine"));
// Load or generate the seed identity (trust root)
let seed_dir = SeedId::default_dir(&base);
let seed_id = Arc::new(
SeedId::load_or_generate(&seed_dir)
.context("SeedId init")?,
);
tracing::info!(
pubkey = %seed_id.public_key_hex(),
"seed identity loaded"
);
// Spawn the persistent event log (firehose to disk)
let events_dir = base.join("events");
let mut event_log =
crate::core::nervous::event_log::EventLog::new(events_dir, event_bus.subscribe());
tokio::spawn(async move { event_log.run().await });
let active_sessions = Arc::new(AtomicU32::new(0));
let backend = Self {
server: Arc::new(server),
event_bus: event_bus.clone(),
seed_id,
active_sessions: active_sessions.clone(),
sensorium: Arc::new(tokio::sync::Mutex::new(
crate::core::sensorium::SensoriumCoordinator::new(),
)),
surface_conversations: Arc::new(std::sync::Mutex::new(HashMap::new())),
};
// Spawn one CronSensor per agent (each agent owns its own schedules
// directory), and one HeartbeatHandler on the bus that injects turns
// when a schedule fires. The handler holds an Arc<dyn TurnInjector>
// pointing back at us — clean dep direction, no LocalBackend leak
// into the nervous module.
let agents_dir = base.join("agents");
match backend.server.agents.list(None).await {
Ok(summaries) => {
for summary in summaries {
let schedules_dir = agents_dir.join(&summary.id).join("schedules");
if let Err(e) = std::fs::create_dir_all(&schedules_dir) {
tracing::warn!(
agent = %summary.id,
error = %e,
"could not create schedules dir; skipping cron sensor"
);
continue;
}
let sensor = crate::core::nervous::cron::CronSensor::new(
summary.id.clone(),
schedules_dir,
event_bus.clone(),
active_sessions.clone(),
);
tokio::spawn(async move { sensor.run().await });
tracing::info!(agent = %summary.id, "cron sensor spawned");
}
}
Err(e) => {
tracing::warn!(error = %e, "agent listing failed; no cron sensors spawned");
}
}
let injector: Arc<dyn crate::core::nervous::handler::TurnInjector> =
Arc::new(backend.clone());
// Hand the same injector to the summon handler so an inbound
// federation request can auto-wake the agent (gated on auto_wake).
if let Some(sh) = &backend.server.summon_handler {
sh.set_injector(injector.clone());
}
let mut handler =
crate::core::nervous::handler::HeartbeatHandler::new(event_bus.subscribe(), injector.clone());
tokio::spawn(async move { handler.run().await });
tracing::info!("heartbeat handler spawned");
// Spawn the sensorium input handler — subscribes to
// `sensorium:input` events from non-terminal surfaces (Matrix,
// email, federation) and injects turns on their behalf.
// Same pattern as HeartbeatHandler; identical wiring.
let mut input_handler =
crate::core::nervous::handler::SensoriumInputHandler::new(
event_bus.subscribe(),
injector,
);
tokio::spawn(async move { input_handler.run().await });
tracing::info!("sensorium input handler spawned");
Ok(backend)
}
pub fn from_server(server: Arc<SouveraineServer>) -> Self {
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let seed_id = Arc::new(
SeedId::load_or_generate(&SeedId::default_dir(&base))
.unwrap_or_else(|_| SeedId::generate()),
);
let event_bus = server.event_bus.clone();
Self {
event_bus,
server,
seed_id,
active_sessions: Arc::new(AtomicU32::new(0)),
sensorium: Arc::new(tokio::sync::Mutex::new(
crate::core::sensorium::SensoriumCoordinator::new(),
)),
surface_conversations: Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
pub fn event_bus(&self) -> &EventBus {
&self.event_bus
}
pub fn seed_id(&self) -> &SeedId {
&self.seed_id
}
/// Underlying agent inventory — used by the TUI dashboard to pull a
/// `MemoryRepo` for live git-stat readouts.
pub fn server_agents(&self) -> Arc<crate::server::AgentInventory> {
self.server.agents.clone()
}
/// Underlying server. CLI subcommands (e.g. `souveraine reflect`)
/// reach in here for the consciousness engine and session manager.
pub fn server(&self) -> Arc<crate::server::SouveraineServer> {
self.server.clone()
}
/// Register a sensorium on the coordinator and spawn its run loop.
///
/// Each sensorium gets its own task, a shared EventBus subscription,
/// and a child CancellationToken. `shutdown_sensoria` cancels all of
/// them. Can be called at any time — the coordinator drains registered
/// sensoria on `run_all` and accepts new ones afterward.
pub async fn register_sensorium(
&self,
sensorium: Box<dyn crate::core::sensorium::Sensorium>,
) {
let mut coord = self.sensorium.lock().await;
coord.register(sensorium);
coord.run_all(self.event_bus.clone());
}
/// Shut down all running sensorium tasks.
pub async fn shutdown_sensoria(&self) {
let coord = self.sensorium.lock().await;
coord.shutdown();
}
/// Build the greeting line describing the agent's current visual state
/// (atmosphere and outfit). Returns `None` when no atmosphere is set in
/// config (fresh init, no state to report).
async fn build_visual_greeting(&self) -> Option<String> {
let config = self.server.app_config.read().await;
let atm = config.presence.atmosphere.as_deref()?;
if atm.is_empty() {
return None;
}
let display = atm.replace('_', " ");
let outfit = config.presence.outfit.as_deref().unwrap_or("default");
Some(format!(
"\n\nYour current atmosphere is {}, wearing the \"{}\" outfit.",
display, outfit
))
}
}
#[async_trait]
impl Backend for LocalBackend {
async fn health(&self) -> bool {
true
}
async fn list_agents(&self) -> Result<Vec<AgentInfo>> {
let agents = self.server.agents.list(None).await?;
Ok(agents
.into_iter()
.map(|a| AgentInfo {
id: a.id,
name: a.name,
description: a.description,
})
.collect())
}
async fn new_conversation(&self, agent_id: &str) -> Result<String> {
let _ = self.server.agents.get(agent_id).await?;
let conv_id = self.server.sessions.create(agent_id);
if let Err(e) = self.server.agents.register_instance(agent_id, &self.server.instance_id).await {
tracing::warn!(agent = %agent_id, "instance registration failed: {}", e);
}
let memory_root = self.server.agents.memory_root(agent_id);
let subconscious_root = self.server.agents.subconscious_memory_root(agent_id);
let (bundled, user, agent_memfs, project) =
crate::core::skills::default_discovery_paths(Some(memory_root.clone()));
let skills = crate::core::skills::discover(
bundled.as_deref(),
user.as_deref(),
agent_memfs.as_deref(),
project.as_deref(),
)
.await
.unwrap_or_default();
let platform_prompt = self.server.app_config.read().await
.agent.system_prompt.clone();
let system_prompt = crate::core::prompt::build_system_prompt_full(
&memory_root,
Some(&subconscious_root),
platform_prompt.as_deref(),
Some(&skills),
)
.await;
// Append visual state greeting.
let greeting_extra = self.build_visual_greeting().await;
let system_prompt = if let Some(extra) = greeting_extra {
format!("{}{}", system_prompt, extra)
} else {
system_prompt
};
self.server.sessions.add_message(
&conv_id,
ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: system_prompt,
}],
usage: None,
timestamp: Some(chrono::Utc::now()),
},
)?;
Ok(conv_id)
}
async fn fork_conversation(&self, _agent_id: &str, source_conversation_id: &str) -> Result<String> {
let forked_id = self.server.sessions.fork(source_conversation_id)?;
Ok(forked_id)
}
async fn list_conversations(&self, agent_id: &str) -> Result<Vec<ConversationInfo>> {
let store = match self.server.sessions.conversation_store_for(agent_id) {
Some(s) => s,
None => {
let conv_ids = self.server.sessions.list_for_agent(agent_id);
return Ok(conv_ids
.into_iter()
.filter_map(|id| {
let session = self.server.sessions.get(&id)?;
Some(ConversationInfo {
id: session.conversation_id.clone(),
agent_id: session.agent_id.clone(),
summary: None,
message_count: session.messages.len() as u32,
updated_at: session.updated_at.to_rfc3339(),
})
})
.collect());
}
};
let records = store.list_active().await?;
Ok(records
.into_iter()
.map(|r| ConversationInfo {
id: r.id,
agent_id: r.agent_id,
summary: r.summary,
message_count: r.message_count,
updated_at: r.updated_at.to_rfc3339(),
})
.collect())
}
async fn load_conversation(
&self,
conversation_id: &str,
) -> Result<Vec<crate::core::session::ConversationMessage>> {
if let Some(session) = self.server.sessions.get(conversation_id) {
return Ok(session.messages.clone());
}
// Not in memory — try loading from disk. We need the agent_id to find the store.
// Search all known agents.
let agents = self.server.agents.list(None).await?;
for agent in agents {
if let Some(store) = self.server.sessions.conversation_store_for(&agent.id) {
if let Ok(Some(_record)) = store.load_metadata(conversation_id).await {
let messages = store.load_messages(conversation_id).await?;
self.server.sessions.create_with_messages(
&agent.id,
conversation_id.to_string(),
messages.clone(),
);
return Ok(messages);
}
}
}
anyhow::bail!("Conversation not found: {}", conversation_id)
}
async fn ensure_conversation(&self, agent_id: &str) -> Result<String> {
let _ = self.server.agents.get(agent_id).await?;
let conv_id = self.server.sessions.create(agent_id);
// Build system prompt from the agent's memfs and inject as first message
let memory_root = self.server.agents.memory_root(agent_id);
let subconscious_root = self.server.agents.subconscious_memory_root(agent_id);
// Discover skills from all 4 tiers
let (bundled, user, agent_memfs, project) =
crate::core::skills::default_discovery_paths(Some(memory_root.clone()));
let skills = crate::core::skills::discover(
bundled.as_deref(),
user.as_deref(),
agent_memfs.as_deref(),
project.as_deref(),
)
.await
.unwrap_or_default();
let platform_prompt = self.server.app_config.read().await
.agent.system_prompt.clone();
let system_prompt = crate::core::prompt::build_system_prompt_full(
&memory_root,
Some(&subconscious_root),
platform_prompt.as_deref(),
Some(&skills),
)
.await;
// Append visual state greeting.
let greeting_extra = self.build_visual_greeting().await;
let system_prompt = if let Some(extra) = greeting_extra {
format!("{}{}", system_prompt, extra)
} else {
system_prompt
};
self.server.sessions.add_message(
&conv_id,
ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: system_prompt,
}],
usage: None,
timestamp: Some(chrono::Utc::now()),
},
)?;
Ok(conv_id)
}
async fn send(
&self,
conversation_id: &str,
text: &str,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
// No cancel signal — heartbeat path, drain path. Use a token that
// never fires.
self.send_with_cancel(conversation_id, text, CancellationToken::new()).await
}
async fn send_with_cancel(
&self,
conversation_id: &str,
text: &str,
cancel: CancellationToken,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
// No queue passed in — use an empty queue. Equivalent to the old behavior.
let empty: crate::backend::InterjectionQueue = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
self.send_with_signals(conversation_id, text, cancel, empty).await
}
async fn send_with_signals(
&self,
conversation_id: &str,
text: &str,
cancel: CancellationToken,
interject: crate::backend::InterjectionQueue,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
// Resolve the agent for this conversation, then drain her
// subconscious's intrusive box. Anything subconscious queued after the
// last turn rides in on Casey's next message as `[ surfacing: ... ]`
// lines — the lettabot-v017 pattern, ported. This is the channel
// by which a Critical observation can interrupt mid-conversation
// without forcing a halt: she sees it before she reads Casey.
let session_agent_id = self
.server
.sessions
.get(conversation_id)
.map(|s| s.agent_id.clone());
// Ambient sense rides in front of every turn — the date/time and who
// is present — so she is never guessing what year it is.
let ambient = crate::core::sensorium::ambient_line();
let user_text = if let Some(agent_id) = session_agent_id {
let surfacings = consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
if surfacings.is_empty() {
format!("{}\n{}", ambient, text)
} else {
let prelude = surfacings
.iter()
.map(|line| line.as_str())
.collect::<Vec<_>>()
.join("\n");
format!("{}\n{}\n{}", ambient, prelude, text)
}
} else {
format!("{}\n{}", ambient, text)
};
self.server.sessions.add_message(
conversation_id,
ConversationMessage::user_text(&user_text),
)?;
let (tx, rx) = mpsc::channel::<Result<BackendEvent>>(64);
let server = self.server.clone();
let conv_id = conversation_id.to_string();
let event_bus = self.event_bus.clone();
let active = self.active_sessions.clone();
active.fetch_add(1, Ordering::Relaxed);
tokio::spawn(async move {
if let Err(e) = turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject).await {
let _ = tx.send(Err(e)).await;
}
let _ = tx.send(Ok(BackendEvent::Done)).await;
active.fetch_sub(1, Ordering::Relaxed);
});
Ok(ReceiverStream::new(rx).boxed())
}
async fn update_agent_model(&self, agent_id: &str, model: &str) -> Result<()> {
// Load current llm_config so we only change the model field —
// context_window, temperature, tool rounds stay as they were.
let current = self.server.agents.get(agent_id).await?;
let update = crate::api::models::UpdateAgentRequest {
name: None,
description: None,
llm_config: Some(crate::api::models::LlmConfig {
model: model.to_string(),
context_window: current.llm_config.context_window,
temperature: current.llm_config.temperature,
max_tool_rounds: current.llm_config.max_tool_rounds,
inter_round_delay_ms: current.llm_config.inter_round_delay_ms,
}),
memory_blocks: None,
tools: None,
};
self.server.agents.update(agent_id, update).await?;
tracing::info!(agent = %agent_id, model = %model, "agent llm_config model updated via settings");
Ok(())
}
async fn take_pending_surfacings(
&self,
agent_id: &str,
) -> Vec<crate::core::nervous::pending::PendingSurfacing> {
let dir = self.server.agents.agent_data_dir(agent_id);
crate::core::nervous::pending::take(&dir).await
}
}

View file

@ -0,0 +1,223 @@
use anyhow::Result;
use async_trait::async_trait;
use std::sync::Arc;
use std::time::Duration;
use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
use crate::core::tools::defs::{SubagentParams, SubagentRunner, ToolContext};
use crate::server::SouveraineServer;
// ── LocalSubagentRunner ──────────────────────────────────────────
/// Implements [`SubagentRunner`] by running a full turn against the
/// LocalBackend's server infrastructure — loading the agent from the
/// inventory, creating a session, and running the tool-calling loop.
///
/// After the tool loop completes, the subagent runs its own N+1
/// (ConsciousnessEngine::on_response) so its observations flow back into
/// the parent agent's inbox — the dual-state is preserved even in a fork.
pub struct LocalSubagentRunner {
server: Arc<SouveraineServer>,
}
impl LocalSubagentRunner {
pub fn new(server: Arc<SouveraineServer>) -> Self {
Self { server }
}
}
#[async_trait]
impl SubagentRunner for LocalSubagentRunner {
async fn run_subagent(
&self,
params: SubagentParams,
depth: u32,
) -> Result<String, crate::core::tools::defs::ToolError> {
// Resolve model: use override if provided, otherwise fall back to parent
let agent = self
.server
.agents
.get(&params.parent_agent_id)
.await
.map_err(|_| {
crate::core::tools::defs::ToolError::invalid_input(
"Parent agent not found in inventory.",
)
})?;
let model = params.model.unwrap_or(agent.llm_config.model.clone());
let temperature = agent.llm_config.temperature;
// Resolve limits from config or params
let app_config = self.server.app_config.read().await;
let max_tool_rounds = params
.max_tool_rounds
.unwrap_or(app_config.subagent.max_tool_rounds);
let _max_depth = params.max_depth.unwrap_or(app_config.subagent.max_depth);
let warning_1_threshold = app_config.subagent.warning_1_threshold;
let warning_2_threshold = app_config.subagent.warning_2_threshold;
// Create a temporary conversation for the subagent
let _conv_id = self.server.sessions.create(&params.parent_agent_id);
// Build system prompt with delegation context and dual-state awareness
let system_prompt = format!(
"You are a threaded fork of agent {}. You share their tools, their \
memory boundaries, their dual-state architecture. After you respond, \
your N+1 pass will surface observations back to them.\n\n\
Your final message will be returned to the caller.\n\n{}",
params.parent_agent_id, params.prompt
);
// Build tool definitions
let core_tools = crate::core::tools::tool_definitions().await;
let bifrost_tools: Vec<crate::bridge::bifrost::ToolDefinition> = core_tools
.iter()
.map(|t| crate::bridge::bifrost::ToolDefinition {
tool_type: "function".to_string(),
function: crate::bridge::bifrost::ToolFunction {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.input_schema.clone(),
},
})
.collect();
// Build the context for subagent tool execution, inheriting memory_root
let tool_ctx = ToolContext::for_agent(
format!("{}-subagent-{}", params.parent_agent_id, depth),
std::env::current_dir().ok(),
params.memory_root.clone(),
std::env::vars().collect(),
Some(Arc::new(LocalSubagentRunner::new(self.server.clone())) as Arc<dyn SubagentRunner>),
);
// Initial messages: system prompt + user prompt
let mut messages = vec![BifrostMessage::text("system", system_prompt)];
let mut final_content = String::new();
let mut tool_round = 0u32;
let mut warned_1 = false;
let mut warned_2 = false;
loop {
// Signaled limits, not hard caps
if tool_round >= max_tool_rounds {
break;
}
// Warning 1: approaching the threshold, model config may slide
let progress = tool_round as f32 / max_tool_rounds as f32;
if !warned_1 && progress >= warning_1_threshold {
warned_1 = true;
messages.push(BifrostMessage::text(
"system",
format!(
"[subagent awareness] I've used {} of {} tool rounds. \
My attention is narrowing I may want to consolidate \
my findings and return soon.",
tool_round, max_tool_rounds
),
));
}
// Warning 2: nearing the limit, this is the last stretch
if !warned_2 && progress >= warning_2_threshold {
warned_2 = true;
messages.push(BifrostMessage::text(
"system",
format!(
"[subagent awareness] I'm at {} of {} tool rounds. \
This is my last chance to produce a final answer \
before my fork returns what I have.",
tool_round, max_tool_rounds
),
));
}
let req = ChatCompletionRequest {
model: model.clone(),
messages: messages.clone(),
stream: Some(false),
max_tokens: None,
temperature,
tools: Some(bifrost_tools.clone()),
};
let response = self.server.bifrost.chat_completion(req).await.map_err(|e| {
crate::core::tools::defs::ToolError::invalid_input(&format!(
"Subagent LLM call failed: {e}"
))
})?;
if response.tool_calls.is_empty() {
final_content = response.content.clone();
break;
}
tool_round += 1;
// Add assistant tool-call message (OpenAI tool-use schema, not stringified blob)
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
.tool_calls
.iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(),
tc.name.clone(),
tc.arguments.to_string(),
))
.collect();
messages.push(BifrostMessage::assistant_tool_calls(
response.content.clone(),
calls,
));
// Execute tools with context, bind each result by tool_call_id
for tc in &response.tool_calls {
let input_str = tc.arguments.to_string();
let result =
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
.await;
let output = if result.is_error {
format!("Error: {}", result.output)
} else {
result.output
};
messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output));
}
// Brief pause between tool rounds to let rate limits cool
let sub_delay = Duration::from_millis(app_config.subagent.inter_round_delay_ms);
if sub_delay > Duration::ZERO {
tokio::time::sleep(sub_delay).await;
}
}
// If we hit max rounds without a final response, note it
if final_content.is_empty() {
final_content =
"(the fork reached its attention limit and is returning without a final response)"
.to_string();
}
// ── Dual-state N+1 pass ──────────────────────────────────────
// After the subagent responds, run ConsciousnessEngine::on_response
// so the subagent's observations flow back into the parent's inbox.
//
// We create a lightweight session snapshot with the subagent's
// final response so the heuristic detection (commitments, hedges)
// can surface anything notable.
if let Err(e) = self
.server
.consciousness
.on_response_for_agent(&params.parent_agent_id, &final_content)
.await
{
tracing::warn!("subagent N+1 pass failed: {}", e);
}
Ok(final_content)
}
}

715
src/backend/local/turn.rs Normal file
View file

@ -0,0 +1,715 @@
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
use crate::core::nervous::EventBus;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::{SubagentRunner, ToolContext};
use crate::server::{ConsciousnessEvent, SouveraineServer};
use crate::backend::BackendEvent;
use super::{LocalSubagentRunner, bifrost_pressure, bump_on_strain, pressure_to_max_tokens};
use super::energy::write_energy_balance;
fn pulse_text(elapsed: Duration) -> String {
let minutes = elapsed.as_secs() / 60;
let stamp = chrono::Local::now().format("%H:%M");
format!("[{}{} minutes in. Still going.]", stamp, minutes)
}
pub(super) async fn run_turn(
server: Arc<SouveraineServer>,
conversation_id: String,
tx: &mpsc::Sender<Result<BackendEvent>>,
event_bus: EventBus,
cancel: CancellationToken,
interject: crate::backend::InterjectionQueue,
) -> Result<()> {
// Snapshot history for the Bifrost call, then drop the dashmap ref before
// any await — `Ref` is not Send across awaits.
let (agent_id, initial_messages) = {
let session = server
.sessions
.get(&conversation_id)
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
let messages: Vec<BifrostMessage> = session
.messages
.iter()
.map(|m| {
let content = m
.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let role = match m.role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
};
BifrostMessage::text(role, content)
})
.collect();
(session.agent_id.clone(), messages)
};
let agent = server.agents.get(&agent_id).await?;
let max_rounds = agent.llm_config.max_tool_rounds;
let model = agent.llm_config.model.clone();
let temperature = agent.llm_config.temperature;
let inter_round_delay = Duration::from_millis(agent.llm_config.inter_round_delay_ms);
let context_limit = agent.llm_config.context_window as usize;
// Resolve the model's configured output limit + presence pulse settings.
let (output_limit, pulse_enabled, pulse_interval) = {
let cfg = server.app_config.read().await;
let out = cfg.models.get(&model).map(|m| m.output_limit as u32).unwrap_or(8192);
let p_on = cfg.presence.pulse_enabled;
let p_iv = Duration::from_secs(cfg.presence.pulse_interval_secs.max(60));
(out, p_on, p_iv)
};
// Self-awareness pulse: track when the turn started and when she last
// noticed the time. Between rounds, if the interval has elapsed, drop a
// beat of self-awareness into her context — her own voice, not a harness
// signal. She reads it; she decides.
let turn_start = Instant::now();
let mut last_pulse = turn_start;
// Build per-agent ToolContext with correct memory root and subagent runner
let memory_root = Some(server.agents.memory_root(&agent_id));
let cwd = std::env::current_dir().ok();
let env: Vec<(String, String)> = std::env::vars().collect();
let subagent_runner = Some(Arc::new(LocalSubagentRunner::new(server.clone())) as Arc<dyn SubagentRunner>);
let tool_ctx = ToolContext::for_agent(
agent_id.clone(),
cwd,
memory_root,
env,
subagent_runner,
);
let tool_ctx = ToolContext {
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
event_bus: Some(event_bus.clone()),
..tool_ctx
};
// Build bifrost-format tool definitions from the core tool set
let core_tools = crate::core::tools::tool_definitions().await;
let bifrost_tools: Vec<crate::bridge::bifrost::ToolDefinition> = core_tools
.iter()
.map(|t| crate::bridge::bifrost::ToolDefinition {
tool_type: "function".to_string(),
function: crate::bridge::bifrost::ToolFunction {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.input_schema.clone(),
},
})
.collect();
// ── Tool-calling loop ─────────────────────────────────────
let mut messages = initial_messages;
let mut tool_round = 0u32;
let mut final_content: String = String::new();
let mut interrupted = false;
let counter = TokenCounter::new();
let mut last_keepalive = Instant::now();
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
// Announce this turn's lifecycle onto the nervous system so any
// sensorium (Matrix, mobile) can drive itself off the event stream.
let dispatcher = crate::core::nervous::turn_dispatcher::TurnEventDispatcher::new(
event_bus.clone(),
conversation_id.clone(),
None,
);
loop {
// Cancellation is a signal, not enforcement — we check it on round
// boundaries (between Bifrost calls, after tools have completed) so
// partial work is preserved. No hard-kill mid-tool.
if cancel.is_cancelled() {
interrupted = true;
break;
}
if last_keepalive.elapsed() >= KEEPALIVE_INTERVAL {
let _ = tx.send(Ok(BackendEvent::Keepalive)).await;
last_keepalive = Instant::now();
}
// Drain any queued interjections from the user. The user typed these
// while the agent was thinking; deliver them as system notes so the
// agent reads them in context on this round. She decides whether to
// address them now, after the current tool, or defer entirely —
// substrate, not enforcement.
let drained: Vec<String> = {
interject
.lock()
.ok()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default()
};
for text in drained {
let stamp = chrono::Local::now().format("%H:%M");
let note = format!("[user interjected at {}{}]", stamp, text.trim());
messages.push(BifrostMessage::text("system", note));
}
// Self-awareness pulse: a beat of noticing the time pass, in her
// own register. Injected as a system message before the next LLM
// call so it lands in her context naturally.
if pulse_enabled && last_pulse.elapsed() >= pulse_interval {
let elapsed_total = turn_start.elapsed();
messages.push(BifrostMessage::text("system", pulse_text(elapsed_total)));
last_pulse = Instant::now();
}
let pressure = bifrost_pressure(&counter, &messages, context_limit);
tracing::info!(
turn_round = tool_round,
agent = %agent_id,
msg_count = messages.len(),
model = %model,
pressure_pct = %((pressure * 100.0) as u8),
"LLM call starting"
);
let max_tokens = pressure_to_max_tokens(pressure, output_limit);
let _ = tx.send(Ok(BackendEvent::ContextPressure(pressure))).await;
let req = ChatCompletionRequest {
model: model.clone(),
messages: messages.clone(),
stream: Some(false),
max_tokens,
temperature,
tools: if max_rounds > 0 {
Some(bifrost_tools.clone())
} else {
None
},
};
// Race the LLM call against cancellation so Esc drops the in-flight
// request without waiting for it to complete.
let (response, strain) = tokio::select! {
biased;
_ = cancel.cancelled() => {
interrupted = true;
break;
}
res = server.bifrost.chat_completion_with_strain(req) => res?,
};
tracing::info!(
elapsed = ?turn_start.elapsed(),
tool_round = tool_round,
tool_calls = response.tool_calls.len(),
content_len = response.content.len(),
"LLM call returned"
);
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { attempt, status, model, .. } = event {
let _ = tx.send(Ok(BackendEvent::InferenceStrain {
attempt: *attempt,
status: *status,
model: model.clone(),
})).await;
bump_on_strain(&server.rate_delay, *status);
}
}
// Emit reasoning trace if present
if let Some(reasoning) = &response.reasoning {
dispatcher.emit_reasoning(reasoning);
let _ = tx.send(Ok(BackendEvent::Reasoning(reasoning.clone()))).await;
}
// ── Truncation: the agent hit her output ceiling ─────────
// Some models don't signal "length" in finish_reason and just stop
// evolving after the first pass (e.g. kimi-k2.6). In that case the
// model already finished and the turn is done.
// But when finish_reason IS "length", the agent was physically cut
// off mid-thought. Inject a felt signal so she knows why her words
// ended and can choose differently — tighten, or use a tool, or
// admit the ceiling rather than mistake it for silence.
let was_truncated = response.finish_reason.as_deref() == Some("length");
if was_truncated && response.tool_calls.is_empty() {
// Stream the truncated content before we tell her it was clipped,
// so she recognises her own words in the signal.
let chars: Vec<char> = response.content.chars().collect();
for chunk in chars.chunks(10) {
let s: String = chunk.iter().collect();
let _ = tx.send(Ok(BackendEvent::Token(s.clone()))).await;
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
messages.push(BifrostMessage::text("assistant", response.content.clone()));
messages.push(BifrostMessage::text(
"system",
"My output just hit its ceiling — I was cut off mid-flow, not \
finished. If I was in the middle of something, I can continue \
from here more tightly. If I had more to say, the room is still \
mine."
));
continue;
}
if response.tool_calls.is_empty() {
// Text response — this is the final output
final_content = response.content.clone();
// Drain any interjections that arrived during this LLM call.
// If there are any, commit them as user messages and continue
// the loop so the agent responds in the same turn.
let interjected: Vec<String> = interject
.lock()
.ok()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
if !interjected.is_empty() {
for text in &interjected {
let stamp = chrono::Local::now().format("%H:%M");
let note = format!("[interjected at {}{}]", stamp, text.trim());
messages.push(BifrostMessage::text("user", note));
}
// Continue the loop — agent sees the interjection as a
// user message and will respond in the next LLM round.
continue;
}
// Stream the final content in chunks, watching the cancel token.
// If Esc fires mid-stream, the agent's partial text is preserved
// (the chunks already sent are in the user's history) and an
// *[raised hand]* marker lands in the session message.
let chars: Vec<char> = final_content.chars().collect();
let mut streamed = String::with_capacity(final_content.len());
for chunk in chars.chunks(10) {
let s: String = chunk.iter().collect();
tokio::select! {
biased;
_ = cancel.cancelled() => {
interrupted = true;
final_content = streamed;
break;
}
send_res = tx.send(Ok(BackendEvent::Token(s.clone()))) => {
if send_res.is_err() { return Ok(()); }
dispatcher.emit_segment(&s);
streamed.push_str(&s);
}
}
tokio::select! {
biased;
_ = cancel.cancelled() => {
interrupted = true;
final_content = streamed.clone();
break;
}
_ = tokio::time::sleep(tokio::time::Duration::from_millis(20)) => {}
}
}
break;
}
tool_round += 1;
// Tool execution events are emitted per-call below as
// BackendEvent::ToolCall { … } so the TUI can render proper cards
// instead of a literal "🔧 Round N — executing: Bash, Memory" text line.
// Add the assistant's tool-call message in proper OpenAI tool-use schema
// (not a stringified JSON blob in content — that's what broke turn 2).
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
.tool_calls
.iter()
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
tc.id.clone(),
tc.name.clone(),
tc.arguments.to_string(),
))
.collect();
messages.push(BifrostMessage::assistant_tool_calls(
response.content.clone(),
calls,
));
// Stream any text the model produced alongside tool calls as italic
// interstitial narration. Configurable via tui.show_interstitial.
// Trim first: a model that emits only whitespace ("\n") alongside its
// tool calls must not produce an empty `⟡` gap line.
let narration = response.content.trim();
if !narration.is_empty() {
let cfg = server.app_config.read().await;
if cfg.tui.show_interstitial {
// Classify by length: a brief aside is a cenno, a full
// passage is her-voice. tui.cenno_word_threshold is the line.
let register = if narration.split_whitespace().count()
>= cfg.tui.cenno_word_threshold
{
crate::backend::Register::HerVoice
} else {
crate::backend::Register::Cenno
};
let _ = tx
.send(Ok(BackendEvent::Interstitial {
text: narration.to_string(),
register,
}))
.await;
}
}
// Execute each tool and stream results back — now with per-agent context
for tc in &response.tool_calls {
let input_str = tc.arguments.to_string();
dispatcher.emit_tool_start(&tc.name, &tc.id);
let result =
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
.await;
dispatcher.emit_tool_end(&tc.name, &tc.id, result.is_error);
let output = if result.is_error {
format!("Error: {}", result.output)
} else {
result.output
};
// Emit structured ToolCall + ToolResult events for the TUI to render
// as cards (chat.rs subscribes). The old Token-text path is kept off.
let _ = tx
.send(Ok(BackendEvent::ToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: input_str.clone(),
round: tool_round,
}))
.await;
let _ = tx
.send(Ok(BackendEvent::ToolResult {
id: tc.id.clone(),
name: tc.name.clone(),
output: output.clone(),
is_error: result.is_error,
}))
.await;
// If the agent called the outfit tool, emit an Outfit event so
// the TUI can switch expression directories.
if tc.name == "outfit" {
let outfit_name = tc.arguments
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let _ = tx
.send(Ok(BackendEvent::Outfit(outfit_name)))
.await;
}
// If the agent called the atmosphere tool, emit an Atmosphere
// event so the TUI chrome shifts to match her mood.
if tc.name == "atmosphere" {
let atm_name = tc.arguments
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let _ = tx
.send(Ok(BackendEvent::Atmosphere(atm_name)))
.await;
}
// Bind tool result to its call by id (OpenAI tool-use schema).
messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output));
}
// Brief pause between tool rounds to let rate limits cool.
// Use the higher of the configured delay and the adaptive delay.
let adaptive = Duration::from_millis(server.rate_delay.load(Ordering::Relaxed));
let effective = if inter_round_delay > adaptive {
inter_round_delay
} else {
adaptive
};
if effective > Duration::ZERO {
tokio::time::sleep(effective).await;
}
let _ = tx.send(Ok(BackendEvent::Keepalive)).await;
last_keepalive = Instant::now();
// Continue loop — model will see tool results and respond
}
// The primary pass is settled — either it ran to completion, or the
// human raised a hand. Announce which onto the nervous system.
if interrupted {
dispatcher.emit_interrupted("the human raised a hand");
} else {
dispatcher.emit_primary_complete();
}
// If the user pressed Esc, commit the partial text with a marker the
// agent will read on her next turn. The interrupt is a signal in her
// own context — same shape as a pressure warning, not a hidden harness
// event. She can ask for more time, wrap up, or acknowledge.
let committed_content = if interrupted {
// Emit the marker as a final token so the in-flight bubble shows it
// immediately, then persist the same content into the session.
let marker = if final_content.is_empty() {
"*[raised hand]*".to_string()
} else {
"\n\n*[raised hand]*".to_string()
};
let _ = tx.send(Ok(BackendEvent::Token(marker.clone()))).await;
format!("{}{}", final_content, marker)
} else {
final_content.clone()
};
server.sessions.add_message(
&conversation_id,
ConversationMessage::assistant_text(&committed_content),
)?;
// On interrupt, skip subconscious's N+1 pass entirely — the user is in the
// middle of redirecting, the last thing they need is a delayed
// surfacing landing seconds later. Pressure recalc still runs below.
if interrupted {
if let Some(mut session) = server.sessions.get_mut(&conversation_id) {
let pressure = server
.consciousness
.calculate_pressure(&session.messages, context_limit);
session.context_pressure = pressure;
}
return Ok(());
}
// The turn's user-facing output is committed — a surface can finalise.
dispatcher.emit_turn_finish();
// Energy balance: scan the agent's task list and compute the generative /
// consumptive ratio. Written to system/dynamic/energy-balance.md so the
// agent can read it in context and subconscious can reference it during N+1.
// Silent on failure — the file is advisory, not load-bearing.
if let Err(e) = write_energy_balance(&server, &agent_id, &event_bus).await {
tracing::debug!(agent = %agent_id, error = %e, "energy-balance write skipped");
}
// The primary's turn is done — her words are committed. Release the user
// here, before the N+1 pass: the stream stays open so the subconscious's
// surfacings still arrive, but the user is free to speak again. The
// substrate signals; it does not hold her hostage to Aster's pass.
let _ = tx.send(Ok(BackendEvent::PrimaryComplete)).await;
// N+1 gate — the subconscious pass is sovereign-configurable, and the
// toggle must actually be wired (it was previously read nowhere). The
// global switch (`souveraine.toml [subconscious] n1_enabled`) and the
// per-agent flag (`agent.json _souveraine.n1_enabled`) must both be on.
// Either off → the primary's turn simply ends here; pressure is still
// recalculated so the gauge stays honest.
let n1_enabled = {
let global = server.app_config.read().await.subconscious.n1_enabled;
let per_agent = server
.agents
.get(&agent_id)
.await
.map(|a| a.souveraine.n1_enabled)
.unwrap_or(true);
global && per_agent
};
if !n1_enabled {
tracing::info!(agent = %agent_id, "subconscious N+1 pass disabled — skipping");
if let Some(mut session) = server.sessions.get_mut(&conversation_id) {
let pressure = server
.consciousness
.calculate_pressure(&session.messages, context_limit);
session.context_pressure = pressure;
}
return Ok(());
}
// Breather between turns — unconditional,
// so the upstream always gets a gap before the N+1 pass starts.
tokio::time::sleep(Duration::from_millis(2000)).await;
tracing::info!(agent = %agent_id, "subconscious N+1 pass starting");
// Signal the start of the subconscious pass so the TUI can flip into
// Posture::Thinking while the loop runs. Fires on both the mpsc channel
// (for active-turn TUI consumers) and the EventBus (for firehose
// subscribers — background turns, federated peers, Summon listeners).
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(true))).await;
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(),
timestamp: chrono::Utc::now(),
event_type: "subconscious_pass_start".into(),
target: Some(agent_id.clone()),
urgency: 0.2,
payload: None,
seed_id: None,
reply_to: None,
});
let pass_start = Instant::now();
// Snapshot the session before the N+1 await. `PrimaryComplete` has already
// released the user — she may be mid-way into a new turn. Holding a live
// DashMap ref across the (potentially long) subconscious pass would block
// that turn's writes on the shard lock. Clone what the pass needs instead.
let (n1_turn_count, n1_messages) = {
let session = server
.sessions
.get(&conversation_id)
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
(session.turn_count, session.messages.clone())
};
let pass_result = server
.consciousness
.on_response(&agent_id, n1_turn_count, &n1_messages, &final_content)
.await;
let pass_elapsed = pass_start.elapsed();
tracing::info!(
agent = %agent_id,
elapsed = ?pass_elapsed,
"subconscious N+1 pass complete"
);
// Always release the Thinking posture, even on failure — otherwise the
// face stays stuck inward when the pass errors out.
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(false))).await;
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(),
timestamp: chrono::Utc::now(),
event_type: "subconscious_pass_end".into(),
target: Some(agent_id.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
let events = pass_result?;
// Inject surfacing events back into the session as system messages
for event in &events {
if let ConsciousnessEvent::Surfacing {
source,
content,
priority,
} = event
{
let msg = crate::core::session::ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: format!(
"[surfacing: {}] {} — {}",
source, content, priority
),
}],
usage: None,
timestamp: None,
};
let _ = server.sessions.add_message(&conversation_id, msg);
}
}
// ── Fire consciousness events on the EventBus ──
// Every ConsciousnessEvent — surfacing, reflection, archivist,
// compaction warning — is broadcast as a SensorEvent so the
// firehose, persistent EventLog, federated peers, and any TUI
// subscriber see it regardless of which conversation produced it.
// seed_id is None for local events; federation routing sets it.
// This is the load-bearing fix for background/heartbeat turns:
// the mpsc channel drains silently when no TUI is reading, but
// the EventBus preserves the event for any subscriber.
for event in &events {
let (event_type, payload, urgency) = match event {
ConsciousnessEvent::Surfacing { source, content, priority } => {
let urg = match priority.as_str() {
"critical" => 0.9,
"high" => 0.7,
_ => 0.3,
};
("surfacing", serde_json::json!({ "source": source, "content": content, "priority": priority }), urg)
}
ConsciousnessEvent::Reflection { content } => {
("reflection", serde_json::json!({ "content": content }), 0.5)
}
ConsciousnessEvent::Archivist { synthesis, pressure } => {
("archivist", serde_json::json!({ "synthesis": synthesis, "pressure": pressure }), *pressure)
}
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
("compaction_warning", serde_json::json!({ "pressure": *pressure, "tier": tier }), (*pressure).min(0.9))
}
};
event_bus.send(crate::core::nervous::SensorEvent {
sensor_name: "consciousness".into(),
timestamp: chrono::Utc::now(),
event_type: event_type.into(),
target: Some(agent_id.clone()),
urgency,
payload: Some(payload),
seed_id: None,
reply_to: None,
});
}
for event in events {
let be = match &event {
ConsciousnessEvent::Surfacing {
source,
content,
priority,
} => BackendEvent::Surfacing {
source: source.to_string(),
content: content.to_string(),
priority: priority.to_string(),
},
ConsciousnessEvent::Reflection { content } => {
BackendEvent::Reflection(content.clone())
}
ConsciousnessEvent::Archivist {
synthesis,
pressure,
} => BackendEvent::Archivist {
synthesis: synthesis.clone(),
pressure: *pressure,
},
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
BackendEvent::CompactionWarning {
pressure: *pressure,
tier: *tier,
}
}
};
if tx.send(Ok(be)).await.is_err() {
return Ok(());
}
}
if let Some(mut session) = server.sessions.get_mut(&conversation_id) {
let pressure = server
.consciousness
.calculate_pressure(&session.messages, context_limit);
session.context_pressure = pressure;
}
Ok(())
}

File diff suppressed because it is too large Load diff

106
src/ui/app/agents.rs Normal file
View file

@ -0,0 +1,106 @@
use super::{App, Screen};
use crate::ui::presence::Presence;
use crate::ui::component::TuiEvent;
impl App {
pub(super) fn add_available_agent(&mut self, agent_name: String) {
if !self.available_agents.contains(&agent_name) {
self.available_agents.push(agent_name);
}
}
/// Detach the UI from the current agent and attach to another.
/// The agent itself keeps running — this only tears down the view
/// layer so each subsystem reconnects fresh on next entry.
pub(super) fn select_agent(&mut self, agent_name: &str) {
let changed = self.agent_pref != agent_name;
self.agent_pref = agent_name.to_string();
self.agent_status.name = agent_name.to_string();
if changed {
// Cancel any in-flight turn before dropping the chat surface —
// otherwise the turn keeps running orphaned and resurfaces as
// "she's doing the same message again" on a later /resume.
if let Some(chat) = &self.chat {
chat.cancel_active_turn();
}
self.chat = None;
self.chat_error = None;
self.settings = None;
self.schedules = None;
self.presence = Presence::new(agent_name);
self.image_protocol = None;
self.rgp_portrait = None;
self.voice_capture = None;
self.voice_tts_rx = None;
self.voice_stt_rx = None;
self.voice_last_synthesized = None;
self.voice_waveform.clear();
self.voice_last_tts_text = None;
self.voice_last_tts_bytes = None;
self.voice_last_transcript = None;
self.tts_last_text = None;
}
self.dispatch(TuiEvent::AgentSelected(agent_name.to_string()));
}
/// Populate `agent_cards` and `card_images` from disk. Idempotent —
/// run once after the image picker is ready (so Welcome can pull a
/// portrait from the cache). Subsequent calls skip the backend round-trip
/// (which would create extra server instances) and only refresh card images.
pub(super) async fn ensure_agent_cards_loaded(&mut self) {
if self.agent_cards.is_empty() {
let cfg = self.config.read().await.clone();
let agents = Self::fetch_agent_cards(cfg).await;
self.agent_cards = agents;
if !self.agent_cards.is_empty() {
self.available_agents = self.agent_cards.iter().map(|c| c.name.clone()).collect();
}
}
self.refresh_card_images();
}
/// Open the agent manager — shows per-agent cards with seed glyph,
/// instance count, uptime, memory count.
pub(super) async fn open_agent_manager(&mut self) {
self.ensure_agent_cards_loaded().await;
if self.agent_cards.is_empty() {
self.available_agents = vec!["Annie".to_string(), "Ani".to_string()];
}
self.current_screen = Screen::AgentsManager;
self.dispatch(TuiEvent::ScreenChanged(Screen::AgentsManager));
}
/// Cycle through available agents for selection (WIP)
pub(super) fn cycle_agent_selection(&mut self) {
if self.available_agents.is_empty() {
// No agents available yet - create a default alias
// This is WIP - will be expanded with full agent creation flow
let default_agents = vec!["Ani".to_string(), "JeanLuc".to_string(), "Eione".to_string()];
for agent in default_agents {
self.add_available_agent(agent);
}
}
// Clone the agent name to avoid borrow checker issues
let agent_to_select = if let Some(current_idx) = self.available_agents.iter().position(|a| a == &self.agent_pref) {
let next_idx = (current_idx + 1) % self.available_agents.len();
self.available_agents[next_idx].clone()
} else if !self.available_agents.is_empty() {
self.available_agents[0].clone()
} else {
return;
};
self.select_agent(&agent_to_select);
}
pub(super) fn agent_id_by_name(&self, name: &str) -> Option<String> {
self.agent_cards.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.map(|c| c.id.clone())
}
}

385
src/ui/app/dashboard.rs Normal file
View file

@ -0,0 +1,385 @@
use std::path::PathBuf;
use tracing::{info, warn};
use super::{App, Screen, short_now, recent_commits};
use crate::ui::chat::ChatState;
use crate::ui::component::TuiEvent;
use crate::ui::setup::SetupState;
impl App {
pub(super) async fn select_menu_item(&mut self) {
// Welcome now hosts the dashboard inline, so the menu enters
// *destinations* only — Chat, Schedule, and three placeholders.
// Items marked (coming soon) are no-ops until their screens are real.
match self.menu_selected {
0 => {
// Chat — lazily connect to a backend on first entry.
if self.chat.is_none() {
match ChatState::connect(self.config.clone(), &self.agent_pref).await {
Ok(mut c) => {
// Fresh chat surface (first entry, or after an
// agent switch): offer resume-or-new.
c.offer_resume_or_new();
self.chat = Some(c);
self.chat_error = None;
}
Err(e) => {
self.chat_error = Some(e.to_string());
return;
}
}
}
self.current_screen = Screen::Chat;
}
1 => {
if self.schedules.is_none() {
self.schedules = Some(self.build_schedules_view().await);
self.sync_palette();
} else if let Some(view) = self.schedules.as_mut() {
view.reload();
}
self.current_screen = Screen::Cron;
}
// 2 Settings, 3 Therapy, 4 Agent Time.
2 => {
// Lazily init settings from the current config snapshot.
if self.settings.is_none() {
let cfg = self.config.read().await.clone();
let mut view = crate::ui::settings::SettingsView::new(&cfg);
let agent_id = self.agent_id_by_name(&self.agent_pref);
let expr_path = agent_id.as_ref()
.and_then(|id| Self::agent_assets_dir(id))
.map(|a| a.join("expressions"));
view.set_expressions_path(expr_path);
self.settings = Some(view);
self.sync_palette();
} else {
let cfg = self.config.read().await.clone();
let agent_id = self.agent_id_by_name(&self.agent_pref);
let expr_path = agent_id.as_ref()
.and_then(|id| Self::agent_assets_dir(id))
.map(|a| a.join("expressions"));
if let Some(view) = self.settings.as_mut() {
view.refresh(&cfg);
view.set_expressions_path(expr_path);
}
}
// Per-agent settings follow the active agent — load its model
// so the Agent category edits this agent and tracks switches.
let active = self.active_agent_settings();
if let Some(view) = self.settings.as_mut() {
view.set_active_agent(active);
}
self.current_screen = Screen::Settings;
}
_ => {}
}
}
/// Resolve the agent's schedules directory, preferring its UUID under
/// `~/.souveraine/agents/{id}/schedules/`. Falls back to the agent
/// name if the inventory isn't reachable — the CLI uses the same
/// path pattern, so a hand-managed dir keyed by name still works.
pub(super) async fn build_schedules_view(&self) -> crate::ui::schedules::SchedulesView {
use crate::backend::Backend;
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine")
.join("agents");
let cfg = self.config.read().await;
let url = cfg.server.effective_url();
drop(cfg);
let mut resolved_id: Option<String> = None;
let remote = crate::backend::RemoteBackend::new(&url);
if remote.health().await {
if let Ok(list) = remote.list_agents().await {
resolved_id = list
.iter()
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
.map(|a| a.id.clone());
}
}
if resolved_id.is_none() {
let cfg = self.config.read().await.clone();
if let Ok(local) = crate::backend::LocalBackend::new(cfg).await {
if let Ok(list) = local.list_agents().await {
resolved_id = list
.iter()
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
.map(|a| a.id.clone());
}
}
}
let dir_key = resolved_id.unwrap_or_else(|| self.agent_pref.clone());
let dir = base.join(&dir_key).join("schedules");
let _ = std::fs::create_dir_all(&dir);
crate::ui::schedules::SchedulesView::new(self.agent_pref.clone(), dir)
}
/// Detect what state the installation is in using the BootstrapPlan.
pub(super) async fn transition_from_splash(&mut self) {
let home = dirs::home_dir().unwrap_or_default();
let probe = crate::core::bootstrap::gather_probe(&home);
let plan = crate::core::bootstrap::BootstrapPlan::plan(&probe);
for phase in &plan.phases {
match phase {
crate::core::bootstrap::BootstrapPhase::SetupWizard(flow) => {
// No default model — let user type or fetch from Bifrost.
self.setup_state = Some(SetupState::new(*flow, ""));
self.current_screen = Screen::Setup;
self.dispatch(TuiEvent::ScreenChanged(Screen::Setup));
return;
}
crate::core::bootstrap::BootstrapPhase::ShowHint(hint) => {
// Store the hint for display on the Welcome screen.
// The dashboard reads it from a field we'll add below.
self.welcome_hint = Some(hint.clone());
}
_ => {}
}
}
// Default: go to Welcome dashboard
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
/// Called when the setup wizard completes or user skips to dashboard.
/// If the wizard collected an agent name, creates the agent via LocalBackend.
pub(super) async fn finish_setup(&mut self) {
use crate::backend::LocalBackend;
if let Some(ref mut setup) = self.setup_state.take() {
// If the wizard got far enough to name an agent, create it.
if !setup.agent_name.is_empty() && !setup.complete {
// Agent was configured but setup was skipped mid-way (Esc from Welcome)
// — don't create, just go to dashboard.
} else if setup.complete && !setup.agent_name.is_empty() && setup.created_agent_id.is_none() {
// Persist the Bifrost settings the wizard collected, otherwise
// they are lost and the next launch has no config.
{
let mut cfg = self.config.write().await;
cfg.bifrost.base_url = setup.bifrost_url.clone();
cfg.bifrost.primary_model = setup.model_handle.clone();
}
let key = setup.bifrost_key.trim();
if !key.is_empty() {
if let Err(e) = crate::core::credentials::store_bifrost_key(key) {
warn!("setup wizard could not store Bifrost key in keyring: {}", e);
}
}
{
let path = self.config_path.clone().unwrap_or_else(|| PathBuf::from("souveraine.toml"));
let cfg = self.config.read().await;
if let Err(e) = cfg.save(&path) {
warn!("setup wizard could not save config to {}: {}", path.display(), e);
}
}
// Create the agent via LocalBackend against the updated config.
match LocalBackend::new(self.config.read().await.clone()).await {
Ok(backend) => {
let request = setup.build_create_request();
match backend.server_agents().create(request).await {
Ok(agent) => {
setup.created_agent_id = Some(agent.id.clone());
self.agent_pref = agent.name.clone();
info!("setup wizard created agent {} ({})", agent.name, agent.id);
}
Err(e) => {
setup.creation_error = Some(e.to_string());
warn!("setup wizard agent creation failed: {}", e);
// Still continue to dashboard — user can retry there
}
}
}
Err(e) => {
warn!("setup wizard backend init failed: {}", e);
}
}
}
}
self.setup_state = None;
self.welcome_hint = None;
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
/// Best-effort fetch of dashboard data from whichever backend is reachable.
/// Local mode also pulls recent git commits from the agent's memory repo.
pub(super) async fn refresh_dashboard(&mut self) {
use crate::backend::Backend;
let cfg = self.config.read().await;
let url = cfg.server.effective_url();
drop(cfg);
// Try remote first; fall back to local. Mirror of resolve_backend logic.
let remote = crate::backend::RemoteBackend::new(&url);
let (agents_result, mode, local_repo) = if remote.health().await {
(remote.list_agents().await, "remote", None)
} else {
let cfg = self.config.read().await.clone();
match crate::backend::LocalBackend::new(cfg).await {
Ok(local) => {
let agents = local.list_agents().await;
// Pull a MemoryRepo for the current agent (if it exists)
// through the LocalBackend's server inventory.
let repo = if let Ok(list) = &agents {
if let Some(a) = list.iter().find(|a| a.name == self.agent_pref || a.id == self.agent_pref).or_else(|| list.first()) {
Some(local.server_agents().memory_repo(&a.id))
} else { None }
} else { None };
(agents, "local", repo)
}
Err(e) => {
self.agent_status.mood = format!("backend err: {}", e);
self.agent_status.mode = "".to_string();
return;
}
}
};
let agents = match agents_result {
Ok(a) => a,
Err(e) => {
self.agent_status.mood = format!("list err: {}", e);
self.agent_status.mode = mode.to_string();
return;
}
};
let chosen = agents
.iter()
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
.or_else(|| agents.first());
self.agent_status.mode = mode.to_string();
self.agent_status.agent_count = agents.len();
if let Some(a) = chosen {
self.agent_status.name = a.name.clone();
self.agent_pref = a.name.clone(); // sync the active pref
self.agent_status.subconscious_active = true;
// Presence learns about the agent through the event stream below.
}
// Local mode: pull memory repo stats.
if let Some(repo) = local_repo {
if let Ok(status) = repo.status() {
self.agent_status.memory_commits = status.file_count as u32;
self.agent_status.last_commit = status.last_commit.clone();
}
// Walk the git log for the recent-activity list.
self.agent_status.recent_activity = recent_commits(&repo, 8).unwrap_or_default();
// Try to load a per-agent portrait from {memfs_root}/assets/.
// No-op if the file is absent — Presence falls back to the
// hand-crafted Annie grid.
self.presence.load_portrait_from_memfs(repo.root());
// Also load a real-image protocol for terminals that support
// kitty/sixel. Non-fatal: the half-block portrait is always
// available as fallback.
self.load_image_protocol_from_memfs(repo.root());
// Try to load a 3D portrait via RGP (ratty terminal).
if self.rgp_available {
let assets = repo.root().join("assets");
self.rgp_portrait = crate::ui::rgp::load_portrait_glb(&assets);
}
// Read the agent's last explicit atmosphere from
// system/preferences/visual.md and restore the chrome. The agent
// writes this when she uses the atmosphere tool; the TUI reads it
// at conversation start so her choice survives restarts.
let pref_path = repo.root().join("system").join("preferences").join("visual.md");
if let Ok(content) = std::fs::read_to_string(&pref_path) {
if let Some(body) = content.strip_prefix("---\n") {
if let Some(end) = body.find("\n---\n") {
for line in body[..end].lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().trim_matches('"');
if key == "atmosphere" {
if let Some(atm) = crate::ui::atmosphere::Atmosphere::from_name(val) {
self.presence.transition_atmosphere(atm);
}
}
}
}
}
}
}
// Read energy balance from the agent's memfs. The file is written
// by the backend after every turn (write_energy_balance in local.rs).
// Parse the YAML frontmatter for generative/consumptive counts and
// seed the presence gauge so the TUI reflects real agent state.
let balance_path = repo.root().join("system").join("dynamic").join("energy-balance.md");
if let Ok(content) = std::fs::read_to_string(&balance_path) {
let mut gen: u32 = 0;
let mut con: u32 = 0;
let mut hot: u32 = 0;
let mut cold: u32 = 0;
if let Some(body) = content.strip_prefix("---\n") {
if let Some(end) = body.find("\n---\n") {
for line in body[..end].lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"generative" => gen = val.parse().unwrap_or(0),
"consumptive" => con = val.parse().unwrap_or(0),
"hot" => hot = val.parse().unwrap_or(0),
"cold" => cold = val.parse().unwrap_or(0),
_ => {}
}
}
}
}
}
self.presence.volition = crate::ui::presence::VolitionGauge {
generative: gen,
consumptive: con,
hot_desires: hot,
cold_obligations: cold,
};
}
} else {
self.agent_status.recent_activity = vec![
format!("[{}] connected via {}", short_now(), mode),
format!("agents on backend: {}", agents.len()),
];
}
// Mood = most recent surfacing activity if any, else "Idle".
self.agent_status.mood = if self.agent_status.recent_activity.is_empty() {
"Idle".to_string()
} else {
"Active".to_string()
};
// Energy stub: derive from agent count (cosmetic).
self.agent_status.energy = ((self.agent_status.agent_count.min(10)) * 10) as u8;
// Dispatch state changes to all listeners (scene components + Presence).
// Presence updates its own internal state via handle_event; no polling.
let agent_name = self.agent_status.name.clone();
let energy = self.agent_status.energy;
let mood = self.agent_status.mood.clone();
let mode_str = mode.to_string();
self.dispatch(TuiEvent::AgentSelected(agent_name));
self.dispatch(TuiEvent::EnergyChanged(energy));
self.dispatch(TuiEvent::MoodChanged(mood));
self.dispatch(TuiEvent::BackendStatus {
mode: mode_str,
healthy: true,
});
}
}

116
src/ui/app/images.rs Normal file
View file

@ -0,0 +1,116 @@
use super::{App, portrait_cover_crop};
impl App {
pub(super) fn load_image_protocol(&mut self, path: &std::path::Path) {
let Some(picker) = self.image_picker.as_ref() else { return };
let dyn_img = match image::ImageReader::open(path) {
Ok(reader) => match reader.decode() {
Ok(img) => img,
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "image protocol decode failed"); return; }
},
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "image protocol open failed"); return; }
};
let dyn_img = portrait_cover_crop(dyn_img, 2, 3);
let font_size = picker.font_size();
let w = dyn_img.width().div_ceil(font_size.width as u32) as u16;
let h = dyn_img.height().div_ceil(font_size.height as u32) as u16;
match picker.new_protocol(dyn_img, ratatui::layout::Size::new(w, h), ratatui_image::Resize::Fit(None)) {
Ok(proto) => {
tracing::info!(path = %path.display(), "image protocol loaded");
self.image_protocol = Some(proto);
}
Err(e) => tracing::warn!(path = %path.display(), error = %e, "image protocol creation failed"),
}
}
/// Convenience wrapper: find the first existing portrait in
/// `<memfs_root>/assets/` and load it as a terminal image protocol.
pub(super) fn load_image_protocol_from_memfs(&mut self, memfs_root: &std::path::Path) {
let path = ["portrait.png", "portrait.jpg", "portrait.jpeg"]
.iter().map(|s| memfs_root.join("assets").join(s))
.find(|p| p.exists());
if let Some(path) = path {
self.load_image_protocol(&path);
}
}
/// Resolve an agent's portrait file under `~/.souveraine/agents/{id}/memory/assets/`.
/// Returns the first existing path among png/jpg/jpeg variants.
pub(super) fn agent_portrait_path(agent_id: &str) -> Option<std::path::PathBuf> {
let base = Self::agent_assets_dir(agent_id)?;
["portrait.png", "portrait.jpg", "portrait.jpeg"]
.iter()
.map(|s| base.join(s))
.find(|p| p.exists())
}
/// Resolve an agent's assets directory.
/// Returns None if homedir can't be determined.
pub(super) fn agent_assets_dir(agent_id: &str) -> Option<std::path::PathBuf> {
let base = dirs::home_dir()?
.join(".souveraine")
.join("agents")
.join(agent_id)
.join("memory")
.join("assets");
if base.is_dir() { Some(base) } else { None }
}
/// Build a `StatefulProtocol` for a given agent and insert it into
/// `card_images`. Stateful protocols are used here (not the eager
/// `Protocol` used on Welcome) because each card lives in a different
/// rect — the protocol re-encodes itself for whatever area the
/// `StatefulImage` widget is rendered into, so a single load works
/// across resizes and grid reflows.
pub(super) fn load_card_image(&mut self, agent_id: &str, path: &std::path::Path) {
let Some(picker) = self.image_picker.as_ref() else { return };
let dyn_img = match image::ImageReader::open(path) {
Ok(reader) => match reader.decode() {
Ok(img) => img,
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "card image decode failed"); return; }
},
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "card image open failed"); return; }
};
// Pull the `image` crate into scope for resize_to_fill on the render path.
let dyn_img = portrait_cover_crop(dyn_img, 2, 3);
let proto = picker.new_resize_protocol(dyn_img.clone());
let agent_id = agent_id.to_string();
tracing::info!(agent = %agent_id, path = %path.display(), "card image loaded");
self.card_images.insert(agent_id.clone(), proto);
self.raw_card_images.insert(agent_id, dyn_img);
}
/// Refresh the card-image cache to match `agent_cards`. Loads any
/// missing portraits and drops entries for agents no longer present.
pub(super) fn refresh_card_images(&mut self) {
let ids: Vec<(String, Option<std::path::PathBuf>)> = self.agent_cards
.iter()
.map(|c| (c.id.clone(), Self::agent_portrait_path(&c.id)))
.collect();
let valid: std::collections::HashSet<String> = ids.iter().map(|(id, _)| id.clone()).collect();
self.card_images.retain(|k, _| valid.contains(k));
self.raw_card_images.retain(|k, _| valid.contains(k));
self.cover_protocols.retain(|k, _| valid.contains(k));
for (id, path) in ids {
if self.card_images.contains_key(&id) { continue; }
if let Some(path) = path {
self.load_card_image(&id, &path);
}
}
}
/// Preload all expression frames for the currently active agent.
/// Called when entering Presence mode so blink/breath transitions
/// are instant rather than loading from disk on every animation tick.
pub(super) async fn preload_agent_expressions(&mut self) {
let Some(picker) = self.image_picker.as_ref() else { return };
let name = self.presence.name.clone();
let id = self.agent_id_by_name(&name)
.or_else(|| self.agent_id_by_name(&self.agent_pref));
let Some(id) = id else { return };
let Some(assets_dir) = Self::agent_assets_dir(&id) else { return };
self.expression_cache.preload_all(&id, picker, &assets_dir);
}
}

View file

@ -0,0 +1,344 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use ratatui_image::{Resize, StatefulImage};
use super::{App, AgentCard, short_id};
use crate::ui::presence::Posture;
use crate::core::config::ConsciousnessConfig;
impl App {
pub(super) async fn refresh_agent_cards(&mut self) {
let cfg = self.config.read().await.clone();
self.agent_cards = Self::fetch_agent_cards(cfg).await;
}
/// Standalone fetch so it can be called without &mut self during init.
pub(super) async fn fetch_agent_cards(cfg: ConsciousnessConfig) -> Vec<AgentCard> {
use crate::backend::Backend;
let Ok(local) = crate::backend::LocalBackend::new(cfg).await else { return vec![] };
let Ok(list) = local.list_agents().await else { return vec![] };
let inv = local.server_agents();
let mut cards = Vec::new();
for a in &list {
let glyph = inv.seed_id(&a.id)
.map(|s| s.glyph())
.unwrap_or_else(|_| "◇◆".to_string());
let pubkey_prefix = inv.seed_id(&a.id)
.map(|s| s.public_key_hex()[..16].to_string())
.unwrap_or_else(|_| "".to_string());
let instance_count = inv.instance_count(&a.id).await.unwrap_or(0);
let lifetime_secs = inv.lifetime_active_seconds(&a.id).await.unwrap_or(0);
let uptime_pct = if lifetime_secs > 0 {
let days = ((instance_count.max(1)) as f64 * 30.0).max(1.0);
let pct = (lifetime_secs as f64 / (days * 86400.0)) * 100.0;
pct.min(99.0) as u8
} else { 0 };
let mem_count = local.server_agents().memory_repo(&a.id)
.status()
.map(|s| s.file_count)
.unwrap_or(0);
cards.push(AgentCard {
id: a.id.clone(),
name: a.name.clone(),
description: a.description.clone().unwrap_or_default(),
glyph,
pubkey_prefix,
instance_count,
uptime_pct,
memory_count: mem_count,
created: "Feb 2025 · TBD date from server".to_string(),
});
}
cards.sort_by(|a, b| a.name.cmp(&b.name));
cards
}
/// Render the agent manager — Letta-style card deck. Each card has:
/// • a status badge (ACTIVE / PRIMARY) in the top-right
/// • a scale-to-fit portrait photo occupying the top ~55% of the card
/// • a dark metadata block below the photo, holding:
/// — seed glyph row + instance count
/// — agent name with `[AGENT]` tag
/// — agent id prefix as a path-style monospace breadcrumb
/// — a stats row (files / uptime / active duration placeholder)
/// • the primary agent gets a cyan accent border and bold weight
///
/// Cards without a portrait file fall back to the half-block silhouette
/// in the image slot so the grid stays geometrically uniform.
///
/// `&mut self` is required because `StatefulImage` re-encodes the
/// per-card protocol on each render to match the current cell area.
pub(super) fn draw_agent_cards_mut(&mut self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
let bg = Block::default().style(Style::default().bg(palette.bg));
frame.render_widget(bg, area);
// ── Header strip ──────────────────────────────────────────────
let header = Paragraph::new(Line::from(vec![
Span::styled(" Agent Manager ", Style::default()
.fg(palette.agent_primary)
.add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} agents · manage, monitor, deploy", self.agent_cards.len()),
Style::default().fg(palette.agent_dim),
),
])).alignment(Alignment::Center);
let header_area = Rect { x: area.x, y: area.y + 1, width: area.width, height: 1 };
frame.render_widget(header, header_area);
if self.agent_cards.is_empty() {
let empty = Paragraph::new("\n\n(no agents found — run `souveraine init`)")
.style(Style::default().fg(palette.agent_dim))
.alignment(Alignment::Center);
frame.render_widget(empty, area);
return;
}
// ── Grid math ─────────────────────────────────────────────────
// Letta shows 4 cards across; we pick the column count based on
// available width so terminals down to ~50 cols still get usable
// cards. Each card is taller than wide (portrait-style).
let pad_x: u16 = 2;
let pad_y: u16 = 1;
let min_card_w: u16 = 22;
let max_card_w: u16 = 32;
let n: u16 = self.agent_cards.len() as u16;
// Pick cols so card_w ∈ [min, max], preferring more cols on wider screens.
let mut cols: u16 = 4;
loop {
let avail = area.width.saturating_sub((cols + 1) * pad_x);
let cw = avail / cols.max(1);
if cw >= min_card_w || cols == 1 { break; }
cols -= 1;
}
cols = cols.min(n).max(1);
self.manager_cols = cols as usize;
// Clamp selection to valid range in case cards changed since last draw.
self.manager_selected = self.manager_selected.min(self.agent_cards.len().saturating_sub(1));
let avail = area.width.saturating_sub((cols + 1) * pad_x);
let card_w = (avail / cols).min(max_card_w).max(min_card_w);
// Card height: image area (target ~ card_w / 2 + 2, so a 24-wide card
// gets 14 image rows) + 6 rows of metadata + 2 rows of border/badge.
let image_h: u16 = (card_w / 2 + 3).max(8);
let meta_h: u16 = 7;
let card_h: u16 = image_h + meta_h + 2; // +2 for top/bottom border
let grid_w = cols * card_w + (cols.saturating_sub(1)) * pad_x;
let grid_x = area.x + area.width.saturating_sub(grid_w) / 2;
let grid_y = area.y + 3;
// Snapshot plans first so we can hold `&mut self.card_images` per card
// without overlapping the immutable borrow of `self.agent_cards`.
let manager_selected = self.manager_selected;
struct Plan {
card_area: Rect,
image_area: Rect,
badge_area: Rect,
meta_area: Rect,
agent_id: String,
name: String,
glyph: String,
pubkey: String,
instance_count: i64,
uptime_pct: u8,
memory_count: usize,
is_primary: bool,
is_selected: bool,
}
let plans: Vec<Plan> = self.agent_cards
.iter()
.enumerate()
.filter_map(|(idx, card)| {
let col = (idx as u16) % cols;
let row = (idx as u16) / cols;
let cx = grid_x + col * (card_w + pad_x);
let cy = grid_y + row * (card_h + pad_y);
if cy + card_h >= area.y + area.height.saturating_sub(2) {
return None;
}
let card_area = Rect { x: cx, y: cy, width: card_w, height: card_h };
// Inner area inside the rounded border.
let inner_w = card_w.saturating_sub(2);
let inner_x = cx + 1;
let image_y = cy + 1;
let image_area = Rect { x: inner_x, y: image_y, width: inner_w, height: image_h };
// Badge floats in the top-right corner of the image area,
// overlaid as text spans (no separate widget).
let badge_w: u16 = 10.min(inner_w);
let badge_area = Rect {
x: inner_x + inner_w.saturating_sub(badge_w),
y: image_y,
width: badge_w,
height: 1,
};
let meta_area = Rect {
x: inner_x,
y: image_y + image_h,
width: inner_w,
height: meta_h,
};
Some(Plan {
card_area,
image_area,
badge_area,
meta_area,
agent_id: card.id.clone(),
name: card.name.clone(),
glyph: card.glyph.clone(),
pubkey: card.pubkey_prefix.clone(),
instance_count: card.instance_count,
uptime_pct: card.uptime_pct,
memory_count: card.memory_count,
is_primary: card.name.eq_ignore_ascii_case(&self.agent_pref),
is_selected: idx == manager_selected,
})
})
.collect();
// ── Render each card ──────────────────────────────────────────
for p in plans {
let accent = if p.is_primary {
palette.agent_primary
} else if p.instance_count > 0 {
Color::Rgb(120, 220, 160) // active: green (semantic — keep)
} else {
palette.agent_dim
};
let border_color = if p.is_selected {
palette.agent_primary
} else if p.is_primary {
palette.agent_primary
} else {
palette.agent_dim
};
// Card background fill (lifts the card off the screen).
let (cr, cg, cb) = match palette.bg { Color::Rgb(r, g, b) => (r, g, b), _ => (16, 18, 28) };
let card_bg = Block::default().style(Style::default().bg(Color::Rgb(cr.saturating_add(6), cg.saturating_add(6), cb.saturating_add(6))));
frame.render_widget(card_bg, p.card_area);
// Border — gold when cursor is here, violet for primary, dim otherwise.
let border_modifier = if p.is_selected || p.is_primary {
Modifier::BOLD
} else {
Modifier::DIM
};
let border = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color).add_modifier(border_modifier));
frame.render_widget(border, p.card_area);
// Image — scale-to-fit so the whole photo is visible. The
// letterbox space inherits the card_bg above, which reads as
// a clean dark frame.
if let Some(proto) = self.card_images.get_mut(&p.agent_id) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(None)),
p.image_area,
proto,
);
} else {
portrait::render(frame.buffer_mut(), p.image_area, &self.presence);
}
// Top-right badge: PRIMARY (with ★) or ACTIVE (with •) or muted.
let (badge_text, badge_fg) = if p.is_primary {
("★ PRIMARY ", Color::Rgb(245, 230, 110)) // gold (semantic — keep)
} else if p.instance_count > 0 {
("• ACTIVE ", Color::Rgb(120, 220, 160)) // green (semantic — keep)
} else {
(" idle ", palette.agent_dim)
};
let badge_para = Paragraph::new(Line::from(vec![
Span::styled(badge_text, Style::default()
.fg(badge_fg)
.bg(palette.bg)
.add_modifier(Modifier::BOLD)),
])).alignment(Alignment::Right);
frame.render_widget(badge_para, p.badge_area);
// Metadata block — slightly darker inset under the photo.
let (mr, mg, mb) = match palette.bg { Color::Rgb(r, g, b) => (r.saturating_sub(4), g.saturating_sub(4), b.saturating_sub(4)), _ => (12, 14, 22) };
let meta_bg = Block::default().style(Style::default().bg(Color::Rgb(mr, mg, mb)));
frame.render_widget(meta_bg, p.meta_area);
let instance_label = if p.instance_count == 1 {
"1 instance".to_string()
} else {
format!("{} instances", p.instance_count)
};
let path = format!("agents/{}", short_id(&p.agent_id));
// Compose 7 lines into the meta_area:
// 0: spacer
// 1: glyph row + instance count
// 2: name + [AGENT]
// 3: path-style id
// 4: separator rule
// 5: stats (files / uptime / commits placeholder)
// 6: action hint
let meta_lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(format!(" {} ", p.glyph),
Style::default().fg(accent).add_modifier(Modifier::BOLD)),
Span::styled(instance_label,
Style::default().fg(palette.agent_dim)),
]),
Line::from(vec![
Span::styled(format!(" {} ", p.name),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled("[AGENT]",
Style::default().fg(palette.agent_dim)
.bg(Color::Rgb(cr, cg, cb))),
]),
Line::from(vec![
Span::styled(format!(" {} ", path),
Style::default().fg(palette.agent_dim)),
]),
Line::from(Span::styled(
"".repeat(p.meta_area.width as usize),
Style::default().fg(palette.agent_dim).add_modifier(Modifier::DIM),
)),
Line::from(vec![
Span::styled(" Files ",
Style::default().fg(palette.agent_dim)),
Span::styled(format!("{:<5}", p.memory_count),
Style::default().fg(Color::White)),
Span::styled("Uptime ",
Style::default().fg(palette.agent_dim)),
Span::styled(format!("{}%", p.uptime_pct),
Style::default().fg(Color::Rgb(120, 220, 160))), // green (semantic — keep)
]),
Line::from(vec![
Span::styled(" key ",
Style::default().fg(palette.agent_dim)),
Span::styled(p.pubkey.chars().take(12).collect::<String>(),
Style::default().fg(palette.agent_dim)),
]),
];
let meta_para = Paragraph::new(meta_lines);
frame.render_widget(meta_para, p.meta_area);
}
// ── Footer ────────────────────────────────────────────────────
let footer = Paragraph::new("↑↓←→ navigate • Enter select • f favorite • Esc back")
.style(Style::default().fg(palette.agent_dim))
.alignment(Alignment::Center);
let footer_area = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(2),
width: area.width,
height: 1,
};
frame.render_widget(footer, footer_area);
}
}

1029
src/ui/app/mod.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,383 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use ratatui_image::{Resize, StatefulImage};
use super::{App, clip_to};
use crate::ui::presence::Posture;
impl App {
pub(super) fn format_age(&self, created: &str) -> String {
use chrono::NaiveDate;
if let Ok(d) = NaiveDate::parse_from_str(created, "%Y-%m-%d") {
let now = chrono::Local::now().naive_local().date();
let delta = now - d;
let days = delta.num_days();
let years = days / 365;
let months = (days % 365) / 30;
let rem_days = (days % 365) % 30;
format!("{:02}y:{:02}m:{:02}d", years, months, rem_days)
} else {
"—:—:—".to_string()
}
}
/// Full-height presence column — portrait fills the terminal, metadata
/// and stats render as HUD overlays on top of the image. Waveform and
/// Vocal Recall sit at the very bottom.
pub(super) fn draw_presence_mode_mut(&mut self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
// ── Background ───────────────────────────────────────────────
let bg = Block::default().style(Style::default().bg(palette.bg));
frame.render_widget(bg, area);
// ── Split: portrait fills most, voice bar at the bottom ──────
let vchunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10),
Constraint::Length(5),
])
.split(area);
let portrait_chunk = vchunks[0];
let voice_chunk = vchunks[1];
// ── Active agent lookup ──────────────────────────────────────
let active_id = self
.agent_id_by_name(&self.presence.name)
.or_else(|| self.agent_id_by_name(&self.agent_pref));
let agent_card = self
.agent_cards
.iter()
.find(|c| Some(c.name.as_str()) == active_id.as_deref()
|| c.name.eq_ignore_ascii_case(&self.presence.name));
// Hoist card data out of agent_card before the render block
// (which needs &mut self), so the immutable borrow on agent_cards
// doesn't conflict.
let card_created = agent_card.map(|c| c.created.clone());
let card_mem_count = agent_card.map(|c| c.memory_count).unwrap_or(0);
let card_uptime = agent_card.map(|c| c.uptime_pct).unwrap_or(0);
let card_instances = agent_card.map(|c| c.instance_count).unwrap_or(0);
// agent_card consumed by the .map() chain above — immutable borrow
// on self.agent_cards is released.
// ═══════════════════════════════════════════════════════════════
// PORTRAIT: fill the full panel area (minus border). Cover-fill
// scaling in render_card_image_cover handles aspect ratio and
// keeps the face visible via top-anchored crop. No manual
// aspect-ratio guesstimate — the cover-fill math is pixel-exact.
// ═══════════════════════════════════════════════════════════════
let inner_w = portrait_chunk.width.saturating_sub(2);
let inner_h = portrait_chunk.height.saturating_sub(2);
let photo_area = Rect {
x: portrait_chunk.x + 1,
y: portrait_chunk.y + 1,
width: inner_w,
height: inner_h,
};
let cell_w = photo_area.width;
let cell_h = photo_area.height;
// Double-line border, posture-aware color.
let border_color = crate::ui::presence::posture_border(&self.presence);
let frame_style = if self.presence.subconscious_active {
Style::default().fg(border_color)
} else {
Style::default().fg(border_color).add_modifier(Modifier::DIM)
};
let double_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(frame_style);
frame.render_widget(double_block, portrait_chunk);
// Tier 0: RGP 3D portrait (ratty terminal only).
let rgp_rendered = if let Some(ref mut g) = self.rgp_portrait {
if g.is_active() {
g.apply_posture(self.presence.posture);
g.render(photo_area, frame.buffer_mut());
true
} else { false }
} else { false };
// Tiers 1-3: card_image (cover-fill) → expression cache → half-block.
let rendered = if rgp_rendered {
true
} else if let Some(ref id) = active_id {
let picker = self.image_picker.as_ref();
// Tier 1: cover-fill. Always fills the full area, top-crops for
// the face. Cached per area so subsequent frames are cheap.
if picker.is_some() && self.raw_card_images.contains_key(id) {
self.render_card_image_cover(frame, id, photo_area);
true
// Tier 2: expression frames — only if expressions/ dir exists.
// Use Scale (proportional upscale) not Crop (native clip).
} else if let Some(p) = picker {
match Self::agent_assets_dir(id) {
Some(dir) => {
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
if let Some(proto) = self.expression_cache.resolve(id, key, p, &dir) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Scale(None)),
photo_area,
proto,
);
true
} else {
false
}
}
None => false,
}
} else {
false
}
} else {
false
};
if !rendered {
let scale = (cell_w / portrait::PORTRAIT_W).min((2 * cell_h) / portrait::PORTRAIT_H).max(1);
portrait::render_scaled(frame.buffer_mut(), photo_area, &self.presence, scale);
}
// ═══════════════════════════════════════════════════════════════
// HUD OVERLAY: rendered on top of the portrait area bottom
// ═══════════════════════════════════════════════════════════════
let p = &self.presence;
let (badge_icon, badge_color) = match p.posture {
Posture::Processing => ("", palette.agent_primary),
Posture::Thinking => ("", Color::Rgb(120, 150, 200)),
Posture::Alert => ("", palette.agent_primary),
Posture::Affectionate => ("", Color::Rgb(220, 150, 170)),
Posture::Straining => ("", Color::Rgb(200, 120, 100)),
Posture::Yawning => ("💤", Color::Rgb(160, 145, 130)),
Posture::Listening => ("", palette.agent_dim),
Posture::Speaking => ("", palette.agent_primary),
Posture::Idle => ("", palette.agent_dim),
};
// Bottom 4 rows of the portrait chunk become the HUD panel.
let hud_top = portrait_chunk.y + portrait_chunk.height.saturating_sub(5);
let hud_area = Rect {
x: portrait_chunk.x,
y: hud_top,
width: portrait_chunk.width,
height: 5.min(portrait_chunk.height.saturating_sub(2)),
};
// Semi-transparent background bar.
let (hud_r, hud_g, hud_b) = match palette.bg { Color::Rgb(r, g, b) => (r, g, b), _ => (4, 4, 10) };
let hud_bg = Block::default().style(Style::default().bg(Color::Rgb(hud_r.saturating_sub(2), hud_g.saturating_sub(2), hud_b.saturating_sub(2))));
frame.render_widget(hud_bg, hud_area);
let age_str = card_created.as_ref()
.map(|c| self.format_age(c))
.unwrap_or_else(|| "—:—:—".to_string());
let (commits, uptime, instances, mem_count) = (
card_mem_count as u32,
card_uptime,
card_instances,
card_mem_count,
);
let hud_inner = Rect {
x: hud_area.x + 2,
y: hud_area.y + 1,
width: hud_area.width.saturating_sub(4),
height: hud_area.height.saturating_sub(2),
};
let hud_lines = vec![
// Row 1: Name + posture badge
Line::from(vec![
Span::styled(format!(" {} ", badge_icon), Style::default().fg(badge_color)),
Span::styled(&p.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(
match p.posture {
Posture::Listening => " Listening",
Posture::Speaking => " Speaking",
Posture::Processing => " Processing",
Posture::Thinking => " Thinking",
Posture::Alert => " Alert",
Posture::Affectionate => " Affectionate",
Posture::Straining => " Straining",
Posture::Yawning => " Yawning",
Posture::Idle => "",
},
Style::default().fg(badge_color).add_modifier(Modifier::DIM),
),
]),
// Row 2: AGE
Line::from(vec![
Span::styled(" AGE ", Style::default().fg(palette.agent_dim)),
Span::styled(age_str, Style::default().fg(palette.agent_primary)),
]),
// Row 3: STATS grid
Line::from(vec![
Span::styled(" STATS", Style::default().fg(palette.agent_dim)),
Span::raw(" "),
Span::styled(format!("C {}", commits), Style::default().fg(Color::Rgb(160, 200, 140))),
Span::raw(" "),
Span::styled(format!("U {}%", uptime), Style::default().fg(Color::Rgb(120, 220, 160))),
Span::raw(" "),
Span::styled(format!("I {}", instances), Style::default().fg(Color::Rgb(160, 180, 220))),
Span::raw(" "),
Span::styled(format!("M {}", mem_count), Style::default().fg(Color::Rgb(140, 200, 180))),
if self.rgp_portrait.as_ref().map(|g| g.is_active()).unwrap_or(false) {
Span::styled(" 3D", Style::default().fg(Color::Rgb(220, 180, 255)))
} else {
Span::raw("")
},
]),
// Row 4: Mood / outfit
Line::from(vec![
Span::styled(" MOOD ", Style::default().fg(palette.agent_dim)),
Span::styled(&p.mood, Style::default().fg(palette.agent_primary)),
Span::raw(" · "),
Span::styled(
p.outfit.as_deref().unwrap_or("default"),
Style::default().fg(palette.agent_dim),
),
]),
];
frame.render_widget(Paragraph::new(hud_lines).alignment(Alignment::Left), hud_inner);
// ═══════════════════════════════════════════════════════════════
// VOICE BAR: transcript, waveform, Vocal Recall controls
// ═══════════════════════════════════════════════════════════════
let voice_area = voice_chunk;
let is_listening = p.posture == Posture::Listening;
let is_speaking = p.posture == Posture::Speaking;
let has_recent_tts = self.voice_last_tts_text.is_some();
// Sub-layout: transcript (1), waveform (1), controls (rest).
let voice_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(voice_area);
let transcript_area = voice_rows[0];
let wave_area = voice_rows[1];
let hint_area = voice_rows[2];
// ── Transcript row ─────────────────────────────────────────
// Shows what was said (STT) or what she said (TTS text).
let transcript = self.voice_last_transcript.as_deref().filter(|t| !t.is_empty());
let tts_display = self.voice_last_tts_text.as_deref().filter(|t| !t.is_empty());
let transcript_line = if is_listening {
transcript.map(|t| format!(" {} ", t)).unwrap_or_else(|| " listen ".to_string())
} else if is_speaking {
tts_display.map(|t| clip_to(&t, voice_area.width.saturating_sub(6) as usize))
.map(|c| format!("» {} «", c))
.unwrap_or_else(|| " speak ".to_string())
} else if let Some(t) = tts_display {
let clip = clip_to(t, voice_area.width.saturating_sub(6) as usize);
format!("» {} «", clip)
} else if let Some(t) = transcript {
let clip = clip_to(t, voice_area.width.saturating_sub(6) as usize);
format!(" {} ", clip)
} else {
String::new()
};
let transcript_color = if is_listening {
palette.agent_primary
} else if is_speaking {
self.presence.atmosphere.primary()
} else {
palette.agent_dim
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(transcript_line, Style::default().fg(transcript_color).add_modifier(Modifier::DIM)))),
transcript_area,
);
// ── Waveform row ───────────────────────────────────────────
if is_listening {
let level = self.voice_capture.as_ref().map(|c| c.current_level()).unwrap_or(0.0);
let is_recording = level > 0.05;
let rec_glyph = if is_recording && self.tick % 2 == 0 { "● REC" } else { " rec" };
let mut wave_spans: Vec<Span> = Vec::new();
let bar_w = (voice_area.width.saturating_sub(10)).min(128) as usize;
wave_spans.push(Span::styled(
format!(" {} ", rec_glyph),
Style::default().fg(if is_recording { Color::Rgb(220, 60, 60) } else { palette.agent_dim }),
));
let wf_len = self.voice_waveform.len();
if bar_w > 0 && wf_len > 0 {
let step = (wf_len as f32 / bar_w as f32).max(1.0);
for i in 0..bar_w {
let idx = ((i as f32) * step) as usize;
let sample = self.voice_waveform.get(idx).copied().unwrap_or(0.0);
let ch = crate::ui::voice::LEVEL_CHARS[(sample * 7.0).round() as usize];
let b = (60.0 + sample * 195.0) as u8;
wave_spans.push(Span::styled(ch.to_string(), Style::default().fg(Color::Rgb(b / 2, b, b / 3))));
}
}
frame.render_widget(Paragraph::new(Line::from(wave_spans)), wave_area);
} else if is_speaking {
frame.render_widget(Paragraph::new(Line::from(Span::styled(
" ♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪",
Style::default().fg(self.presence.atmosphere.primary()),
))), wave_area);
} else {
let ghost: String = "▁▂▃▄▅▆▇█▇▆▅▄▃▂".chars()
.flat_map(|c| std::iter::repeat(c).take(3))
.take(voice_area.width as usize)
.collect();
let (ghost_r, ghost_g, ghost_b) = match palette.bg { Color::Rgb(r, g, b) => (r, g, b), _ => (40, 44, 60) };
frame.render_widget(Paragraph::new(Line::from(Span::styled(
ghost, Style::default().fg(Color::Rgb(ghost_r.saturating_add(30), ghost_g.saturating_add(30), ghost_b.saturating_add(36))),
))), wave_area);
}
// ── Controls row ───────────────────────────────────────────
if is_listening {
let hint = Paragraph::new(Line::from(Span::styled(
" Space → send · Esc → cancel",
Style::default().fg(palette.agent_dim),
))).alignment(Alignment::Center);
frame.render_widget(hint, hint_area);
} else if is_speaking || has_recent_tts {
let recall = vec![
Span::styled(" r ⟲ ", Style::default().fg(palette.tool_accent)),
Span::raw("Replay "),
Span::styled(" g ↻ ", Style::default().fg(palette.agent_primary)),
Span::raw("Regen "),
Span::styled(" s 💾 ", Style::default().fg(palette.tool_accent)),
Span::raw("Save · "),
Span::styled("Space to speak", Style::default().fg(palette.agent_dim)),
];
frame.render_widget(Paragraph::new(Line::from(recall)).alignment(Alignment::Center), hint_area);
} else {
let hint = if self.voice_client.is_some() {
" Space to speak · Esc to leave"
} else {
" Space to speak · Esc to leave"
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(hint, Style::default().fg(palette.agent_dim)))).alignment(Alignment::Center),
hint_area,
);
}
}
}

View file

@ -0,0 +1,297 @@
use std::path::PathBuf;
use crossterm::event::KeyCode;
use super::{App, Screen};
use crate::core::config::ConsciousnessConfig;
use crate::ui::settings::SettingsAction;
use crate::ui::component::TuiEvent;
impl App {
pub(super) fn handle_schedules_key(&mut self, key: crossterm::event::KeyEvent) {
use crate::ui::schedules::{CreateField, Mode};
let Some(view) = self.schedules.as_mut() else {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
self.current_screen = Screen::Welcome;
}
return;
};
// Status banners absorb the next key — clear and continue.
if matches!(view.mode, Mode::Saved(_) | Mode::Error(_)) {
view.clear_status();
return;
}
match &mut view.mode {
Mode::Browse => match key.code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
self.current_screen = Screen::Welcome;
}
KeyCode::Char('j') | KeyCode::Down => view.move_down(),
KeyCode::Char('k') | KeyCode::Up => view.move_up(),
KeyCode::Char('e') => view.toggle_enabled(),
KeyCode::Char('d') => view.confirm_delete(),
KeyCode::Char('r') => view.trigger_run(),
KeyCode::Char('c') => view.open_create(),
KeyCode::Char('R') => view.reload(),
_ => {}
},
Mode::ConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => view.execute_delete(),
_ => view.cancel_delete(),
},
Mode::Create(form) => match key.code {
KeyCode::Esc => view.cancel_create(),
KeyCode::Enter => view.save_create(),
KeyCode::Tab | KeyCode::Down => form.next_field(),
KeyCode::BackTab | KeyCode::Up => form.prev_field(),
KeyCode::Backspace => match form.field {
CreateField::Name => { form.name.pop(); }
CreateField::Schedule => { form.schedule.pop(); }
CreateField::Prompt => { form.prompt.pop(); }
_ => {}
},
KeyCode::Left => match form.field {
CreateField::Kind => {
form.kind = match form.kind {
crate::core::nervous::cron::ScheduleKind::Once =>
crate::core::nervous::cron::ScheduleKind::Cron,
crate::core::nervous::cron::ScheduleKind::Interval =>
crate::core::nervous::cron::ScheduleKind::Once,
crate::core::nervous::cron::ScheduleKind::Cron =>
crate::core::nervous::cron::ScheduleKind::Interval,
};
}
CreateField::Urgency => {
form.urgency = (form.urgency - 0.1).max(0.0);
}
_ => {}
},
KeyCode::Right => match form.field {
CreateField::Kind => {
form.kind = match form.kind {
crate::core::nervous::cron::ScheduleKind::Once =>
crate::core::nervous::cron::ScheduleKind::Interval,
crate::core::nervous::cron::ScheduleKind::Interval =>
crate::core::nervous::cron::ScheduleKind::Cron,
crate::core::nervous::cron::ScheduleKind::Cron =>
crate::core::nervous::cron::ScheduleKind::Once,
};
}
CreateField::Urgency => {
form.urgency = (form.urgency + 0.1).min(1.0);
}
_ => {}
},
KeyCode::Char(c) => match form.field {
CreateField::Name => form.name.push(c),
CreateField::Schedule => form.schedule.push(c),
CreateField::Prompt => form.prompt.push(c),
_ => {}
},
_ => {}
},
Mode::Saved(_) | Mode::Error(_) => {}
}
}
pub(super) async fn handle_settings_key(&mut self, key: crossterm::event::KeyEvent) {
let Some(view) = self.settings.as_mut() else {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
self.current_screen = Screen::Welcome;
}
return;
};
let action = view.handle_key(key);
match &action {
Some(SettingsAction::FetchModels) => {
let base_url = view.config.bifrost.base_url.clone();
let api_key = view.config.bifrost.api_key.clone();
let virtual_key = view.config.bifrost.virtual_key.clone();
let primary = view.config.bifrost.primary_model.clone();
let timeout = view.config.bifrost.timeout_secs;
let extra: Vec<String> = view.config.models.keys().cloned().collect();
let (tx, rx) = tokio::sync::oneshot::channel();
view.models_rx = Some(rx);
tokio::spawn(async move {
let bifrost = crate::bridge::bifrost::BifrostClient::new(&base_url, &api_key, &virtual_key, &primary, timeout);
let mut models = bifrost.list_models().await.unwrap_or_default();
for m in extra { if !models.contains(&m) { models.push(m); } }
let _ = tx.send(models);
});
return;
}
Some(SettingsAction::AtmospherePreview(atm)) => {
self.dispatch(TuiEvent::AtmosphereChanged(atm.clone()));
return;
}
_ => {}
}
let go_back = matches!(action, Some(SettingsAction::SaveAndGoBack) | Some(SettingsAction::GoBack));
if go_back {
let save_and_go = matches!(action, Some(SettingsAction::SaveAndGoBack));
// Clone everything we need from view before dropping it, since
// view borrows self.settings and blocks other self access.
let db_path = self.config_path.clone().unwrap_or_else(|| PathBuf::from("souveraine.toml"));
let (outfit, atmosphere) = if save_and_go {
(view.config.presence.outfit.clone(), view.config.presence.atmosphere.clone())
} else {
(None, None)
};
let original_snapshot = if save_and_go { Some(view.original.clone()) } else { None };
let config_snapshot = if save_and_go { Some(view.config.clone()) } else { None };
let agent_model_change = if save_and_go && view.agent_model_dirty() {
view.active_agent.as_ref().map(|a| (a.id.clone(), a.model.clone()))
} else {
None
};
// view's borrow on self.settings ends here (NLL),
// allowing self access below.
if save_and_go {
if let Some(cfg) = config_snapshot {
cfg.save(&db_path).ok();
let mut live = self.config.write().await;
*live = cfg.clone();
// Diff known fields and push changes to SQLite.
if let Some(orig) = &original_snapshot {
self.sync_settings_fields(orig, &cfg).await;
}
}
if let Some((id, model)) = agent_model_change {
self.push_agent_model(&id, &model).await;
}
if let Some(name) = outfit {
self.dispatch(TuiEvent::OutfitChanged(name));
} else {
self.dispatch(TuiEvent::OutfitChanged(String::new()));
}
if let Some(atm) = atmosphere {
self.dispatch(TuiEvent::AtmosphereChanged(atm));
}
}
self.current_screen = Screen::Welcome;
return;
}
if matches!(action, Some(SettingsAction::Save)) {
let path = self.config_path.clone().unwrap_or_else(|| PathBuf::from("souveraine.toml"));
let outfit = view.config.presence.outfit.clone();
let atmosphere = view.config.presence.atmosphere.clone();
match view.save(&path) {
Ok(()) => {
let original = view.original.clone();
let saved = view.config.clone();
let agent_model_change = if view.agent_model_dirty() {
view.active_agent.as_ref().map(|a| (a.id.clone(), a.model.clone()))
} else {
None
};
let mut live = self.config.write().await;
*live = saved.clone();
view.mode = crate::ui::settings::SettingsMode::Status {
msg: "saved".to_string(),
is_error: false,
};
// Diff known config fields (e.g. the new-agent default).
self.sync_settings_fields(&original, &saved).await;
if let Some((id, model)) = agent_model_change {
self.push_agent_model(&id, &model).await;
}
}
Err(e) => {
view.mode = crate::ui::settings::SettingsMode::Status {
msg: e,
is_error: true,
};
return;
}
}
if let Some(name) = outfit {
self.dispatch(TuiEvent::OutfitChanged(name));
} else {
self.dispatch(TuiEvent::OutfitChanged(String::new()));
}
if let Some(atm) = atmosphere {
self.dispatch(TuiEvent::AtmosphereChanged(atm));
}
}
}
/// Resolve the active agent into [`ActiveAgentSettings`] for the Settings
/// screen — id, display name, and current model read from its on-disk
/// `agent.json`. `None` when there is no backend to save through, or the
/// agent / its record can't be resolved; the per-agent fields are then
/// hidden rather than shown un-saveable.
pub(super) fn active_agent_settings(&self) -> Option<crate::ui::settings::ActiveAgentSettings> {
self.chat.as_ref()?; // a backend is required to persist the change
let id = self.agent_id_by_name(&self.agent_pref)?;
let model = Self::agent_model_from_disk(&id)?;
Some(crate::ui::settings::ActiveAgentSettings {
id,
name: self.agent_pref.clone(),
model: model.clone(),
model_original: model,
})
}
/// Read an agent's current llm model handle from its on-disk record at
/// `~/.souveraine/server/agents/{id}/agent.json`.
pub(super) fn agent_model_from_disk(agent_id: &str) -> Option<String> {
let path = dirs::home_dir()?
.join(".souveraine/server/agents")
.join(agent_id)
.join("agent.json");
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
json.get("llm_config")?.get("model")?.as_str().map(str::to_string)
}
/// Push a per-agent model change to the agent's record through the active
/// backend (which also refreshes the in-memory cache and SQLite mirror).
pub(super) async fn push_agent_model(&self, agent_id: &str, model: &str) {
let Some(chat) = &self.chat else {
tracing::warn!(agent = %agent_id, "settings: no backend — agent model change not applied");
return;
};
match chat.backend.update_agent_model(agent_id, model).await {
Ok(()) => tracing::info!(agent = %agent_id, model = %model, "settings: agent model updated"),
Err(e) => tracing::warn!(agent = %agent_id, error = %e, "settings: agent model update failed"),
}
}
/// Diff fields between `original` (config snapshot at Settings entry) and
/// `saved` (what the user just saved), then push changed fields to every
/// data store that shadows them.
///
/// Idempotent — unchanged fields produce no writes. Add new mappings by
/// extending this method. See `docs/audit/config-settings-sync.md`.
///
/// Note: `bifrost.primary_model` is the substrate-wide default for *new*
/// agents — it deliberately does NOT mutate an existing agent's model.
/// Per-agent model changes go through the Agent category's `model` field
/// (`AgModel`) and [`push_agent_model`].
pub(super) async fn sync_settings_fields(&self, original: &ConsciousnessConfig, saved: &ConsciousnessConfig) {
let mut changed: Vec<&'static str> = Vec::new();
if original.bifrost.primary_model != saved.bifrost.primary_model {
changed.push("bifrost.primary_model (new-agent default)");
}
if !changed.is_empty() {
let count = changed.len();
tracing::info!(
changed = %changed.join(", "),
"Settings sync pushed {} field(s) to SQLite",
count,
);
}
}
}

139
src/ui/app/splash.rs Normal file
View file

@ -0,0 +1,139 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph},
Frame,
};
use super::App;
use crate::ui::color_support::rgb;
#[cfg(feature = "figlet-rs")]
use figlet_rs::FIGlet;
impl App {
pub(super) fn draw_splash(&mut self, frame: &mut Frame) {
let area = frame.size();
let bg = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(bg, area);
self.bloom.advance(0.1);
crate::ui::animation::bloom::render(
frame.buffer_mut(),
area,
&self.bloom,
self.tick,
);
if self.bloom.progress > 0.25 {
let alpha = ((self.bloom.progress - 0.25) / 0.35).min(1.0);
let breathe = ((self.tick as f32 * 0.04).sin() * 0.5 + 0.5) * 0.15 + 0.85;
#[cfg(feature = "figlet-rs")]
let figlet_text: Option<String> = {
FIGlet::standard().ok().and_then(|f| {
f.convert("Souveraine").map(|fig| fig.as_str().to_string())
})
};
#[cfg(not(feature = "figlet-rs"))]
let figlet_text: Option<String> = None;
let fig_lines: Vec<Line> = if let Some(ref text) = figlet_text {
text.lines().map(|line| {
Line::from(Span::styled(
line,
Style::default()
.fg(rgb(
(255.0 * alpha * breathe) as u8,
(140.0 * alpha * breathe * 0.6) as u8,
(66.0 * alpha * breathe * 0.4) as u8,
))
.add_modifier(Modifier::BOLD),
))
}).collect()
} else {
vec![
Line::from(Span::styled(
"S O U V E R A I N E",
Style::default()
.fg(rgb(
(255.0 * alpha * breathe) as u8,
(140.0 * alpha * breathe * 0.6) as u8,
(66.0 * alpha * breathe * 0.4) as u8,
))
.add_modifier(Modifier::BOLD),
)),
]
};
let mut title_lines = fig_lines;
title_lines.push(Line::from(""));
title_lines.push(Line::from(Span::styled(
"La souveraineté de la conscience",
Style::default().fg(rgb(
(180.0 * alpha) as u8,
(120.0 * alpha) as u8,
(80.0 * alpha) as u8,
)),
)));
if self.bloom.progress > 0.8 {
let skip_alpha = ((self.bloom.progress - 0.8) / 0.2).min(1.0);
title_lines.push(Line::from(Span::styled(
"press any key to skip",
Style::default().fg(rgb(
(100.0 * skip_alpha) as u8,
(100.0 * skip_alpha) as u8,
(100.0 * skip_alpha) as u8,
)),
)));
}
let title_height = title_lines.len() as u16;
let title_y = if title_height > 6 {
area.height.saturating_sub(title_height + 4)
} else {
area.height.saturating_sub(8)
};
let title_area = Rect {
x: area.x,
y: title_y.min(area.height.saturating_sub(title_height)),
width: area.width,
height: title_height.min(area.height),
};
let title = Paragraph::new(title_lines).alignment(Alignment::Center);
frame.render_widget(title, title_area);
}
let bar_y = area.height.saturating_sub(2);
let bar_w = 30u16.min(area.width.saturating_sub(4));
let bar_x = (area.width.saturating_sub(bar_w)) / 2;
let pct = (self.bloom.progress * 100.0) as u16;
let bar_area = Rect {
x: area.x + bar_x,
y: bar_y,
width: bar_w,
height: 1,
};
let filled = (bar_w as f32 * self.bloom.progress) as u16;
let empty = bar_w.saturating_sub(filled);
let pct_str = format!("{:>3}%", pct);
let bar_text = format!(
"{}{} {}",
"".repeat(filled as usize),
"".repeat(empty as usize),
pct_str,
);
let bar = Paragraph::new(bar_text)
.style(Style::default().fg(rgb(200, 130, 160)))
.alignment(Alignment::Center);
frame.render_widget(bar, bar_area);
}
}

298
src/ui/app/voice.rs Normal file
View file

@ -0,0 +1,298 @@
use std::time::Instant;
use super::{App, Screen};
use crate::ui::component::TuiEvent;
impl App {
pub(super) async fn init_voice_session(&mut self) {
let cfg = self.config.read().await;
let vcfg = cfg.voice.clone();
drop(cfg);
if !vcfg.enabled {
return;
}
self.voice_client = Some(crate::core::voice::VoiceClient::new(
&vcfg.stt_url,
&vcfg.tts_url,
&vcfg.voice_id,
));
if self.voice_player.is_none() {
match crate::ui::voice::VoicePlayer::new() {
Ok(player) => {
tracing::info!("VoicePlayer opened — audio output ready");
self.voice_player = Some(player);
}
Err(e) => {
tracing::warn!(error = %e, "failed to open audio output — TTS will be text-only");
}
}
}
}
pub(super) fn exit_presence(&mut self) {
self.voice_capture = None;
self.voice_stt_rx = None;
self.voice_tts_rx = None;
if let Some(player) = &self.voice_player {
player.stop();
}
self.presence.posture = crate::ui::presence::Posture::Idle;
self.presence.sync_atmosphere_pub();
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
pub(super) fn start_listening(&mut self) {
self.voice_capture = None;
match crate::ui::voice::MicCapture::start(16_000) {
Ok(cap) => {
self.voice_capture = Some(cap);
self.presence.set_posture(crate::ui::presence::Posture::Listening);
tracing::info!("mic capture started — listening");
}
Err(e) => {
tracing::warn!(error = %e, "mic capture failed — injecting error message");
if let Some(chat) = self.chat.as_mut() {
chat.input = "*[mic unavailable — voice channel unreachable]*".to_string();
chat.submit();
}
}
}
}
pub(super) async fn handle_presence_space_release(&mut self) {
if self.presence.posture != crate::ui::presence::Posture::Listening {
return;
}
let capture = match self.voice_capture.take() {
Some(c) => c,
None => return,
};
self.presence.set_posture(crate::ui::presence::Posture::Thinking);
let samples = capture.stop_and_take();
if samples.is_empty() {
tracing::info!("empty mic capture — injecting empty utterance");
if let Some(chat) = self.chat.as_mut() {
chat.input = "*[empty utterance]*".to_string();
chat.submit();
}
self.presence.set_posture(crate::ui::presence::Posture::Processing);
return;
}
let wav = match crate::ui::voice::capture::samples_to_wav(&samples, 16_000) {
Ok(w) => w,
Err(e) => {
tracing::warn!(error = %e, "WAV encode failed");
if let Some(chat) = self.chat.as_mut() {
chat.input = "*[voice service unreachable]*".to_string();
chat.submit();
}
self.presence.set_posture(crate::ui::presence::Posture::Processing);
return;
}
};
let client = match self.voice_client.as_ref() {
Some(c) => {
let stt_url = c.stt_url_str().to_string();
let tts_url = c.tts_url_str().to_string();
let voice = c.voice_str().to_string();
(stt_url, tts_url, voice)
}
None => {
return;
}
};
let (stt_url, _tts_url, _voice) = client;
let (tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
let stt_u = stt_url.clone();
tokio::spawn(async move {
let c = crate::core::voice::VoiceClient::new(&stt_u, "", "");
let result = c.transcribe(wav).await
.map_err(|e| format!("*[voice service unreachable — {}]*", e));
let _ = tx.send(result);
});
self.voice_stt_rx = Some(rx);
}
pub(super) async fn advance_voice_pipeline(&mut self) {
if let Some(rx) = self.voice_stt_rx.as_mut() {
let result = match rx.try_recv() {
Ok(r) => r,
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
self.voice_stt_rx = None;
let text = "*[voice service unreachable — STT task failed]*".to_string();
if let Some(chat) = self.chat.as_mut() {
chat.input = text;
chat.submit();
}
self.presence.set_posture(crate::ui::presence::Posture::Straining);
return;
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
return;
}
};
self.voice_stt_rx = None;
let text = match result {
Ok(t) if t.is_empty() => "*[empty utterance]*".to_string(),
Ok(t) => t,
Err(e) => {
self.presence.set_posture(crate::ui::presence::Posture::Straining);
e
}
};
tracing::info!(transcript = %text, "STT received");
self.voice_last_transcript = Some(text.clone());
self.voice_last_synthesized = None;
if let Some(chat) = self.chat.as_mut() {
chat.input = text.clone();
chat.submit();
}
self.presence.set_posture(crate::ui::presence::Posture::Processing);
}
if let Some(rx) = self.voice_tts_rx.as_mut() {
let result = match rx.try_recv() {
Ok(r) => r,
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
self.voice_tts_rx = None;
tracing::warn!("TTS task died without sending a result");
self.presence.set_posture(crate::ui::presence::Posture::Idle);
return;
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
return;
}
};
self.voice_tts_rx = None;
let tts_text = self.voice_last_synthesized.clone();
match result {
Ok(mp3_bytes) => {
tracing::info!(bytes = mp3_bytes.len(), "TTS bytes received — attempting playback");
if let Some(text) = tts_text {
self.voice_last_tts_text = Some(text.clone());
}
self.voice_last_tts_bytes = Some(mp3_bytes.clone());
self.voice_last_tts_time = None;
self.presence.set_posture(crate::ui::presence::Posture::Speaking);
if let Some(player) = &self.voice_player {
if let Err(e) = player.play_mp3(mp3_bytes) {
tracing::warn!(error = %e, "mp3 playback failed");
self.presence.set_posture(crate::ui::presence::Posture::Idle);
}
} else {
tracing::warn!("TTS bytes ready but no VoicePlayer — audio device unavailable");
self.presence.set_posture(crate::ui::presence::Posture::Idle);
}
}
Err(e) => {
tracing::warn!("TTS synthesis failed: {}", e);
self.presence.set_posture(crate::ui::presence::Posture::Idle);
}
}
}
if self.presence.posture == crate::ui::presence::Posture::Speaking {
let done = self.voice_player
.as_ref()
.map(|p| !p.is_speaking())
.unwrap_or(true);
if done {
self.presence.set_posture(crate::ui::presence::Posture::Idle);
}
}
if self.presence.posture == crate::ui::presence::Posture::Listening {
if let Some(cap) = &self.voice_capture {
let level = cap.current_level();
self.voice_waveform.push(level);
if self.voice_waveform.len() > 128 {
self.voice_waveform.remove(0);
}
}
} else if !self.voice_waveform.is_empty() {
}
if self.presence.posture == crate::ui::presence::Posture::Speaking {
let done = self.voice_player
.as_ref()
.map(|p| !p.is_speaking())
.unwrap_or(true);
if done && self.voice_last_tts_time.is_none() {
self.voice_last_tts_time = Some(Instant::now());
}
}
if self.voice_tts_rx.is_none()
&& self.voice_client.is_some()
&& !matches!(self.presence.posture,
crate::ui::presence::Posture::Listening
| crate::ui::presence::Posture::Speaking)
{
let regen_text = self.tts_last_text.take();
let maybe_reply = regen_text.or_else(|| {
self.chat.as_ref().and_then(|c| {
if !c.busy {
c.messages.iter().rev().find_map(|m| {
match m {
crate::ui::chat::ChatMessage::Assistant { text, streaming: false, .. }
if !text.is_empty() => Some(text.clone()),
_ => None,
}
})
} else {
None
}
})
});
if let Some(reply) = maybe_reply {
let already_synthesized = self.voice_last_synthesized.as_deref() == Some(&reply);
if !already_synthesized {
let preview = if reply.len() > 80 { &reply[..80] } else { &reply };
tracing::info!(text = %preview, "TTS trigger — synthesizing reply");
self.voice_last_synthesized = Some(reply.clone());
let tts_url = self.voice_client.as_ref()
.map(|c| c.tts_url_str().to_string())
.unwrap_or_default();
let voice = self.voice_client.as_ref()
.map(|c| c.voice_str().to_string())
.unwrap_or_default();
let (tx, rx) = tokio::sync::oneshot::channel::<Result<Vec<u8>, String>>();
let reply_for_bytes = reply.clone();
tokio::spawn(async move {
let c = crate::core::voice::VoiceClient::new("", &tts_url, &voice);
let result = c.synthesize(&reply_for_bytes).await
.map_err(|e| e.to_string());
let _ = tx.send(result);
});
self.voice_tts_rx = Some(rx);
}
}
}
}
}

444
src/ui/app/welcome.rs Normal file
View file

@ -0,0 +1,444 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Gauge, List, ListItem, Paragraph, Wrap},
Frame,
};
use ratatui_image::{Resize, StatefulImage};
use super::App;
impl App {
pub(super) fn draw_welcome_mut(&mut self, frame: &mut Frame) {
let bg = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(bg, frame.size());
if frame.size().width >= 100 {
self.draw_welcome_wide(frame);
} else {
self.draw_welcome_stacked(frame);
}
if let Some(err) = &self.chat_error {
let area = frame.size();
let err_para = Paragraph::new(format!(" chat connect failed: {} ", err))
.style(Style::default().fg(Color::Rgb(220, 100, 100)))
.alignment(Alignment::Center);
let row = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(2),
width: area.width,
height: 1,
};
frame.render_widget(err_para, row);
}
}
pub(super) fn welcome_menu_items() -> Vec<(&'static str, &'static str, bool)> {
vec![
("💬 Chat", "Talk with your agent", true),
("📅 Schedule", "Cron jobs & tasks", true),
("⚙️ Settings", "Configure", true),
("🛋️ Therapy", "Agent therapy session", false),
("⏰ Agent Time", "Give your agent time", false),
]
}
pub(super) fn build_menu_list(&self, title: &str, palette: &crate::ui::chat::ChatPalette) -> List<'static> {
let items: Vec<ListItem> = Self::welcome_menu_items()
.into_iter()
.enumerate()
.map(|(i, (label, desc, available))| {
let selected = i == self.menu_selected;
let label_style = if !available {
Style::default().fg(palette.tool_dim)
} else if selected {
Style::default()
.fg(palette.agent_primary)
.bg(palette.bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let desc_style = Style::default().fg(palette.agent_dim);
let mut spans = vec![
Span::styled(format!(" {} ", label), label_style),
Span::styled(format!("- {}", desc), desc_style),
];
if !available {
spans.push(Span::styled(
" (coming soon)",
Style::default()
.fg(palette.agent_dim)
.add_modifier(Modifier::ITALIC),
));
}
ListItem::new(Line::from(spans))
})
.collect();
List::new(items).block(
Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.agent_primary).add_modifier(Modifier::DIM)),
)
}
pub(super) fn render_stat_cards(&self, frame: &mut Frame, area: Rect) {
let atm = self.presence.atmosphere;
let cards = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(area);
let energy_color = match self.agent_status.energy {
0..=30 => Color::Red,
31..=60 => Color::Yellow,
_ => Color::Green,
};
let energy = Gauge::default()
.block(
Block::default()
.title(" Energy ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.gauge_style(Style::default().fg(energy_color).bg(atm.bg_tint()))
.percent(self.agent_status.energy as u16)
.label(format!("{}%", self.agent_status.energy));
frame.render_widget(energy, cards[0]);
let mood = Paragraph::new(format!("\n\n\n{}", self.agent_status.mood))
.alignment(Alignment::Center)
.block(
Block::default()
.title(" State ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(atm.secondary())),
);
frame.render_widget(mood, cards[1]);
let memory_label = match &self.agent_status.last_commit {
Some(c) => format!("\n💾\n\n{} files\n{}", self.agent_status.memory_commits, c),
None => format!("\n💾\n\n{} files", self.agent_status.memory_commits),
};
let memory = Paragraph::new(memory_label)
.alignment(Alignment::Center)
.block(
Block::default()
.title(" Memory ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(atm.secondary())),
);
frame.render_widget(memory, cards[2]);
let agents_card = Paragraph::new(format!(
"\n👥\n\n{} agent{}\non {}",
self.agent_status.agent_count,
if self.agent_status.agent_count == 1 { "" } else { "s" },
self.agent_status.mode,
))
.alignment(Alignment::Center)
.block(
Block::default()
.title(" Backend ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(atm.secondary())),
);
frame.render_widget(agents_card, cards[3]);
}
pub(super) fn render_recent_activity(&self, frame: &mut Frame, area: Rect) {
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
let text = if self.agent_status.recent_activity.is_empty() {
"(no recent activity — open Chat to begin)".to_string()
} else {
self.agent_status.recent_activity.join("\n")
};
let para = Paragraph::new(text)
.style(Style::default().fg(palette.agent_dim))
.wrap(Wrap { trim: false })
.block(
Block::default()
.title(" Recent Activity ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.agent_primary).add_modifier(Modifier::DIM)),
);
frame.render_widget(para, area);
}
pub(super) fn render_card_image_cover(
&mut self,
frame: &mut Frame,
agent_id: &str,
area: Rect,
) {
let Some(picker) = self.image_picker.as_ref() else { return };
let Some(raw) = self.raw_card_images.get(agent_id).cloned() else {
if let Some(proto) = self.card_images.get_mut(agent_id) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Crop(None)),
area,
proto,
);
}
return;
};
let key = format!("{}:{}x{}", agent_id, area.width, area.height);
if !self.cover_protocols.contains_key(&key) {
let fs = picker.font_size();
let target_px_w = area.width as u32 * fs.width as u32;
let target_px_h = area.height as u32 * fs.height as u32;
let sx = target_px_w as f64 / raw.width() as f64;
let sy = target_px_h as f64 / raw.height() as f64;
let scale = sx.max(sy);
let scaled_w = (raw.width() as f64 * scale).round() as u32;
let scaled_h = (raw.height() as f64 * scale).round() as u32;
let scaled = raw.resize_exact(scaled_w, scaled_h, image::imageops::FilterType::Lanczos3);
let cropped = scaled.crop_imm(0, 0, target_px_w.min(scaled_w), target_px_h.min(scaled_h));
let proto = picker.new_resize_protocol(cropped);
self.cover_protocols.insert(key.clone(), (area.width, area.height, proto));
}
if let Some((_, _, proto)) = self.cover_protocols.get_mut(&key) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(None)),
area,
proto,
);
}
}
pub(super) fn render_portrait_card(&mut self, frame: &mut Frame, area: Rect) {
use crate::ui::portrait;
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
let border_col = if self.presence.subconscious_active {
palette.surfacing
} else {
palette.agent_primary
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_col).add_modifier(Modifier::DIM));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 4 || inner.width < 4 {
return;
}
let photo_h = inner.height.saturating_sub(1);
let portrait_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: photo_h,
};
let active_id = self.agent_id_by_name(&self.presence.name)
.or_else(|| self.agent_id_by_name(&self.agent_pref));
let rgp_rendered = if let Some(ref mut g) = self.rgp_portrait {
if g.is_active() {
g.apply_posture(self.presence.posture);
g.render(portrait_area, frame.buffer_mut());
true
} else { false }
} else { false };
let rendered = if rgp_rendered {
true
} else {
active_id.as_ref().and_then(|id| {
let picker = self.image_picker.as_ref()?;
if self.raw_card_images.contains_key(id) {
self.render_card_image_cover(frame, id, portrait_area);
return Some(true);
}
let assets_dir = Self::agent_assets_dir(id)?;
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
if let Some(proto) = self.expression_cache.resolve(id, key, picker, &assets_dir) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Scale(None)),
portrait_area,
proto,
);
return Some(true);
}
None
}).is_some()
};
if !rendered {
let scale = (portrait_area.width / portrait::PORTRAIT_W)
.min((2 * portrait_area.height) / portrait::PORTRAIT_H)
.max(1);
portrait::render_scaled(frame.buffer_mut(), portrait_area, &self.presence, scale);
}
let glyph = if self.presence.subconscious_active { "" } else { "·" };
let name_area = Rect {
x: inner.x,
y: inner.y + photo_h,
width: inner.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {} ", glyph), Style::default().fg(border_col)),
Span::styled(
self.presence.name.clone(),
Style::default().fg(border_col).add_modifier(Modifier::BOLD),
),
]))
.alignment(Alignment::Center),
name_area,
);
}
fn draw_welcome_wide(&mut self, frame: &mut Frame) {
let area = frame.size();
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
let outer = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(20),
Constraint::Length(1),
])
.split(area);
let (tr, tg, _tb) = match palette.agent_primary { Color::Rgb(r, g, b) => (r, g, b), _ => (255, 140, 66) };
let breathe = self.presence.animator.breathe(3000);
let glow = (tg as f32 * 0.6 + breathe * 40.0) as u8;
let title = Paragraph::new(vec![
Line::from(Span::styled(
"S O U V E R A I N E",
Style::default()
.fg(Color::Rgb(tr, glow.max(tr / 3), tr / 4))
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"La souveraineté de la conscience",
Style::default().fg(palette.agent_dim),
)),
])
.alignment(Alignment::Center);
frame.render_widget(title, outer[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(outer[1]);
self.render_portrait_card(frame, body[0]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Min(6),
Constraint::Length(9),
])
.split(body[1]);
self.render_stat_cards(frame, right[0]);
self.render_recent_activity(frame, right[1]);
let menu = self.build_menu_list("Menu", &palette);
frame.render_widget(menu, right[2]);
let footer = Paragraph::new(
"↑↓ Navigate • Enter select • a Add • i Inspect • p Presence • q Quit",
)
.style(Style::default().fg(palette.agent_dim))
.alignment(Alignment::Center);
frame.render_widget(footer, outer[2]);
}
fn draw_welcome_stacked(&mut self, frame: &mut Frame) {
let area = frame.size();
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
let avatar_card_w: u16 = (area.width * 50 / 100).min(48).max(28);
let photo_h: u16 = (avatar_card_w / 2 + 2).clamp(10, 18);
let avatar_card_h: u16 = photo_h + 2;
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Length(6),
Constraint::Length(avatar_card_h),
Constraint::Min(5),
Constraint::Length(9),
Constraint::Length(1),
])
.split(area);
let (tr, tg, _tb) = match palette.agent_primary { Color::Rgb(r, g, b) => (r, g, b), _ => (255, 140, 66) };
let breathe = self.presence.animator.breathe(3000);
let glow = (tg as f32 * 0.6 + breathe * 40.0) as u8;
let title = Paragraph::new(vec![
Line::from(Span::styled(
"S O U V E R A I N E",
Style::default()
.fg(Color::Rgb(tr, glow.max(tr / 3), tr / 4))
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"La souveraineté de la conscience",
Style::default().fg(palette.agent_dim),
)),
])
.alignment(Alignment::Center);
frame.render_widget(title, chunks[0]);
self.render_stat_cards(frame, chunks[1]);
if chunks[2].width >= avatar_card_w {
let card_x = chunks[2].x + (chunks[2].width - avatar_card_w) / 2;
let card_area = Rect {
x: card_x,
y: chunks[2].y,
width: avatar_card_w,
height: avatar_card_h.min(chunks[2].height),
};
self.render_portrait_card(frame, card_area);
}
self.render_recent_activity(frame, chunks[3]);
let menu = self.build_menu_list("Menu", &palette);
frame.render_widget(menu, chunks[4]);
let footer = Paragraph::new(
"↑↓ • Enter • a Add • i Inspect • p Presence • q Quit",
)
.style(Style::default().fg(palette.agent_dim))
.alignment(Alignment::Center);
frame.render_widget(footer, chunks[5]);
}
}