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:
parent
8f2d2e7227
commit
9a9a31916c
10 changed files with 691 additions and 67 deletions
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
152
src/ui/buddy_panel.rs
Normal 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 0–100.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
174
src/ui/cockpit_panel.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Reference in a new issue