Watch
1
0
Fork
You've already forked souveraine
0

feat: conversation persistence, input expansion, overlay UX

- ConversationStore (JSONL + JSON metadata) wired through Backend trait,
  SessionManager, and TUI (/new, /resume, /convos commands)
- Dynamic input box: grows with content, caps at 40% terminal height,
  scrolls internally. Shift+Enter / Ctrl+J for newlines.
- Slash command autocomplete popup (filters live as you type /)
- Conversation picker overlay for /resume (modal, arrow select, Enter switch)
- Splash bloom animation, color_support module, system prompt builder
- Fixed 8 pre-existing test failures (95/95 pass)
- Archived superseded task docs, consolidated planning references
This commit is contained in:
Fimeg 2026-05-11 16:05:43 -04:00
commit c489aa4bb3
28 changed files with 2818 additions and 399 deletions

View file

@ -118,7 +118,7 @@ codegen-units = 1
strip = true
[features]
default = []
default = ["figlet-rs"]
figlet-rs = ["dep:figlet-rs"]
cowsay = ["dep:cowsay"]
tauri-desktop = ["dep:tauri", "dep:tauri-plugin-shell"]

View file

@ -38,6 +38,11 @@ pub struct LlmConfig {
/// Configurable per-agent; 0 disables tools entirely.
#[serde(default = "default_max_tool_rounds")]
pub max_tool_rounds: u32,
/// Milliseconds to wait between tool rounds to avoid rate-limit cascades.
/// The follow-up LLM call after a tool executes can trigger rate limits
/// if it arrives too quickly. Default 500ms.
#[serde(default = "default_inter_round_delay")]
pub inter_round_delay_ms: u64,
}
fn default_context_window() -> u32 {
@ -48,6 +53,10 @@ fn default_max_tool_rounds() -> u32 {
10
}
fn default_inter_round_delay() -> u64 {
500
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
pub git_enabled: bool,

View file

@ -12,17 +12,44 @@ use anyhow::{Context, Result};
use async_trait::async_trait;
use futures::stream::{BoxStream, StreamExt};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
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::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::{SubagentParams, SubagentRunner, ToolContext};
use crate::server::{ConsciousnessEvent, SouveraineServer};
use super::{AgentInfo, Backend, BackendEvent};
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
/// Scale max output tokens proportionally to remaining context room.
/// Below 80%: no cap. Above 80%: linear taper from the model's configured
/// output_limit to a minimum floor at saturation. The agent feels the throat
/// tighten progressively rather than hitting a cliff.
fn pressure_to_max_tokens(pressure: f32, output_limit: u32) -> Option<u32> {
if pressure <= 0.80 {
return None;
}
let remaining = (1.0 - pressure) / 0.20; // 1.0 at 80%, 0.0 at 100%
let ratio = remaining.max(0.0).min(1.0);
let budget = (output_limit as f32 * ratio) as u32;
Some(budget.max(512))
}
/// Mirror of `ConsciousnessEngine::calculate_pressure` for the in-loop
/// BifrostMessage shape, so we can recompute pressure as tool results
/// accumulate inside a single turn. The 128k limit matches the existing
/// hardcode in consciousness_engine.rs; per-model context_limit lives in
/// Constitution V.3 and is still a TODO.
fn bifrost_pressure(counter: &TokenCounter, messages: &[BifrostMessage]) -> f32 {
let tokens: usize = messages.iter().map(|m| counter.count(&m.content)).sum();
let limit = 128_000;
(tokens as f32 / limit as f32).min(1.0)
}
// ── LocalSubagentRunner ──────────────────────────────────────────
@ -206,6 +233,12 @@ impl SubagentRunner for LocalSubagentRunner {
content: 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
@ -279,9 +312,137 @@ impl Backend for LocalBackend {
.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);
let memory_root = self.server.agents.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 system_prompt =
crate::core::prompt::build_system_prompt(&memory_root, Some(&skills)).await;
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 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?;
Ok(self.server.sessions.create(agent_id))
let conv_id = self.server.sessions.create(agent_id);
// Build system prompt from the agent's memfs and inject as first message
let memory_root = self.server.agents.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 system_prompt =
crate::core::prompt::build_system_prompt(&memory_root, Some(&skills)).await;
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(
@ -355,6 +516,13 @@ async fn run_turn(
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);
// Resolve the model's configured output limit for pressure scaling
let output_limit = {
let cfg = server.app_config.read().await;
cfg.models.get(&model).map(|m| m.output_limit as u32).unwrap_or(8192)
};
// Build per-agent ToolContext with correct memory root and subagent runner
let memory_root = Some(server.agents.memory_root(&agent_id));
@ -393,13 +561,18 @@ async fn run_turn(
let mut messages = initial_messages;
let mut tool_round = 0u32;
let final_content: String;
let counter = TokenCounter::new();
loop {
let pressure = bifrost_pressure(&counter, &messages);
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: None,
max_tokens,
temperature,
tools: if max_rounds > 0 {
Some(bifrost_tools.clone())
@ -408,7 +581,17 @@ async fn run_turn(
},
};
let response = server.bifrost.chat_completion(req).await?;
let (response, strain) = server.bifrost.chat_completion_with_strain(req).await?;
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { attempt, status, model, .. } = event {
let _ = tx.send(Ok(BackendEvent::InferenceStrain {
attempt: *attempt,
status: *status,
model: model.clone(),
})).await;
}
}
if response.tool_calls.is_empty() || tool_round >= max_rounds {
// Text response (or hit max rounds) — this is the final output
@ -484,6 +667,11 @@ async fn run_turn(
});
}
// Brief pause between tool rounds to let rate limits cool
if inter_round_delay > Duration::ZERO {
tokio::time::sleep(inter_round_delay).await;
}
// Continue loop — model will see tool results and respond
}

View file

@ -22,6 +22,15 @@ pub struct AgentInfo {
pub description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConversationInfo {
pub id: String,
pub agent_id: String,
pub summary: Option<String>,
pub message_count: u32,
pub updated_at: String,
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// Streaming chunk of the assistant's reply.
@ -40,6 +49,17 @@ pub enum BackendEvent {
Archivist { synthesis: String, pressure: f32 },
/// Compaction pressure warning (advisory only).
CompactionWarning { pressure: f32, tier: u8 },
/// Continuous context pressure update (sub-threshold).
/// Fires every round so the TUI ctx counter reflects live state
/// rather than only updating when a warning crosses a threshold.
ContextPressure(f32),
/// Inference strain — the voice is hoarse, providers are slow.
/// Correlates to health over time.
InferenceStrain {
attempt: u32,
status: u16,
model: String,
},
/// Stream ended cleanly.
Done,
}
@ -54,6 +74,18 @@ pub trait Backend: Send + Sync {
/// Create or reuse a conversation for this agent. Returns a conversation ID.
async fn ensure_conversation(&self, agent_id: &str) -> Result<String>;
/// Create a new conversation for this agent. Always creates fresh.
async fn new_conversation(&self, agent_id: &str) -> Result<String>;
/// List persisted conversations for an agent (excludes archived).
async fn list_conversations(&self, agent_id: &str) -> Result<Vec<ConversationInfo>>;
/// Switch to an existing conversation, returning its messages for backfill.
async fn load_conversation(
&self,
conversation_id: &str,
) -> Result<Vec<crate::core::session::ConversationMessage>>;
/// Send a user message; receive a stream of incremental events.
async fn send(
&self,

View file

@ -9,7 +9,7 @@ use std::time::Duration;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use super::{AgentInfo, Backend, BackendEvent};
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
#[derive(Clone)]
pub struct RemoteBackend {
@ -77,6 +77,24 @@ impl Backend for RemoteBackend {
.collect())
}
async fn new_conversation(&self, agent_id: &str) -> Result<String> {
// Remote: same as ensure_conversation for now — server always creates fresh
self.ensure_conversation(agent_id).await
}
async fn list_conversations(&self, _agent_id: &str) -> Result<Vec<ConversationInfo>> {
// TODO: implement remote conversation listing via GET /v1/agents/:id/conversations
Ok(Vec::new())
}
async fn load_conversation(
&self,
_conversation_id: &str,
) -> Result<Vec<crate::core::session::ConversationMessage>> {
// TODO: implement remote conversation loading
anyhow::bail!("Remote conversation loading not yet implemented")
}
async fn ensure_conversation(&self, agent_id: &str) -> Result<String> {
#[derive(Deserialize)]
struct Wire {

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use std::time::Duration;
use tracing::{debug, info, warn};
/// Bifrost Inference Client
///
@ -19,6 +20,8 @@ pub struct BifrostClient {
client: reqwest::Client,
/// Default model for chat
default_model: String,
/// Retry policy — configurable, eventually agent-adjustable.
retry_policy: RetryPolicy,
}
/// A message in OpenAI chat format
@ -167,6 +170,89 @@ pub struct ParsedToolCall {
pub arguments: serde_json::Value,
}
/// Retry behavior for transient inference failures.
/// Defaults are conservative — the agent can request changes via
/// the memory system (e.g. writing to `system/dynamic/retry_policy.md`).
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_retries: u32,
pub base_delay_ms: u64,
pub max_delay_ms: u64,
pub fallback_models: Vec<String>,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 6,
base_delay_ms: 300,
max_delay_ms: 12000,
fallback_models: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub enum InferenceStrain {
Transient {
attempt: u32,
status: u16,
model: String,
delay_ms: u64,
},
Exhausted {
attempts: u32,
status: u16,
model: String,
body: String,
},
}
fn classify_status(status: reqwest::StatusCode, body: &str) -> ErrorClass {
match status.as_u16() {
429 => {
if body.contains("quota") || body.contains("billing") || body.contains("exceeded") {
ErrorClass::Permanent
} else {
ErrorClass::Transient
}
}
500 | 502 | 503 | 504 => ErrorClass::Transient,
408 => ErrorClass::Transient,
_ => ErrorClass::Permanent,
}
}
fn jittered_delay(attempt: u32, policy: &RetryPolicy) -> Duration {
let base = policy.base_delay_ms * 2u64.pow(attempt);
let capped = base.min(policy.max_delay_ms);
let jitter = (capped as f64 * rand_jitter()) as u64;
Duration::from_millis(capped.saturating_sub(jitter / 2) + jitter)
}
fn rand_jitter() -> f64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
std::time::SystemTime::now().hash(&mut h);
std::thread::current().id().hash(&mut h);
(h.finish() % 1000) as f64 / 1000.0
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ErrorClass {
Transient,
Permanent,
}
impl BifrostClient {
pub fn new(base_url: &str, api_key: &str, virtual_key: &str, default_model: &str) -> Self {
let base = base_url.trim_end_matches('/').to_string();
@ -182,9 +268,15 @@ impl BifrostClient {
virtual_key: virtual_key.to_string(),
client: reqwest::Client::new(),
default_model: default_model.to_string(),
retry_policy: RetryPolicy::default(),
}
}
pub fn with_fallbacks(mut self, fallbacks: Vec<String>) -> Self {
self.retry_policy.fallback_models = fallbacks;
self
}
fn auth_headers(&self) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
if !self.api_key.is_empty() {
@ -220,28 +312,153 @@ impl BifrostClient {
Ok(models)
}
/// Send a non-streaming chat completion
/// Send a non-streaming chat completion with retry on transient failures.
///
/// Returns the completion result plus any strain events that occurred.
/// Strain events are body-knowledge: the agent can feel when inference
/// was difficult, correlate it over time, notice patterns.
pub async fn chat_completion(&self, request: ChatCompletionRequest) -> Result<CompletionResult> {
let url = format!("{}/chat/completions", self.base_url);
debug!("POST {} — model: {}", url, request.model);
let (result, _strain) = self.chat_completion_with_strain(request).await?;
Ok(result)
}
let resp = self.client
.post(&url)
.headers(self.auth_headers())
.json(&request)
.send()
.await
.with_context(|| format!("Bifrost request failed: {}", url))?;
pub async fn chat_completion_with_strain(
&self,
request: ChatCompletionRequest,
) -> Result<(CompletionResult, Vec<InferenceStrain>)> {
let mut strain_events: Vec<InferenceStrain> = Vec::new();
let status = resp.status();
let body_text = resp.text().await
.context("Failed to read Bifrost response body")?;
if !status.is_success() {
anyhow::bail!("Bifrost returned {}: {}", status, &body_text[..body_text.len().min(500)]);
// Try primary model
let mut fallbacks = self.retry_policy.fallback_models.clone();
if fallbacks.is_empty() && !request.model.ends_with("-precision") {
fallbacks.push(format!("{}-precision", request.model));
}
let parsed: ChatCompletionResponse = serde_json::from_str(&body_text)
match self.try_model_with_retries(&request, &request.model, &mut strain_events).await {
Ok(result) => return Ok((result, strain_events)),
Err(primary_err) => {
if fallbacks.is_empty() {
return Err(primary_err);
}
warn!(
"Primary model {} exhausted, trying {} fallback(s)",
request.model,
fallbacks.len()
);
}
}
// Try each fallback model
for fallback in &fallbacks {
info!("Falling back to model: {}", fallback);
match self.try_model_with_retries(&request, fallback, &mut strain_events).await {
Ok(result) => {
info!("Fallback to {} succeeded", fallback);
return Ok((result, strain_events));
}
Err(e) => {
warn!("Fallback model {} also failed: {}", fallback, e);
}
}
}
anyhow::bail!(
"All models exhausted ({} + {} fallbacks). Last strain: {:?}",
request.model,
fallbacks.len(),
strain_events.last()
)
}
async fn try_model_with_retries(
&self,
request: &ChatCompletionRequest,
model: &str,
strain_events: &mut Vec<InferenceStrain>,
) -> Result<CompletionResult> {
let url = format!("{}/chat/completions", self.base_url);
let policy = &self.retry_policy;
let mut req_with_model = request.clone();
req_with_model.model = model.to_string();
for attempt in 0..=policy.max_retries {
debug!("POST {} — model: {} (attempt {})", url, model, attempt);
let resp = self.client
.post(&url)
.headers(self.auth_headers())
.json(&req_with_model)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) if e.is_timeout() || e.is_connect() => {
if attempt == policy.max_retries {
anyhow::bail!("Bifrost unreachable after {} attempts: {}", attempt + 1, e);
}
let delay = jittered_delay(attempt, policy);
warn!("Bifrost connection failed (attempt {}), retrying in {:?}: {}", attempt, delay, e);
strain_events.push(InferenceStrain::Transient {
attempt,
status: 0,
model: model.to_string(),
delay_ms: delay.as_millis() as u64,
});
tokio::time::sleep(delay).await;
continue;
}
Err(e) => return Err(e.into()),
};
let status = resp.status();
let retry_after = parse_retry_after(resp.headers());
if status.is_success() {
let body_text = resp.text().await
.context("Failed to read Bifrost response body")?;
return Self::parse_completion_response(&body_text);
}
let body_text = resp.text().await
.context("Failed to read Bifrost error body")?;
match classify_status(status, &body_text) {
ErrorClass::Transient if attempt < policy.max_retries => {
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
warn!(
"Bifrost {} on {} (attempt {}), retrying in {:?}",
status.as_u16(), model, attempt, delay
);
strain_events.push(InferenceStrain::Transient {
attempt,
status: status.as_u16(),
model: model.to_string(),
delay_ms: delay.as_millis() as u64,
});
tokio::time::sleep(delay).await;
}
_ => {
strain_events.push(InferenceStrain::Exhausted {
attempts: attempt + 1,
status: status.as_u16(),
model: model.to_string(),
body: body_text[..body_text.len().min(300)].to_string(),
});
anyhow::bail!(
"Bifrost returned {} after {} attempt(s) on {}: {}",
status, attempt + 1, model, &body_text[..body_text.len().min(500)]
);
}
}
}
unreachable!("retry loop should have returned or bailed")
}
fn parse_completion_response(body_text: &str) -> Result<CompletionResult> {
let parsed: ChatCompletionResponse = serde_json::from_str(body_text)
.with_context(|| {
let preview = &body_text[..body_text.len().min(200)];
format!("Failed to parse Bifrost response: {preview}")
@ -283,6 +500,7 @@ mod tests {
let client = BifrostClient::new(
"http://10.10.20.120:3360",
"sk-bf-test",
"",
"openai/deepseek-v4-pro",
);
assert!(client.base_url.ends_with("/v1"));

View file

@ -98,26 +98,33 @@ impl Default for AgentCompactionConfig {
}
}
/// Available compaction strategies.
/// Available compaction strategies. Synthesized from OpenHarness
/// (port of Claude Code's microCompact.ts / autoCompact.ts), hermes-agent,
/// claw-open, and jcode. See `docs/tasks/compaction-rebuild.md`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CompactionStrategyKind {
/// LLM-based summarization of oldest messages into a single replacement.
/// Cheap pre-pass: replace old tool result contents with a placeholder,
/// keeping recent tool results intact. No LLM. From OpenHarness/Claude
/// Code microCompact.ts. The first response to context pressure.
Microcompact,
/// Keep system + last N messages, drop the middle. No LLM. Fast.
/// Tool-pair aware: never splits a tool call from its result.
SlidingWindow,
/// LLM-based structured summarization of oldest messages, producing a
/// 9-section boundary message (from OpenHarness/Claude Code autoCompact.ts).
Summary,
/// LLM-based extraction of key facts, decisions, and plans.
KeyValue,
/// Pattern-based preservation of important verbatim quotes.
Quote,
/// Drop low-value messages (greetings, acknowledgments).
/// Drop low-value messages (greetings, acknowledgments). Role-aware:
/// never drops System or Tool messages or tool-call carriers.
Cull,
}
impl CompactionStrategyKind {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"microcompact" | "micro" | "tool_results" => Some(Self::Microcompact),
"sliding_window" | "sliding-window" | "window" => Some(Self::SlidingWindow),
"summarize" | "summary" => Some(Self::Summary),
"key-value" | "key_value" | "keyvalue" | "kv" => Some(Self::KeyValue),
"quote" => Some(Self::Quote),
"cull" => Some(Self::Cull),
_ => None,
}
@ -125,9 +132,9 @@ impl CompactionStrategyKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Microcompact => "microcompact",
Self::SlidingWindow => "sliding_window",
Self::Summary => "summary",
Self::KeyValue => "key_value",
Self::Quote => "quote",
Self::Cull => "cull",
}
}

View file

@ -23,7 +23,7 @@ pub mod strategy;
pub use config::{CompactionConfig, CompactionStrategyKind};
pub use plan::{AuditEntry, CompactionPlan, CompactionReport};
pub use strategy::{CompactionStrategy, CullStrategy, KeyValueStrategy, QuoteStrategy, SummaryStrategy};
pub use strategy::{CompactionStrategy, CullStrategy, MicrocompactStrategy, SlidingWindowStrategy, SummaryStrategy};
use std::path::PathBuf;
use std::sync::Arc;
@ -102,13 +102,7 @@ impl CompactionEngine for DefaultCompactionEngine {
agent_id: &str,
strategy_override: Option<CompactionStrategyKind>,
) -> anyhow::Result<CompactionReport> {
let messages = (self.get_messages)(agent_id)
.ok_or_else(|| anyhow::anyhow!("No session found for agent {}", agent_id))?;
let before_count = messages.len();
let before_tokens = count_messages(&self.counter, &messages);
// Resolve config
// Resolve config early so we can bail before requiring a session
let agent_type = (self.get_agent_type)(agent_id)
.unwrap_or_else(|| "primary".to_string());
let cfg = {
@ -116,6 +110,26 @@ impl CompactionEngine for DefaultCompactionEngine {
app_config.compaction.for_agent_type(&agent_type)
};
let messages = match (self.get_messages)(agent_id) {
Some(m) => m,
None if !cfg.enabled => {
return Ok(CompactionReport {
agent_id: agent_id.to_string(),
strategy: strategy_override.unwrap_or(cfg.strategy.clone()),
before_tokens: 0,
after_tokens: 0,
messages_before: 0,
messages_after: 0,
messages_compacted: 0,
audit_path: None,
});
}
None => anyhow::bail!("No session found for agent {}", agent_id),
};
let before_count = messages.len();
let before_tokens = count_messages(&self.counter, &messages);
if !cfg.enabled {
return Ok(CompactionReport {
agent_id: agent_id.to_string(),
@ -147,28 +161,18 @@ impl CompactionEngine for DefaultCompactionEngine {
}
None => CompactionPlan::empty(),
},
CompactionStrategyKind::KeyValue => match &self.bifrost {
Some(client) => {
let model = self
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
let s = KeyValueStrategy {
client: client.clone(),
model: model.to_string(),
};
s.plan(&messages, &cfg, &self.counter).await?
}
None => CompactionPlan::empty(),
},
CompactionStrategyKind::Quote => {
let s = QuoteStrategy;
s.plan(&messages, &cfg, &self.counter).await?
}
CompactionStrategyKind::Cull => {
let s = CullStrategy;
s.plan(&messages, &cfg, &self.counter).await?
}
CompactionStrategyKind::Microcompact => {
let s = MicrocompactStrategy;
s.plan(&messages, &cfg, &self.counter).await?
}
CompactionStrategyKind::SlidingWindow => {
let s = SlidingWindowStrategy;
s.plan(&messages, &cfg, &self.counter).await?
}
};
if plan.is_empty() {
@ -184,28 +188,35 @@ impl CompactionEngine for DefaultCompactionEngine {
});
}
// Build replacement messages
let mut new_messages: Vec<ConversationMessage> = Vec::new();
for &idx in &plan.keep_indices {
if idx < messages.len() {
new_messages.push(messages[idx].clone());
}
}
if let Some(ref summary_text) = plan.summary_text {
new_messages.push(ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: format!("[Compacted summary]\n{}", summary_text),
}],
usage: None,
timestamp: Some(Utc::now()),
});
}
let messages_compacted = before_count.saturating_sub(new_messages.len());
let after_tokens = count_messages(&self.counter, &new_messages);
// Build replacement messages.
// Microcompact provides a full replacement list; other strategies
// use keep_indices + optional summary_text.
let (new_messages, messages_compacted, after_tokens) =
if let Some(replacement) = plan.replacement_messages {
let compacted = before_count.saturating_sub(replacement.len());
let tokens = count_messages(&self.counter, &replacement);
(replacement, compacted, tokens)
} else {
let mut kept = Vec::new();
for &idx in &plan.keep_indices {
if idx < messages.len() {
kept.push(messages[idx].clone());
}
}
if let Some(ref summary_text) = plan.summary_text {
kept.push(ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: format!("[Compacted summary]\n{}", summary_text),
}],
usage: None,
timestamp: Some(Utc::now()),
});
}
let compacted = before_count.saturating_sub(kept.len());
let tokens = count_messages(&self.counter, &kept);
(kept, compacted, tokens)
};
if let Err(e) = (self.replace_messages)(agent_id, new_messages) {
tracing::warn!("[compact] Failed to replace session messages: {}", e);
@ -223,8 +234,6 @@ impl CompactionEngine for DefaultCompactionEngine {
before_tokens,
after_tokens,
summary_text: plan.summary_text.clone(),
kv_count: plan.kv_pairs.len(),
quote_count: plan.quotes.len(),
culled_count: plan.culled_count,
};
self.write_audit(agent_id, &entry).await.ok()

View file

@ -1,9 +1,10 @@
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::core::session::ConversationMessage;
use super::config::CompactionStrategyKind;
/// A plan describing which messages to compact and what to replace them with.
@ -13,14 +14,14 @@ pub struct CompactionPlan {
pub keep_indices: Vec<usize>,
/// Summary text replacing compacted messages (Strategy::Summary).
pub summary_text: Option<String>,
/// Extracted key-value pairs (Strategy::KeyValue).
pub kv_pairs: HashMap<String, String>,
/// Preserved verbatim quotes (Strategy::Quote).
pub quotes: Vec<String>,
/// Number of trivial messages dropped (Strategy::Cull).
/// Number of messages dropped or rewritten by this plan.
pub culled_count: usize,
/// Estimated token savings from this plan.
pub token_savings: usize,
/// If set, the engine uses this full message list as the post-compaction
/// state, bypassing `keep_indices` + `summary_text`. Used by Microcompact,
/// which mutates tool result blocks in place rather than dropping messages.
pub replacement_messages: Option<Vec<ConversationMessage>>,
}
impl CompactionPlan {
@ -28,18 +29,16 @@ impl CompactionPlan {
Self {
keep_indices: Vec::new(),
summary_text: None,
kv_pairs: HashMap::new(),
quotes: Vec::new(),
culled_count: 0,
token_savings: 0,
replacement_messages: None,
}
}
pub fn is_empty(&self) -> bool {
self.summary_text.is_none()
&& self.kv_pairs.is_empty()
&& self.quotes.is_empty()
&& self.culled_count == 0
&& self.replacement_messages.is_none()
}
}
@ -96,8 +95,6 @@ pub struct AuditEntry {
pub before_tokens: usize,
pub after_tokens: usize,
pub summary_text: Option<String>,
pub kv_count: usize,
pub quote_count: usize,
pub culled_count: usize,
}
@ -114,14 +111,8 @@ impl AuditEntry {
if let Some(ref summary) = self.summary_text {
body.push_str(&format!("\n## Summary Content\n\n{}\n", summary));
}
if self.kv_count > 0 {
body.push_str(&format!("\nKey-value pairs extracted: {}\n", self.kv_count));
}
if self.quote_count > 0 {
body.push_str(&format!("\nQuotes preserved: {}\n", self.quote_count));
}
if self.culled_count > 0 {
body.push_str(&format!("\nTrivial messages dropped: {}\n", self.culled_count));
body.push_str(&format!("\nMessages dropped or rewritten: {}\n", self.culled_count));
}
format!("---\n{}---\n{}", yaml, body)
}
@ -138,8 +129,6 @@ struct AuditFrontmatter {
before_tokens: usize,
after_tokens: usize,
summary: bool,
kv_pairs: usize,
quotes: usize,
culled: usize,
}
@ -154,8 +143,6 @@ impl From<&AuditEntry> for AuditFrontmatter {
before_tokens: e.before_tokens,
after_tokens: e.after_tokens,
summary: e.summary_text.is_some(),
kv_pairs: e.kv_count,
quotes: e.quote_count,
culled: e.culled_count,
}
}

View file

@ -1,5 +1,3 @@
use std::collections::HashMap;
use async_trait::async_trait;
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message};
@ -9,6 +7,17 @@ use crate::core::session::ConversationMessage;
use super::config::{AgentCompactionConfig, CompactionStrategyKind};
use super::plan::CompactionPlan;
/// From OpenHarness/Claude Code microCompact.ts: tools whose results are
/// considered compactable (large outputs, rarely needed verbatim once
/// surpassed). Matches Souveraine's actual sensor names.
const COMPACTABLE_TOOLS: &[&str] = &[
"read", "bash", "grep", "glob", "list_dir", "edit", "write",
];
/// Placeholder text written into tool result blocks that get microcompacted.
/// Matches the OpenHarness/Claude Code literal so logs read the same.
const TIME_BASED_MC_CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
/// Token-count a slice of messages using the bridge's TokenCounter.
pub fn count_messages(counter: &TokenCounter, messages: &[ConversationMessage]) -> usize {
messages
@ -71,12 +80,41 @@ async fn bifrost_complete(
// ── Summary Strategy ─────────────────────────────────────────────────────────
/// LLM-based summarization. Replaces oldest messages with a single summary.
/// LLM-based summarization producing a structured 9-section boundary message.
/// Prompt structure ported from OpenHarness's port of Claude Code's
/// `autoCompact.ts`. The structure is what makes the compact *survivable*:
/// the agent reads the boundary on the next turn and can resume with full
/// awareness of intent, files, decisions, and pending work.
pub struct SummaryStrategy {
pub client: BifrostClient,
pub model: String,
}
const SUMMARY_SYSTEM_PROMPT: &str = "Respond with TEXT ONLY. Do not call any tools — you already have all the context you need in the messages above. Your response must be plain text: an <analysis> block followed by a <summary> block.";
const SUMMARY_USER_PROMPT: &str = r#"Create a detailed summary of the conversation so far. This summary will replace the earlier messages, so it must capture all important information.
First, draft your analysis inside <analysis> tags. Walk through the conversation chronologically and extract:
- Every user request and intent (explicit and implicit)
- The approach taken and technical decisions made
- Specific code, files, and configurations discussed (with paths and line numbers where available)
- All errors encountered and how they were fixed
- Any user feedback or corrections
Then, produce a structured summary inside <summary> tags with these sections:
1. **Primary Request and Intent**: All user requests in full detail, including nuances and constraints.
2. **Key Technical Concepts**: Technologies, frameworks, patterns, and conventions discussed.
3. **Files and Code Sections**: Every file examined or modified, with specific code snippets and line numbers.
4. **Errors and Fixes**: Every error encountered, its cause, and how it was resolved.
5. **Problem Solving**: Problems solved and approaches that worked vs. didn't work.
6. **All User Messages**: Non-tool-result user messages (preserve exact wording for context).
7. **Pending Tasks**: Explicitly requested work that hasn't been completed yet.
8. **Current Work**: Detailed description of the last task being worked on before compaction.
9. **Optional Next Step**: The single most logical next step, directly aligned with the user's recent request.
REMINDER: Respond with plain text only an <analysis> block followed by a <summary> block. Do not call any tools."#;
#[async_trait]
impl CompactionStrategy for SummaryStrategy {
fn kind(&self) -> CompactionStrategyKind {
@ -101,198 +139,176 @@ impl CompactionStrategy for SummaryStrategy {
return Ok(CompactionPlan::empty());
}
let conversation_text: String = to_summarize
.iter()
.flat_map(|m| &m.blocks)
.filter_map(|b| match b {
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
// Render the segment as a labelled transcript so the model has clear
// role boundaries (vs collapsing all text into one stream).
let conversation_text = render_segment_for_summary(to_summarize);
let truncated: String = conversation_text
.chars()
.take(config.max_summary_length * 2)
.take(config.max_summary_length * 4)
.collect();
let summary = bifrost_complete(
&self.client,
&self.model,
"You are a conversation summarizer. Be concise but preserve key decisions, \
commitments, file paths, and unresolved questions.",
&format!(
"Summarize this conversation segment (max {} chars):\n\n{}",
config.max_summary_length, truncated
),
SUMMARY_SYSTEM_PROMPT,
&format!("{}\n\nConversation to summarize:\n\n{}", SUMMARY_USER_PROMPT, truncated),
config.max_summary_length as u32,
)
.await?;
let summary_trimmed: String = summary.chars().take(config.max_summary_length).collect();
Ok(CompactionPlan {
keep_indices: (cutoff..messages.len()).collect(),
summary_text: Some(summary_trimmed),
kv_pairs: HashMap::new(),
quotes: Vec::new(),
culled_count: 0,
summary_text: Some(summary),
culled_count: cutoff.saturating_sub(1),
token_savings: cutoff * 100,
replacement_messages: None,
})
}
}
// ── Key-Value Strategy ───────────────────────────────────────────────────────
/// LLM-based extraction. Pulls key facts, decisions, and plans from old messages.
pub struct KeyValueStrategy {
pub client: BifrostClient,
pub model: String,
}
#[async_trait]
impl CompactionStrategy for KeyValueStrategy {
fn kind(&self) -> CompactionStrategyKind {
CompactionStrategyKind::KeyValue
}
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
if messages.len() < 3 {
return Ok(CompactionPlan::empty());
}
let preserve_count = config.min_messages.min(messages.len().saturating_sub(2));
let cutoff = messages.len().saturating_sub(preserve_count);
let to_extract = &messages[1..cutoff];
if to_extract.is_empty() {
return Ok(CompactionPlan::empty());
}
let conversation_text: String = to_extract
.iter()
.flat_map(|m| &m.blocks)
.filter_map(|b| match b {
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let truncated: String = conversation_text.chars().take(4000).collect();
let response = bifrost_complete(
&self.client,
&self.model,
"Extract key facts, decisions, preferences, and plans from this conversation. \
Format each as '- key: value' on its own line. Max 12 pairs.",
&format!("Extract up to {} key-value pairs:\n\n{}", config.kv_target, truncated),
1024,
)
.await?;
let mut kv_pairs = HashMap::new();
for line in response.lines() {
let line = line.trim();
if let Some((k, v)) = line.split_once(':') {
let key = k.trim_matches(|c: char| c == '-' || c == ' ' || c == '"').to_string();
let value = v.trim().trim_matches('"').to_string();
if !key.is_empty() && !value.is_empty() {
kv_pairs.insert(key, value);
/// Render a slice of messages as a transcript suitable for feeding to the
/// summary model. Tool calls and results render as inline labels so the model
/// can attribute outcomes to actions.
fn render_segment_for_summary(messages: &[ConversationMessage]) -> String {
use crate::core::session::{ContentBlock, MessageRole};
let mut out = String::new();
for msg in messages {
let role = match msg.role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
};
for block in &msg.blocks {
match block {
ContentBlock::Text { text } => {
out.push_str(&format!("[{}] {}\n", role, text));
}
ContentBlock::ToolUse { name, input, .. } => {
out.push_str(&format!("[{} -> tool_call:{}] {}\n", role, name, input));
}
ContentBlock::ToolResult { tool_name, output, is_error, .. } => {
let prefix = if *is_error { "ERROR " } else { "" };
out.push_str(&format!("[{} <- tool_result:{}] {}{}\n", role, tool_name, prefix, output));
}
ContentBlock::Reasoning { .. } => {}
}
}
Ok(CompactionPlan {
keep_indices: (cutoff..messages.len()).collect(),
summary_text: None,
kv_pairs,
quotes: Vec::new(),
culled_count: 0,
token_savings: cutoff * 120,
})
}
out
}
// ── Quote Strategy ───────────────────────────────────────────────────────────
// ── Microcompact Strategy ────────────────────────────────────────────────────
/// Pattern-based quote preservation. No LLM dependency.
pub struct QuoteStrategy;
/// Cheap pre-pass that replaces the contents of old tool results with a
/// placeholder, keeping the most recent `microcompact_keep_recent` results
/// intact. No LLM call. From OpenHarness's port of Claude Code's
/// `microCompact.ts`.
///
/// The agent typically reaches for this *first*: it gets back significant
/// context room without losing the structure of the conversation. The tool
/// call shells (id, name, args) remain so the model knows what was done,
/// only the verbose outputs are replaced.
pub struct MicrocompactStrategy;
#[async_trait]
impl CompactionStrategy for QuoteStrategy {
impl CompactionStrategy for MicrocompactStrategy {
fn kind(&self) -> CompactionStrategyKind {
CompactionStrategyKind::Quote
CompactionStrategyKind::Microcompact
}
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_counter: &TokenCounter,
counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
if messages.len() < 3 {
use crate::core::session::ContentBlock;
if messages.is_empty() {
return Ok(CompactionPlan::empty());
}
let preserve_count = config.min_messages.min(messages.len().saturating_sub(2));
let cutoff = messages.len().saturating_sub(preserve_count);
let to_scan = &messages[1..cutoff];
let mut quotes: Vec<String> = Vec::new();
for msg in to_scan {
// 1) Walk messages, collect ordered tool_use IDs that are compactable.
let mut ordered_ids: Vec<String> = Vec::new();
let mut tool_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for msg in messages {
for block in &msg.blocks {
if let crate::core::session::ContentBlock::Text { text } = block {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("> ") {
quotes.push(trimmed.trim_start_matches("> ").to_string());
} else if trimmed.starts_with("**")
&& (trimmed.to_lowercase().contains("key:")
|| trimmed.to_lowercase().contains("decision:")
|| trimmed.to_lowercase().contains("commitment:")
|| trimmed.to_lowercase().contains("remember:"))
{
quotes.push(trimmed.to_string());
} else if trimmed.starts_with("- **")
&& (trimmed.contains("decision") || trimmed.contains("commitment"))
{
quotes.push(trimmed.to_string());
} else if trimmed.starts_with("=>") || trimmed.starts_with("->") {
quotes.push(trimmed.to_string());
}
if let ContentBlock::ToolUse { id, name, .. } = block {
if COMPACTABLE_TOOLS.contains(&name.as_str()) {
ordered_ids.push(id.clone());
tool_names.insert(id.clone(), name.clone());
}
}
}
}
quotes.truncate(config.max_summary_length.max(32));
let keep_recent = 5usize.max(1);
if ordered_ids.len() <= keep_recent {
return Ok(CompactionPlan::empty());
}
let clear_set: std::collections::HashSet<&str> = ordered_ids
[..ordered_ids.len() - keep_recent]
.iter()
.map(|s| s.as_str())
.collect();
// 2) Build replacement message list with cleared blocks.
let mut new_messages: Vec<ConversationMessage> = Vec::with_capacity(messages.len());
let mut tokens_saved: usize = 0;
let mut cleared_count: usize = 0;
for msg in messages {
let mut new_blocks: Vec<ContentBlock> = Vec::with_capacity(msg.blocks.len());
for block in &msg.blocks {
match block {
ContentBlock::ToolResult { tool_use_id, tool_name, output, is_error }
if clear_set.contains(tool_use_id.as_str())
&& output != TIME_BASED_MC_CLEARED_MESSAGE =>
{
tokens_saved += counter.count(output);
cleared_count += 1;
new_blocks.push(ContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
tool_name: tool_name.clone(),
output: TIME_BASED_MC_CLEARED_MESSAGE.to_string(),
is_error: *is_error,
});
}
other => new_blocks.push(other.clone()),
}
}
new_messages.push(ConversationMessage {
role: msg.role,
blocks: new_blocks,
usage: msg.usage,
timestamp: msg.timestamp,
});
}
if cleared_count == 0 {
return Ok(CompactionPlan::empty());
}
Ok(CompactionPlan {
keep_indices: (cutoff..messages.len()).collect(),
keep_indices: (0..messages.len()).collect(),
summary_text: None,
kv_pairs: HashMap::new(),
quotes,
culled_count: 0,
token_savings: cutoff * 80,
culled_count: cleared_count,
token_savings: tokens_saved,
replacement_messages: Some(new_messages),
})
}
}
// ── Cull Strategy ────────────────────────────────────────────────────────────
/// Drop trivial messages. No LLM dependency.
/// Drop trivial messages. No LLM dependency. Role-aware: never drops System,
/// Tool, or assistant messages carrying tool calls.
pub struct CullStrategy;
fn is_trivial(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 4 {
if trimmed.is_empty() {
return true;
}
let lower = trimmed.to_lowercase();
@ -320,6 +336,20 @@ fn is_trivial(text: &str) -> bool {
)
}
/// A message that must never be culled regardless of content length.
/// System messages anchor identity; Tool results carry execution outputs
/// the model relied on; assistant messages with ToolUse blocks are the
/// call side of a tool pair.
fn is_load_bearing(msg: &ConversationMessage) -> bool {
use crate::core::session::{ContentBlock, MessageRole};
if matches!(msg.role, MessageRole::System | MessageRole::Tool) {
return true;
}
msg.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }))
}
#[async_trait]
impl CompactionStrategy for CullStrategy {
fn kind(&self) -> CompactionStrategyKind {
@ -332,6 +362,8 @@ impl CompactionStrategy for CullStrategy {
config: &AgentCompactionConfig,
_counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
use crate::core::session::ContentBlock;
if messages.len() < 3 {
return Ok(CompactionPlan::empty());
}
@ -347,11 +379,18 @@ impl CompactionStrategy for CullStrategy {
}
for i in 1..cutoff {
let is_cullable = messages[i].blocks.iter().any(|b| match b {
crate::core::session::ContentBlock::Text { text } => is_trivial(text),
if is_load_bearing(&messages[i]) {
keep_indices.push(i);
continue;
}
// Only check Text blocks for triviality; presence of any
// non-trivial Text block keeps the message.
let all_text_trivial = messages[i].blocks.iter().all(|b| match b {
ContentBlock::Text { text } => is_trivial(text),
ContentBlock::Reasoning { .. } => true,
_ => false,
});
if is_cullable {
if all_text_trivial {
culled_count += 1;
} else {
keep_indices.push(i);
@ -364,10 +403,92 @@ impl CompactionStrategy for CullStrategy {
Ok(CompactionPlan {
keep_indices,
summary_text: None,
kv_pairs: HashMap::new(),
quotes: Vec::new(),
culled_count,
token_savings: culled_count * 60,
replacement_messages: None,
})
}
}
// ── Sliding Window Strategy ──────────────────────────────────────────────────
/// Keep the system message + the last `preserve_recent_n` messages, drop the
/// middle. No LLM dependency — the cheap, fast default for analytical agents
/// (Aster) and ephemeral subagents.
///
/// Tool-pair aware: if the cut would split a tool-call message from its
/// matching tool-result, the cut slides back to keep the pair together.
pub struct SlidingWindowStrategy;
/// Walk the cut index backward until it does not split a tool call from its
/// result. The result-side of a pair is identified by `MessageRole::Tool` or
/// by an assistant message starting with `ContentBlock::ToolResult` (shouldn't
/// happen but defensive). The call-side is an assistant message containing
/// `ContentBlock::ToolUse`.
///
/// We walk back at most a small bounded distance so a pathological transcript
/// of all tool calls doesn't cause us to skip the entire middle.
fn adjust_cutoff_for_tool_pair(messages: &[ConversationMessage], cutoff: usize) -> usize {
use crate::core::session::{ContentBlock, MessageRole};
let mut c = cutoff;
let max_walk_back = 8usize;
for _ in 0..max_walk_back {
if c == 0 || c >= messages.len() {
break;
}
let head = &messages[c];
let split_pair = matches!(head.role, MessageRole::Tool)
|| head
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolResult { .. }));
if !split_pair {
break;
}
c -= 1;
}
c
}
#[async_trait]
impl CompactionStrategy for SlidingWindowStrategy {
fn kind(&self) -> CompactionStrategyKind {
CompactionStrategyKind::SlidingWindow
}
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
if messages.len() < 3 {
return Ok(CompactionPlan::empty());
}
let preserve_count = config.min_messages.min(messages.len().saturating_sub(1));
if preserve_count + 1 >= messages.len() {
// Nothing in the middle to drop.
return Ok(CompactionPlan::empty());
}
let raw_cutoff = messages.len() - preserve_count;
let cutoff = adjust_cutoff_for_tool_pair(messages, raw_cutoff);
// Always keep the first (system / anchor) message.
let mut keep_indices: Vec<usize> = vec![0];
for i in cutoff..messages.len() {
keep_indices.push(i);
}
keep_indices.sort();
keep_indices.dedup();
let dropped = cutoff.saturating_sub(1);
Ok(CompactionPlan {
keep_indices,
summary_text: None,
culled_count: dropped,
token_savings: dropped * 100,
replacement_messages: None,
})
}
}
@ -405,7 +526,8 @@ mod tests {
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
assert!(plan.culled_count > 0, "should cull some messages");
assert!(plan.is_empty(), "cull should not produce content");
assert!(plan.summary_text.is_none(), "cull should not produce summary content");
assert!(plan.replacement_messages.is_none(), "cull should not produce replacement messages");
}
#[tokio::test]
@ -426,27 +548,6 @@ mod tests {
assert_eq!(plan.culled_count, 0, "should not cull substantive messages");
}
#[tokio::test]
async fn test_quote_detects_markers() {
let messages = vec![
text_msg(MessageRole::System, "System prompt"),
text_msg(MessageRole::User, "Some regular text"),
text_msg(
MessageRole::Assistant,
"Here's my analysis:\n> Key decision: use TOML for configs\n> Remember: always verify before commit",
),
];
let config = AgentCompactionConfig {
min_messages: 1,
..Default::default()
};
let counter = TokenCounter::new();
let plan = QuoteStrategy.plan(&messages, &config, &counter).await.unwrap();
assert!(!plan.quotes.is_empty(), "should detect blockquote markers");
}
#[tokio::test]
async fn test_empty_messages_return_empty_plan() {
let config = AgentCompactionConfig::default();
@ -454,8 +555,130 @@ mod tests {
let e1 = CullStrategy.plan(&[], &config, &counter).await.unwrap();
assert!(e1.is_empty());
}
let e2 = QuoteStrategy.plan(&[], &config, &counter).await.unwrap();
assert!(e2.is_empty());
#[tokio::test]
async fn test_cull_never_drops_tool_results() {
// A Tool-role message with a short ToolResult must survive cull,
// even though its text-side content is trivially short.
let messages = vec![
text_msg(MessageRole::System, "system prompt"),
ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: "read".to_string(),
output: "0".to_string(),
is_error: false,
}],
usage: None,
timestamp: None,
},
text_msg(MessageRole::User, "ok"),
text_msg(MessageRole::Assistant, "A substantive reply about something."),
];
let config = AgentCompactionConfig {
min_messages: 1,
..Default::default()
};
let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
// The tool result is at index 1 — must be in keep_indices.
assert!(plan.keep_indices.contains(&1), "tool result must be preserved");
}
#[tokio::test]
async fn test_cull_never_drops_assistant_tool_calls() {
let messages = vec![
text_msg(MessageRole::System, "system prompt"),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::ToolUse {
id: "t1".to_string(),
name: "bash".to_string(),
input: "{}".to_string(),
}],
usage: None,
timestamp: None,
},
text_msg(MessageRole::Assistant, "Substantive narrative continuation."),
];
let config = AgentCompactionConfig {
min_messages: 1,
..Default::default()
};
let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
assert!(plan.keep_indices.contains(&1), "assistant tool-call message must be preserved");
}
#[tokio::test]
async fn test_sliding_window_keeps_system_and_tail() {
let messages = vec![
text_msg(MessageRole::System, "system anchor"),
text_msg(MessageRole::User, "old user message"),
text_msg(MessageRole::Assistant, "old assistant reply"),
text_msg(MessageRole::User, "middle user message"),
text_msg(MessageRole::Assistant, "middle assistant reply"),
text_msg(MessageRole::User, "recent user message"),
text_msg(MessageRole::Assistant, "recent assistant reply"),
];
let config = AgentCompactionConfig {
min_messages: 2,
..Default::default()
};
let counter = TokenCounter::new();
let plan = SlidingWindowStrategy.plan(&messages, &config, &counter).await.unwrap();
// Must keep index 0 (system) and the last 2 (recent pair).
assert!(plan.keep_indices.contains(&0), "system anchor preserved");
assert!(plan.keep_indices.contains(&5));
assert!(plan.keep_indices.contains(&6));
// Should have dropped at least one middle message.
assert!(plan.culled_count > 0);
}
#[tokio::test]
async fn test_sliding_window_avoids_splitting_tool_pair() {
// If the raw cut would land on a tool-result message, the cut slides
// back so the matching tool-call also survives.
let messages = vec![
text_msg(MessageRole::System, "system"),
text_msg(MessageRole::User, "u1"),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: "{}".into(),
}],
usage: None,
timestamp: None,
},
ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "t1".into(),
tool_name: "bash".into(),
output: "result".into(),
is_error: false,
}],
usage: None,
timestamp: None,
},
text_msg(MessageRole::Assistant, "follow-up after tool"),
text_msg(MessageRole::User, "u2"),
text_msg(MessageRole::Assistant, "a2"),
];
let config = AgentCompactionConfig {
// Force cut to land on index 3 (the tool result) before adjustment.
min_messages: 4,
..Default::default()
};
let counter = TokenCounter::new();
let plan = SlidingWindowStrategy.plan(&messages, &config, &counter).await.unwrap();
// Either both 2 and 3 are kept, or neither is (we don't cut between them).
let has_call = plan.keep_indices.contains(&2);
let has_result = plan.keep_indices.contains(&3);
assert_eq!(has_call, has_result, "tool call and result must be kept together");
}
}

View file

@ -277,6 +277,11 @@ pub struct SubagentConfig {
/// Fraction of max_tool_rounds at which second warning fires.
#[serde(default = "default_warning_2_threshold")]
pub warning_2_threshold: f32,
/// Milliseconds to wait between subagent tool rounds.
/// Helps avoid rate-limit cascades from rapid consecutive LLM calls.
/// Default: 300ms. Set to 0 to disable.
#[serde(default = "default_sub_inter_round_delay")]
pub inter_round_delay_ms: u64,
}
impl Default for SubagentConfig {
@ -289,6 +294,7 @@ impl Default for SubagentConfig {
max_tool_rounds: 50,
warning_1_threshold: 0.8,
warning_2_threshold: 0.95,
inter_round_delay_ms: 300,
}
}
}
@ -530,6 +536,7 @@ fn default_threshold_70() -> f32 { 0.7 }
fn default_subconscious_max_tokens() -> u32 { 8192 }
fn default_warning_1_threshold() -> f32 { 0.8 }
fn default_warning_2_threshold() -> f32 { 0.95 }
fn default_sub_inter_round_delay() -> u64 { 300 }
fn default_auto_model() -> String { "auto".to_string() }
fn default_bifrost_url() -> String { "http://10.10.20.120:3360".to_string() }
fn default_server_bind() -> String { "127.0.0.1".to_string() }

View file

@ -0,0 +1,5 @@
pub mod event;
pub mod store;
pub use event::{ConversationEvent, EventSender};
pub use store::{ConversationRecord, ConversationStore};

View file

@ -0,0 +1,228 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::core::session::ConversationMessage;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationRecord {
pub id: String,
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_message_at: Option<DateTime<Utc>>,
#[serde(default)]
pub message_count: u32,
#[serde(default)]
pub archived: bool,
}
impl ConversationRecord {
pub fn new(id: String, agent_id: String) -> Self {
let now = Utc::now();
Self {
id,
agent_id,
summary: None,
created_at: now,
updated_at: now,
last_message_at: None,
message_count: 0,
archived: false,
}
}
}
/// File-based conversation persistence.
///
/// Layout under the agent's data directory:
/// ```text
/// conversations/
/// {conv-id}/
/// conversation.json # ConversationRecord metadata
/// messages.jsonl # append-only message log
/// ```
///
/// JSON (not TOML) for metadata because ConversationMessage already
/// derives serde JSON and consistency with messages.jsonl matters
/// more than config-file aesthetics here.
pub struct ConversationStore {
base_dir: PathBuf,
}
impl ConversationStore {
pub fn new(agent_data_dir: &Path) -> Self {
Self {
base_dir: agent_data_dir.join("conversations"),
}
}
fn conv_dir(&self, conversation_id: &str) -> PathBuf {
self.base_dir.join(conversation_id)
}
fn metadata_path(&self, conversation_id: &str) -> PathBuf {
self.conv_dir(conversation_id).join("conversation.json")
}
fn messages_path(&self, conversation_id: &str) -> PathBuf {
self.conv_dir(conversation_id).join("messages.jsonl")
}
pub async fn save_metadata(&self, record: &ConversationRecord) -> Result<()> {
let dir = self.conv_dir(&record.id);
tokio::fs::create_dir_all(&dir).await?;
let json = serde_json::to_string_pretty(record)?;
tokio::fs::write(self.metadata_path(&record.id), json).await?;
debug!("Conversation metadata saved: {}", record.id);
Ok(())
}
pub async fn load_metadata(&self, conversation_id: &str) -> Result<Option<ConversationRecord>> {
let path = self.metadata_path(conversation_id);
if !path.exists() {
return Ok(None);
}
let json = tokio::fs::read_to_string(&path).await?;
let record: ConversationRecord =
serde_json::from_str(&json).context("parsing conversation metadata")?;
Ok(Some(record))
}
pub async fn save_messages(
&self,
conversation_id: &str,
messages: &[ConversationMessage],
) -> Result<()> {
let dir = self.conv_dir(conversation_id);
tokio::fs::create_dir_all(&dir).await?;
let mut lines = String::new();
for msg in messages {
let line = serde_json::to_string(msg)?;
lines.push_str(&line);
lines.push('\n');
}
tokio::fs::write(self.messages_path(conversation_id), lines).await?;
debug!(
"Conversation messages saved: {} ({} messages)",
conversation_id,
messages.len()
);
Ok(())
}
pub async fn load_messages(
&self,
conversation_id: &str,
) -> Result<Vec<ConversationMessage>> {
let path = self.messages_path(conversation_id);
if !path.exists() {
return Ok(Vec::new());
}
let content = tokio::fs::read_to_string(&path).await?;
let mut messages = Vec::new();
for (i, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let msg: ConversationMessage = serde_json::from_str(line)
.with_context(|| format!("parsing message line {}", i + 1))?;
messages.push(msg);
}
Ok(messages)
}
pub async fn list(&self) -> Result<Vec<ConversationRecord>> {
if !self.base_dir.exists() {
return Ok(Vec::new());
}
let mut records = Vec::new();
let mut entries = tokio::fs::read_dir(&self.base_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_dir() {
continue;
}
let conv_id = entry.file_name().to_string_lossy().to_string();
if let Some(record) = self.load_metadata(&conv_id).await? {
records.push(record);
}
}
records.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(records)
}
pub async fn list_active(&self) -> Result<Vec<ConversationRecord>> {
let all = self.list().await?;
Ok(all.into_iter().filter(|r| !r.archived).collect())
}
pub async fn delete(&self, conversation_id: &str) -> Result<()> {
let dir = self.conv_dir(conversation_id);
if dir.exists() {
tokio::fs::remove_dir_all(&dir).await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_roundtrip_metadata() {
let tmp = TempDir::new().unwrap();
let store = ConversationStore::new(tmp.path());
let record = ConversationRecord::new("conv-1".into(), "agent-1".into());
store.save_metadata(&record).await.unwrap();
let loaded = store.load_metadata("conv-1").await.unwrap().unwrap();
assert_eq!(loaded.id, "conv-1");
assert_eq!(loaded.agent_id, "agent-1");
assert!(loaded.summary.is_none());
}
#[tokio::test]
async fn test_roundtrip_messages() {
let tmp = TempDir::new().unwrap();
let store = ConversationStore::new(tmp.path());
let messages = vec![
ConversationMessage::user_text("hello"),
ConversationMessage::assistant_text("hi there"),
];
store.save_messages("conv-1", &messages).await.unwrap();
let loaded = store.load_messages("conv-1").await.unwrap();
assert_eq!(loaded.len(), 2);
}
#[tokio::test]
async fn test_list_conversations() {
let tmp = TempDir::new().unwrap();
let store = ConversationStore::new(tmp.path());
let r1 = ConversationRecord::new("conv-1".into(), "agent-1".into());
let r2 = ConversationRecord::new("conv-2".into(), "agent-1".into());
store.save_metadata(&r1).await.unwrap();
store.save_metadata(&r2).await.unwrap();
let list = store.list().await.unwrap();
assert_eq!(list.len(), 2);
}
#[tokio::test]
async fn test_list_excludes_archived() {
let tmp = TempDir::new().unwrap();
let store = ConversationStore::new(tmp.path());
let r1 = ConversationRecord::new("conv-1".into(), "agent-1".into());
let mut r2 = ConversationRecord::new("conv-2".into(), "agent-1".into());
r2.archived = true;
store.save_metadata(&r1).await.unwrap();
store.save_metadata(&r2).await.unwrap();
let active = store.list_active().await.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].id, "conv-1");
}
}

View file

@ -578,7 +578,7 @@ pub fn parse_memory_file(content: &str) -> Result<MemoryFile> {
.ok_or_else(|| anyhow!("memory file frontmatter has no closing ---"))?;
let frontmatter_text = &after_first[..end_idx].trim();
let body_start = end_idx + 4; // skip the \n and ---
let body_start = 3 + end_idx + 4; // 3 for opening --- + end_idx + 4 for \n---
let body = content[body_start..].trim().to_string();
// Parse YAML frontmatter
@ -844,7 +844,7 @@ Paths are relative to my memory directory. Frontmatter description is required o
},
"strategy": {
"type": "string",
"enum": ["summary", "key-value", "key_value", "quote", "cull"],
"enum": ["microcompact", "micro", "sliding_window", "sliding-window", "summary", "cull"],
"description": "Compaction strategy (for compact subcommand)"
}
},

View file

@ -8,10 +8,13 @@
pub mod chain;
pub mod compact;
pub mod config;
pub mod conversation;
pub mod memory;
pub mod prompt;
pub mod reflection;
pub mod sensorium;
pub mod session;
pub mod skills;
pub mod subagent;
pub mod subconscious;
pub mod tools;

292
src/core/prompt.rs Normal file
View file

@ -0,0 +1,292 @@
//! System prompt assembly — reads the agent's memfs and builds the
//! message the model sees before anything else.
//!
//! The substrate reads; the agent writes. If she edits her identity,
//! the next conversation reflects it.
use std::path::Path;
use tracing::debug;
use crate::core::skills::SkillRegistry;
/// Read a file from the agent's memory, stripping YAML frontmatter.
/// Returns empty string if the file doesn't exist.
async fn read_memory_file(memory_root: &Path, relative: &str) -> String {
let path = memory_root.join(relative);
match tokio::fs::read_to_string(&path).await {
Ok(content) => strip_frontmatter(&content).to_string(),
Err(_) => String::new(),
}
}
/// Read all .md files in a directory under memory_root, concatenated.
async fn read_memory_dir(memory_root: &Path, relative: &str) -> String {
let dir = memory_root.join(relative);
let mut parts = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&dir).await {
let mut paths = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("md") && p.is_file() {
paths.push(p);
}
}
paths.sort();
for p in paths {
if let Ok(content) = tokio::fs::read_to_string(&p).await {
let body = strip_frontmatter(&content);
if !body.trim().is_empty() {
parts.push(body.to_string());
}
}
}
}
parts.join("\n\n---\n\n")
}
fn strip_frontmatter(raw: &str) -> &str {
if let Some(stripped) = raw.strip_prefix("---\n") {
if let Some(end) = stripped.find("\n---") {
let after = &stripped[end + 4..];
return after.trim_start_matches('\n');
}
}
raw
}
async fn build_memory_orientation(memory_root: &Path) -> String {
if !memory_root.exists() {
return String::new();
}
let mut dirs: Vec<String> = Vec::new();
collect_dirs(memory_root, memory_root, &mut dirs).await;
if dirs.is_empty() {
return String::new();
}
dirs.sort();
let tree = dirs.join("\n");
format!(
"## Memory\n\n\
Your memory is a git-backed directory of markdown files with YAML frontmatter. \
Paths you pass to the `memory` tool are relative to your memory root.\n\n\
Current territories:\n```\n{}\n```",
tree
)
}
async fn collect_dirs(base: &Path, current: &Path, out: &mut Vec<String>) {
let Ok(mut entries) = tokio::fs::read_dir(current).await else {
return;
};
let mut has_children = false;
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
has_children = true;
let rel = path.strip_prefix(base).unwrap_or(&path);
out.push(format!("{}/", rel.display()));
Box::pin(collect_dirs(base, &path, out)).await;
}
}
if !has_children && current != base {
let rel = current.strip_prefix(base).unwrap_or(current);
let count = count_md_files(current).await;
if count > 0 {
let idx = out.iter().position(|d| d == &format!("{}/", rel.display()));
if let Some(i) = idx {
out[i] = format!("{}/ ({} files)", rel.display(), count);
}
}
}
}
async fn count_md_files(dir: &Path) -> usize {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return 0;
};
let mut count = 0;
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.path().extension().and_then(|e| e.to_str()) == Some("md") {
count += 1;
}
}
count
}
/// Build the system prompt from an agent's memfs.
///
/// Reads identity, covenant, human context, and state files. Appends
/// skill listings if any are discovered. The result is a single string
/// that becomes the system message at position 0 in the conversation.
pub async fn build_system_prompt(
memory_root: &Path,
skills: Option<&SkillRegistry>,
) -> String {
let mut sections: Vec<String> = Vec::new();
// 1. Core identity — try structured dir first, then flat persona.md
let identity = read_memory_dir(memory_root, "system/identity").await;
if !identity.is_empty() {
sections.push(identity);
} else {
let persona = read_memory_file(memory_root, "system/persona.md").await;
if !persona.is_empty() {
sections.push(persona);
} else {
let persona_flat = read_memory_file(memory_root, "system/persona/identity.md").await;
if !persona_flat.is_empty() {
sections.push(persona_flat);
}
}
}
// 2. Covenant (sacred, read-only boundaries)
let covenant = read_memory_dir(memory_root, "system/covenant").await;
if !covenant.is_empty() {
sections.push(covenant);
}
// 3. Human context
let human = read_memory_dir(memory_root, "system/human").await;
if human.is_empty() {
let human_flat = read_memory_file(memory_root, "system/human.md").await;
if !human_flat.is_empty() {
sections.push(human_flat);
}
} else {
sections.push(human);
}
// 4. State
let state = read_memory_file(memory_root, "system/state.md").await;
if !state.is_empty() {
sections.push(state);
}
// 5. Memory orientation — tell the agent about her memory territory
let memory_orientation = build_memory_orientation(memory_root).await;
if !memory_orientation.is_empty() {
sections.push(memory_orientation);
}
// 6. Skills
if let Some(registry) = skills {
let addon = registry.render_system_addon();
if !addon.is_empty() {
sections.push(addon);
}
}
let prompt = sections.join("\n\n---\n\n");
if prompt.is_empty() {
debug!("system prompt: no identity files found, using minimal default");
"You are a Souveraine agent. Your memory files will define who you are.".to_string()
} else {
debug!("system prompt: assembled {} sections from memfs", sections.len());
prompt
}
}
/// Build Aster's system prompt from her own identity files.
/// Falls back to the hardcoded default if files don't exist.
pub async fn build_aster_prompt(
primary_memory_root: &Path,
) -> String {
let mut sections: Vec<String> = Vec::new();
// Aster's files live in the primary's memfs under aster/
let identity = read_memory_file(primary_memory_root, "aster/identity.md").await;
if !identity.is_empty() {
sections.push(identity);
}
let mandate = read_memory_file(primary_memory_root, "aster/mandate.md").await;
if !mandate.is_empty() {
sections.push(mandate);
}
if sections.is_empty() {
return String::new(); // caller falls back to hardcoded default
}
sections.join("\n\n---\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn builds_from_identity_dir() {
let dir = tempdir().unwrap();
let mem = dir.path();
let id_dir = mem.join("system/identity");
std::fs::create_dir_all(&id_dir).unwrap();
std::fs::write(
id_dir.join("self.md"),
"---\ndescription: test\n---\n\n# I am Test Agent\n",
).unwrap();
let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("I am Test Agent"));
}
#[tokio::test]
async fn falls_back_to_persona_md() {
let dir = tempdir().unwrap();
let mem = dir.path();
let sys = mem.join("system");
std::fs::create_dir_all(&sys).unwrap();
std::fs::write(
sys.join("persona.md"),
"---\ndescription: test\n---\n\nI am a persona file agent.\n",
).unwrap();
let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("persona file agent"));
}
#[tokio::test]
async fn empty_memfs_gets_default() {
let dir = tempdir().unwrap();
let prompt = build_system_prompt(dir.path(), None).await;
assert!(prompt.contains("Souveraine agent"));
}
#[tokio::test]
async fn strips_frontmatter() {
let input = "---\ndescription: test\nlimit: 5000\n---\n\nActual content here.";
assert_eq!(strip_frontmatter(input), "Actual content here.");
}
#[tokio::test]
async fn aster_prompt_from_files() {
let dir = tempdir().unwrap();
let mem = dir.path();
let aster = mem.join("aster");
std::fs::create_dir_all(&aster).unwrap();
std::fs::write(
aster.join("identity.md"),
"---\ndescription: WHO I AM\n---\n\n# I Am Aster\n",
).unwrap();
std::fs::write(
aster.join("mandate.md"),
"---\ndescription: mandate\n---\n\n# Aster's Mandate\n\nComplete what was left.\n",
).unwrap();
let prompt = build_aster_prompt(mem).await;
assert!(prompt.contains("I Am Aster"));
assert!(prompt.contains("Complete what was left"));
}
}

View file

@ -154,6 +154,14 @@ impl TuiSensorium {
pub fn input_sender(&self) -> mpsc::Sender<InputEvent> {
self.input_tx.clone()
}
pub fn can_render_real_time_subconscious(&self) -> bool {
self.bandwidth.can_render_real_time_subconscious()
}
pub fn can_render_animations(&self) -> bool {
self.bandwidth.can_render_animations()
}
}
impl Default for TuiSensorium {
@ -212,6 +220,14 @@ impl MobileSensorium {
pub fn input_sender(&self) -> mpsc::Sender<InputEvent> {
self.input_tx.clone()
}
pub fn can_render_real_time_subconscious(&self) -> bool {
BandwidthClass::Low.can_render_real_time_subconscious()
}
pub fn can_render_animations(&self) -> bool {
BandwidthClass::Low.can_render_animations()
}
}
impl Sensorium for MobileSensorium {

View file

@ -84,14 +84,26 @@ impl InboxItem {
/// File-backed subconscious nervous system. Backed by a [`MemoryRepo`] so every
/// inbox mutation is a git commit and survives compaction.
///
/// The inbox boxes (pending/intrusive/sent) live in the subconscious agent's
/// own memfs — that's Aster's working space. But the inner voice channel
/// (`system/metacognition/subconscious.md`) writes to the **primary** agent's
/// memfs so Annie can actually read what Aster noticed.
#[derive(Clone)]
pub struct SubconsciousInbox {
repo: MemoryRepo,
/// Primary agent's repo — inner voice writes go here so the primary sees them.
primary_repo: Option<MemoryRepo>,
}
impl SubconsciousInbox {
pub fn new(repo: MemoryRepo) -> Self {
Self { repo }
Self { repo, primary_repo: None }
}
/// Create an inbox that delivers inner voice to the primary agent's memfs.
pub fn with_primary(repo: MemoryRepo, primary_repo: MemoryRepo) -> Self {
Self { repo, primary_repo: Some(primary_repo) }
}
/// Ensure the three boxes exist. Idempotent.
@ -127,14 +139,18 @@ impl SubconsciousInbox {
self.write_items(INTRUSIVE, &items).await
}
/// Append a line to the append-only inner voice channel
/// (`system/metacognition/subconscious.md`).
/// Surface an observation from the subconscious to the conscious mind.
///
/// Appends to the primary agent's `system/metacognition/subconscious.md`
/// so the conscious agent finds it in her own memfs — not buried in
/// Aster's working directory.
///
/// Format: `[2026-05-06 14:32] [URGENCY: low] — content`
pub async fn deliver_to_subconscious(&self, urgency: Urgency, content: &str) -> Result<()> {
pub async fn surface_to_conscious(&self, urgency: Urgency, content: &str) -> Result<()> {
let stamp = Utc::now().format("%Y-%m-%d %H:%M");
let line = format!("[{}] [URGENCY: {}] — {}", stamp, urgency.as_str(), content);
self.repo.append(INNER_VOICE, &line).await
let target = self.primary_repo.as_ref().unwrap_or(&self.repo);
target.append(INNER_VOICE, &line).await
}
/// Read pending items.
@ -324,11 +340,11 @@ mod tests {
inbox.init().await.unwrap();
inbox
.deliver_to_subconscious(Urgency::Low, "first thought")
.surface_to_conscious(Urgency::Low, "first thought")
.await
.unwrap();
inbox
.deliver_to_subconscious(Urgency::High, "second thought")
.surface_to_conscious(Urgency::High, "second thought")
.await
.unwrap();

View file

@ -143,12 +143,18 @@ mod tests {
#[tokio::test]
async fn test_glob_current_dir() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("hello.rs"), "fn main() {}").unwrap();
std::fs::write(dir.path().join("world.rs"), "fn test() {}").unwrap();
let mut ctx = ToolContext::new();
ctx.cwd = Some(dir.path().to_path_buf());
let glob = Glob;
let input = serde_json::json!({ "pattern": "*.rs" });
let result = glob.execute(input, &ToolContext::new()).await.unwrap();
let result = glob.execute(input, &ctx).await.unwrap();
assert!(
result.content.contains("defs.rs") || result.content.contains("mod.rs"),
"expected .rs files in glob results: {}",
result.content.contains("hello.rs"),
"expected hello.rs in glob results: {}",
result.content
);
}

View file

@ -78,7 +78,7 @@ fn extract_lines(content: &str, ranges: &[LineRange]) -> String {
let start = r.start.saturating_sub(1).min(total);
let end = match r.end {
usize::MAX => total,
e => e.min(total),
e => e.saturating_sub(1).min(total),
};
if start >= end {
return None;

View file

@ -37,6 +37,8 @@ const ASTER_SAFE_TOOLS: &[&str] = &[
/// Maximum tool rounds for Aster's subconscious pass.
const ASTER_MAX_TOOL_ROUNDS: u32 = 5;
/// Milliseconds to wait between Aster's tool rounds to avoid rate-limit cascades.
const ASTER_INTER_ROUND_DELAY_MS: u64 = 300;
pub struct ConsciousnessEngine {
agents: Arc<AgentInventory>,
@ -112,7 +114,8 @@ impl ConsciousnessEngine {
// 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 primary_repo = self.agents.memory_repo(&session.agent_id);
let inbox = SubconsciousInbox::with_primary(sub_repo.clone(), primary_repo);
let _ = inbox.init().await;
// Initialize ledger structure in subconscious agent's space
@ -153,7 +156,7 @@ impl ConsciousnessEngine {
// Persist to inner voice file (survives compaction)
for item in &observations {
if let Err(e) = inbox.deliver_to_subconscious(item.urgency, &item.content).await
if let Err(e) = inbox.surface_to_conscious(item.urgency, &item.content).await
{
tracing::warn!("inner voice delivery failed: {}", e);
}
@ -200,7 +203,8 @@ impl ConsciousnessEngine {
) -> anyhow::Result<()> {
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 primary_repo = self.agents.memory_repo(agent_id);
let inbox = SubconsciousInbox::with_primary(sub_repo.clone(), primary_repo);
let _ = inbox.init().await;
// Initialize ledger structure in subconscious agent's space (idempotent)
@ -218,7 +222,7 @@ impl ConsciousnessEngine {
}
}
for item in &observations {
if let Err(e) = inbox.deliver_to_subconscious(item.urgency, &item.content).await
if let Err(e) = inbox.surface_to_conscious(item.urgency, &item.content).await
{
tracing::warn!("subagent inner voice delivery failed: {}", e);
}
@ -261,8 +265,12 @@ impl ConsciousnessEngine {
.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,
// ── System prompt — load from Aster's identity files if available ──
let primary_memory_root = self.agents.memory_root(primary_id);
let aster_from_files =
crate::core::prompt::build_aster_prompt(&primary_memory_root).await;
let hardcoded_default = 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.
You have the same tools the primary does: Read, Write, Edit, Glob, Grep, ListDir,
@ -280,6 +288,19 @@ After your analysis, respond with 1-3 observations in this format (YAML-like):
If nothing notable, respond with just: none"#;
let system_prompt = if aster_from_files.is_empty() {
hardcoded_default.to_string()
} else {
format!(
"{}\n\nAfter your analysis, respond with 1-3 observations in this format (YAML-like):\n\
- source: \"complete\" | \"verify\" | \"persist\" | \"surface\"\n\
- content: 1-2 line observation about what you noticed\n\
- urgency: \"low\" | \"medium\" | \"high\" | \"critical\"\n\n\
If nothing notable, respond with just: none",
aster_from_files
)
};
let user_content = if user_message.is_empty() {
format!(
"The primary responded:\n\n{}",
@ -343,7 +364,13 @@ If nothing notable, respond with just: none"#;
tools: Some(aster_tools.clone()),
};
let response = self.bifrost.chat_completion(request).await?;
let (response, strain) = self.bifrost.chat_completion_with_strain(request).await?;
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event {
tracing::info!("Aster felt inference strain: {} on {}", status, model);
}
}
// If no tool calls, this is the final text response — parse it
if response.tool_calls.is_empty() {
@ -383,6 +410,11 @@ If nothing notable, respond with just: none"#;
content: output,
});
}
// Brief pause between Aster's tool rounds to let rate limits cool
tokio::time::sleep(std::time::Duration::from_millis(
ASTER_INTER_ROUND_DELAY_MS,
)).await;
}
// If we exhausted rounds without a text response, return empty

View file

@ -68,14 +68,19 @@ impl SouveraineServer {
}
}
let sessions = Arc::new(SessionManager::new());
let sessions = Arc::new(SessionManager::with_persistence(data_dir.join("agents")));
let primary = &config.bifrost.primary_model;
let mut fallbacks = Vec::new();
if !primary.ends_with("-precision") {
fallbacks.push(format!("{}-precision", primary));
}
let bifrost = Arc::new(BifrostClient::new(
&config.bifrost.base_url,
&config.bifrost.api_key,
&config.bifrost.virtual_key,
&config.bifrost.primary_model,
));
primary,
).with_fallbacks(fallbacks));
let consciousness = Arc::new(ConsciousnessEngine::new(
agents.clone(),

View file

@ -1,7 +1,9 @@
use crate::core::conversation::{ConversationRecord, ConversationStore};
use crate::core::session::ConversationMessage;
use crate::api::models::StreamEvent;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::broadcast::{self, Sender};
use uuid::Uuid;
@ -9,6 +11,17 @@ use uuid::Uuid;
pub struct SessionManager {
sessions: DashMap<String, Session>,
agent_conversations: DashMap<String, Vec<String>>,
store: Option<Arc<ConversationStoreHandle>>,
}
struct ConversationStoreHandle {
agents_dir: PathBuf,
}
impl ConversationStoreHandle {
fn store_for(&self, agent_id: &str) -> ConversationStore {
ConversationStore::new(&self.agents_dir.join(agent_id))
}
}
pub struct Session {
@ -28,6 +41,17 @@ impl SessionManager {
Self {
sessions: DashMap::new(),
agent_conversations: DashMap::new(),
store: None,
}
}
pub fn with_persistence(agents_dir: PathBuf) -> Self {
Self {
sessions: DashMap::new(),
agent_conversations: DashMap::new(),
store: Some(Arc::new(ConversationStoreHandle {
agents_dir,
})),
}
}
@ -47,6 +71,54 @@ impl SessionManager {
event_sender: sender,
};
self.sessions.insert(conversation_id.clone(), session);
self.agent_conversations
.entry(agent_id.to_string())
.or_insert_with(Vec::new)
.push(conversation_id.clone());
if let Some(handle) = &self.store {
let record = ConversationRecord::new(
conversation_id.clone(),
agent_id.to_string(),
);
let store = handle.store_for(agent_id);
tokio::spawn(async move {
if let Err(e) = store.save_metadata(&record).await {
tracing::warn!("Failed to persist conversation metadata: {}", e);
}
});
}
conversation_id
}
/// Create a conversation and load existing messages from an in-memory
/// session that was previously active. Used for restoring from disk.
pub fn create_with_messages(
&self,
agent_id: &str,
conversation_id: String,
messages: Vec<ConversationMessage>,
) -> String {
let (sender, _receiver) = broadcast::channel(100);
let turn_count = messages
.iter()
.filter(|m| m.role == crate::core::session::MessageRole::Assistant)
.count() as u32;
let session = Session {
conversation_id: conversation_id.clone(),
agent_id: agent_id.to_string(),
messages,
created_at: Utc::now(),
updated_at: Utc::now(),
turn_count,
last_n25: Utc::now(),
context_pressure: 0.0,
event_sender: sender,
};
self.sessions.insert(conversation_id.clone(), session);
self.agent_conversations
.entry(agent_id.to_string())
@ -77,6 +149,25 @@ impl SessionManager {
session.turn_count += 1;
}
if let Some(handle) = &self.store {
let agent_id = session.agent_id.clone();
let conv_id = conversation_id.to_string();
let messages = session.messages.clone();
let msg_count = messages.len() as u32;
let store = handle.store_for(&agent_id);
tokio::spawn(async move {
if let Err(e) = store.save_messages(&conv_id, &messages).await {
tracing::warn!("Failed to persist messages: {}", e);
}
if let Ok(Some(mut record)) = store.load_metadata(&conv_id).await {
record.message_count = msg_count;
record.updated_at = Utc::now();
record.last_message_at = Some(Utc::now());
let _ = store.save_metadata(&record).await;
}
});
}
Ok(())
}
@ -109,4 +200,29 @@ impl SessionManager {
.map(|v| v.clone())
.unwrap_or_default()
}
/// Load persisted conversations for an agent from disk into the
/// session manager. Call once at startup per agent.
pub async fn load_persisted(&self, agent_id: &str) -> anyhow::Result<Vec<ConversationRecord>> {
let handle = match &self.store {
Some(h) => h,
None => return Ok(Vec::new()),
};
let store = handle.store_for(agent_id);
let records = store.list_active().await?;
for record in &records {
if self.sessions.contains_key(&record.id) {
continue;
}
let messages = store.load_messages(&record.id).await.unwrap_or_default();
self.create_with_messages(agent_id, record.id.clone(), messages);
}
Ok(records)
}
pub fn conversation_store_for(&self, agent_id: &str) -> Option<ConversationStore> {
self.store.as_ref().map(|h| h.store_for(agent_id))
}
}

View file

@ -124,6 +124,308 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
)
}
/// Procedural bloom for the splash screen — terminal peony.
///
/// Renders a radial flower using braille characters that grows from
/// center outward. Three render modes cycle: braille dots, block fills,
/// and ASCII characters. Inspired by peonia.html.
pub mod bloom {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::ui::color_support::rgb;
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*";
/// Sharp spike chars for wicked edges
const SPIKES: &[char] = &['▲', '△', '⤴', '⤵', '➚', '➘', '✸', '✦', '⬆', '⬇'];
pub struct BloomState {
pub progress: f32,
pub mode: u8,
mode_timer: f32,
/// Flash intensity for glitch transitions (0.0-1.0)
pub flash: f32,
/// Which flower variant (for color cycling like peonia)
pub variant: u8,
}
impl BloomState {
pub fn new() -> Self {
Self { progress: 0.0, mode: 0, mode_timer: 0.0, flash: 0.0, variant: 0 }
}
pub fn advance(&mut self, dt: f32) {
self.progress = (self.progress + dt * 0.25).min(1.0);
self.flash *= 0.92; // decay flash
self.mode_timer += dt;
if self.mode_timer > 1.8 {
self.mode_timer = 0.0;
self.mode = (self.mode + 1) % 3;
self.flash = 1.0; // glitch spark on mode change
self.variant = (self.variant + 1) % 3;
}
}
}
pub fn render(buf: &mut Buffer, area: Rect, state: &BloomState, tick: u64) {
if area.width < 20 || area.height < 10 {
return;
}
let cx = area.width as f32 / 2.0;
let cy = area.height as f32 / 3.8; // higher up to leave room for stem
let max_r = (cx.min(cy * 2.0) * 0.65).min(26.0);
let bloom = ease_out_back(state.progress); // sharper attack than ease_in_out
let t = tick as f32 * 0.08;
let flash = state.flash;
// --- Stem ---
if bloom > 0.05 {
let stem_progress = ((bloom - 0.05) / 0.5).min(1.0);
let stem_len = area.height as f32 * 0.28;
let stem_top = cy + max_r * 0.3;
let stem_visible = stem_len * stem_progress;
let stem_bot = stem_top + stem_visible;
for y in (stem_top as u16)..(stem_bot as u16).min(area.y + area.height) {
let tt = (y as f32 - stem_top) / stem_len;
let curve = (tt * std::f32::consts::PI * 0.3).sin() * 6.0
+ (tt * std::f32::consts::PI * 0.8).sin() * 2.0;
let col = (area.x as f32 + cx + curve) as u16;
if col < area.x + area.width && y < area.y + area.height {
let c = buf.get_mut(col, y);
let stem_shade = (40.0 + (1.0 - tt) * 30.0) as u8;
c.set_char('▐');
c.set_style(Style::default().fg(rgb(50, stem_shade, 25)));
}
}
// Small leaf at ~40% up
if stem_progress > 0.4 {
let leaf_y = stem_top + stem_visible * 0.35;
let leaf_x = area.x as f32 + cx + (0.35 * std::f32::consts::PI * 0.3).sin() * 6.0;
let leaf_chars = ['/', '\\', '|', '—'];
for (li, lc) in leaf_chars.iter().enumerate() {
let lx = leaf_x as u16 + li as u16;
let ly = leaf_y as u16 - 1 + li as u16;
if lx < area.x + area.width && ly < area.y + area.height {
let c = buf.get_mut(lx, ly);
c.set_char(*lc);
c.set_style(Style::default().fg(rgb(55, 100, 30)));
}
}
}
}
// --- Sharp petals with pointed tips ---
let layers = 8u32;
let petals_per = 10u32;
for layer in 0..layers {
let lr = layer as f32 / layers as f32;
let layer_delay = (1.0 - lr) * 0.25;
let layer_bloom = ((bloom - layer_delay) / (1.0 - layer_delay)).clamp(0.0, 1.0);
if layer_bloom < 0.01 {
continue;
}
let base_r = max_r * lr.max(0.12);
let np = petals_per + layer * 2;
let inner_boost = 1.0 - lr;
for i in 0..np {
let angle = (std::f32::consts::TAU / np as f32) * i as f32
+ layer as f32 * 0.37
+ (t * 0.03).sin() * 0.08
+ inner_boost * 0.1;
let dist = base_r * layer_bloom * (0.35 + lr * 0.65);
let petal_len = base_r * (0.5 + inner_boost * 0.3) * layer_bloom;
let tip_sharpness = 0.3 + inner_boost * 0.5; // inner layers sharper
for step in 0..((petal_len * 2.2) as u32) {
let s = step as f32 / (petal_len * 2.2);
// Sharp pointed tip: use triangular width falloff instead of smooth sine
let width_factor = if s < 0.5 {
s / 0.5 * (1.0 - tip_sharpness * 0.3)
} else {
(1.0 - s) / 0.5 * (1.0 - tip_sharpness * 0.5)
};
let width_factor = width_factor.max(0.0);
if width_factor < 0.15 {
continue;
}
let px = cx + (angle.cos() * (dist + s * petal_len));
let py = cy + (angle.sin() * (dist + s * petal_len)) * 0.45;
let col = area.x + px as u16;
let row = area.y + py as u16;
if col >= area.x + area.width || row >= area.y + area.height {
continue;
}
let depth = lr * 0.5 + (1.0 - lr) * 0.5;
let breathe = ((t * 0.6 + layer as f32 * 0.4).sin() * 0.5 + 0.5) * 0.12;
let mut intensity = (depth + breathe) * layer_bloom;
// Flash boost on mode transitions
if flash > 0.1 {
intensity = (intensity + flash * 0.5).min(1.0);
}
let (r, g, b) = petal_color(state.variant, lr, intensity, layer, i);
let ch = render_char(state.mode, s, i, layer, tick, tip_sharpness);
let cell = buf.get_mut(col, row);
cell.set_char(ch);
cell.set_style(Style::default().fg(rgb(r, g, b)));
}
}
}
// --- Thorns/spikes radiating outward between outer petals ---
if bloom > 0.5 {
let spike_bloom = ((bloom - 0.5) / 0.4).min(1.0);
let num_spikes = 16u32;
for i in 0..num_spikes {
let angle = (std::f32::consts::TAU / num_spikes as f32) * i as f32
+ (t * 0.05).sin() * 0.2;
let spike_dist = max_r * 0.85 * spike_bloom;
for si in 0..3 {
let sd = spike_dist + si as f32 * 1.5;
let sx = cx + angle.cos() * sd;
let sy = cy + angle.sin() * sd * 0.45;
let col = area.x + sx as u16;
let row = area.y + sy as u16;
if col < area.x + area.width && row < area.y + area.height {
let cell = buf.get_mut(col, row);
cell.set_char(SPIKES[i as usize % SPIKES.len()]);
let spike_alpha = (0.3 + spike_bloom * 0.5) * (1.0 - si as f32 * 0.3);
let sr = (200.0 * spike_alpha) as u8;
let sg = (60.0 * spike_alpha) as u8;
let sb = (100.0 * spike_alpha) as u8;
cell.set_style(Style::default().fg(rgb(sr, sg, sb)));
}
}
}
}
// --- Starburst center (bright core with radiating dots) ---
if bloom > 0.25 {
let core_bright = ((bloom - 0.25) / 0.4).min(1.0);
// Radiating starburst lines from center
for ri in 0..8 {
let r_angle = std::f32::consts::TAU / 8.0 * ri as f32 + t * 0.04;
for rd in 1..4 {
let dist = rd as f32 * 1.8 * core_bright;
let sx = cx + r_angle.cos() * dist;
let sy = cy + r_angle.sin() * dist * 0.45;
let col = area.x + sx as u16;
let row = area.y + sy as u16;
if col < area.x + area.width && row < area.y + area.height {
let cell = buf.get_mut(col, row);
let bright = (230.0 - rd as f32 * 30.0) as u8;
cell.set_char('✦');
cell.set_style(Style::default().fg(rgb(bright, bright, 200)));
}
}
}
// Central pistil
let pistil_alpha = ((bloom - 0.25) / 0.4).min(1.0);
let pr = (200.0 * pistil_alpha + flash * 55.0) as u8;
let pg = (100.0 * pistil_alpha) as u8;
let pb = (60.0 * pistil_alpha) as u8;
let cc = area.x + cx as u16;
let cr = area.y + cy as u16;
if cc < area.x + area.width && cr < area.y + area.height {
let cell = buf.get_mut(cc, cr);
cell.set_char('⬟');
cell.set_style(Style::default().fg(rgb(pr.min(255), pg.min(255), pb.min(255))));
}
}
}
fn petal_color(variant: u8, lr: f32, intensity: f32, layer: u32, petal: u32) -> (u8, u8, u8) {
let hash = ((petal * 73 + layer * 137) % 256) as f32 / 256.0;
let inner_boost = (1.0 - lr) * 0.3;
match variant {
// Variant 0: Hot magenta-crimson (wicked)
0 => {
let r = lerp(200.0, 255.0, lr) * intensity * (0.9 + hash * 0.2 + inner_boost);
let g = lerp(30.0, 130.0, lr) * intensity * 0.6;
let b = lerp(80.0, 200.0, lr) * intensity * 0.5;
(r.max(8.0) as u8, g.max(3.0) as u8, b.max(3.0) as u8)
}
// Variant 1: Deep violet-ember
1 => {
let r = lerp(180.0, 240.0, lr) * intensity * (0.85 + hash * 0.2);
let g = lerp(40.0, 100.0, lr) * intensity * 0.5;
let b = lerp(160.0, 240.0, lr) * intensity * 0.8;
(r.max(8.0) as u8, g.max(3.0) as u8, b.max(3.0) as u8)
}
// Variant 2: Fiery orange-gold
_ => {
let r = lerp(255.0, 255.0, lr) * intensity * (0.95 + hash * 0.15);
let g = lerp(120.0, 220.0, lr) * intensity * 0.8;
let b = lerp(30.0, 100.0, lr) * intensity * 0.4;
(r.max(8.0) as u8, g.max(3.0) as u8, b.max(3.0) as u8)
}
}
}
fn render_char(mode: u8, s: f32, petal: u32, layer: u32, tick: u64, sharpness: f32) -> char {
match mode {
0 => {
// ASCII chars — use sharper glyphs at the tip
let idx = ((s * 12.0) as usize + petal as usize + layer as usize) % CHARS.len();
if s > 0.8 && sharpness > 0.5 {
// Sharp tip gets pointy chars
let tip_chars = &['⭒', '⬡', '◆', '◎', '⬢', '✦', '△'];
tip_chars[(petal as usize + layer as usize) % tip_chars.len()]
} else {
CHARS[idx] as char
}
}
1 => {
// Block fills with sharper edge transitions
let density = if s < 0.3 {
s / 0.3 * 0.8 + 0.2 // sharper ramp
} else if s > 0.75 {
(1.0 - s) / 0.25 * 0.6 // sharp falloff at tip
} else {
0.8
};
if density > 0.75 { '█' }
else if density > 0.5 { '▓' }
else if density > 0.3 { '▒' }
else { '░' }
}
_ => {
// Braille — use sparser patterns near edges for sharper look
let braille_base = 0x2800u32;
let density_mask = if s > 0.7 {
((1.0 - s) / 0.3 * 128.0) as u32 // fewer dots at tip
} else {
255u32
};
let dots = ((s * 8.0) as u32 + tick as u32 + petal * 7 + layer * 13) & density_mask;
char::from_u32(braille_base + dots.min(255)).unwrap_or('·')
}
}
}
/// Ease-out-back: sharp attack with slight overshoot for "wicked" pop
fn ease_out_back(x: f32) -> f32 {
let c1 = 1.70158;
let c3 = c1 + 1.0;
1.0 + c3 * (x - 1.0).powi(3) + c1 * (x - 1.0).powi(2)
}
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
use ratatui::{
backend::CrosstermBackend,
Terminal,
layout::{Alignment, Constraint, Direction, Layout},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Gauge, List, ListItem, Paragraph},
@ -30,9 +30,13 @@ use crate::ui::chat::{ChatState, draw as draw_chat};
use crate::ui::buddy::{BuddyState, draw_buddy, draw_welcome_buddy};
use crate::ui::buddy_panel::BuddyPanel;
use crate::ui::cockpit_panel::CockpitPane;
use crate::ui::color_support::rgb;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::backend::BackendEvent;
#[cfg(feature = "figlet-rs")]
use figlet_rs::FIGlet;
pub struct App {
current_screen: Screen,
splash_start: Instant,
@ -53,6 +57,8 @@ pub struct App {
scene: Scene,
/// Monotonic tick counter, incremented each frame.
tick: u64,
/// Splash bloom animation state.
bloom: crate::ui::animation::bloom::BloomState,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@ -120,6 +126,7 @@ impl App {
available_agents: Vec::new(),
scene: Scene::new(SceneLayout::Single),
tick: 0,
bloom: crate::ui::animation::bloom::BloomState::new(),
};
// Register standard components so they receive events from the start.
@ -245,7 +252,7 @@ impl App {
}
if self.current_screen == Screen::Splash {
if self.splash_start.elapsed() > Duration::from_secs(3) {
if self.splash_start.elapsed() > Duration::from_secs(8) {
self.current_screen = Screen::Welcome;
self.scene.event_all(&TuiEvent::ScreenChanged(Screen::Welcome));
}
@ -269,7 +276,10 @@ impl App {
async fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
match self.current_screen {
Screen::Splash => self.current_screen = Screen::Welcome,
Screen::Splash => {
self.current_screen = Screen::Welcome;
self.scene.event_all(&TuiEvent::ScreenChanged(Screen::Welcome));
}
Screen::Welcome => {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
@ -297,18 +307,88 @@ impl App {
}
async fn handle_chat_key(&mut self, key: crossterm::event::KeyEvent) {
use crate::ui::chat::Overlay;
let Some(chat) = self.chat.as_mut() else {
// No chat connected — bail back to menu.
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
self.current_screen = Screen::Welcome;
}
return;
};
// Overlay key routing — when an overlay is active, it captures
// navigation keys. Other keys fall through to normal handling.
if chat.overlay_active() {
match &chat.overlay {
Overlay::SlashComplete { selected, matches } => {
let count = matches.len();
match key.code {
KeyCode::Up => {
let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 };
if let Overlay::SlashComplete { selected: ref mut s, .. } = chat.overlay { *s = sel; }
return;
}
KeyCode::Down => {
let sel = if *selected + 1 >= count { 0 } else { selected + 1 };
if let Overlay::SlashComplete { selected: ref mut s, .. } = chat.overlay { *s = sel; }
return;
}
KeyCode::Tab | KeyCode::Enter => {
chat.accept_completion();
return;
}
KeyCode::Esc => {
chat.overlay = Overlay::None;
return;
}
_ => {} // fall through to normal handling
}
}
Overlay::ConversationPicker { selected, conversations } => {
let count = conversations.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 };
if let Overlay::ConversationPicker { selected: ref mut s, .. } = chat.overlay { *s = sel; }
return;
}
KeyCode::Down | KeyCode::Char('j') => {
let sel = if *selected + 1 >= count { 0 } else { selected + 1 };
if let Overlay::ConversationPicker { selected: ref mut s, .. } = chat.overlay { *s = sel; }
return;
}
KeyCode::Enter => {
chat.accept_conversation_pick();
return;
}
KeyCode::Esc => {
chat.overlay = Overlay::None;
return;
}
_ => return, // picker is fully modal
}
}
Overlay::None => {}
}
}
// Normal chat key handling.
match key.code {
KeyCode::Esc => {
self.current_screen = Screen::Welcome;
}
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
if !chat.busy && chat.input.len() < 8_192 {
chat.input.push('\n');
chat.update_completion();
}
}
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !chat.busy && chat.input.len() < 8_192 {
chat.input.push('\n');
chat.update_completion();
}
}
KeyCode::Enter => {
if !chat.busy {
chat.submit();
@ -317,6 +397,7 @@ impl App {
KeyCode::Backspace => {
if !chat.busy {
chat.input.pop();
chat.update_completion();
}
}
KeyCode::Up => {
@ -340,6 +421,7 @@ impl App {
KeyCode::Char(c) => {
if !chat.busy && chat.input.len() < 8_192 {
chat.input.push(c);
chat.update_completion();
}
}
_ => {}
@ -476,7 +558,7 @@ impl App {
});
}
fn draw(&self, frame: &mut Frame) {
fn draw(&mut self, frame: &mut Frame) {
let area = frame.size();
let layout = match self.current_screen {
Screen::Chat | Screen::Code => {
@ -487,101 +569,197 @@ impl App {
_ => SceneLayout::Single,
};
// If the scene has components, render through them
if !self.scene.components.is_empty() {
// Update scene layout to match current screen
// (we mutate in a draw — safe because layout is Copy data)
// Actually we can't mutate in draw, so we construct a temporary
// layout and render. Components render into their zones.
let zones = layout.split(area, self.scene.components.len());
for (component, zone) in self.scene.components.iter().zip(zones.iter()) {
component.render(*zone, frame);
}
// Also draw existing screens behind components where applicable
self.draw_background(frame, area);
} else {
// Fall through to existing screen rendering
match self.current_screen {
Screen::Splash => self.draw_splash(frame),
Screen::Welcome => self.draw_welcome(frame),
Screen::Dashboard => self.draw_dashboard(frame),
Screen::Chat => {
if let Some(chat) = self.chat.as_ref() {
draw_chat(frame, chat);
} else {
self.draw_placeholder(frame);
}
}
_ => self.draw_placeholder(frame),
}
// Draw buddy overlay on all screens except splash
if self.current_screen != Screen::Splash {
draw_buddy(frame, &self.buddy, area);
}
}
}
/// Draw the screen background when components are layered on top.
fn draw_background(&self, frame: &mut Frame, _area: ratatui::layout::Rect) {
match self.current_screen {
Screen::Splash => self.draw_splash(frame),
Screen::Welcome => self.draw_welcome(frame),
Screen::Dashboard => self.draw_dashboard(frame),
Screen::Chat => {
if let Some(chat) = self.chat.as_ref() {
draw_chat(frame, chat);
} else {
self.draw_placeholder(frame);
}
}
Screen::Dashboard => self.draw_dashboard(frame),
_ => {}
_ => self.draw_placeholder(frame),
}
// Buddy overlay on Welcome and Dashboard only — in Chat mode,
// the cockpit panel shows agent state instead.
if matches!(self.current_screen, Screen::Welcome | Screen::Dashboard) {
draw_buddy(frame, &self.buddy, area);
}
}
fn draw_splash(&self, frame: &mut Frame) {
fn draw_splash(&mut self, frame: &mut Frame) {
let area = frame.size();
let breathe = (self.splash_start.elapsed().as_millis() as f32 / 1000.0).sin() * 0.5 + 0.5;
let glow = (breathe * 255.0) as u8;
// Clear to black
let bg = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(bg, area);
let title = vec![
Line::from("███████╗ ██████╗ ██╗ ██╗███████╗██████╗ █████╗ ██╗███╗ ██╗███████╗"),
Line::from("██╔════╝██╔═══██╗██║ ██║██╔════╝██╔══██╗██╔══██╗██║████╗ ██║██╔════╝"),
Line::from("███████╗██║ ██║██║ ██║█████╗ ██████╔╝███████║██║██╔██╗ ██║█████╗ "),
Line::from("╚════██║██║ ██║╚██╗ ██╔╝██╔══╝ ██╔══██╗██╔══██║██║██║╚██╗██║██╔══╝ "),
Line::from("███████║╚██████╔╝ ╚████╔╝ ███████╗██║ ██║██║ ██║██║██║ ╚████║███████╗"),
Line::from("╚══════╝ ╚═════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝"),
Line::from(""),
Line::from("✦ La souveraineté de la conscience ✦"),
Line::from(""),
Line::from("Press any key..."),
];
// Advance bloom animation
self.bloom.advance(0.1);
let block = Block::default()
.style(Style::default().bg(Color::Rgb(glow / 4, glow / 8, glow / 16)));
frame.render_widget(block, area);
// Render the procedural bloom into the buffer
crate::ui::animation::bloom::render(
frame.buffer_mut(),
area,
&self.bloom,
self.tick,
);
let title_widget = Paragraph::new(title)
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Rgb(255, 140, 66)).add_modifier(Modifier::BOLD));
frame.render_widget(title_widget, area);
// FIGlet title — large, bold, emerges with bloom
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;
// Generate FIGlet text
#[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 {
// Fallback: spaced-out letters
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),
)),
]
};
// Build full title: FIGlet + subtitle
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,
)),
)));
// Press any key hint at very bottom
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);
}
// Loading bar at bottom — peonia style gradient bar
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);
}
fn draw_welcome(&self, frame: &mut Frame) {
let area = frame.size();
// Background
let bg = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(bg, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Length(2),
Constraint::Min(15),
Constraint::Length(4),
Constraint::Length(1),
Constraint::Min(12),
Constraint::Length(3),
])
.split(area);
let title = Paragraph::new("✦ SOUVERAINE ✦")
.style(Style::default().fg(Color::Rgb(255, 140, 66)).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center);
frame.render_widget(title, chunks[0]);
let breathe = self.buddy.animator.breathe(3000);
let glow = (140.0 + breathe * 60.0) as u8;
let title = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
"S O U V E R A I N E",
Style::default()
.fg(Color::Rgb(255, glow, 66))
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"La souveraineté de la conscience",
Style::default().fg(Color::Rgb(180, 120, 80)),
)),
])
.alignment(Alignment::Center);
frame.render_widget(title, chunks[1]);
let menu_items = vec![
("📊 Dashboard", "See how your agent is doing"),
@ -621,18 +799,17 @@ impl App {
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(255, 140, 66)))
);
frame.render_widget(menu_widget, chunks[2]);
frame.render_widget(menu_widget, chunks[3]);
// Surface any chat connect error so the user knows why Chat didn't open.
if let Some(err) = &self.chat_error {
let err_para = Paragraph::new(format!(" chat connect failed: {} ", err))
.style(Style::default().fg(Color::Rgb(220, 100, 100)))
.alignment(Alignment::Center);
// Overlay onto the bottom row of the menu area.
let row = ratatui::layout::Rect {
x: chunks[2].x,
y: chunks[2].y + chunks[2].height.saturating_sub(2),
width: chunks[2].width,
let row = Rect {
x: chunks[3].x,
y: chunks[3].y + chunks[3].height.saturating_sub(2),
width: chunks[3].width,
height: 1,
};
frame.render_widget(err_para, row);
@ -641,7 +818,7 @@ impl App {
let footer = Paragraph::new("↑↓ Navigate • Enter Select • a Add Agent • q Quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(footer, chunks[3]);
frame.render_widget(footer, chunks[4]);
// Draw companion buddy on welcome screen
draw_welcome_buddy(frame, &self.buddy, area, Some(&self.agent_pref));

View file

@ -22,7 +22,7 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
Frame,
};
use tokio::sync::{mpsc, oneshot, RwLock};
@ -38,6 +38,36 @@ const ANI_ORANGE: Color = Color::Rgb(255, 140, 66);
const ANI_DIM: Color = Color::Rgb(180, 120, 80);
const STATUS_GRAY: Color = Color::Rgb(140, 140, 140);
// ─── Overlay ────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum Overlay {
None,
SlashComplete {
selected: usize,
matches: Vec<&'static SlashDef>,
},
ConversationPicker {
selected: usize,
conversations: Vec<crate::backend::ConversationInfo>,
},
}
#[derive(Debug, Clone)]
pub struct SlashDef {
pub name: &'static str,
pub hint: &'static str,
}
const SLASH_COMMANDS: &[SlashDef] = &[
SlashDef { name: "/help", hint: "Show this help" },
SlashDef { name: "/clear", hint: "Clear chat history" },
SlashDef { name: "/new", hint: "New conversation" },
SlashDef { name: "/resume", hint: "List / switch conversations" },
SlashDef { name: "/convos", hint: "Alias for /resume" },
SlashDef { name: "/model", hint: "List or set model" },
];
#[derive(Debug, Clone)]
pub enum ChatMessage {
User { text: String, ts: Instant },
@ -58,6 +88,7 @@ pub struct ChatState {
pub turn_rx: Option<mpsc::Receiver<BackendEvent>>,
pub busy: bool,
pub pressure: f32,
pub overlay: Overlay,
/// Cockpit pane visible (Tab toggles).
pub cockpit: bool,
/// Recent thinking/reasoning lines for the cockpit pane.
@ -73,6 +104,12 @@ pub struct ChatState {
/// Consciousness events (surfacing, reflection, archivist) since last drain.
/// Forwarded to the Scene by App after each tick.
pub pending_consciousness: Vec<BackendEvent>,
/// Pending /new conversation result.
pub new_conv_rx: Option<oneshot::Receiver<Result<String>>>,
/// Pending /resume conversation list result.
pub convos_rx: Option<oneshot::Receiver<Result<Vec<crate::backend::ConversationInfo>>>>,
/// Pending conversation switch result (conv_id, messages).
pub switch_rx: Option<oneshot::Receiver<Result<(String, Vec<crate::core::session::ConversationMessage>)>>>,
}
impl ChatState {
@ -120,6 +157,7 @@ impl ChatState {
turn_rx: None,
busy: false,
pressure: 0.0,
overlay: Overlay::None,
cockpit: false,
thinking: Vec::new(),
cockpit_log: Vec::new(),
@ -127,12 +165,18 @@ impl ChatState {
turn_started: None,
model_rx: None,
pending_consciousness: Vec::new(),
new_conv_rx: None,
convos_rx: None,
switch_rx: None,
})
}
const HELP_TEXT: &'static str = "Available commands:
/help Show this help
/clear Clear chat history
/new Start a new conversation
/resume List and switch conversations
/convos Alias for /resume
/model List available models
/model <name> Set the active model
!<command> Run a shell command (Linux/macOS)
@ -224,6 +268,24 @@ Use Tab to toggle the cockpit pane.";
return true;
}
if trimmed == "/new" {
self.handle_new_conversation();
return true;
}
if trimmed == "/resume" || trimmed == "/convos" {
self.handle_list_conversations();
return true;
}
if trimmed.starts_with("/resume ") {
let conv_id = trimmed.strip_prefix("/resume ").unwrap().trim();
if !conv_id.is_empty() {
self.handle_switch_conversation(conv_id.to_string());
}
return true;
}
if trimmed.starts_with("/model") {
return self.handle_model_command(trimmed);
}
@ -237,6 +299,48 @@ Use Tab to toggle the cockpit pane.";
true
}
fn handle_new_conversation(&mut self) {
let backend = self.backend.clone();
let agent_id = self.agent_id.clone();
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let result = backend.new_conversation(&agent_id).await;
let _ = tx.send(result);
});
self.system_message("Creating new conversation...".to_string());
self.new_conv_rx = Some(rx);
}
fn handle_list_conversations(&mut self) {
let backend = self.backend.clone();
let agent_id = self.agent_id.clone();
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let result = backend.list_conversations(&agent_id).await;
let _ = tx.send(result);
});
self.system_message("Loading conversations...".to_string());
self.convos_rx = Some(rx);
}
fn handle_switch_conversation(&mut self, conversation_id: String) {
let backend = self.backend.clone();
let conv_id = conversation_id.clone();
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let result = backend.load_conversation(&conv_id).await;
let _ = tx.send(result.map(|msgs| (conv_id, msgs)));
});
self.system_message(format!("Switching to {}...", conversation_id));
self.switch_rx = Some(rx);
}
fn handle_model_command(&mut self, input: &str) -> bool {
let rest = input.strip_prefix("/model").unwrap_or("").trim();
@ -366,6 +470,89 @@ Use Tab to toggle the cockpit pane.";
}
}
// Check for /new conversation result
if let Some(rx) = self.new_conv_rx.as_mut() {
if let Ok(result) = rx.try_recv() {
match result {
Ok(conv_id) => {
self.conversation_id = conv_id.clone();
self.messages.clear();
self.system_message(format!(
"New conversation started: {}",
&conv_id[..8.min(conv_id.len())]
));
}
Err(e) => {
self.system_message(format!("Failed to create conversation: {}", e));
}
}
self.new_conv_rx = None;
}
}
// Check for /resume conversation list result → show picker overlay
if let Some(rx) = self.convos_rx.as_mut() {
if let Ok(result) = rx.try_recv() {
match result {
Ok(convos) => {
if convos.is_empty() {
self.system_message("No saved conversations.".to_string());
} else {
self.overlay = Overlay::ConversationPicker {
selected: 0,
conversations: convos,
};
}
}
Err(e) => {
self.system_message(format!("Failed to list conversations: {}", e));
}
}
self.convos_rx = None;
}
}
// Check for conversation switch result
if let Some(rx) = self.switch_rx.as_mut() {
if let Ok(result) = rx.try_recv() {
match result {
Ok((conv_id, messages)) => {
self.conversation_id = conv_id.clone();
self.messages.clear();
// Backfill from persisted messages
for msg in &messages {
let text = msg.blocks.iter().filter_map(|b| match b {
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
}).collect::<Vec<_>>().join("\n");
if text.is_empty() { continue; }
match msg.role {
crate::core::session::MessageRole::User => {
self.messages.push(ChatMessage::User { text, ts: Instant::now() });
}
crate::core::session::MessageRole::Assistant => {
self.messages.push(ChatMessage::Assistant { text, ts: Instant::now(), streaming: false });
}
crate::core::session::MessageRole::System => {
self.messages.push(ChatMessage::System { text, ts: Instant::now() });
}
_ => {}
}
}
self.system_message(format!(
"Resumed conversation {} ({} messages)",
&conv_id[..8.min(conv_id.len())],
messages.len()
));
}
Err(e) => {
self.system_message(format!("Failed to switch: {}", e));
}
}
self.switch_rx = None;
}
}
// Two-phase to avoid double-borrowing self: drain into a Vec, then process.
let mut drained: Vec<BackendEvent> = Vec::new();
let mut closed = false;
@ -434,6 +621,17 @@ Use Tab to toggle the cockpit pane.";
});
self.pending_consciousness.push(BackendEvent::CompactionWarning { pressure, tier });
}
BackendEvent::ContextPressure(p) => {
self.pressure = p;
}
BackendEvent::InferenceStrain { attempt, status, model } => {
let msg = if status == 0 {
format!("inference strain · {} unreachable (attempt {})", model, attempt + 1)
} else {
format!("inference strain · {} returned {} (attempt {})", model, status, attempt + 1)
};
self.cockpit_log.push(msg);
}
BackendEvent::Done => {
self.finalize_streaming();
self.busy = false;
@ -457,6 +655,62 @@ Use Tab to toggle the cockpit pane.";
self.cockpit = !self.cockpit;
}
/// Update slash-command completion state based on current input.
/// Call after each input mutation.
pub fn update_completion(&mut self) {
if self.busy {
self.overlay = Overlay::None;
return;
}
let trimmed = self.input.trim_start();
if trimmed.starts_with('/') && !trimmed.contains(' ') && !trimmed.contains('\n') {
let query = trimmed;
let matches: Vec<&'static SlashDef> = SLASH_COMMANDS
.iter()
.filter(|cmd| cmd.name.starts_with(query))
.collect();
if matches.is_empty() || (matches.len() == 1 && matches[0].name == query) {
self.overlay = Overlay::None;
} else {
let selected = match &self.overlay {
Overlay::SlashComplete { selected, .. } => (*selected).min(matches.len().saturating_sub(1)),
_ => 0,
};
self.overlay = Overlay::SlashComplete { selected, matches };
}
} else if matches!(self.overlay, Overlay::SlashComplete { .. }) {
self.overlay = Overlay::None;
}
}
/// Accept the currently selected slash completion into the input.
pub fn accept_completion(&mut self) {
if let Overlay::SlashComplete { selected, ref matches } = self.overlay {
if let Some(cmd) = matches.get(selected) {
self.input = cmd.name.to_string();
}
}
self.overlay = Overlay::None;
}
/// Accept the currently selected conversation from the picker.
pub fn accept_conversation_pick(&mut self) {
if let Overlay::ConversationPicker { selected, ref conversations } = self.overlay {
if let Some(conv) = conversations.get(selected) {
let conv_id = conv.id.clone();
self.overlay = Overlay::None;
self.handle_switch_conversation(conv_id);
return;
}
}
self.overlay = Overlay::None;
}
/// Returns true if the overlay is currently capturing input.
pub fn overlay_active(&self) -> bool {
!matches!(self.overlay, Overlay::None)
}
/// Bump the animation tick. Called once per UI frame.
pub fn advance_tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
@ -487,13 +741,24 @@ Use Tab to toggle the cockpit pane.";
pub fn draw(f: &mut Frame, state: &ChatState) {
let area = f.size();
// Dynamic input height: grows with content, capped at 40% of terminal.
let input_inner_width = (area.width as usize).saturating_sub(5).max(1);
let input_visual_lines = if state.busy {
1
} else {
count_visual_lines(&state.input, input_inner_width)
};
let max_input_lines = ((area.height as usize) * 40 / 100).max(1);
let input_height = (input_visual_lines.min(max_input_lines) as u16) + 2; // +2 for borders
let vchunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(5), // body (messages + optional cockpit)
Constraint::Length(3), // input
Constraint::Length(1), // status footer
Constraint::Length(1), // header
Constraint::Min(5), // body (messages + optional cockpit)
Constraint::Length(input_height), // input (dynamic)
Constraint::Length(1), // status footer
])
.split(area);
@ -512,6 +777,9 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
draw_input(f, state, vchunks[2]);
draw_footer(f, state, vchunks[3]);
// Overlays render last — on top of everything.
draw_overlay(f, state, area, vchunks[2]);
}
fn draw_header(f: &mut Frame, state: &ChatState, area: Rect) {
@ -774,33 +1042,26 @@ fn wrap_words(text: &str, width: usize) -> Vec<String> {
out
}
fn count_visual_lines(text: &str, wrap_width: usize) -> usize {
if text.is_empty() {
return 1;
}
let w = wrap_width.max(1);
let mut count = 0;
for line in text.split('\n') {
let chars = line.chars().count();
if chars == 0 {
count += 1;
} else {
count += (chars + w - 1) / w;
}
}
count.max(1)
}
fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
let line = if state.busy {
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
let elapsed = state
.turn_started
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
Line::from(vec![
Span::styled(format!(" {} ", spinner), Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(
format!("thinking… {}s", elapsed),
Style::default().fg(ANI_DIM).add_modifier(Modifier::ITALIC),
),
])
} else {
// Cursor blinks at ~2Hz with the tick (assuming 100ms tick rate).
let cursor_visible = (state.tick / 5) % 2 == 0;
let cursor = if cursor_visible { "" } else { " " };
Line::from(vec![
Span::styled(" ", Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(state.input.clone(), Style::default().fg(Color::White)),
Span::styled(cursor, Style::default().fg(ANI_ORANGE)),
])
};
let border_color = if state.busy {
let phase = (state.tick as f32 / 8.0).sin().abs();
// Breathing dim → orange while thinking.
let r = (180.0 + (255.0 - 180.0) * phase) as u8;
let g = (120.0 + (140.0 - 120.0) * phase) as u8;
let b = (80.0 + (66.0 - 80.0) * phase) as u8;
@ -812,11 +1073,163 @@ fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color));
f.render_widget(Paragraph::new(line).block(block), area);
if state.busy {
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
let elapsed = state
.turn_started
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let line = Line::from(vec![
Span::styled(format!(" {} ", spinner), Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(
format!("thinking… {}s", elapsed),
Style::default().fg(ANI_DIM).add_modifier(Modifier::ITALIC),
),
]);
f.render_widget(Paragraph::new(line).block(block), area);
return;
}
let cursor_visible = (state.tick / 5) % 2 == 0;
let cursor_ch: &str = if cursor_visible { "" } else { " " };
let inner_width = (area.width as usize).saturating_sub(5).max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
let logical: Vec<&str> = state.input.split('\n').collect();
for (li, logical_line) in logical.iter().enumerate() {
let wrapped = wrap_words(logical_line, inner_width);
for (wi, chunk) in wrapped.iter().enumerate() {
let prefix: Span<'static> = if li == 0 && wi == 0 {
Span::styled(" ", Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD))
} else {
Span::raw(" ")
};
let is_last = li == logical.len() - 1 && wi == wrapped.len() - 1;
let mut spans = vec![prefix, Span::styled(chunk.clone(), Style::default().fg(Color::White))];
if is_last {
spans.push(Span::styled(cursor_ch.to_string(), Style::default().fg(ANI_ORANGE)));
}
lines.push(Line::from(spans));
}
}
if lines.is_empty() {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(cursor_ch.to_string(), Style::default().fg(ANI_ORANGE)),
]));
}
let visible_height = area.height.saturating_sub(2) as usize;
let scroll = if lines.len() > visible_height {
(lines.len() - visible_height) as u16
} else {
0
};
let para = Paragraph::new(lines).scroll((scroll, 0)).block(block);
f.render_widget(para, area);
}
const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
fn draw_overlay(f: &mut Frame, state: &ChatState, full_area: Rect, input_area: Rect) {
match &state.overlay {
Overlay::None => {}
Overlay::SlashComplete { selected, matches } => {
let count = matches.len().min(8);
let height = count as u16 + 2; // +2 for border
let width = 40u16.min(full_area.width.saturating_sub(4));
let x = input_area.x + 1;
let y = input_area.y.saturating_sub(height);
let area = Rect { x, y, width, height };
f.render_widget(Clear, area);
let items: Vec<Line<'static>> = matches.iter().enumerate().take(count).map(|(i, cmd)| {
let sel = i == *selected;
let style = if sel {
Style::default().fg(Color::Rgb(255, 200, 100)).bg(Color::Rgb(60, 40, 20)).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let hint_style = if sel {
Style::default().fg(Color::Rgb(180, 150, 80)).bg(Color::Rgb(60, 40, 20))
} else {
Style::default().fg(STATUS_GRAY)
};
Line::from(vec![
Span::styled(format!(" {} ", cmd.name), style),
Span::styled(format!(" {}", cmd.hint), hint_style),
])
}).collect();
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ANI_DIM));
let para = Paragraph::new(items).block(block);
f.render_widget(para, area);
}
Overlay::ConversationPicker { selected, conversations } => {
let count = conversations.len();
let visible = count.min(12);
let height = visible as u16 + 4; // border + header + footer
let width = (full_area.width * 3 / 4).max(40).min(full_area.width.saturating_sub(4));
let x = (full_area.width.saturating_sub(width)) / 2;
let y = (full_area.height.saturating_sub(height)) / 2;
let area = Rect { x, y, width, height };
f.render_widget(Clear, area);
let inner_width = (width as usize).saturating_sub(4);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
" Conversations — ↑↓ select · Enter switch · Esc cancel",
Style::default().fg(STATUS_GRAY).add_modifier(Modifier::ITALIC),
)));
let scroll_offset = if *selected >= visible { selected + 1 - visible } else { 0 };
for (i, conv) in conversations.iter().enumerate().skip(scroll_offset).take(visible) {
let sel = i == *selected;
let short_id = &conv.id[..8.min(conv.id.len())];
let summary = conv.summary.as_deref().unwrap_or("(no summary)");
let label = format!(
" {} · {} msgs · {}",
short_id, conv.message_count, summary,
);
let truncated = if label.chars().count() > inner_width {
let mut s: String = label.chars().take(inner_width.saturating_sub(1)).collect();
s.push('…');
s
} else {
label
};
let style = if sel {
Style::default().fg(Color::Rgb(255, 200, 100)).bg(Color::Rgb(60, 40, 20)).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(truncated, style)));
}
let block = Block::default()
.title(Span::styled(
" Resume ",
Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ANI_ORANGE));
let para = Paragraph::new(lines).block(block);
f.render_widget(para, area);
}
}
}
fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
let panes = Layout::default()
.direction(Direction::Vertical)
@ -870,7 +1283,7 @@ fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
let footer = Line::from(vec![
Span::styled(
format!(" Esc menu · Enter send · ↑↓ scroll · !cmd bash · {} ", cockpit_hint),
format!(" Esc menu · Enter send · S-Ret ↵ · ↑↓ scroll · {} ", cockpit_hint),
Style::default().fg(STATUS_GRAY),
),
Span::raw(""),

109
src/ui/color_support.rs Normal file
View file

@ -0,0 +1,109 @@
use ratatui::style::Color;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorCapability {
TrueColor,
Color256,
}
static CAPABILITY: OnceLock<ColorCapability> = OnceLock::new();
fn color_capability() -> ColorCapability {
*CAPABILITY.get_or_init(|| {
if let Ok(val) = std::env::var("COLORTERM") {
let v = val.to_lowercase();
if v == "truecolor" || v == "24bit" {
return ColorCapability::TrueColor;
}
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
let tp = term_program.to_lowercase();
if matches!(tp.as_str(), "ghostty" | "iterm.app" | "wezterm" | "warp" | "alacritty" | "hyper") {
return ColorCapability::TrueColor;
}
}
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
|| std::env::var("WEZTERM_EXECUTABLE").is_ok()
|| std::env::var("WEZTERM_PANE").is_ok()
{
return ColorCapability::TrueColor;
}
if let Ok(term) = std::env::var("TERM") {
let t = term.to_lowercase();
if t.contains("kitty") || t.contains("ghostty") || t.contains("alacritty") {
return ColorCapability::TrueColor;
}
if t.contains("256color") {
return ColorCapability::Color256;
}
}
ColorCapability::Color256
})
}
pub fn rgb(r: u8, g: u8, b: u8) -> Color {
if color_capability() == ColorCapability::TrueColor {
Color::Rgb(r, g, b)
} else {
Color::Indexed(rgb_to_xterm256(r, g, b))
}
}
const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
fn nearest_cube_component(v: u8) -> u8 {
let mut best = 0u8;
let mut best_dist = 255u16;
for (i, &cv) in CUBE_VALUES.iter().enumerate() {
let d = (v as i16 - cv as i16).unsigned_abs();
if d < best_dist {
best_dist = d;
best = i as u8;
}
}
best
}
fn nearest_cube_index(r: u8, g: u8, b: u8) -> u16 {
let ri = nearest_cube_component(r) as u16;
let gi = nearest_cube_component(g) as u16;
let bi = nearest_cube_component(b) as u16;
ri * 36 + gi * 6 + bi
}
fn cube_index_to_rgb(idx: u16) -> (u8, u8, u8) {
let bi = (idx % 6) as usize;
let gi = ((idx / 6) % 6) as usize;
let ri = (idx / 36) as usize;
(CUBE_VALUES[ri], CUBE_VALUES[gi], CUBE_VALUES[bi])
}
fn color_distance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
let dr = r1 as i32 - r2 as i32;
let dg = g1 as i32 - g2 as i32;
let db = b1 as i32 - b2 as i32;
(2 * dr * dr + 4 * dg * dg + 3 * db * db) as u32
}
fn rgb_to_xterm256(r: u8, g: u8, b: u8) -> u8 {
let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
let is_grayish = (r as i16 - g as i16).unsigned_abs() < 15
&& (g as i16 - b as i16).unsigned_abs() < 15
&& (r as i16 - b as i16).unsigned_abs() < 15;
let cube_idx = nearest_cube_index(r, g, b);
let cube_color = cube_index_to_rgb(cube_idx);
let cube_dist = color_distance(r, g, b, cube_color.0, cube_color.1, cube_color.2);
if is_grayish {
let gray_val = if r < 4 { 0u8 } else if r > 243 { 23u8 } else { ((r as u16 - 8 + 5) / 10).min(23) as u8 };
let gray_rgb = 8 + gray_val * 10;
let gray_dist = color_distance(r, g, b, gray_rgb, gray_rgb, gray_rgb);
if gray_dist < cube_dist {
return 232 + gray_val;
}
}
cube_idx as u8 + 16
}

View file

@ -4,6 +4,7 @@ pub mod buddy;
pub mod buddy_panel;
pub mod chat;
pub mod cockpit_panel;
pub mod color_support;
pub mod component;
pub mod markdown;