Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-18 10:54:11 -04:00
commit ebb0bf4e65
12 changed files with 638 additions and 93 deletions

View file

@ -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();

View file

@ -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()
}
}

View file

@ -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,
})
}

View file

@ -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(())
}

View file

@ -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
View 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());
}
}

View file

@ -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?;

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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(),

View file

@ -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,