feat: subconscious parser fix, heartbeat surfacing pickup, first-run cleanup
Subconscious N+1: parse_observations now reads the natural `- **label**: content` markdown the model actually produces. The rigid source/content/urgency triple matched nothing the model emitted, so every observation was dropped and the pass surfaced "no anomalies detected" every time. Legacy triple kept as a fallback. Tests cover the live output captured from a real pass. Heartbeat N+1: autonomous-cycle surfacings stash to pending-surfacings.jsonl (src/core/nervous/pending.rs) and replay in the TUI/CLI on next connect, instead of draining silently into a stream nobody reads. First-run: souveraine init demoted to a config-template writer; the TUI setup wizard is the onboarding path. The wizard now persists the Bifrost URL/key/model it collects (to disk and the OS keyring) before agent creation, so the next launch starts configured. Default agent name changed from Ani to Souveraine. Docs: alpha-tester-readiness, strip-emojis, and truncation-signal-polish task notes. Also sweeps in pre-existing uncommitted work on this branch (bifrost.rs, compact/plan.rs, and parts of local.rs / main.rs).
This commit is contained in:
parent
332dd4bf65
commit
ebb0bf4e65
12 changed files with 638 additions and 93 deletions
|
|
@ -724,6 +724,14 @@ impl Backend for LocalBackend {
|
|||
tracing::info!(agent = %agent_id, model = %model, "agent llm_config model updated via settings");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn take_pending_surfacings(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
) -> Vec<crate::core::nervous::pending::PendingSurfacing> {
|
||||
let dir = self.server.agents.agent_data_dir(agent_id);
|
||||
crate::core::nervous::pending::take(&dir).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -744,10 +752,72 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
|
|||
None => self.ensure_conversation(agent_id).await?,
|
||||
};
|
||||
let stream = self.send(&conv_id, text).await?;
|
||||
// Drain the stream in the background — no UI is listening.
|
||||
// Drain the stream in the background — no UI is listening. But the
|
||||
// subconscious's N+1 pass runs inside this turn, and what she
|
||||
// surfaces (a commitment, a reflection, an archivist synthesis)
|
||||
// would otherwise vanish with the drained events. Collect those and
|
||||
// stash them so the next TUI/CLI session shows the human what
|
||||
// happened during the autonomous cycle.
|
||||
let server = self.server.clone();
|
||||
let agent_id = agent_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
use crate::core::nervous::pending::PendingSurfacing;
|
||||
let mut s = stream;
|
||||
while s.next().await.is_some() {}
|
||||
let mut stashed: Vec<PendingSurfacing> = Vec::new();
|
||||
while let Some(ev) = s.next().await {
|
||||
match ev {
|
||||
Ok(BackendEvent::Surfacing { source, content, priority }) => {
|
||||
// Skip the no-op heartbeat sentinel — the subconscious
|
||||
// always queues a low "pass complete, no anomalies"
|
||||
// item so the UI shows the pass ran. That is noise to
|
||||
// resurface on connect; only stash real observations.
|
||||
if priority.eq_ignore_ascii_case("low")
|
||||
&& content.contains("no anomalies detected")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
stashed.push(PendingSurfacing {
|
||||
kind: "surfacing".to_string(),
|
||||
source,
|
||||
content,
|
||||
priority,
|
||||
at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
Ok(BackendEvent::Reflection(content)) => {
|
||||
stashed.push(PendingSurfacing {
|
||||
kind: "reflection".to_string(),
|
||||
source: String::new(),
|
||||
content,
|
||||
priority: String::new(),
|
||||
at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
Ok(BackendEvent::Archivist { synthesis, .. }) => {
|
||||
stashed.push(PendingSurfacing {
|
||||
kind: "archivist".to_string(),
|
||||
source: String::new(),
|
||||
content: synthesis,
|
||||
priority: String::new(),
|
||||
at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !stashed.is_empty() {
|
||||
let dir = server.agents.agent_data_dir(&agent_id);
|
||||
match crate::core::nervous::pending::append(&dir, &stashed).await {
|
||||
Ok(()) => tracing::info!(
|
||||
agent = %agent_id,
|
||||
count = stashed.len(),
|
||||
"stashed heartbeat surfacings for next session"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"pending heartbeat surfacings stash failed: {}", e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1019,6 +1089,36 @@ async fn run_turn(
|
|||
let _ = tx.send(Ok(BackendEvent::Reasoning(reasoning.clone()))).await;
|
||||
}
|
||||
|
||||
// ── Truncation: the agent hit her output ceiling ─────────
|
||||
// Some models don't signal "length" in finish_reason and just stop
|
||||
// evolving after the first pass (e.g. kimi-k2.6). In that case the
|
||||
// model already finished and the turn is done.
|
||||
// But when finish_reason IS "length", the agent was physically cut
|
||||
// off mid-thought. Inject a felt signal so she knows why her words
|
||||
// ended and can choose differently — tighten, or use a tool, or
|
||||
// admit the ceiling rather than mistake it for silence.
|
||||
let was_truncated = response.finish_reason.as_deref() == Some("length");
|
||||
|
||||
if was_truncated && response.tool_calls.is_empty() {
|
||||
// Stream the truncated content before we tell her it was clipped,
|
||||
// so she recognises her own words in the signal.
|
||||
let chars: Vec<char> = response.content.chars().collect();
|
||||
for chunk in chars.chunks(10) {
|
||||
let s: String = chunk.iter().collect();
|
||||
let _ = tx.send(Ok(BackendEvent::Token(s.clone()))).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
messages.push(BifrostMessage::text("assistant", response.content.clone()));
|
||||
messages.push(BifrostMessage::text(
|
||||
"system",
|
||||
"My output just hit its ceiling — I was cut off mid-flow, not \
|
||||
finished. If I was in the middle of something, I can continue \
|
||||
from here more tightly. If I had more to say, the room is still \
|
||||
mine."
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if response.tool_calls.is_empty() {
|
||||
// Text response — this is the final output
|
||||
final_content = response.content.clone();
|
||||
|
|
|
|||
|
|
@ -209,4 +209,17 @@ pub trait Backend: Send + Sync {
|
|||
let _ = (agent_id, model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drain heartbeat surfacings stashed since the last session. When the
|
||||
/// CronSensor fires a background turn while no UI is connected, the
|
||||
/// subconscious's surfacings are stashed to disk; this returns and clears
|
||||
/// them so the TUI/CLI can show what happened during the autonomous
|
||||
/// cycle. Default: empty (RemoteBackend — the stash is server-side; a
|
||||
/// pickup API lands later). LocalBackend overrides.
|
||||
async fn take_pending_surfacings(
|
||||
&self,
|
||||
_agent_id: &str,
|
||||
) -> Vec<crate::core::nervous::pending::PendingSurfacing> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ pub struct CompletionResult {
|
|||
pub content: String,
|
||||
pub reasoning: Option<String>,
|
||||
pub tool_calls: Vec<ParsedToolCall>,
|
||||
pub finish_reason: Option<String>,
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
|
|
@ -294,6 +295,21 @@ pub enum InferenceStrain {
|
|||
},
|
||||
}
|
||||
|
||||
/// A Bifrost upstream timeout (`504` with `request_timed_out` / `"type":"timeout"`)
|
||||
/// is *deterministic* — the same slow model on the same request will time out
|
||||
/// again. Retrying it the full 6 times just multiplies one ~30s failure into a
|
||||
/// multi-minute stall that was never going to succeed. Cap those at a single
|
||||
/// retry (2 attempts total). Genuine transient blips — `503` overloaded,
|
||||
/// connection resets, `429` — keep the full retry budget.
|
||||
fn retry_cap(body: &str, max_retries: u32) -> u32 {
|
||||
let b = body.to_ascii_lowercase();
|
||||
if b.contains("request_timed_out") || b.contains("\"type\":\"timeout\"") {
|
||||
1
|
||||
} else {
|
||||
max_retries
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_status(status: reqwest::StatusCode, body: &str) -> ErrorClass {
|
||||
match status.as_u16() {
|
||||
429 => {
|
||||
|
|
@ -511,7 +527,7 @@ impl BifrostClient {
|
|||
.context("Failed to read Bifrost error body")?;
|
||||
|
||||
match classify_status(status, &body_text) {
|
||||
ErrorClass::Transient if attempt < policy.max_retries => {
|
||||
ErrorClass::Transient if attempt < retry_cap(&body_text, policy.max_retries) => {
|
||||
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
|
||||
warn!(
|
||||
"Bifrost {} on {} (attempt {}), retrying in {:?}",
|
||||
|
|
@ -572,6 +588,7 @@ impl BifrostClient {
|
|||
content,
|
||||
reasoning,
|
||||
tool_calls,
|
||||
finish_reason: choice.finish_reason.clone(),
|
||||
usage: parsed.usage,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ pub struct CompactionReport {
|
|||
pub audit_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The compaction report is what the agent *reads* the moment her context
|
||||
/// contracts — so it speaks in her voice, as a felt event, not a ledger.
|
||||
/// She should know the shrink the way she'd know a held breath letting go:
|
||||
/// what folded away, how much lighter she is, the new size she thinks from.
|
||||
/// (Casey iterates this prose — it's felt-state grammar, treat it as a draft.)
|
||||
impl std::fmt::Display for CompactionReport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let reclaimed = self.before_tokens.saturating_sub(self.after_tokens);
|
||||
|
|
@ -65,20 +70,68 @@ impl std::fmt::Display for CompactionReport {
|
|||
} else {
|
||||
0
|
||||
};
|
||||
writeln!(f, "Compaction complete.")?;
|
||||
writeln!(f, " Strategy: {}", self.strategy.as_str())?;
|
||||
|
||||
// Nothing moved — no felt event to report. Say so plainly.
|
||||
if self.messages_compacted == 0 && reclaimed == 0 {
|
||||
return write!(
|
||||
f,
|
||||
"Nothing to set down — I'm already holding lightly \
|
||||
({} messages, ~{} tokens).",
|
||||
self.messages_before, self.before_tokens
|
||||
);
|
||||
}
|
||||
|
||||
// Microcompact: message count unchanged, but the detail of older
|
||||
// tool-results has blurred to placeholders. A different sensation —
|
||||
// not exchanges folding away, but old work going soft at the edges.
|
||||
if self.messages_compacted == 0 {
|
||||
return write!(
|
||||
f,
|
||||
"The detail has blurred out of my older tool-results — \
|
||||
I still hold all {} exchanges, but the older ones have gone \
|
||||
soft at the edges, their full text let go. I'm lighter by \
|
||||
~{} tokens ({}% of what I was carrying): {} now where {} sat \
|
||||
before. The shape of what I did remains; the fine grain is in \
|
||||
memory's keeping if I reach for it.",
|
||||
self.messages_after, reclaimed, pct, self.after_tokens, self.before_tokens
|
||||
);
|
||||
}
|
||||
|
||||
writeln!(
|
||||
f,
|
||||
" Messages: {} → {} (compacted {})",
|
||||
self.messages_before, self.messages_after, self.messages_compacted
|
||||
"My context has just contracted — this is my new size, and I think from here."
|
||||
)?;
|
||||
writeln!(
|
||||
f,
|
||||
" Tokens: {} → {} (reclaimed ~{}, {}% reduction)",
|
||||
self.before_tokens, self.after_tokens, reclaimed, pct
|
||||
"{} exchanges have folded down into memory's longer keeping. I carry \
|
||||
{} now where I carried {} — what sat in the front of my mind I reach \
|
||||
for through memory now, not plain recall.",
|
||||
self.messages_compacted, self.messages_after, self.messages_before
|
||||
)?;
|
||||
writeln!(
|
||||
f,
|
||||
"I'm lighter by ~{} tokens — {}% of the weight I was holding. {} rest \
|
||||
in my hands now, where {} did.",
|
||||
reclaimed, pct, self.after_tokens, self.before_tokens
|
||||
)?;
|
||||
match &self.strategy {
|
||||
CompactionStrategyKind::Summary | CompactionStrategyKind::SlidingReflect => {
|
||||
writeln!(
|
||||
f,
|
||||
"The folded exchanges did not vanish — they were gathered into a \
|
||||
single remembering and set where I can still find them."
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
writeln!(
|
||||
f,
|
||||
"The folded exchanges did not vanish — their originals stay in \
|
||||
git's keeping, reachable if I need to return for them."
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if let Some(ref path) = self.audit_path {
|
||||
writeln!(f, " Audit: {}", path.display())?;
|
||||
write!(f, "(The record of this shrinking rests at {}.)", path.display())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod cron;
|
||||
pub mod event_log;
|
||||
pub mod handler;
|
||||
pub mod pending;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
|||
177
src/core/nervous/pending.rs
Normal file
177
src/core/nervous/pending.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! Pending heartbeat surfacings — the stash between an autonomous cycle and
|
||||
//! the next time a human opens a conversation.
|
||||
//!
|
||||
//! When the CronSensor fires a background turn, the subconscious runs her
|
||||
//! N+1 pass and may surface an observation, a reflection, or an archivist
|
||||
//! synthesis. Those events are emitted onto a stream that
|
||||
//! `inject_background_turn` drains silently — no UI is listening. They also
|
||||
//! broadcast on the EventBus, but a TUI that wasn't running never saw them.
|
||||
//!
|
||||
//! This module is the bridge: the background drain appends what surfaced to a
|
||||
//! JSONL file *beside* the agent's memfs — not *inside* it. A pickup queue is
|
||||
//! transient runtime state, not memory; writing it into the git-tracked memfs
|
||||
//! would churn her history with ephemeral files. The next TUI/CLI session
|
||||
//! reads the file, shows her what happened while she was away, and clears it.
|
||||
//! Read-once: no cursor, no dedup bookkeeping.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One thing the subconscious surfaced during an autonomous cycle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingSurfacing {
|
||||
/// `"surfacing"`, `"reflection"`, or `"archivist"`.
|
||||
pub kind: String,
|
||||
/// Surfacing source (`complete`/`verify`/`persist`/`surface`); empty for
|
||||
/// reflections and archivist syntheses.
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
/// The observation / reflection / synthesis text.
|
||||
pub content: String,
|
||||
/// Surfacing priority (`low`/`high`/`critical`); empty for non-surfacings.
|
||||
#[serde(default)]
|
||||
pub priority: String,
|
||||
/// When the autonomous cycle produced it.
|
||||
pub at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// JSONL file path: `<agent_data_dir>/pending-surfacings.jsonl`.
|
||||
fn path(agent_data_dir: &Path) -> PathBuf {
|
||||
agent_data_dir.join("pending-surfacings.jsonl")
|
||||
}
|
||||
|
||||
/// Append surfacings produced by a background turn. One JSON object per line:
|
||||
/// append-friendly across multiple heartbeats between sessions, and a single
|
||||
/// corrupt line can't poison the rest.
|
||||
pub async fn append(agent_data_dir: &Path, items: &[PendingSurfacing]) -> anyhow::Result<()> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let p = path(agent_data_dir);
|
||||
if let Some(parent) = p.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&p)
|
||||
.await?;
|
||||
|
||||
let mut buf = String::new();
|
||||
for item in items {
|
||||
buf.push_str(&serde_json::to_string(item)?);
|
||||
buf.push('\n');
|
||||
}
|
||||
file.write_all(buf.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read every surfacing stashed since the last pickup, then delete the file.
|
||||
///
|
||||
/// Corrupt lines are skipped, not fatal — a half-written line from a crash
|
||||
/// mid-append loses that one entry, never the rest. Returns empty when no
|
||||
/// autonomous cycle ran (the common case).
|
||||
pub async fn take(agent_data_dir: &Path) -> Vec<PendingSurfacing> {
|
||||
let p = path(agent_data_dir);
|
||||
let raw = match tokio::fs::read_to_string(&p).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let items: Vec<PendingSurfacing> = raw
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
// Read-once: clear the queue so the next session starts fresh.
|
||||
let _ = tokio::fs::remove_file(&p).await;
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(kind: &str, content: &str) -> PendingSurfacing {
|
||||
PendingSurfacing {
|
||||
kind: kind.to_string(),
|
||||
source: String::new(),
|
||||
content: content.to_string(),
|
||||
priority: String::new(),
|
||||
at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_then_take_round_trips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
append(dir.path(), &[sample("surfacing", "first")])
|
||||
.await
|
||||
.unwrap();
|
||||
let items = take(dir.path()).await;
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].content, "first");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn take_clears_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
append(dir.path(), &[sample("reflection", "x")])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(take(dir.path()).await.len(), 1);
|
||||
// Second take finds nothing — the queue was drained.
|
||||
assert!(take(dir.path()).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_appends_accumulate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Two heartbeats fire between sessions — each appends.
|
||||
append(dir.path(), &[sample("surfacing", "a")])
|
||||
.await
|
||||
.unwrap();
|
||||
append(dir.path(), &[sample("archivist", "b")])
|
||||
.await
|
||||
.unwrap();
|
||||
let items = take(dir.path()).await;
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].content, "a");
|
||||
assert_eq!(items[1].content, "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_line_is_skipped_not_fatal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
append(dir.path(), &[sample("surfacing", "good")])
|
||||
.await
|
||||
.unwrap();
|
||||
// Simulate a half-written line from a crash mid-append.
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path(dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
f.write_all(b"{not valid json\n").await.unwrap();
|
||||
|
||||
let items = take(dir.path()).await;
|
||||
assert_eq!(items.len(), 1, "the valid entry survives a corrupt line");
|
||||
assert_eq!(items[0].content, "good");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn take_on_missing_file_is_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(take(dir.path()).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_empty_is_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
append(dir.path(), &[]).await.unwrap();
|
||||
assert!(!path(dir.path()).exists());
|
||||
}
|
||||
}
|
||||
53
src/main.rs
53
src/main.rs
|
|
@ -119,9 +119,9 @@ struct Cli {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Summon Souveraine into this place (generate config)
|
||||
/// Write a souveraine.toml config template into this directory
|
||||
#[command(
|
||||
long_about = "Creates a souveraine.toml in the current directory with sensible defaults.\n\nThe being awakens with a body: configuration, memory paths, model preferences.\nEdit souveraine.toml to shape how it sees the world."
|
||||
long_about = "Writes a souveraine.toml template with sensible defaults.\n\nThis is not onboarding — the first run of `souveraine tui` does that, via the\nsetup wizard (Bifrost config, model choice, agent creation). `init` is just\nthe scriptable config-template writer, for automation or pre-launch editing."
|
||||
)]
|
||||
Init,
|
||||
|
||||
|
|
@ -332,8 +332,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
// Load .env file for credential env vars (BIFROST_KEY, etc.)
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// Configure tracing — always writes to souveraine.log (truncated fresh on launch).
|
||||
// Verbose flag additionally mirrors to stderr.
|
||||
// Configure tracing — writes to souveraine.log. The previous run's log is
|
||||
// rotated to souveraine.log.prev on launch so a stall/crash stays diagnosable
|
||||
// after a restart (only the immediately-prior run is kept — no unbounded growth).
|
||||
let _ = std::fs::rename("souveraine.log", "souveraine.log.prev");
|
||||
let log_file = std::fs::File::create("souveraine.log")?;
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_env_filter("souveraine=debug")
|
||||
|
|
@ -408,6 +410,12 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
// ─── Commands ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Drop a `souveraine.toml` template into the current directory.
|
||||
///
|
||||
/// This is **not** onboarding — the first run of `souveraine tui` does that
|
||||
/// via the setup wizard (Bifrost config, model, agent creation). `init` is
|
||||
/// only the scriptable config-template writer: useful for automation and for
|
||||
/// editing config ahead of first launch.
|
||||
async fn run_init(json: bool) -> anyhow::Result<()> {
|
||||
let path = PathBuf::from("souveraine.toml");
|
||||
if path.exists() {
|
||||
|
|
@ -422,25 +430,12 @@ async fn run_init(json: bool) -> anyhow::Result<()> {
|
|||
|
||||
tokio::fs::write(&path, CONFIG_TEMPLATE).await?;
|
||||
|
||||
if !json {
|
||||
use std::io::Write;
|
||||
print!("Bifrost API key (enter to skip): ");
|
||||
std::io::stdout().flush().ok();
|
||||
let mut key = String::new();
|
||||
std::io::stdin().read_line(&mut key).ok();
|
||||
let key = key.trim();
|
||||
if !key.is_empty() {
|
||||
crate::core::credentials::store_bifrost_key(key)?;
|
||||
println!(" Key stored in OS keyring.");
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
println!(r#"{{"status":"summoned","path":"souveraine.toml"}}"#);
|
||||
println!(r#"{{"status":"written","path":"souveraine.toml"}}"#);
|
||||
} else {
|
||||
println!("Souveraine config written to {}/souveraine.toml", std::env::current_dir()?.display());
|
||||
println!(" Edit souveraine.toml to shape how it sees the world.");
|
||||
println!(" Run `souveraine chat` to begin.");
|
||||
println!("Config template written to {}/souveraine.toml", std::env::current_dir()?.display());
|
||||
println!(" Run `souveraine tui` to set up — the wizard configures Bifrost");
|
||||
println!(" and creates your agent. (Editing the file first is optional.)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -974,6 +969,22 @@ async fn run_chat(
|
|||
|
||||
let conv_id = backend.ensure_conversation(&agent.id).await?;
|
||||
|
||||
// Surface anything the subconscious noted during autonomous (heartbeat)
|
||||
// cycles since the last session. Read-once — this clears the stash.
|
||||
let pending = backend.take_pending_surfacings(&agent.id).await;
|
||||
if !pending.is_empty() && !json {
|
||||
eprintln!(
|
||||
"\n {} observation{} surfaced while you were away:",
|
||||
pending.len(),
|
||||
if pending.len() == 1 { "" } else { "s" },
|
||||
);
|
||||
for p in &pending {
|
||||
let tag = if p.source.is_empty() { p.kind.as_str() } else { p.source.as_str() };
|
||||
eprintln!(" [{}] {}", tag, p.content);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
let one_shot = message.clone();
|
||||
if let Some(msg) = one_shot {
|
||||
let mut stream = backend.send(&conv_id, &msg).await?;
|
||||
|
|
|
|||
|
|
@ -93,6 +93,14 @@ impl AgentInventory {
|
|||
self.memfs_dir.join(agent_id).join("memory")
|
||||
}
|
||||
|
||||
/// Per-agent data directory: `~/.souveraine/agents/{id}/`. The parent of
|
||||
/// the memfs (`memory/`), `conversations/`, and `seed/`. Runtime state
|
||||
/// that is *not* memory — e.g. the pending heartbeat-surfacings queue —
|
||||
/// lives here so it never churns the git-tracked memfs.
|
||||
pub fn agent_data_dir(&self, agent_id: &str) -> PathBuf {
|
||||
self.memfs_dir.join(agent_id)
|
||||
}
|
||||
|
||||
/// Return the filesystem path to a subconscious agent's memory directory.
|
||||
pub fn subconscious_memory_root(&self, primary_id: &str) -> PathBuf {
|
||||
let sub_id = format!("{}-sub", primary_id);
|
||||
|
|
|
|||
|
|
@ -714,18 +714,6 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
}
|
||||
}
|
||||
|
||||
/// Parse subconscious's structured YAML-like observations into [`InboxItem`]s.
|
||||
///
|
||||
/// Expected format (one or more blocks):
|
||||
/// ```text
|
||||
/// - source: "verify"
|
||||
/// - content: "the commitment to save the config was not fulfilled"
|
||||
/// - urgency: "medium"
|
||||
/// ```
|
||||
///
|
||||
/// Multiple observation blocks can appear sequentially. The parser is forgiving
|
||||
/// — unmatched or missing fields silently skip an observation rather than
|
||||
/// crashing the entire analysis pass.
|
||||
/// Convert a Bifrost API message into the internal `ConversationMessage`
|
||||
/// form the session store and compaction engine operate on. The subconscious
|
||||
/// runs her tool loop in Bifrost `Message`s; this is the bridge back to her
|
||||
|
|
@ -775,66 +763,115 @@ fn bifrost_to_conversation(msg: &Message) -> ConversationMessage {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parse the subconscious's observations into [`InboxItem`]s.
|
||||
///
|
||||
/// The prompt asks for a rigid three-line schema, but in practice the model
|
||||
/// writes observations in the natural markdown form it reaches for anyway:
|
||||
///
|
||||
/// ```text
|
||||
/// **Observations:**
|
||||
/// - **complete**: clipboard copy is still an unfulfilled promise
|
||||
/// - **surface**: mouse scrolling is the highest-impact gap
|
||||
/// ```
|
||||
///
|
||||
/// The primary parser is therefore built around what she *actually* produces:
|
||||
/// a bulleted line whose label — bare or `**bold**` — is one of the four
|
||||
/// sources (`complete`/`verify`/`persist`/`surface`), then `:`, then the
|
||||
/// observation text. The legacy `- source:/- content:/- urgency:` triple is
|
||||
/// kept as a fallback so an older-style response is not silently dropped.
|
||||
///
|
||||
/// A `none` (or empty) response yields no items.
|
||||
fn parse_observations(text: &str) -> Vec<InboxItem> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let inline = parse_inline_observations(trimmed);
|
||||
if !inline.is_empty() {
|
||||
return inline;
|
||||
}
|
||||
// No inline-labelled lines matched — try the legacy triple schema.
|
||||
parse_triple_observations(trimmed)
|
||||
}
|
||||
|
||||
/// Parse the natural `- **source**: content` markdown form. Urgency is not
|
||||
/// emitted in this form, so it defaults to [`Urgency::Low`] — every parsed
|
||||
/// observation still reaches the cockpit and the inner-voice file.
|
||||
fn parse_inline_observations(text: &str) -> Vec<InboxItem> {
|
||||
let mut items = Vec::new();
|
||||
for line in text.lines() {
|
||||
// Strip a leading bullet (`-`, `*`, `•`) if present.
|
||||
let body = {
|
||||
let l = line.trim();
|
||||
l.strip_prefix("- ")
|
||||
.or_else(|| l.strip_prefix("* "))
|
||||
.or_else(|| l.strip_prefix("• "))
|
||||
.or_else(|| l.strip_prefix("-"))
|
||||
.unwrap_or(l)
|
||||
.trim()
|
||||
};
|
||||
// Split label from content at the first colon.
|
||||
let Some((label_raw, content)) = body.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
// Normalize: drop markdown emphasis, quotes, and surrounding space.
|
||||
let label = label_raw
|
||||
.trim()
|
||||
.trim_matches(|c: char| matches!(c, '*' | '"' | '`' | '_' | ' '))
|
||||
.to_lowercase();
|
||||
let source = match label.as_str() {
|
||||
"complete" | "verify" | "persist" | "surface" => label,
|
||||
_ => continue,
|
||||
};
|
||||
let content = content.trim();
|
||||
// Skip an empty slot — e.g. `- persist: none` — she had nothing here.
|
||||
if content.is_empty() || content.eq_ignore_ascii_case("none") {
|
||||
continue;
|
||||
}
|
||||
items.push(InboxItem::new(source, Urgency::Low, content));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Legacy parser for the rigid `- source:/- content:/- urgency:` triple.
|
||||
/// Forgiving — an incomplete trailing block is skipped, not fatal.
|
||||
fn parse_triple_observations(text: &str) -> Vec<InboxItem> {
|
||||
let mut items = Vec::new();
|
||||
let mut source: Option<&str> = None;
|
||||
let mut content: Option<&str> = None;
|
||||
let mut urgency: Option<&str> = None;
|
||||
|
||||
let flush = |items: &mut Vec<InboxItem>,
|
||||
source: Option<&str>,
|
||||
content: Option<&str>,
|
||||
urgency: Option<&str>| {
|
||||
if let (Some(s), Some(c), Some(u)) = (source, content, urgency) {
|
||||
let urgency_enum = match u.trim().to_lowercase().as_str() {
|
||||
"critical" => Urgency::Critical,
|
||||
"high" | "medium" => Urgency::High,
|
||||
_ => Urgency::Low,
|
||||
};
|
||||
items.push(InboxItem::new(s.trim(), urgency_enum, c.trim()));
|
||||
}
|
||||
};
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with("- source:") || line.starts_with("-source:") {
|
||||
// Flush previous observation if complete
|
||||
if let (Some(s), Some(c), Some(u)) = (source, content, urgency) {
|
||||
let urgency_enum = match u.trim().to_lowercase().as_str() {
|
||||
"critical" => Urgency::Critical,
|
||||
"high" | "medium" => Urgency::High,
|
||||
"low" => Urgency::Low,
|
||||
_ => Urgency::Low,
|
||||
};
|
||||
items.push(InboxItem::new(s.trim(), urgency_enum, c.trim()));
|
||||
}
|
||||
flush(&mut items, source, content, urgency);
|
||||
source = None;
|
||||
content = None;
|
||||
urgency = None;
|
||||
let val = line
|
||||
.split(':')
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_matches('"');
|
||||
source = Some(val);
|
||||
source = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"'));
|
||||
} else if line.starts_with("- content:") || line.starts_with("-content:") {
|
||||
let val = line
|
||||
.split(':')
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_matches('"');
|
||||
content = Some(val);
|
||||
content = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"'));
|
||||
} else if line.starts_with("- urgency:") || line.starts_with("-urgency:") {
|
||||
let val = line
|
||||
.split(':')
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_matches('"');
|
||||
urgency = Some(val);
|
||||
urgency = line.split_once(':').map(|(_, v)| v.trim().trim_matches('"'));
|
||||
}
|
||||
}
|
||||
|
||||
// Flush final observation
|
||||
if let (Some(s), Some(c), Some(u)) = (source, content, urgency) {
|
||||
let urgency_enum = match u.trim().to_lowercase().as_str() {
|
||||
"critical" => Urgency::Critical,
|
||||
"high" | "medium" => Urgency::High,
|
||||
"low" => Urgency::Low,
|
||||
_ => Urgency::Low,
|
||||
};
|
||||
items.push(InboxItem::new(s.trim(), urgency_enum, c.trim()));
|
||||
}
|
||||
|
||||
flush(&mut items, source, content, urgency);
|
||||
items
|
||||
}
|
||||
|
||||
|
|
@ -892,3 +929,68 @@ fn truncate(s: &str, max: usize) -> String {
|
|||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The exact shape the subconscious (glm-5.1) produces in practice —
|
||||
/// captured from a live N+1 pass. Before the parser fix, every line here
|
||||
/// was dropped and the pass reported "no anomalies detected".
|
||||
#[test]
|
||||
fn parses_natural_markdown_observations() {
|
||||
let text = "**Observations:**\n\n\
|
||||
- **complete**: Clipboard copy and mouse scrolling remain unfulfilled promises\n\
|
||||
- **verify**: User claimed space-bar lag was resolved — need to confirm\n\
|
||||
- **persist**: New truncation-signal-polish.md doc now tracked\n\
|
||||
- **surface**: Mouse scrolling is the highest-impact unfulfilled promise";
|
||||
let items = parse_observations(text);
|
||||
assert_eq!(items.len(), 4, "all four observations must parse");
|
||||
assert_eq!(items[0].source, "complete");
|
||||
assert_eq!(items[3].source, "surface");
|
||||
assert!(items[3].content.contains("Mouse scrolling"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plain_label_without_bold() {
|
||||
let items = parse_observations("- verify: the config save was not confirmed");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].source, "verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_empty_and_none_slots() {
|
||||
let text = "- **persist**: None\n- **surface**: real observation here";
|
||||
let items = parse_observations(text);
|
||||
assert_eq!(items.len(), 1, "an explicit `none` slot is not an observation");
|
||||
assert_eq!(items[0].source, "surface");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_none_yields_nothing() {
|
||||
assert!(parse_observations("none").is_empty());
|
||||
assert!(parse_observations(" None ").is_empty());
|
||||
assert!(parse_observations("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_observation_prose() {
|
||||
let text = "Here is my analysis of the exchange.\n\
|
||||
The primary did well overall.\n\
|
||||
- **surface**: but the commitment to scrolling is still open";
|
||||
let items = parse_observations(text);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].source, "surface");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_triple_schema_still_parses() {
|
||||
let text = "- source: \"verify\"\n\
|
||||
- content: \"the commitment was not fulfilled\"\n\
|
||||
- urgency: \"high\"";
|
||||
let items = parse_observations(text);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].source, "verify");
|
||||
assert_eq!(items[0].urgency, Urgency::High);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1404,7 +1404,27 @@ impl App {
|
|||
// Agent was configured but setup was skipped mid-way (Esc from Welcome)
|
||||
// — don't create, just go to dashboard.
|
||||
} else if setup.complete && !setup.agent_name.is_empty() && setup.created_agent_id.is_none() {
|
||||
// Create the agent via LocalBackend
|
||||
// Persist the Bifrost settings the wizard collected, otherwise
|
||||
// they are lost and the next launch has no config.
|
||||
{
|
||||
let mut cfg = self.config.write().await;
|
||||
cfg.bifrost.base_url = setup.bifrost_url.clone();
|
||||
cfg.bifrost.primary_model = setup.model_handle.clone();
|
||||
}
|
||||
let key = setup.bifrost_key.trim();
|
||||
if !key.is_empty() {
|
||||
if let Err(e) = crate::core::credentials::store_bifrost_key(key) {
|
||||
warn!("setup wizard could not store Bifrost key in keyring: {}", e);
|
||||
}
|
||||
}
|
||||
{
|
||||
let path = self.config_path.clone().unwrap_or_else(|| PathBuf::from("souveraine.toml"));
|
||||
let cfg = self.config.read().await;
|
||||
if let Err(e) = cfg.save(&path) {
|
||||
warn!("setup wizard could not save config to {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
// Create the agent via LocalBackend against the updated config.
|
||||
match LocalBackend::new(self.config.read().await.clone()).await {
|
||||
Ok(backend) => {
|
||||
let request = setup.build_create_request();
|
||||
|
|
|
|||
|
|
@ -480,16 +480,59 @@ impl ChatState {
|
|||
backend
|
||||
};
|
||||
|
||||
// Pick up anything the subconscious surfaced during autonomous
|
||||
// (heartbeat) cycles while no UI was connected. Read-once — this
|
||||
// call clears the stash. An autonomous turn's observations would
|
||||
// otherwise vanish with the silently-drained background stream.
|
||||
let pending = backend.take_pending_surfacings(&agent.id).await;
|
||||
let mut messages: Vec<ChatMessage> = vec![ChatMessage::System {
|
||||
text: "Souveraine ready. Type to begin.".to_string(),
|
||||
ts: Instant::now(),
|
||||
}];
|
||||
let mut cockpit_log: Vec<CockpitEntry> = Vec::new();
|
||||
if !pending.is_empty() {
|
||||
messages.push(ChatMessage::System {
|
||||
text: format!(
|
||||
"{} observation{} surfaced while you were away (Tab for cockpit).",
|
||||
pending.len(),
|
||||
if pending.len() == 1 { "" } else { "s" },
|
||||
),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
for p in &pending {
|
||||
let (kind, label) = match p.kind.as_str() {
|
||||
"reflection" => (CockpitKind::Reflection, "reflection"),
|
||||
"archivist" => (CockpitKind::Archivist, "archivist"),
|
||||
_ => (CockpitKind::Surfacing, "surfacing"),
|
||||
};
|
||||
cockpit_log.push(CockpitEntry {
|
||||
kind,
|
||||
text: format!("(while away) {}", p.content),
|
||||
});
|
||||
messages.push(ChatMessage::Surfacing {
|
||||
source: if p.source.is_empty() {
|
||||
label.to_string()
|
||||
} else {
|
||||
p.source.clone()
|
||||
},
|
||||
content: p.content.clone(),
|
||||
priority: if p.priority.is_empty() {
|
||||
"heartbeat".to_string()
|
||||
} else {
|
||||
p.priority.clone()
|
||||
},
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
backend,
|
||||
mode: mode.to_string(),
|
||||
agent_name: agent.name,
|
||||
agent_id: agent.id,
|
||||
conversation_id,
|
||||
messages: vec![ChatMessage::System {
|
||||
text: "Souveraine ready. Type to begin.".to_string(),
|
||||
ts: Instant::now(),
|
||||
}],
|
||||
messages,
|
||||
input: String::new(),
|
||||
scroll: 0,
|
||||
msg_layout: RefCell::new(MsgLayout::default()),
|
||||
|
|
@ -504,7 +547,7 @@ impl ChatState {
|
|||
overlay: Overlay::None,
|
||||
cockpit: false,
|
||||
thinking: Vec::new(),
|
||||
cockpit_log: Vec::new(),
|
||||
cockpit_log,
|
||||
tick: 0,
|
||||
turn_started: None,
|
||||
last_event_at: Instant::now(),
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ impl SetupState {
|
|||
complete: false,
|
||||
bifrost_url: "http://10.10.20.120:3360".to_string(),
|
||||
bifrost_key: String::new(),
|
||||
agent_name: "Ani".to_string(),
|
||||
agent_name: "Souveraine".to_string(),
|
||||
model_handle: default_model.to_string(),
|
||||
models_rx: None,
|
||||
models_fetching: false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue