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:
parent
832d80a1ff
commit
c489aa4bb3
28 changed files with 2818 additions and 399 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
5
src/core/conversation/mod.rs
Normal file
5
src/core/conversation/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod event;
|
||||
pub mod store;
|
||||
|
||||
pub use event::{ConversationEvent, EventSender};
|
||||
pub use store::{ConversationRecord, ConversationStore};
|
||||
228
src/core/conversation/store.rs
Normal file
228
src/core/conversation/store.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
292
src/core/prompt.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
335
src/ui/app.rs
335
src/ui/app.rs
|
|
@ -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));
|
||||
|
|
|
|||
475
src/ui/chat.rs
475
src/ui/chat.rs
|
|
@ -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
109
src/ui/color_support.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue