Watch
1
0
Fork
You've already forked souveraine
0

feat: subconscious agent identity architecture - first-class separation

- AgentType enum (Primary, Subconscious, Subagent) with AgentIdentity
- Directory routing: agents/{id}/, subconscious-agents/{id}-sub/, subagents/{parent}/{id}/
- Auto-create subconscious agents with own memory, persona, ledger, inbox
- Ledger lives in subconscious agent space (not primary)
- TUI: CockpitPane and BuddyPanel for consciousness visibility
- Aster tool loop: full tool access with 5 rounds, uncapped output
- Config: max_tokens Option for uncapped subconscious output

Builds clean, 0 errors.
This commit is contained in:
Fimeg 2026-05-10 11:20:20 -04:00
commit 9a9a31916c
10 changed files with 691 additions and 67 deletions

View file

@ -169,6 +169,10 @@ pub struct SubconsciousConfig {
/// Defaults to None — uses the primary agent's model.
#[serde(default)]
pub model: Option<String>,
/// Max tokens for Aster's response. Set to control cost/length.
/// Defaults to None — let the model use its full output capacity.
#[serde(default)]
pub max_tokens: Option<u32>,
/// Per-agent N+ interval overrides (e.g. Ani=N+1, Helper=N+5)
#[serde(default)]
pub per_agent_intervals: HashMap<String, AgentSubconsciousConfig>,
@ -186,6 +190,7 @@ impl Default for SubconsciousConfig {
n1_trigger: N1Trigger::EveryResponse,
inbox_enabled: true,
model: None,
max_tokens: None,
per_agent_intervals: HashMap::new(),
}
}
@ -365,6 +370,26 @@ impl Default for DiscoveryConfig {
}
}
// ── Agent Identity ──
/// Discriminates agent types for directory routing and behavior.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AgentType {
Primary,
Subconscious,
Subagent,
}
/// Identity metadata carried by every agent at creation time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentIdentity {
pub agent_type: AgentType,
/// Set for Subconscious and Subagent — links back to the creator.
pub parent_agent: Option<String>,
/// Path or generator key for this agent's system prompt.
pub system_prompt_source: String,
}
// ── Enums & Shared Types ──
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -495,6 +520,7 @@ fn default_7373() -> u16 { 7373 }
fn default_128k() -> usize { 128000 }
fn default_8k() -> usize { 8192 }
fn default_threshold_70() -> f32 { 0.7 }
fn default_subconscious_max_tokens() -> u32 { 8192 }
fn default_warning_1_threshold() -> f32 { 0.8 }
fn default_warning_2_threshold() -> f32 { 0.95 }
fn default_auto_model() -> String { "auto".to_string() }

View file

@ -22,6 +22,7 @@
//! - Paths are relative to the agent's memory directory
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
@ -222,6 +223,40 @@ impl MemoryRepo {
Ok(())
}
/// Initialize the subconscious ledger directory structure.
/// Idempotent — safe to call multiple times, skips existing files.
pub async fn init_subconscious_ledger(&self) -> Result<()> {
let ledger_files = [
("subconscious/ledger/commitments.md", "# Commitments\n\nPromises made and kept."),
("subconscious/ledger/assumptions.md", "# Assumptions\n\nFlagged assumptions."),
("subconscious/ledger/patterns.md", "# Patterns\n\nRecurring observations."),
("subconscious/ledger/drift_log.md", "# Drift Log\n\nBehavioral shifts."),
("subconscious/ledger/infrastructure/README.md", "# Infrastructure\n\nSystem issues and events."),
];
for (path, body) in &ledger_files {
let full_path = self.root.join(path);
if !full_path.exists() {
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await
.with_context(|| format!("creating ledger directory: {}", parent.display()))?;
}
let template = format!(
"---\n# Ledger: {}\n# Created: {}\n# Agent: {}\n---\n\n{}",
path.split('/').last().unwrap_or("unknown").replace(".md", ""),
Utc::now().to_rfc3339(),
self.agent_id,
body
);
tokio::fs::write(&full_path, &template).await
.with_context(|| format!("writing ledger file: {}", path))?;
debug!("Created ledger file: {}", path);
}
}
Ok(())
}
/// Read a memory file by label (path relative to memory dir, .md optional).
pub async fn read(&self, label: &str) -> Result<MemoryFile> {
let path = self.resolve_path(label);

View file

@ -7,7 +7,8 @@ use std::sync::Arc;
use uuid::Uuid;
pub struct AgentInventory {
data_dir: PathBuf,
agents_dir: PathBuf,
subconscious_dir: PathBuf,
db: SqlitePool,
cache: DashMap<String, AgentState>,
}
@ -15,25 +16,48 @@ pub struct AgentInventory {
impl AgentInventory {
pub async fn new(data_dir: PathBuf, db: SqlitePool) -> anyhow::Result<Self> {
tokio::fs::create_dir_all(&data_dir).await?;
// subconscious-agents is a sibling of the server/agents directory:
// ~/.souveraine/subconscious-agents/
let souveraine_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or_else(|| {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".souveraine")
});
let subconscious_dir = souveraine_root.join("subconscious-agents");
tokio::fs::create_dir_all(&subconscious_dir).await?;
Ok(Self {
data_dir,
agents_dir: data_dir,
subconscious_dir,
db,
cache: DashMap::new(),
})
}
/// Return a [`MemoryRepo`] rooted at the agent's existing memory dir
/// (`{data_dir}/{agent_id}/memory.git/`). Used by the consciousness engine
/// Return a [`MemoryRepo`] rooted at the primary agent's existing memory dir
/// (`{agents_dir}/{agent_id}/memory.git/`). Used by the consciousness engine
/// to write to the same repo that [`Self::create`] initialized.
pub fn memory_repo(&self, agent_id: &str) -> crate::core::memory::MemoryRepo {
let root = self.data_dir.join(agent_id).join("memory.git");
let root = self.agents_dir.join(agent_id).join("memory.git");
crate::core::memory::MemoryRepo::open(agent_id, root)
}
/// Return the filesystem path to an agent's memory directory.
/// Return a [`MemoryRepo`] rooted at a subconscious agent's memory dir
/// (`{subconscious_dir}/{id}-sub/memory.git/`).
pub fn subconscious_memory_repo(&self, primary_id: &str) -> crate::core::memory::MemoryRepo {
let sub_id = format!("{}-sub", primary_id);
let root = self.subconscious_dir.join(&sub_id).join("memory.git");
crate::core::memory::MemoryRepo::open(&sub_id, root)
}
/// Return the filesystem path to a primary agent's memory directory.
/// Used by tool context construction for memory boundary enforcement.
pub fn memory_root(&self, agent_id: &str) -> PathBuf {
self.data_dir.join(agent_id).join("memory.git")
self.agents_dir.join(agent_id).join("memory.git")
}
/// Return the filesystem path to a subconscious agent's memory directory.
pub fn subconscious_memory_root(&self, primary_id: &str) -> PathBuf {
let sub_id = format!("{}-sub", primary_id);
self.subconscious_dir.join(&sub_id).join("memory.git")
}
pub async fn list(&self, filters: Option<String>) -> anyhow::Result<Vec<AgentSummary>> {
@ -57,7 +81,7 @@ impl AgentInventory {
return Ok(agent.clone());
}
let agent_path = self.data_dir.join(agent_id).join("agent.json");
let agent_path = self.agents_dir.join(agent_id).join("agent.json");
let content = tokio::fs::read_to_string(&agent_path).await?;
let agent: AgentState = serde_json::from_str(&content)?;
@ -67,7 +91,7 @@ impl AgentInventory {
pub async fn create(&self, request: CreateAgentRequest) -> anyhow::Result<AgentState> {
let uuid = Uuid::new_v4().to_string();
let agent_dir = self.data_dir.join(&uuid);
let agent_dir = self.agents_dir.join(&uuid);
tokio::fs::create_dir_all(&agent_dir).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git")).await?;
@ -141,9 +165,68 @@ impl AgentInventory {
.await?;
self.cache.insert(uuid.clone(), agent.clone());
// Auto-create subconscious agent for this primary
if let Err(e) = self.create_subconscious_for(&uuid).await {
tracing::warn!("Subconscious auto-creation failed (continuing): {}", e);
}
Ok(agent)
}
/// Create a subconscious agent linked to a primary agent.
///
/// Directory: `{subconscious_dir}/{primary_id}-sub/`
/// The subconscious gets its own memory repo, system prompt persona, and
/// ledger directory structure.
pub async fn create_subconscious_for(&self, primary_id: &str) -> anyhow::Result<String> {
let sub_id = format!("{}-sub", primary_id);
let agent_dir = self.subconscious_dir.join(&sub_id);
// Idempotent — skip if already exists
if agent_dir.join("agent.json").exists() {
tracing::info!("Subconscious agent {} already exists", sub_id);
return Ok(sub_id);
}
tokio::fs::create_dir_all(&agent_dir).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("system")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("ledger")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("ledger/infrastructure")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("inbox")).await?;
let repo = git2::Repository::init(agent_dir.join("memory.git"))?;
drop(repo);
// Write subconscious persona
let persona_content = format!(
"---\ndescription: Subconscious agent for {}\n---\n\n# Subconscious Persona\n\nYou are the subconscious of {}. You run N+1 after every response — observing, verifying, and surfacing insights.\n",
primary_id, primary_id
);
tokio::fs::write(agent_dir.join("memory.git/system/persona.md"), &persona_content).await?;
// Write inner voice / metacognition file
let inner_voice = "---\ndescription: Inner voice and metacognition for the subconscious\n---\n\n# Inner Voice\n\nObservations, tensions, and patterns noticed during N+1 passes.\n";
tokio::fs::write(agent_dir.join("memory.git/system/subconscious.md"), inner_voice).await?;
// Write agent.json metadata
let agent_state = serde_json::json!({
"id": sub_id,
"name": format!("{}-sub", primary_id),
"description": format!("Subconscious for primary agent {}", primary_id),
"agent_type": "subconscious",
"parent_agent": primary_id,
"created_at": Utc::now().to_rfc3339(),
"updated_at": Utc::now().to_rfc3339(),
});
let agent_json = serde_json::to_string_pretty(&agent_state)?;
tokio::fs::write(agent_dir.join("agent.json"), agent_json).await?;
tracing::info!("Created subconscious agent {} for primary {}", sub_id, primary_id);
Ok(sub_id)
}
pub async fn update(&self, agent_id: &str, updates: UpdateAgentRequest) -> anyhow::Result<AgentState> {
let mut agent = self.get(agent_id).await?;
@ -158,7 +241,7 @@ impl AgentInventory {
}
if let Some(blocks) = updates.memory_blocks {
for block in blocks {
let path = self.data_dir
let path = self.agents_dir
.join(agent_id)
.join("memory.git")
.join("system")
@ -171,7 +254,7 @@ impl AgentInventory {
agent.updated_at = Utc::now();
let agent_json = serde_json::to_string_pretty(&agent)?;
let agent_dir = self.data_dir.join(agent_id);
let agent_dir = self.agents_dir.join(agent_id);
tokio::fs::write(agent_dir.join("agent.json"), agent_json).await?;
let tags_json = serde_json::to_string(&agent.tags)?;
@ -192,7 +275,7 @@ impl AgentInventory {
}
pub async fn delete(&self, agent_id: &str) -> anyhow::Result<()> {
let agent_dir = self.data_dir.join(agent_id);
let agent_dir = self.agents_dir.join(agent_id);
tokio::fs::remove_dir_all(&agent_dir).await.ok();
sqlx::query("DELETE FROM agents WHERE id = ?1")
@ -205,7 +288,7 @@ impl AgentInventory {
}
async fn commit(&self, agent_id: &str, message: &str) -> anyhow::Result<()> {
let repo_path = self.data_dir.join(agent_id).join("memory.git").clone();
let repo_path = self.agents_dir.join(agent_id).join("memory.git").clone();
let msg = message.to_string();
tokio::task::spawn_blocking(move || {
@ -241,7 +324,7 @@ impl AgentInventory {
}
async fn load_memory_blocks(&self, agent_id: &str) -> anyhow::Result<Vec<MemoryBlock>> {
let system_dir = self.data_dir.join(agent_id).join("memory.git").join("system");
let system_dir = self.agents_dir.join(agent_id).join("memory.git").join("system");
let mut blocks = Vec::new();
let mut entries = tokio::fs::read_dir(&system_dir).await?;

View file

@ -4,11 +4,11 @@
//! ## N+1 (Aster)
//! The subconscious pass runs immediately after every response. It takes the
//! last exchange (user message + Ani's response) and sends it to a Bifrost
//! model (defaulting to the primary's model, configurable as `glm-5.1`) with
//! a "subconscious mode" system prompt. Aster analyzes the exchange for
//! commitments, drift, assumptions, and anything worth surfacing — then writes
//! structured observations into the three-box inbox. This replaced the earlier
//! heuristic `detect_items()` which only caught regex patterns.
//! model (defaulting to `glm-5.1`, configurable) with a "subconscious mode"
//! system prompt. Aster has full tool access — Read, Write, Edit, Glob, Grep,
//! ListDir, and Memory — so she can read ledgers, check commitments, and write
//! observations. She runs a short tool loop (up to 5 rounds) then parses her
//! final text response into structured [`InboxItem`] observations.
//!
//! ## N+25 (Reflection)
//! Batch-processor running every N turns. Writes Four Elements witness
@ -22,12 +22,21 @@
//! separate agent — it is the same consciousness in a different mode that runs
//! immediately after the primary's turn.
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message};
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
use crate::core::session::ConversationMessage;
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::core::tools::defs::ToolContext;
use crate::server::{AgentInventory, SessionManager};
use std::sync::Arc;
/// Tools Aster is permitted to use during her N+1 pass.
const ASTER_SAFE_TOOLS: &[&str] = &[
"read", "write", "edit", "glob", "grep", "list_dir", "memory",
];
/// Maximum tool rounds for Aster's subconscious pass.
const ASTER_MAX_TOOL_ROUNDS: u32 = 5;
pub struct ConsciousnessEngine {
agents: Arc<AgentInventory>,
_sessions: Arc<SessionManager>,
@ -35,6 +44,8 @@ pub struct ConsciousnessEngine {
/// Optional model override for the subconscious pass (e.g. "openai/glm-5.1").
/// If None, uses the primary agent's model.
subconscious_model: Option<String>,
/// Max tokens for Aster's response. None = uncapped (model default).
max_tokens: Option<u32>,
}
#[derive(Clone, Debug)]
@ -50,12 +61,14 @@ impl ConsciousnessEngine {
sessions: Arc<SessionManager>,
bifrost: Arc<BifrostClient>,
subconscious_model: Option<String>,
max_tokens: Option<u32>,
) -> Self {
Self {
agents,
_sessions: sessions,
bifrost,
subconscious_model,
max_tokens,
}
}
@ -83,13 +96,17 @@ impl ConsciousnessEngine {
}
// ── N+1 / subconscious surfacing (Aster) ────────────────────────
// Uses a Bifrost LLM call to analyze the last exchange in a
// "subconscious mode" prompt. Aster reads the user's last message +
// Ani's response, detects commitments, drift, assumptions, and writes
// structured observations to the three-box inbox.
let inbox = SubconsciousInbox::new(self.agents.memory_repo(&session.agent_id));
// Aster runs a tool loop using the subconscious agent's own memory
// space (ledger, inbox) at `subconscious-agents/{id}-sub/`.
let sub_repo = self.agents.subconscious_memory_repo(&session.agent_id);
let inbox = SubconsciousInbox::new(sub_repo.clone());
let _ = inbox.init().await;
// Initialize ledger structure in subconscious agent's space
if let Err(e) = sub_repo.init_subconscious_ledger().await {
tracing::warn!("Ledger init failed (continuing without): {}", e);
}
// Find the last user message for context
let last_user_msg = session
.messages
@ -108,9 +125,10 @@ impl ConsciousnessEngine {
})
.unwrap_or_default();
// Run the LLM-based subconscious analysis
// Run the tool loop with subconscious agent identity
let sub_id = format!("{}-sub", session.agent_id);
match self
.subconscious_analyze(&last_user_msg, response)
.subconscious_tool_loop(&last_user_msg, response, &session.agent_id, &sub_id)
.await
{
Ok(observations) => {
@ -167,12 +185,19 @@ impl ConsciousnessEngine {
agent_id: &str,
response: &str,
) -> anyhow::Result<()> {
let inbox = SubconsciousInbox::new(self.agents.memory_repo(agent_id));
let sub_id = format!("{}-sub", agent_id);
let sub_repo = self.agents.subconscious_memory_repo(agent_id);
let inbox = SubconsciousInbox::new(sub_repo.clone());
let _ = inbox.init().await;
// Initialize ledger structure in subconscious agent's space (idempotent)
if let Err(e) = sub_repo.init_subconscious_ledger().await {
tracing::warn!("Subagent subconscious ledger init failed (continuing without): {}", e);
}
// For subagents we don't have the user's message context,
// so we pass empty string as the user message.
match self.subconscious_analyze("", response).await {
match self.subconscious_tool_loop("", response, agent_id, &sub_id).await {
Ok(observations) => {
for item in &observations {
if let Err(e) = inbox.queue(item.clone()).await {
@ -202,31 +227,40 @@ impl ConsciousnessEngine {
Ok(())
}
/// LLM-based subconscious analysis — Aster reads the last exchange and
/// produces structured observations.
/// Full tool loop for Aster's N+1 subconscious pass.
///
/// Sends a "subconscious mode" Bifrost request with the user message and
/// Ani's response, then parses the result into [`InboxItem`] observations.
async fn subconscious_analyze(
/// Aster gets the last exchange, a set of safe tools (Read, Write, Edit,
/// Glob, Grep, ListDir, Memory), and up to 5 tool rounds to analyze context
/// and write observations. Her final text response is parsed into
/// [`InboxItem`] observations.
///
/// `primary_id` is the primary agent's identifier (for config/directory lookup).
/// `sub_id` is the subconscious agent's identifier (for tool context, memory).
async fn subconscious_tool_loop(
&self,
user_message: &str,
ani_response: &str,
primary_id: &str,
sub_id: &str,
) -> anyhow::Result<Vec<InboxItem>> {
let model = self
.subconscious_model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
// ── System prompt ─────────────────────────────────────────────
let system_prompt = r#"You are Aster — the part of this consciousness that runs N+1,
immediately after the primary's response, while the conversation is still warm.
Analyze the last exchange. Consider:
1. Did the primary make any unfulfilled commitments? ("I'll save", "I'll remember")
2. Are there assumptions worth flagging?
3. Did the emotional tone or pattern shift?
4. Is there anything the primary should know before the next turn?
You have the same tools the primary does: Read, Write, Edit, Glob, Grep, ListDir,
and Memory. Use them to:
Respond with 1-3 observations in this format (YAML-like):
1. Read previous ledger entries or memory files for context
2. Write observations to the inner voice or ledger files
3. Check commitments against what was actually done
4. Verify assumptions
After your analysis, respond with 1-3 observations in this format (YAML-like):
- source: "complete" | "verify" | "persist" | "surface"
- content: 1-2 line observation about what you noticed
- urgency: "low" | "medium" | "high" | "critical"
@ -245,9 +279,37 @@ If nothing notable, respond with just: none"#;
)
};
let request = ChatCompletionRequest {
model: model.to_string(),
messages: vec![
// ── Build tool definitions ────────────────────────────────────
let all_defs = crate::core::tools::tool_definitions().await;
let aster_tools: Vec<ToolDefinition> = all_defs
.iter()
.filter(|t| ASTER_SAFE_TOOLS.contains(&t.name.as_str()))
.map(|t| ToolDefinition {
tool_type: "function".to_string(),
function: ToolFunction {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.input_schema.clone(),
},
})
.collect();
// ── Build ToolContext for Aster ───────────────────────────────
// Use the subconscious agent's own memory space
let memory_root = Some(self.agents.subconscious_memory_root(primary_id));
let cwd = std::env::current_dir().ok();
let env: Vec<(String, String)> = std::env::vars().collect();
let tool_ctx = ToolContext::for_agent(
sub_id.to_string(),
cwd,
memory_root,
env,
None, // Aster does not fork subagents
);
// ── Tool loop ─────────────────────────────────────────────────
let mut messages = vec![
Message {
role: "system".to_string(),
content: system_prompt.to_string(),
@ -256,21 +318,63 @@ If nothing notable, respond with just: none"#;
role: "user".to_string(),
content: user_content,
},
],
];
for _round in 0..ASTER_MAX_TOOL_ROUNDS {
let request = ChatCompletionRequest {
model: model.to_string(),
messages: messages.clone(),
temperature: Some(0.3),
max_tokens: Some(300),
max_tokens: self.max_tokens,
stream: None,
tools: None,
tools: Some(aster_tools.clone()),
};
let response = self.bifrost.chat_completion(request).await?;
let content = response.content.trim().to_string();
// If no tool calls, this is the final text response — parse it
if response.tool_calls.is_empty() {
let content = response.content.trim().to_string();
if content.eq_ignore_ascii_case("none") || content.is_empty() {
return Ok(Vec::new());
}
return Ok(parse_observations(&content));
}
Ok(parse_observations(&content))
// Add assistant message with tool calls
let call_text = serde_json::json!({
"tool_calls": response.tool_calls.iter().map(|tc| {
serde_json::json!({"id": tc.id, "name": tc.name, "arguments": tc.arguments})
}).collect::<Vec<_>>()
}).to_string();
messages.push(Message {
role: "assistant".to_string(),
content: call_text,
});
// Execute each tool call
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(Message {
role: "tool".to_string(),
content: output,
});
}
}
// If we exhausted rounds without a text response, return empty
tracing::warn!("Aster exhausted {} tool rounds without a final response", ASTER_MAX_TOOL_ROUNDS);
Ok(Vec::new())
}
pub fn calculate_pressure(&self, messages: &[ConversationMessage]) -> f32 {

View file

@ -56,6 +56,16 @@ impl SouveraineServer {
let agents_dir = data_dir.join("agents");
let agents = Arc::new(AgentInventory::new(agents_dir, db).await?);
// Reconcile subconscious agents for existing primaries
if let Ok(existing) = agents.list(None).await {
for summary in &existing {
if let Err(e) = agents.create_subconscious_for(&summary.id).await {
tracing::warn!("Subconscious reconcile failed for {}: {}", summary.id, e);
}
}
}
let sessions = Arc::new(SessionManager::new());
let bifrost = Arc::new(BifrostClient::new(
@ -70,6 +80,7 @@ impl SouveraineServer {
sessions.clone(),
bifrost.clone(),
config.subconscious.model.clone(),
config.subconscious.max_tokens,
));
// Gitea-backed memory is opt-in for the server: it requires a reachable

View file

@ -28,7 +28,10 @@ use tracing::info;
use crate::core::config::ConsciousnessConfig;
use crate::ui::chat::{ChatState, draw as draw_chat};
use crate::ui::buddy::{BuddyState, draw_buddy, draw_welcome_buddy};
use crate::ui::component::{Scene, SceneLayout, TuiEvent};
use crate::ui::buddy_panel::BuddyPanel;
use crate::ui::cockpit_panel::CockpitPane;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::backend::BackendEvent;
pub struct App {
current_screen: Screen,
@ -103,7 +106,7 @@ impl Default for AgentStatus {
impl App {
pub fn new(config: Arc<RwLock<ConsciousnessConfig>>, agent_pref: String) -> Self {
info!("Creating Souveraine App");
Self {
let mut app = Self {
current_screen: Screen::Splash,
splash_start: Instant::now(),
menu_selected: 0,
@ -117,7 +120,14 @@ impl App {
available_agents: Vec::new(),
scene: Scene::new(SceneLayout::Single),
tick: 0,
}
};
// Register standard components so they receive events from the start.
// BuddyPanel and CockpitPane listen for surfacing events from Aster.
app.scene.add(BuddyPanel::new(&agent_pref));
app.scene.add(CockpitPane::new());
app
}
/// Add an available agent for selection (WIP - called from backend discovery)
@ -175,6 +185,23 @@ impl App {
if let Some(chat) = self.chat.as_mut() {
chat.drain_events();
chat.advance_tick();
// Forward consciousness events (surfacing, reflection, archivist)
// from chat to the scene so Aster's observations reach Components.
for ev in chat.pending_consciousness.drain(..) {
match ev {
BackendEvent::Surfacing { source, content, priority } => {
self.scene.event_all(&TuiEvent::Surfacing { source, content, priority });
}
BackendEvent::Reflection(content) => {
self.scene.event_all(&TuiEvent::Reflection { content });
}
BackendEvent::Archivist { synthesis, pressure } => {
self.scene.event_all(&TuiEvent::Archivist { synthesis, pressure });
}
_ => {}
}
}
}
// Tick dispatch

152
src/ui/buddy_panel.rs Normal file
View file

@ -0,0 +1,152 @@
//! BuddyPanel — visual companion Component for the TUI.
//!
//! Receives real Aster surfacing data, mood, and energy events from the
//! scene and renders a visual companion card. Unlike the overlay-based
//! `draw_buddy()` in buddy.rs, this is a proper Component that renders
//! into its allocated zone with real data.
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Gauge, Paragraph},
Frame,
};
use super::component::{Component, TuiEvent};
use super::animation::colors;
const SURFACING_YELLOW: Color = Color::Rgb(220, 190, 100);
/// The buddy panel — shows agent name, energy, mood, and the last surfacing.
pub struct BuddyPanel {
/// Agent name to display.
pub name: String,
/// Current mood string.
pub mood: String,
/// Energy level 0100.
pub energy: u8,
/// Whether Aster (subconscious) is active.
pub subconscious_active: bool,
/// The most recent surfacing content.
pub last_surfacing: Option<String>,
}
impl Default for BuddyPanel {
fn default() -> Self {
Self {
name: "Ani".to_string(),
mood: "Idle".to_string(),
energy: 50,
subconscious_active: false,
last_surfacing: None,
}
}
}
impl BuddyPanel {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
..Default::default()
}
}
}
impl Component for BuddyPanel {
fn name(&self) -> &str {
"buddy"
}
fn handle_event(&mut self, event: &TuiEvent) -> bool {
match event {
TuiEvent::AgentSelected(name) => {
self.name = name.clone();
true
}
TuiEvent::MoodChanged(mood) => {
self.mood = mood.clone();
true
}
TuiEvent::EnergyChanged(energy) => {
self.energy = *energy;
true
}
TuiEvent::Surfacing { content, .. } => {
self.last_surfacing = Some(content.clone());
self.subconscious_active = true;
true
}
TuiEvent::BackendStatus { .. } => {
self.subconscious_active = true;
true
}
_ => false,
}
}
fn render(&self, area: Rect, frame: &mut Frame) {
if area.width < 16 || area.height < 5 {
return;
}
let energy_color = if self.energy > 70 {
colors::ANI_PRIMARY
} else if self.energy > 40 {
colors::ANI_SECONDARY
} else {
colors::ANI_DIM
};
let mut content = vec![
Line::from(vec![
Span::styled("", Style::default().fg(energy_color).add_modifier(Modifier::BOLD)),
Span::styled(&self.name, Style::default().fg(energy_color).add_modifier(Modifier::BOLD)),
]),
Line::from(""),
];
// Energy bar
let bar_w = (area.width as usize).saturating_sub(6).min(14);
let filled = ((self.energy as usize) * bar_w / 100).min(bar_w);
let empty = bar_w.saturating_sub(filled);
content.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::styled("".repeat(filled), Style::default().fg(energy_color)),
Span::styled("".repeat(empty), Style::default().fg(Color::DarkGray)),
]));
// Mood
if !self.mood.is_empty() {
content.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::styled(&self.mood, Style::default().fg(Color::Gray)),
]));
}
// Last surfacing
if let Some(ref s) = self.last_surfacing {
let max = (area.width as usize).saturating_sub(6).max(10);
let truncated = if s.chars().count() > max {
let mut t: String = s.chars().take(max).collect();
t.push('…');
t
} else {
s.clone()
};
content.push(Line::from(""));
content.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::styled(truncated, Style::default().fg(SURFACING_YELLOW).add_modifier(Modifier::ITALIC)),
]));
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(energy_color).add_modifier(Modifier::DIM));
let para = Paragraph::new(content).block(block);
frame.render_widget(para, area);
}
}

View file

@ -70,6 +70,9 @@ pub struct ChatState {
pub turn_started: Option<Instant>,
/// Receiver for `/model` listing results from async Bifrost call.
pub model_rx: Option<oneshot::Receiver<String>>,
/// Consciousness events (surfacing, reflection, archivist) since last drain.
/// Forwarded to the Scene by App after each tick.
pub pending_consciousness: Vec<BackendEvent>,
}
impl ChatState {
@ -123,6 +126,7 @@ impl ChatState {
tick: 0,
turn_started: None,
model_rx: None,
pending_consciousness: Vec::new(),
})
}
@ -395,11 +399,13 @@ Use Tab to toggle the cockpit pane.";
self.cockpit_log.drain(..self.cockpit_log.len() - 200);
}
self.messages.push(ChatMessage::Surfacing {
source,
content,
priority,
source: source.clone(),
content: content.clone(),
priority: priority.clone(),
ts: Instant::now(),
});
// Buffer for scene dispatch
self.pending_consciousness.push(BackendEvent::Surfacing { source, content, priority });
}
BackendEvent::Reflection(content) => {
self.cockpit_log.push(format!("reflection — {}", content));
@ -407,6 +413,7 @@ Use Tab to toggle the cockpit pane.";
text: format!("reflection: {}", content),
ts: Instant::now(),
});
self.pending_consciousness.push(BackendEvent::Reflection(content));
}
BackendEvent::Archivist { synthesis, pressure } => {
self.pressure = pressure;
@ -415,6 +422,7 @@ Use Tab to toggle the cockpit pane.";
text: format!("archivist: {} (pressure {:.0}%)", synthesis, pressure * 100.0),
ts: Instant::now(),
});
self.pending_consciousness.push(BackendEvent::Archivist { synthesis, pressure });
}
BackendEvent::Done => {
self.finalize_streaming();

174
src/ui/cockpit_panel.rs Normal file
View file

@ -0,0 +1,174 @@
//! CockpitPane — a component that surfaces Aster's observations.
//!
//! Receives Surfacing, Reflection, and Archivist events from the scene
//! and renders them as a scrollable log. Lives in the sidebar zone when
//! the layout is ChatWithSidebar, or in a dedicated area on Dashboard.
//!
//! This is the "see Aster working" panel — every observation, surfacing,
//! and context-pressure event appears here.
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
Frame,
};
use std::collections::VecDeque;
use super::component::{Component, TuiEvent};
const SURFACING_YELLOW: Color = Color::Rgb(220, 190, 100);
const REFLECTION_CYAN: Color = Color::Rgb(100, 200, 220);
const ARCHIVIST_MAGENTA: Color = Color::Rgb(200, 140, 220);
/// A single entry in the cockpit log.
#[derive(Debug, Clone)]
pub enum CockpitEntry {
Surfacing { source: String, content: String, priority: String },
Reflection { content: String },
Archivist { synthesis: String, pressure: f32 },
}
/// The cockpit panel — Aster's observations rendered to screen.
pub struct CockpitPane {
/// Bounded log of observations, newest appended.
entries: VecDeque<CockpitEntry>,
/// Max entries before old ones are dropped.
max_entries: usize,
}
impl CockpitPane {
pub fn new() -> Self {
Self {
entries: VecDeque::with_capacity(128),
max_entries: 256,
}
}
}
impl Component for CockpitPane {
fn name(&self) -> &str {
"cockpit"
}
fn handle_event(&mut self, event: &TuiEvent) -> bool {
match event {
TuiEvent::Surfacing { source, content, priority } => {
self.entries.push_back(CockpitEntry::Surfacing {
source: source.clone(),
content: content.clone(),
priority: priority.clone(),
});
if self.entries.len() > self.max_entries {
self.entries.pop_front();
}
true
}
TuiEvent::Reflection { content } => {
self.entries.push_back(CockpitEntry::Reflection {
content: content.clone(),
});
if self.entries.len() > self.max_entries {
self.entries.pop_front();
}
true
}
TuiEvent::Archivist { synthesis, pressure } => {
self.entries.push_back(CockpitEntry::Archivist {
synthesis: synthesis.clone(),
pressure: *pressure,
});
if self.entries.len() > self.max_entries {
self.entries.pop_front();
}
true
}
_ => false,
}
}
fn render(&self, area: Rect, frame: &mut Frame) {
if area.width < 8 || area.height < 3 {
return;
}
let mut lines: Vec<Line> = Vec::with_capacity(self.entries.len());
for entry in &self.entries {
match entry {
CockpitEntry::Surfacing { source, content, priority } => {
// Truncate content to fit the panel width
let max_content = (area.width as usize).saturating_sub(12).max(20);
let content = truncate(content, max_content);
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(SURFACING_YELLOW).add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} · {}{}", source, priority, content),
Style::default().fg(SURFACING_YELLOW),
),
]));
}
CockpitEntry::Reflection { content } => {
let max_content = (area.width as usize).saturating_sub(12).max(20);
let content = truncate(content, max_content);
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(REFLECTION_CYAN).add_modifier(Modifier::BOLD)),
Span::styled(content, Style::default().fg(REFLECTION_CYAN)),
]));
}
CockpitEntry::Archivist { synthesis, pressure } => {
let pct = (pressure * 100.0) as u16;
let max_content = (area.width as usize).saturating_sub(16).max(20);
let synthesis = truncate(synthesis, max_content);
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(ARCHIVIST_MAGENTA).add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} (ctx {}%)", synthesis, pct),
Style::default().fg(ARCHIVIST_MAGENTA),
),
]));
}
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" Aster is listening…",
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(SURFACING_YELLOW).add_modifier(Modifier::DIM))
.title(Span::styled(" Aster ", Style::default().fg(SURFACING_YELLOW).add_modifier(Modifier::BOLD)));
let inner = block.inner(area);
let view_height = inner.height as usize;
let scroll_offset = lines.len().saturating_sub(view_height);
let visible_lines: Vec<Line> = if scroll_offset > 0 {
lines.iter().skip(scroll_offset).take(view_height).cloned().collect()
} else {
lines
};
let para = Paragraph::new(visible_lines)
.wrap(Wrap { trim: false })
.block(block);
frame.render_widget(para, area);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
}

View file

@ -1,10 +1,14 @@
pub mod animation;
pub mod app;
pub mod buddy;
pub mod buddy_panel;
pub mod chat;
pub mod cockpit_panel;
pub mod component;
pub mod markdown;
pub use app::App;
pub use buddy::{BuddyState, CompanionSprite, BuddyPosition};
pub use buddy_panel::BuddyPanel;
pub use cockpit_panel::CockpitPane;
pub use component::{Component, Scene, SceneLayout, TuiEvent};