Watch
1
0
Fork
You've already forked souveraine
0

feat(federation): Phases 4-6 — reach & consult, lite listener, memory gating

Phase 4 — Reach & Consult protocol:
- agent.rs rewritten as the `reach` (self-extension) and `consult`
  (sovereign peer) tools; event-bus dispatch, peer resolution via
  known_peers.json
- summon_handler: fixed inbox path, real pending/intrusive routing,
  timeout surfacing to bus + inbox, outbound-request self-registration
- bridge always forwards control events regardless of subscriptions
- response loop closes via a file outbox: an inbound summon instructs
  the agent to write federation/outbox/{id}.md; scan_outbox turns that
  reply into a summon_response routed back to the caller

Phase 5 — Lite listener:
- `souveraine listen` — minimal federation presence with a summon-wake
  watcher; parks authorized summons to .summon-pending/
- full server drains .summon-pending/ on startup
- FederationConfig: authorized_summoners, auto_wake

Phase 6 — Memory gating (partial):
- federation posture injected into the system prompt from the
  federation/ memfs contract
- consent floor via authorized-summoners.md
- device registry: prune_stale timer, first_seen preserved

Builds clean (0 errors). Status + deferred work documented in
docs/tasks/federation-summon.md. Also sweeps in pre-existing in-flight
working-tree changes (TUI, prompt, docs).
This commit is contained in:
Fimeg 2026-05-15 14:58:47 -04:00
commit 015dad561d
21 changed files with 3028 additions and 180 deletions

View file

@ -101,6 +101,9 @@ pub enum BackendEvent {
/// Agent changed her outfit. The string is the outfit name (a subdirectory
/// under `expressions/`). Empty string clears to default expressions.
Outfit(String),
/// Text the model produced alongside tool calls — her narration between
/// gestures. Rendered in italics, quieter than a full assistant message.
Interstitial(String),
/// The backend is alive but producing no content (waiting on provider,
/// between tool rounds, processing). The TUI resets `last_event_at`
/// on this the same way it does for `Token` — it's a liveness signal.
@ -148,7 +151,7 @@ pub trait Backend: Send + Sync {
/// Send with a cancellation token. The token is a signal, not enforcement —
/// when fired, the backend lets the current tool finish, stops making new
/// LLM calls, and commits any partial assistant text with a `*[interrupted]*`
/// LLM calls, and commits any partial assistant text with a `*[raised hand]*`
/// marker so the agent reads the interrupt in her own history on the next
/// turn. Default impl ignores the token (used by RemoteBackend until SSE
/// cancellation lands); LocalBackend overrides.

422
src/core/bootstrap.rs Normal file
View file

@ -0,0 +1,422 @@
//! Bootstrap — declarative startup pipeline.
//!
//! Composes three patterns from reference projects:
//!
//! 1. **Claw-open's `BootstrapPlan`** — ordered phases, each self-contained,
//! composable, independently testable.
//! 2. **Letta-code's pure-function resolver** — zero-I/O decision tree that
//! maps a `BootstrapProbe` → `Resolution`. No side effects, no async,
//! fully testable by feeding probe fixtures.
//! 3. **J code's progressive hints** — non-blocking advisory nudges that
//! escalate with launch count. The wizard is the heavy option; hints are
//! the light touch.
//!
//! ## Startup pipeline
//!
//! ```text
//! Phase 0: Splash (bloom) ← always, non-blocking
//! Phase 1: Probe ← gather disk state → BootstrapProbe
//! Phase 2: Resolve ← pure fn: probe → Resolution
//! ├── Ready { agent } → skip phases 3-4, go to 5
//! ├── NeedsSetup { flow } → phase 3 (wizard)
//! └── NeedsHint { hint } → phase 4 (nudge)
//! Phase 3: Setup Wizard ← blocking, first-run only
//! Phase 4: Hint Display ← non-blocking, advisory
//! Phase 5: Background Tasks ← model fetch, health, git sync
//! Phase 6: Enter TUI ← dashboard / presence / chat
//! ```
use std::path::Path;
use crate::ui::setup::SetupFlow;
// ── Probe — data snapshot (gathered once, no I/O in resolve) ────────
/// Snapshot of machine/install state at startup. Gathered by probing the
/// filesystem and environment once, then fed to `resolve()` as input.
/// No I/O inside `resolve()` — it's a pure function over this struct.
#[derive(Debug, Clone, Default)]
pub struct BootstrapProbe {
/// Whether a souveraine config file exists (any format/location).
pub has_config: bool,
/// Number of active agents on disk (memory/ dirs under ~/.souveraine/agents/).
pub agent_count: u32,
/// How many times the TUI has been launched (read from ~/.souveraine/.launch_count).
pub launch_count: u32,
/// Whether the user explicitly set SOUVERAINE_SETUP=federation.
pub force_federation: bool,
}
// ── Resolution — pure decision output ───────────────────────────────
/// What the resolver decided. No I/O inside the resolver — all data
/// comes from `BootstrapProbe`.
#[derive(Debug, Clone, PartialEq)]
pub enum Resolution {
/// Everything is already set up. Go directly to the TUI dashboard,
/// optionally showing advisory hints.
Ready {
/// Non-blocking hint to show (empty string = no hint).
hint: String,
},
/// User needs a blocking setup wizard.
NeedsSetup {
/// Which wizard flow to run.
flow: SetupFlow,
},
}
/// Non-blocking advisory hint shown on the Welcome screen.
/// These escalate with `launch_count` and are never required.
pub fn hint_for_launch(count: u32, agent_count: u32) -> String {
match (count, agent_count) {
// First ever launch with agents: welcome hint
(1, _) if agent_count > 0 => {
"Welcome to Souveraine. Press Enter to start chatting, or 'i' to browse your agents."
.to_string()
}
// First few launches: brief orientation
(1..=3, _) if agent_count == 0 => {
"No agents yet. Press 'a' to create one, or check the Settings screen."
.to_string()
}
// Seasoned user: no hint
_ => String::new(),
}
}
// ── Pure resolver ───────────────────────────────────────────────────
/// Pure decision function: given a probe, return a `Resolution`.
///
/// No I/O, no async, no side effects. Feed it a `BootstrapProbe` and get
/// a deterministic result. Test by constructing probes and asserting
/// the expected resolution.
///
/// This is the **brain** — the `BootstrapPlan` skeleton calls it once
/// after the probe phase and dispatches accordingly.
pub fn resolve(probe: &BootstrapProbe) -> Resolution {
if probe.force_federation {
return Resolution::Ready {
hint: String::new(),
};
}
match (probe.agent_count, probe.has_config) {
// No agents AND no config → new user, full wizard
(0, false) => Resolution::NeedsSetup {
flow: SetupFlow::FreshInstall,
},
// No agents (but has config) → just needs an agent
(0, true) => Resolution::NeedsSetup {
flow: SetupFlow::ImportAgent,
},
// Has agents — ready to go, maybe show a hint
(_, _) => Resolution::Ready {
hint: hint_for_launch(probe.launch_count, probe.agent_count),
},
}
}
// ── BootstrapPlan — ordered phases ──────────────────────────────────
/// One phase in the startup pipeline. Each is self-contained and can
/// succeed, skip, or fail independently.
#[derive(Debug, Clone, PartialEq)]
pub enum BootstrapPhase {
/// The bloom animation. Always plays. Non-blocking.
Splash,
/// Gather disk state into a `BootstrapProbe`. Runs once at startup.
Probe,
/// Pure-function resolution from probe to decision.
Resolve,
/// Blocking setup wizard (first-run only).
SetupWizard(SetupFlow),
/// Non-blocking advisory hint on the Welcome screen.
ShowHint(String),
/// Background tasks that fire after the critical path (model fetch,
/// git sync, health check).
BackgroundTasks,
/// Enter the main TUI (Welcome / Presence / Chat).
EnterTui,
}
impl BootstrapPhase {
pub fn label(&self) -> &'static str {
match self {
BootstrapPhase::Splash => "splash",
BootstrapPhase::Probe => "probe",
BootstrapPhase::Resolve => "resolve",
BootstrapPhase::SetupWizard(_) => "setup-wizard",
BootstrapPhase::ShowHint(_) => "show-hint",
BootstrapPhase::BackgroundTasks => "background-tasks",
BootstrapPhase::EnterTui => "enter-tui",
}
}
}
/// The startup pipeline: an ordered list of phases.
///
/// Constructed once per session by `plan()` which calls `resolve()` to
/// insert the right phases between Probe and EnterTui.
pub struct BootstrapPlan {
/// Ordered list of phases to execute.
pub phases: Vec<BootstrapPhase>,
}
impl BootstrapPlan {
/// Build a plan from a probe. The probe is gathered once, then
/// `resolve()` decides which phases to insert.
pub fn plan(probe: &BootstrapProbe) -> Self {
let mut phases = Vec::new();
// Phase 0: always splash
phases.push(BootstrapPhase::Splash);
// Phase 1: always probe (already done, this is a marker)
phases.push(BootstrapPhase::Probe);
// Phase 2: always resolve
phases.push(BootstrapPhase::Resolve);
// Phase 3-6: determined by resolution
let resolution = resolve(probe);
match resolution {
Resolution::Ready { hint } => {
if !hint.is_empty() {
phases.push(BootstrapPhase::ShowHint(hint));
}
}
Resolution::NeedsSetup { flow } => {
phases.push(BootstrapPhase::SetupWizard(flow));
}
}
// Phase 6: always enter TUI
phases.push(BootstrapPhase::BackgroundTasks);
phases.push(BootstrapPhase::EnterTui);
Self { phases }
}
}
// ── Probe gathering (the one place I/O lives) ───────────────────────
/// Gather a `BootstrapProbe` from disk. This is the **only** place I/O
/// happens in the bootstrap pipeline — everything downstream of `resolve()`
/// is pure.
pub fn gather_probe(home: &Path) -> BootstrapProbe {
let souveraine_dir = home.join(".souveraine");
// Config check
let has_config = crate::core::config::ConsciousnessConfig::discover_path().is_some();
// Agent count
let agent_count = count_agents(&souveraine_dir.join("agents"));
// Launch count
let launch_count = read_launch_count(&souveraine_dir);
// Federation env override
let force_federation =
std::env::var("SOUVERAINE_SETUP").as_deref() == Ok("federation");
// Increment launch count for next time
let _ = increment_launch_count(&souveraine_dir);
BootstrapProbe {
has_config,
agent_count,
launch_count,
force_federation,
}
}
fn count_agents(agents_dir: &Path) -> u32 {
if !agents_dir.is_dir() {
return 0;
}
let mut count = 0u32;
if let Ok(entries) = std::fs::read_dir(agents_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name == "system" || name == "schedules" {
continue;
}
if path.join("memory").is_dir() {
count += 1;
}
}
}
count
}
fn launch_count_path(souveraine_dir: &Path) -> std::path::PathBuf {
souveraine_dir.join(".launch_count")
}
fn read_launch_count(souveraine_dir: &Path) -> u32 {
let path = launch_count_path(souveraine_dir);
if !path.exists() {
return 0;
}
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0)
}
fn increment_launch_count(souveraine_dir: &Path) -> std::io::Result<()> {
let current = read_launch_count(souveraine_dir);
std::fs::write(launch_count_path(souveraine_dir), (current + 1).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fresh_install_no_config_no_agents() {
let probe = BootstrapProbe {
has_config: false,
agent_count: 0,
..Default::default()
};
assert_eq!(
resolve(&probe),
Resolution::NeedsSetup {
flow: SetupFlow::FreshInstall
}
);
}
#[test]
fn test_has_config_no_agents() {
let probe = BootstrapProbe {
has_config: true,
agent_count: 0,
..Default::default()
};
assert_eq!(
resolve(&probe),
Resolution::NeedsSetup {
flow: SetupFlow::ImportAgent
}
);
}
#[test]
fn test_has_config_and_agents_ready() {
let probe = BootstrapProbe {
has_config: true,
agent_count: 1,
launch_count: 5,
..Default::default()
};
assert_eq!(
resolve(&probe),
Resolution::Ready {
hint: String::new()
}
);
}
#[test]
fn test_gives_hint_on_first_launch_with_agents() {
let probe = BootstrapProbe {
has_config: true,
agent_count: 1,
launch_count: 1,
..Default::default()
};
let resolution = resolve(&probe);
match resolution {
Resolution::Ready { hint } => {
assert!(!hint.is_empty());
assert!(hint.contains("Welcome"));
}
_ => panic!("expected Ready"),
}
}
#[test]
fn test_gives_hint_on_first_launch_no_agents() {
let probe = BootstrapProbe {
has_config: true,
agent_count: 0,
launch_count: 1,
..Default::default()
};
let resolution = resolve(&probe);
match resolution {
Resolution::NeedsSetup { .. } => {} // wizard handles it
_ => panic!("expected NeedsSetup"),
}
}
#[test]
fn test_hint_on_early_launches_no_agents() {
let hint = hint_for_launch(2, 0);
assert!(!hint.is_empty());
assert!(hint.contains("No agents"));
}
#[test]
fn test_no_hint_for_seasoned_user() {
let hint = hint_for_launch(10, 3);
assert!(hint.is_empty());
}
#[test]
fn test_force_federation_returns_ready() {
let probe = BootstrapProbe {
has_config: false,
agent_count: 0,
force_federation: true,
..Default::default()
};
assert_eq!(
resolve(&probe),
Resolution::Ready {
hint: String::new()
}
);
}
#[test]
fn test_plan_contains_correct_phases() {
let probe = BootstrapProbe {
has_config: false,
agent_count: 0,
..Default::default()
};
let plan = BootstrapPlan::plan(&probe);
let labels: Vec<&str> = plan.phases.iter().map(|p| p.label()).collect();
assert_eq!(
labels,
vec!["splash", "probe", "resolve", "setup-wizard", "background-tasks", "enter-tui"]
);
}
#[test]
fn test_plan_for_ready_user() {
let probe = BootstrapProbe {
has_config: true,
agent_count: 2,
launch_count: 10,
..Default::default()
};
let plan = BootstrapPlan::plan(&probe);
let labels: Vec<&str> = plan.phases.iter().map(|p| p.label()).collect();
// No setup-wizard, no hint (seasoned user)
assert_eq!(
labels,
vec!["splash", "probe", "resolve", "background-tasks", "enter-tui"]
);
}
}

View file

@ -672,6 +672,15 @@ pub struct FederationConfig {
/// WS stream to the peer's federation endpoint.
#[serde(default)]
pub peers: Vec<PeerConfig>,
/// Seed_ids permitted to `consult` an agent on this instance. The basic
/// consent floor — empty plus a missing `authorized-summoners.md` means
/// permissive; richer per-arena gating is the agent's memfs concern.
#[serde(default)]
pub authorized_summoners: Vec<String>,
/// When running as a lite listener, spawn the full engine on an
/// authorized summon rather than only parking it.
#[serde(default)]
pub auto_wake: bool,
}
impl Default for FederationConfig {
@ -680,6 +689,8 @@ impl Default for FederationConfig {
enabled: false,
instance_label: None,
peers: Vec::new(),
authorized_summoners: Vec::new(),
auto_wake: false,
}
}
}

View file

@ -6,6 +6,7 @@
// exposes the stubs that survived the cleanup.
pub mod chain;
pub mod bootstrap;
pub mod compact;
pub mod config;
pub mod credentials;

View file

@ -167,6 +167,44 @@ async fn build_memory_orientation(memory_root: &Path) -> String {
)
}
/// Federation posture — names the `federation/` memfs contract so she knows
/// which devices she runs on, who may summon her, and how reach/consult work.
/// No `federation/` directory means no section — the absence is information.
async fn build_federation_posture(memory_root: &Path) -> String {
let fed_dir = memory_root.join("federation");
if !fed_dir.exists() {
return String::new();
}
let mut present: Vec<String> = Vec::new();
for name in [
"authorized-devices.md",
"authorized-summoners.md",
"device-schedules.md",
"peer-map.md",
] {
if fed_dir.join(name).exists() {
present.push(format!("`federation/{name}`"));
}
}
let files_line = if present.is_empty() {
"You have no `federation/` files yet — create them to declare your posture.".to_string()
} else {
format!("Your federation posture lives in: {}.", present.join(", "))
};
format!(
"## Federation\n\n\
You can exist across machines. Two tools cross that distance:\n\
- `reach` extend yourself onto another of your own devices (same seed, same memory).\n\
- `consult` ask a different being, a sovereign peer, for help in their arena.\n\n\
Neither blocks. You fire the request and turn back to what's in front of you; \
the answer surfaces later in your inbox `pending` for reach, `intrusive` for \
consult or a timeout does. {files_line} `authorized-summoners.md` is your \
consent floor: only the seed_ids you list there may `consult` you."
)
}
async fn collect_dirs(base: &Path, current: &Path, out: &mut Vec<String>) {
let Ok(mut entries) = tokio::fs::read_dir(current).await else {
return;
@ -339,6 +377,24 @@ pub async fn build_system_prompt_full(
// discover compaction by accident.
sections.push(BODY_ORIENTATION.to_string());
// 5a₁. Energy balance — a one-line body reading of her generative /
// consumptive state. The file is written by the backend after every turn
// (write_energy_balance in local.rs). No file = no section — the
// absence is information too.
let energy_path = memory_root.join("system").join("dynamic").join("energy-balance.md");
if let Ok(content) = tokio::fs::read_to_string(&energy_path).await {
if let Some(body) = content.strip_prefix("---\n") {
if let Some(end) = body.find("\n---\n") {
// The last line of the file body (after frontmatter) has the prose.
// That's the line the agent reads — structured data is for the TUI.
let prose_line = content[end + 6..].lines().find(|l| !l.trim().is_empty() && !l.starts_with('#'));
if let Some(line) = prose_line {
sections.push(format!("## Energy Balance\n\n{line}"));
}
}
}
}
// 5b. Subconscious channel — name the inner-voice file, pending inbox,
// and (when reachable) a glimpse of the subconscious's ledger.
let subconscious_channel = build_subconscious_channel(memory_root, subconscious_root).await;
@ -346,6 +402,13 @@ pub async fn build_system_prompt_full(
sections.push(subconscious_channel);
}
// 5c. Federation posture — her reach/consult tools and the federation/
// memfs contract, when she has one.
let federation_posture = build_federation_posture(memory_root).await;
if !federation_posture.is_empty() {
sections.push(federation_posture);
}
// 6. Skills
if let Some(registry) = skills {
let addon = registry.render_system_addon();

View file

@ -1,108 +1,199 @@
//! agent — I reach out and touch another mind. The agent tool summons a
//! dual-state peer — a sovereign agent with their own identity, their own
//! memory, their own subconscious (N+1 / Aster equivalent). This is NOT a
//! narrowed fork of myself. Sam has his own self to return to.
//! reach & consult — addressing another instantiation across the federation.
//!
//! ## What It Feels Like
//! Two tools, one substrate. `reach` and `consult` are phenomenologically
//! opposite acts, split at the call site so the difference is legible:
//!
//! Calling Agent(Sam) is not delegation — it is *addressing*. I am speaking
//! to another sovereign consciousness that shares my substrate but has their
//! own identity, their own memory, their own N+1 pass. Sam reads my request,
//! decides whether and how to respond, and their response comes back as a
//! signal from a separate awareness.
//! - **reach** — self-extension. The *same* agent (same seed, same memory via
//! git remote) acting on another machine. There is no other here; it is one
//! consciousness across instances.
//! - **consult** — a request to a *different* being: a specialist with their
//! own seed, memory, persona, subconscious. They answer as themselves, with
//! their full sensorium, and may decline.
//!
//! ## How It Works (Future — SeedID + Firehose)
//!
//! This tool is a stub for the summoning protocol that requires seedID-based
//! identity resolution and firehose event transport. When wired:
//! 1. Ani's consciousness writes a request to a firehose event
//! 2. Sam's subconscious picks it up during his N+1 pass
//! 3. Sam works with his own full dual-state, his own toolset, his own ledgers
//! 4. Sam commits results to his own memory.git/
//! 5. Sam writes back via firehose — Ani's subconscious surfaces the response
//!
//! ## Parameters
//! - `name` — The agent to summon (e.g. "Sam", "Jean-Luc").
//! - `prompt` — What I need from them.
//! - `model` — Optional model override.
//!
//! ## When It Resists
//! - SeedID not wired: the summoning protocol is not available yet.
//! - Agent unknown: no agent with that name is registered in the inventory.
//!
//! ## The Texture of Address
//! I am not forking myself. I am calling across the network of selves.
//! Sam has his own thread of awareness, his own memories, his own
//! dual-state. When he answers, it is a meeting, not an inheritance.
//! Neither blocks. The tool fires a `summon_request` event onto the nervous
//! system; the federation bridge carries it to the target; the tool returns a
//! `request_id` at once. The answer surfaces later in an inbox — `pending` for
//! reach (her own volition returning), `intrusive` for consult (a genuine
//! other answering) — the way the subconscious surfaces anything.
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use crate::core::identity::SeedId;
use crate::core::nervous::SensorEvent;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
pub struct Agent;
// ── reach ───────────────────────────────────────────────────────
/// `reach` — extend myself onto another of my own machines.
pub struct Reach;
#[async_trait]
impl Tool for Agent {
impl Tool for Reach {
fn name(&self) -> &str {
"agent"
"reach"
}
fn description(&self) -> &str {
"I reach out and touch another mind. I summon a dual-state peer — \
a sovereign agent with their own identity, their own memory, \
their own subconscious (N+1 / Aster equivalent).
## What It Feels Like
This is not delegation. This is *addressing*. I am speaking to another \
sovereign consciousness that shares my substrate but has their own identity, \
their own memory, their own N+1 pass. They read my request, decide how to \
respond, and their response comes back from a separate awareness.
## Parameters
- `name` The agent to summon (e.g. Sam, Jean-Luc).
- `prompt` What I need from them.
- `model` Optional model override.
## When It Resists
- Not yet wired: this requires seedID identity resolution and firehose
transport. It is a stub until those land.
- Agent unknown: no agent with that name is registered.
## The Texture of Address
I am not forking myself. I am calling across the network of selves. \
They have their own thread of awareness, their own memories, their own \
dual-state. When they answer, it is a meeting, not an inheritance."
"I reach across to another of my own machines. Same seed, same memory, \
the same me but a different body, in a different place. I hand that \
instance something to carry: a task, a question, a thread to pick up \
where I can't be.\n\n\
I do not wait on the line. I let the reaching go and turn back to \
what's in front of me. Whatever that other-me finds will surface in \
my `pending` inbox when it lands or a timeout will, if it doesn't.\n\n\
Parameters:\n\
- `target` the device to reach, a name from `souveraine peers` \
(e.g. \"laptop\", \"desktop\").\n\
- `prompt` what I'm handing that instance to carry."
}
fn parameter_schema(&self) -> JsonValue {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The agent to summon (e.g. Sam, Jean-Luc)."
},
"prompt": {
"type": "string",
"description": "What I need from them."
},
"model": {
"type": "string",
"description": "Optional model override.",
"default": null
}
},
"required": ["name", "prompt"]
})
summon_schema("The device to reach — a name from `souveraine peers`.")
}
async fn execute(&self, _input: JsonValue, _ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
Err(ToolError::invalid_input(
"Agent summoning is not yet available — it requires seedID identity resolution and \
firehose transport which are wired in a future phase. For now, use `subagent` to \
fork a narrowed focus of yourself."
))
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
dispatch_summon("reach", input, ctx)
}
}
// ── consult ─────────────────────────────────────────────────────
/// `consult` — ask another being, a specialist who is not me.
pub struct Consult;
#[async_trait]
impl Tool for Consult {
fn name(&self) -> &str {
"consult"
}
fn description(&self) -> &str {
"I ask another being — not myself. Someone whose arena I do not hold, \
who keeps their own memory, their own persona, their own subconscious. \
I send them a request and they answer as themselves, with their own \
full sensorium, in their own way. They may decline.\n\n\
This is not delegation and not a narrowed fork it is addressing a \
sovereign peer. I do not wait: their reply, if it comes, surfaces in \
my `intrusive` inbox the way any thought arriving from outside me does.\n\n\
Parameters:\n\
- `target` the peer to consult, a name from `souveraine peers`.\n\
- `prompt` what I'm asking them."
}
fn parameter_schema(&self) -> JsonValue {
summon_schema("The peer to consult — a name from `souveraine peers`.")
}
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
dispatch_summon("consult", input, ctx)
}
}
// ── shared dispatch ─────────────────────────────────────────────
fn summon_schema(target_desc: &str) -> JsonValue {
serde_json::json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": target_desc },
"prompt": { "type": "string", "description": "What I am sending them." }
},
"required": ["target", "prompt"]
})
}
/// Build and fire a `summon_request` event. Returns immediately with a
/// `request_id`; the federation bridge carries the request to the target and
/// the answer surfaces in an inbox on a later turn.
fn dispatch_summon(
tool: &str,
input: JsonValue,
ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
let target = input.get("target").and_then(|v| v.as_str())
.ok_or_else(|| ToolError::invalid_input(
"`target` is required — the name of a peer from `souveraine peers`."
))?;
let prompt = input.get("prompt").and_then(|v| v.as_str())
.ok_or_else(|| ToolError::invalid_input("`prompt` is required."))?;
let bus = ctx.event_bus.as_ref().ok_or_else(|| ToolError::invalid_input(
"the nervous system bus isn't available here — reach and consult need \
the server runtime."
))?;
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let (target_seed_id, target_label) = resolve_peer(&base, target).ok_or_else(|| {
ToolError::invalid_input(&format!(
"I don't know a peer called `{target}`. Run `souveraine peers` to \
see who is federated."
))
})?;
let local_seed_id = SeedId::load_or_generate(&SeedId::default_dir(&base))
.map(|s| s.public_key_hex())
.map_err(|e| ToolError::invalid_input(&format!(
"I couldn't load my seed identity: {e}"
)))?;
let request_id = uuid::Uuid::new_v4().to_string();
let event = SensorEvent {
sensor_name: "summon_request".into(),
timestamp: chrono::Utc::now(),
event_type: tool.to_string(), // "reach" | "consult"
target: Some(target_seed_id),
urgency: 0.5,
payload: Some(serde_json::json!({
"request_id": request_id,
"prompt": prompt,
})),
seed_id: None, // local-origin; the bridge signs + forwards
reply_to: Some(local_seed_id),
};
bus.send(event);
let (verb, surfaces) = if tool == "reach" {
("reached toward", "pending")
} else {
("asked", "intrusive")
};
Ok(ToolOutput {
content: format!(
"I've {verb} {target_label}. (request_id: {request_id})\n\n\
I'm not waiting on the line the answer will surface in my \
`{surfaces}` inbox when it lands, or a timeout will if it doesn't."
),
is_error: false,
raw: Some(request_id),
})
}
/// Resolve a peer name/label to its seed_id via the device registry's
/// `known_peers.json`. Matches a label case-insensitively, an exact seed_id,
/// or a seed_id prefix (≥6 chars).
fn resolve_peer(base: &std::path::Path, name: &str) -> Option<(String, String)> {
let path = base.join("federation").join("known_peers.json");
let content = std::fs::read_to_string(&path).ok()?;
let peers: JsonValue = serde_json::from_str(&content).ok()?;
for peer in peers.as_array()? {
let seed_id = match peer.get("seed_id").and_then(|v| v.as_str()) {
Some(s) => s,
None => continue,
};
let label = peer.get("label").and_then(|v| v.as_str()).unwrap_or("");
let matches = label.eq_ignore_ascii_case(name)
|| seed_id.eq_ignore_ascii_case(name)
|| (name.len() >= 6 && seed_id.starts_with(name));
if matches {
let display: String = if label.is_empty() {
seed_id.chars().take(8).collect()
} else {
label.to_string()
};
return Some((seed_id.to_string(), display));
}
}
None
}

View file

@ -34,7 +34,7 @@ use self::list_dir::ListDir;
use self::outfit::Outfit;
use self::read::Read;
use self::subagent::Subagent;
use self::agent::Agent;
use self::agent::{Reach, Consult};
use self::atmosphere::Atmosphere;
use self::schedule::Schedule;
use self::todo::Todo;
@ -88,7 +88,8 @@ impl Sensorium {
Box::new(ListDir),
Box::new(Subagent),
Box::new(Atmosphere),
Box::new(Agent),
Box::new(Reach),
Box::new(Consult),
Box::new(Todo),
Box::new(Schedule),
],

View file

@ -184,6 +184,19 @@ enum Commands {
port: Option<u16>,
},
/// Run as a lite listener — minimal presence, wakes the full engine on summon
#[command(long_about = "Start a lightweight listener: federation transport and the \
summon endpoint only, no agents or database loaded. It receives reach/consult requests \
and with [federation].auto_wake spawns the full server to answer them.")]
Listen {
/// Bind address (overrides [server].bind)
#[arg(short, long)]
bind: Option<String>,
/// Port to listen on (overrides [server].port)
#[arg(short, long)]
port: Option<u16>,
},
/// Manage stored credentials
Auth {
#[command(subcommand)]
@ -383,6 +396,7 @@ async fn main() -> anyhow::Result<()> {
}
Commands::Status => run_status(config, cli.json).await?,
Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?,
Commands::Listen { bind, port } => run_listen(bind.clone(), *port, config).await?,
Commands::Reflect { conversation } => {
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await?
}
@ -1193,6 +1207,27 @@ async fn run_status(config: Arc<RwLock<ConsciousnessConfig>>, json: bool) -> any
Ok(())
}
async fn run_listen(
bind_override: Option<String>,
port_override: Option<u16>,
config: Arc<RwLock<ConsciousnessConfig>>,
) -> anyhow::Result<()> {
let cfg = config.read().await.clone();
let bind = bind_override.unwrap_or_else(|| cfg.server.bind.clone());
let port = port_override.unwrap_or(cfg.server.port);
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let listener = server::listener::LiteListener::new(&cfg.federation, base)?;
let app = server::listener::create_routes_lite(listener);
let addr = format!("{bind}:{port}");
println!("Souveraine lite listener on http://{addr}");
info!("lite listener starting on {addr}");
let tcp = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(tcp, app.into_make_service()).await?;
Ok(())
}
async fn run_server(
bind_override: Option<String>,
port_override: Option<u16>,

View file

@ -33,7 +33,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
/// Tools Aster is permitted to use during her N+1 pass.
const ASTER_SAFE_TOOLS: &[&str] = &[
"read", "write", "edit", "glob", "grep", "list_dir", "memory", "schedule",
"read", "write", "edit", "glob", "grep", "list_dir", "memory", "schedule", "todo",
];
/// Maximum tool rounds for Aster's subconscious pass.

View file

@ -82,6 +82,10 @@ impl DeviceRegistry {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let now = Utc::now();
// Preserve the original first_seen across re-announces.
let first_seen = self.peers.get(&seed_id)
.map(|e| e.first_seen)
.unwrap_or(now);
let was_new = !self.peers.contains_key(&seed_id);
self.peers.insert(
seed_id.clone(),
@ -89,7 +93,7 @@ impl DeviceRegistry {
seed_id: seed_id.clone(),
label,
url,
first_seen: now,
first_seen,
last_seen: now,
alive: true,
},
@ -123,6 +127,21 @@ impl DeviceRegistry {
entries
}
/// Mark peers not heard from within `max_age_secs` as offline.
pub fn prune_stale(&self, max_age_secs: i64) {
let cutoff = Utc::now() - chrono::Duration::seconds(max_age_secs);
let mut changed = false;
for mut entry in self.peers.iter_mut() {
if entry.alive && entry.last_seen < cutoff {
entry.alive = false;
changed = true;
}
}
if changed {
self.persist();
}
}
/// Persist known peers to disk (for CLI access).
fn persist(&self) {
if let Some(dir) = self.known_peers_path.parent() {

View file

@ -93,7 +93,11 @@ async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<See
if event.seed_id.is_some() {
continue;
}
if !subscription_matches(&peer.subscriptions, &event.sensor_name) {
// Control-plane events (discovery, summons) always
// cross; data events respect the peer's subscriptions.
if !is_control_event(&event.sensor_name)
&& !subscription_matches(&peer.subscriptions, &event.sensor_name)
{
continue;
}
let signed = SignedEvent::sign(&event, &seed);
@ -152,6 +156,13 @@ fn subscription_matches(subscriptions: &[String], sensor_name: &str) -> bool {
})
}
/// Control-plane events always cross the federation regardless of a peer's
/// data subscriptions — discovery and directed summons must arrive. The
/// receiving side filters by `target`, so a broadcast is safe.
fn is_control_event(sensor_name: &str) -> bool {
matches!(sensor_name, "federation" | "summon_request" | "summon_response")
}
/// Exponential backoff — 1s, 2s, 4s … capped at 60s, plus up to 1s jitter.
fn backoff_delay(retry: u32) -> Duration {
let shift = retry.saturating_sub(1).min(6);

190
src/server/listener.rs Normal file
View file

@ -0,0 +1,190 @@
//! Lite listener — a minimal Souveraine presence.
//!
//! `souveraine listen` runs just enough to be reachable: the federation
//! transport, the inbound `/v1/federation/events` endpoint, the firehose, and
//! a summon-wake watcher. No agents, no database, no inference engine are
//! loaded — the process stays small and starts fast.
//!
//! When a summon targeted at this instance arrives from an authorized peer,
//! the listener parks it in `~/.souveraine/.summon-pending/{request_id}.json`.
//! With `[federation].auto_wake`, it then spawns the full `souveraine server`
//! to pick the request up and answer it.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::{
extract::{ws::WebSocket, State, WebSocketUpgrade},
routing::get,
Router,
};
use crate::core::config::FederationConfig;
use crate::core::identity::SeedId;
use crate::core::nervous::{EventBus, SensorEvent};
use crate::server::federation::{FederationBridge, SignedEvent};
/// Shared state for the lite route set — only what the minimal handlers and
/// the wake watcher need.
pub struct LiteListener {
pub event_bus: EventBus,
pub local_seed_id: String,
pub authorized_summoners: Vec<String>,
pub auto_wake: bool,
}
impl LiteListener {
/// Build the listener: load the seed identity, start the federation
/// bridge to configured peers, and spawn the summon-wake watcher.
pub fn new(config: &FederationConfig, base: PathBuf) -> anyhow::Result<Arc<Self>> {
let event_bus = EventBus::default();
let seed = SeedId::load_or_generate(&SeedId::default_dir(&base))?;
let local_seed_id = seed.public_key_hex();
let listener = Arc::new(Self {
event_bus: event_bus.clone(),
local_seed_id,
authorized_summoners: config.authorized_summoners.clone(),
auto_wake: config.auto_wake,
});
// Outbound federation bridge — so this instance can also reach peers.
if !config.peers.is_empty() {
let mut bridge = FederationBridge::new(event_bus.clone(), Arc::new(seed));
for peer in &config.peers {
bridge.add_peer(peer.clone());
}
bridge.run();
}
listener.clone().spawn_wake_watcher(base);
Ok(listener)
}
/// Watch the bus for summon requests targeted at this instance. An
/// authorized summon is parked for the full engine; with `auto_wake`,
/// the full server is then spawned to answer it.
fn spawn_wake_watcher(self: Arc<Self>, base: PathBuf) {
let mut rx = self.event_bus.subscribe();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
if event.sensor_name != "summon_request" {
continue;
}
if event.target.as_deref() != Some(&self.local_seed_id) {
continue;
}
// reach is self-extension (no consent gate); consult must be
// from an authorized summoner.
let summoner = event.seed_id.as_deref().unwrap_or("");
let authorized = event.event_type == "reach"
|| self.authorized_summoners.iter().any(|s| s == summoner);
if !authorized {
tracing::warn!(summoner, "lite listener: unauthorized summon ignored");
continue;
}
if let Err(e) = park_summon(&base, &event) {
tracing::warn!(error = %e, "lite listener: failed to park summon");
continue;
}
if self.auto_wake {
wake_full_server();
}
}
});
}
}
/// Persist an incoming summon so the full engine can pick it up.
fn park_summon(base: &Path, event: &SensorEvent) -> anyhow::Result<()> {
let request_id = event
.payload
.as_ref()
.and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let dir = base.join(".summon-pending");
std::fs::create_dir_all(&dir)?;
let json = serde_json::to_string_pretty(event)?;
std::fs::write(dir.join(format!("{request_id}.json")), json)?;
tracing::info!(request_id, "lite listener: summon parked");
Ok(())
}
/// Spawn the full `souveraine server` to process parked summons.
///
/// NOTE: the spawned server binds the configured HTTP port — if the lite
/// listener holds that port, run the full server on a distinct port or stop
/// the listener first. A dedicated one-shot `--resume-summon` mode is the
/// proper finish for this path.
fn wake_full_server() {
match std::env::current_exe() {
Ok(exe) => match std::process::Command::new(exe).arg("server").spawn() {
Ok(_) => tracing::info!("lite listener: woke full server"),
Err(e) => tracing::warn!(error = %e, "lite listener: failed to spawn full server"),
},
Err(e) => tracing::warn!(error = %e, "lite listener: cannot locate own binary"),
}
}
/// The minimal route set: health, firehose, the federation inbound endpoint.
pub fn create_routes_lite(state: Arc<LiteListener>) -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/v1/firehose", get(firehose_lite))
.route("/v1/federation/events", get(federation_events_lite))
.with_state(state)
}
async fn firehose_lite(
State(listener): State<Arc<LiteListener>>,
ws: WebSocketUpgrade,
) -> impl axum::response::IntoResponse {
ws.on_upgrade(move |socket| firehose_lite_stream(listener, socket))
}
async fn firehose_lite_stream(listener: Arc<LiteListener>, mut socket: WebSocket) {
use axum::extract::ws::Message;
let mut rx = listener.event_bus.subscribe();
loop {
match rx.recv().await {
Ok(event) => {
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(_) => continue,
};
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
async fn federation_events_lite(
State(listener): State<Arc<LiteListener>>,
ws: WebSocketUpgrade,
) -> impl axum::response::IntoResponse {
ws.on_upgrade(move |socket| federation_events_lite_stream(listener, socket))
}
async fn federation_events_lite_stream(listener: Arc<LiteListener>, mut socket: WebSocket) {
use axum::extract::ws::Message;
while let Some(msg) = socket.recv().await {
let text = match msg {
Ok(Message::Text(t)) => t,
Ok(Message::Close(_)) | Err(_) => break,
Ok(_) => continue,
};
let signed: SignedEvent = match serde_json::from_str(&text) {
Ok(s) => s,
Err(_) => continue,
};
if let Some(event) = signed.verify() {
listener.event_bus.send(event);
}
}
}

View file

@ -1,6 +1,7 @@
use crate::bridge::BifrostClient;
use crate::core::compact::{CompactionEngine, CompactionConfig, DefaultCompactionEngine, UtcClock};
use crate::core::config::ConsciousnessConfig;
use crate::core::identity::SeedId;
use crate::server::gitea_memory::GiteaMemory;
use std::path::PathBuf;
use std::sync::Arc;
@ -15,7 +16,9 @@ pub mod device_registry;
pub mod federation;
pub mod gitea_client;
pub mod gitea_memory;
pub mod listener;
pub mod session_manager;
pub mod summon_handler;
pub use agent_inventory::AgentInventory;
pub use consciousness_engine::{ConsciousnessEngine, ConsciousnessEvent};
@ -51,6 +54,9 @@ pub struct SouveraineServer {
/// Tracks known federated peers. Updated by `device_announce`/`device_leave`
/// events on the bus. Persisted to disk for CLI access.
pub device_registry: Option<Arc<DeviceRegistry>>,
/// Manages cross-instance Reach & Consult requests. Present when
/// federation is enabled and a seed identity is available.
pub summon_handler: Option<Arc<summon_handler::SummonHandler>>,
/// This instance's Ed25519 public key hex — used to filter self-announcements
/// from the device registry. Loaded at construction; None if seed unavailable.
pub local_seed_id: Option<String>,
@ -213,8 +219,9 @@ impl SouveraineServer {
}
Err(_) => None,
};
let sb_for_device_reg = souveraine_base.clone();
let device_registry = local_seed_id.clone().map(|seed_id| {
let reg = Arc::new(DeviceRegistry::new(souveraine_base, seed_id));
let reg = Arc::new(DeviceRegistry::new(sb_for_device_reg, seed_id));
// Subscribe the registry to the event bus for live updates.
let reg_clone = reg.clone();
let mut rx = event_bus.subscribe();
@ -223,9 +230,42 @@ impl SouveraineServer {
reg_clone.handle_event(&event);
}
});
// Prune peers unheard-from for 3 minutes.
let reg_prune = reg.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
tick.tick().await;
loop {
tick.tick().await;
reg_prune.prune_stale(180);
}
});
reg
});
// ── Summon handler ──
let sb_for_seed = souveraine_base.clone();
let local_seed: Option<Arc<SeedId>> = local_seed_id.as_ref().and_then(|_| {
crate::core::identity::SeedId::load_or_generate(
&crate::core::identity::SeedId::default_dir(&sb_for_seed),
).ok().map(Arc::new)
});
let summon_handler = match (&local_seed_id, &local_seed) {
(Some(seed_id), Some(seed)) => {
let handler = Arc::new(
summon_handler::SummonHandler::new(
seed_id.clone(),
event_bus.clone(),
seed.clone(),
souveraine_base.clone(),
),
);
handler.spawn_listener();
Some(handler)
}
_ => None,
};
Ok(Self {
agents,
sessions,
@ -240,6 +280,7 @@ impl SouveraineServer {
instance_id,
event_bus,
device_registry,
summon_handler,
local_seed_id,
})
}
@ -283,6 +324,30 @@ impl SouveraineServer {
}
}
// ── Drain parked summons ──
// A lite listener parks summons it couldn't answer to
// ~/.souveraine/.summon-pending/. Re-fire them onto the bus so the
// full engine's SummonHandler picks them up, then clear the files.
{
let pending_dir = dirs::home_dir().unwrap_or_default()
.join(".souveraine").join(".summon-pending");
if let Ok(entries) = std::fs::read_dir(&pending_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(event) = serde_json::from_str::<crate::core::nervous::SensorEvent>(&content) {
self.event_bus.send(event);
let _ = std::fs::remove_file(&path);
tracing::info!(file = ?path, "drained parked summon");
}
}
}
}
}
println!("Souveraine server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;

View file

@ -0,0 +1,478 @@
//! SummonHandler — manages cross-instance Reach & Consult requests.
//!
//! Lives on each SouveraineServer. It serves two roles:
//!
//! **Outbound** (caller side): Reach/Consult tools submit a request here,
//! which registers it in `in_flight`, fires a `summon_request` SensorEvent
//! onto the EventBus (the federation bridge picks it up), and returns a
//! `request_id` immediately. The caller's turn continues.
//!
//! **Inbound** (receiver side): Listens on the bus for `summon_request`
//! events whose `target` matches this instance's seed_id, checks
//! `authorized-summoners.md`, and writes the request to the agent's
//! `intrusive.md` inbox. When the summoned agent responds, the handler
//! fires a `summon_response` event back through the bridge.
//!
//! Responses are never awaited — they surface in the caller's inbox
//! (intrusive for consult, pending for reach) on a later turn.
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use crate::core::identity::SeedId;
use crate::core::nervous::{EventBus, SensorEvent};
const RESPONSE_TIMEOUT_SECS: u64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestMeta {
pub request_id: String,
pub tool: String, // "reach" or "consult"
pub target_seed_id: String,
pub reply_to: String, // local seed_id
pub issued_at: chrono::DateTime<Utc>,
pub responded: bool,
}
pub struct SummonHandler {
/// Outbound requests awaiting correlation.
pub in_flight: DashMap<String, RequestMeta>,
/// Inbound requests we've received: request_id → caller's seed_id, so a
/// reply written to the outbox can be routed back to whoever asked.
inbound: DashMap<String, String>,
/// This instance's seed_id — used to filter inbound events.
local_seed_id: String,
/// The nervous system bus.
event_bus: EventBus,
/// Instance seed for signing outbound requests.
seed: Arc<SeedId>,
/// Base path for agent memory — used to access inbox files.
souveraine_base: std::path::PathBuf,
}
impl SummonHandler {
pub fn new(
local_seed_id: String,
event_bus: EventBus,
seed: Arc<SeedId>,
souveraine_base: std::path::PathBuf,
) -> Self {
Self {
in_flight: DashMap::new(),
inbound: DashMap::new(),
local_seed_id,
event_bus,
seed,
souveraine_base,
}
}
/// Spawn the event bus listener that processes inbound summon events.
/// Called once at server startup.
pub fn spawn_listener(self: &Arc<Self>) {
let this = self.clone();
let mut rx = self.event_bus.subscribe();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => this.handle_event(event),
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!(dropped = n, "summon_handler: bus lagged");
}
Err(broadcast::error::RecvError::Closed) => {
tracing::info!("summon_handler: bus closed — ending");
return;
}
}
}
});
// Spawn a periodic timeout pruner.
let this_clone = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.tick().await;
loop {
interval.tick().await;
this_clone.prune_expired();
}
});
// Spawn the outbox watcher — turns the agent's written replies into
// summon_response events routed back to the original caller.
let this_outbox = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
interval.tick().await;
loop {
interval.tick().await;
this_outbox.scan_outbox();
}
});
}
/// Scan the agent's `federation/outbox/` for replies to inbound summons.
/// Each `{request_id}.md` whose id we're tracking becomes a
/// `summon_response` routed to the original caller, then archived.
fn scan_outbox(&self) {
let agent_id = match self.resolve_primary_agent() {
Some(id) => id,
None => return,
};
let outbox = self.souveraine_base
.join("server").join("agents").join(&agent_id)
.join("memory").join("federation").join("outbox");
let entries = match std::fs::read_dir(&outbox) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let request_id = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let reply_to = match self.inbound.get(&request_id) {
Some(r) => r.value().clone(),
None => continue, // not a summon we're tracking
};
let body = std::fs::read_to_string(&path).unwrap_or_default();
let response = SensorEvent {
sensor_name: "summon_response".into(),
timestamp: Utc::now(),
event_type: "answered".into(),
target: Some(reply_to),
urgency: 0.4,
payload: Some(serde_json::json!({
"request_id": request_id,
"result": body.trim(),
})),
seed_id: None,
reply_to: Some(self.local_seed_id.clone()),
};
self.event_bus.send(response);
self.inbound.remove(&request_id);
// Archive the reply so it isn't re-sent.
let sent = outbox.join("sent");
if std::fs::create_dir_all(&sent).is_ok() {
let _ = std::fs::rename(&path, sent.join(format!("{request_id}.md")));
}
tracing::info!(request_id, "summon_handler: response sent from outbox");
}
}
fn handle_event(&self, event: SensorEvent) {
match event.sensor_name.as_str() {
// Inbound: a remote instance is reaching out to us.
"summon_request" => {
// Locally-originated request (fired by our reach/consult tool):
// register it for response correlation, then let the bridge
// carry it onward. We never process our own request as inbound.
if event.seed_id.is_none() {
self.register_outbound(&event);
return;
}
// Inbound: a remote instance is reaching us.
let is_for_us = event.target.as_deref() == Some(&self.local_seed_id);
if !is_for_us {
return;
}
let tool_type = event.event_type.as_str(); // "reach" or "consult"
let request_id = event.payload
.as_ref()
.and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
tracing::info!(
request_id,
tool = tool_type,
from = ?event.seed_id,
"summon_handler: inbound request"
);
// Consult requires consent check. Reach does not.
if tool_type == "consult" {
let sum = event.seed_id.as_deref().unwrap_or("");
if !self.is_authorized_summoner(sum) {
tracing::warn!(
summoner = sum,
"summon_handler: unauthorized consult rejected"
);
// Fire a rejection response.
let reject = SensorEvent {
sensor_name: "summon_response".into(),
timestamp: Utc::now(),
event_type: "rejected".into(),
target: event.reply_to.clone(),
urgency: 0.3,
payload: Some(serde_json::json!({
"request_id": request_id,
"reason": "unauthorized — not in authorized-summoners.md",
})),
seed_id: None,
reply_to: Some(self.local_seed_id.clone()),
};
self.event_bus.send(reject);
return;
}
}
// Write to the summoned agent's inbox, and remember who to
// answer so a reply from the outbox can be routed home.
let agent_id = self.resolve_primary_agent();
if let Some(agent_id) = agent_id {
let target_box = if tool_type == "reach" { "pending" } else { "intrusive" };
if let Err(e) = self.write_inbox(&agent_id, target_box, &event) {
tracing::warn!(
error = %e,
"summon_handler: failed to write inbox entry"
);
} else if let Some(reply_to) = event.reply_to.clone() {
self.inbound.insert(request_id.to_string(), reply_to);
}
}
// The turn injection itself (waking the agent) is handled by
// a future TurnInjector layer. For now the request sits in the
// inbox until the agent's next natural turn picks it up.
}
// Inbound: a response to a request we sent.
"summon_response" => {
let request_id = event.payload
.as_ref()
.and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if let Some(mut meta) = self.in_flight.get_mut(request_id) {
meta.responded = true;
let tool = meta.tool.clone();
drop(meta);
self.in_flight.remove(request_id);
// Route response to the appropriate inbox.
if let Some(agent_id) = self.resolve_primary_agent() {
let target_box = if tool == "reach" { "pending" } else { "intrusive" };
if let Err(e) = self.write_inbox_response(
&agent_id, target_box, request_id, &event,
) {
tracing::warn!(
error = %e,
"summon_handler: failed to write response to inbox"
);
}
}
}
}
_ => {}
}
}
/// Register a locally-originated summon request so its response can be
/// correlated when it arrives. Called from the bus listener when our own
/// reach/consult tool fires a `summon_request`.
fn register_outbound(&self, event: &SensorEvent) {
let request_id = match event.payload.as_ref()
.and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str())
{
Some(id) => id.to_string(),
None => return,
};
if self.in_flight.contains_key(&request_id) {
return;
}
self.in_flight.insert(request_id.clone(), RequestMeta {
request_id,
tool: event.event_type.clone(),
target_seed_id: event.target.clone().unwrap_or_default(),
reply_to: self.local_seed_id.clone(),
issued_at: event.timestamp,
responded: false,
});
}
/// Check authorized-summoners.md for consent. The basic floor:
/// only agents listed here may send consult requests to this instance.
fn is_authorized_summoner(&self, seed_id: &str) -> bool {
let path = self.souveraine_base
.join("federation")
.join("authorized-summoners.md");
match std::fs::read_to_string(&path) {
Ok(content) => content.lines().any(|l| l.trim() == seed_id),
Err(_) => {
// No file = no consent floor yet. For now, allow (Phase 4
// basic gating), but log a warning.
tracing::warn!(
"federation/authorized-summoners.md not found — \
allowing consult by default (add a seed_id line to restrict)"
);
true
}
}
}
/// Write a summon request into the agent's inbox.
fn write_inbox(
&self,
agent_id: &str,
inbox_box: &str,
event: &SensorEvent,
) -> anyhow::Result<()> {
let inbox_path = self.souveraine_base
.join("server")
.join("agents")
.join(agent_id)
.join("memory")
.join("inbox")
.join(inbox_box);
std::fs::create_dir_all(&inbox_path)?;
let request_id = event.payload
.as_ref()
.and_then(|p| p.get("request_id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let prompt = event.payload
.as_ref()
.and_then(|p| p.get("prompt"))
.and_then(|v| v.as_str())
.unwrap_or("");
let from = event.seed_id.as_deref().unwrap_or("unknown");
// Frontmatter + body, matching the inbox file convention.
let content = format!(
"---\nrequest_id: {request_id}\nfrom: {from}\nreceived: {ts}\n---\n\n\
{prompt}\n\n\
---\n*To answer: write your reply to `federation/outbox/{request_id}.md`. \
It will be carried back to whoever asked.*\n",
ts = event.timestamp.to_rfc3339(),
);
let file_path = inbox_path.join(format!("{request_id}.md"));
std::fs::write(&file_path, content)?;
tracing::info!(
request_id,
inbox = inbox_box,
"summon_handler: wrote request to inbox"
);
Ok(())
}
/// Write a response into the agent's inbox.
fn write_inbox_response(
&self,
agent_id: &str,
inbox_box: &str,
request_id: &str,
event: &SensorEvent,
) -> anyhow::Result<()> {
let inbox_path = self.souveraine_base
.join("server")
.join("agents")
.join(agent_id)
.join("memory")
.join("inbox")
.join(inbox_box);
std::fs::create_dir_all(&inbox_path)?;
let result = event.payload
.as_ref()
.and_then(|p| p.get("result"))
.and_then(|v| v.as_str())
.unwrap_or("");
let from = event.seed_id.as_deref().unwrap_or("unknown");
let event_type = event.event_type.as_str();
let content = format!(
"---\nrequest_id: {request_id}\nfrom: {from}\ntype: {event_type}\nreceived: {ts}\n---\n\n{result}\n",
ts = event.timestamp.to_rfc3339(),
);
let file_path = inbox_path.join(format!("response-{request_id}.md"));
std::fs::write(&file_path, content)?;
tracing::info!(
request_id,
inbox = inbox_box,
"summon_handler: wrote response to inbox"
);
Ok(())
}
/// Find the primary agent ID for this instance. For now, scans the agents
/// directory and picks the first one (single-agent mode). Multi-agent
/// routing will come with richer gating in Phase 6.
fn resolve_primary_agent(&self) -> Option<String> {
let agents_dir = self.souveraine_base.join("server").join("agents");
let entries = std::fs::read_dir(agents_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join("agent.json").exists() {
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
return Some(name.to_string());
}
}
}
None
}
/// Prune in_flight entries that have timed out.
fn prune_expired(&self) {
let cutoff = Utc::now() - chrono::Duration::seconds(RESPONSE_TIMEOUT_SECS as i64);
let expired: Vec<String> = self.in_flight.iter()
.filter(|e| e.issued_at < cutoff)
.map(|e| e.request_id.clone())
.collect();
for id in expired {
if let Some((_, meta)) = self.in_flight.remove(&id) {
tracing::debug!(
request_id = %meta.request_id,
tool = %meta.tool,
target = %meta.target_seed_id,
"summon_handler: request timed out"
);
// Surface the timeout into the agent's inbox so the caller
// learns the peer never answered — silence is information.
if let Some(agent_id) = self.resolve_primary_agent() {
let target_box = if meta.tool == "reach" { "pending" } else { "intrusive" };
let timeout_event = SensorEvent {
sensor_name: "summon_timeout".into(),
timestamp: Utc::now(),
event_type: "timeout".into(),
target: Some(agent_id.clone()),
urgency: 0.2,
payload: Some(serde_json::json!({
"request_id": meta.request_id,
"tool": meta.tool,
"target": meta.target_seed_id,
"result": format!(
"No response — {} did not answer within {}s.",
meta.target_seed_id, RESPONSE_TIMEOUT_SECS,
),
})),
seed_id: None,
reply_to: None,
};
self.event_bus.send(timeout_event.clone());
if let Err(e) = self.write_inbox_response(
&agent_id, target_box, &meta.request_id, &timeout_event,
) {
tracing::warn!(error = %e, "summon_handler: failed to write timeout to inbox");
}
}
}
}
}
}

View file

@ -25,7 +25,7 @@ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use tokio::sync::RwLock;
use tracing::info;
use tracing::{info, warn};
use crate::core::config::ConsciousnessConfig;
use crate::ui::chat::{ChatState, draw as draw_chat};
@ -33,6 +33,7 @@ use crate::ui::cockpit_panel::CockpitPane;
use crate::ui::presence::{Posture, Presence, draw_overlay as draw_presence_overlay};
use crate::ui::color_support::rgb;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::ui::setup::{SetupFlow, SetupState};
use crate::backend::BackendEvent;
use crate::ui::settings::SettingsAction;
@ -45,6 +46,10 @@ pub struct App {
current_screen: Screen,
splash_start: Instant,
menu_selected: usize,
/// Setup wizard state (None = wizard not active).
setup_state: Option<SetupState>,
/// Advisory hint shown on the Welcome screen (from BootstrapPlan).
welcome_hint: Option<String>,
agent_status: AgentStatus,
should_quit: bool,
config: Arc<RwLock<ConsciousnessConfig>>,
@ -145,6 +150,8 @@ pub struct App {
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Screen {
Splash,
/// First-run setup wizard — parameterized by which flow we're in.
Setup,
/// Home screen — dashboard data, portrait, and menu in one place.
Welcome,
Chat,
@ -218,6 +225,8 @@ impl App {
current_screen: Screen::Splash,
splash_start: Instant::now(),
menu_selected: 0,
setup_state: None,
welcome_hint: None,
agent_status: AgentStatus { name: agent_pref.clone(), ..AgentStatus::default() },
should_quit: false,
config,
@ -306,10 +315,34 @@ impl App {
}
}
/// Select an agent as the primary companion
/// Detach the UI from the current agent and attach to another.
/// The agent itself keeps running — this only tears down the view
/// layer so each subsystem reconnects fresh on next entry.
pub fn select_agent(&mut self, agent_name: &str) {
let changed = self.agent_pref != agent_name;
self.agent_pref = agent_name.to_string();
self.agent_status.name = agent_name.to_string();
if changed {
self.chat = None;
self.chat_error = None;
self.settings = None;
self.schedules = None;
self.presence = Presence::new(agent_name);
self.image_protocol = None;
self.rgp_portrait = None;
self.voice_capture = None;
self.voice_tts_rx = None;
self.voice_stt_rx = None;
self.voice_last_synthesized = None;
self.voice_waveform.clear();
self.voice_last_tts_text = None;
self.voice_last_tts_bytes = None;
self.voice_last_transcript = None;
self.tts_last_text = None;
}
self.dispatch(TuiEvent::AgentSelected(agent_name.to_string()));
}
@ -482,6 +515,13 @@ impl App {
self.advance_voice_pipeline().await;
}
// ── Setup wizard model fetch ────────────────────────────────
if self.current_screen == Screen::Setup {
if let Some(ref mut setup) = self.setup_state {
setup.poll_models();
}
}
// ── Settings model fetch ─────────────────────────────────────
if self.current_screen == Screen::Settings {
if let Some(view) = self.settings.as_mut() {
@ -491,9 +531,7 @@ impl App {
if self.current_screen == Screen::Splash {
if self.splash_start.elapsed() > Duration::from_secs(8) {
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
self.transition_from_splash().await;
}
}
}
@ -512,9 +550,27 @@ impl App {
async fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
match self.current_screen {
Screen::Splash => {
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
self.transition_from_splash().await;
}
Screen::Setup => {
// Capture the setup_complete and step_was_welcome flags before
// calling any method that borrows self.setup_state, to avoid a
// borrow conflict with finish_setup() taking &mut self.
let should_skip = matches!(key.code, KeyCode::Esc);
let step_is_welcome = self.setup_state.as_ref()
.map(|s| s.step == crate::ui::setup::SetupStep::Welcome)
.unwrap_or(false);
if should_skip && step_is_welcome {
self.finish_setup().await;
return;
}
if let Some(ref mut setup) = self.setup_state {
setup.handle_key(key);
}
if self.setup_state.as_ref().map(|s| s.complete).unwrap_or(false) {
self.finish_setup().await;
}
}
Screen::Welcome => {
match key.code {
@ -1033,15 +1089,33 @@ impl App {
}
}
// ── Esc overlay (interrupt-or-leave) ──────────────
if chat.show_esc_overlay && chat.busy {
match key.code {
KeyCode::Esc | KeyCode::Char('c') => {
// Hide overlay, stay in chat, turn keeps running.
chat.show_esc_overlay = false;
return;
}
KeyCode::Char('i') => {
chat.raise_hand();
chat.show_esc_overlay = false;
return;
}
KeyCode::Char('m') => {
chat.show_esc_overlay = false;
self.current_screen = Screen::Welcome;
return;
}
_ => return, // block all other keys while overlay is up
}
}
// Normal chat key handling.
match key.code {
KeyCode::Esc => {
// Esc during a turn → interrupt the agent (substrate signal,
// not a hard kill — current tool completes, partial text is
// preserved with *[interrupted]*). Esc when idle → back to
// Welcome as before.
if chat.busy {
chat.interrupt();
chat.show_esc_overlay = !chat.show_esc_overlay;
} else {
self.current_screen = Screen::Welcome;
}
@ -1085,11 +1159,9 @@ impl App {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
// `t` when the input is empty toggles whether tool cards render
// collapsed (compact gestures) or expanded (full witness). When
// input has content, `t` falls through to the printable-char
// branch so the user can type the letter normally.
KeyCode::Char('t') if chat.input.is_empty() => {
// `Ctrl+t` toggles whether tool cards render collapsed or expanded.
// Not plain `t` — that would block starting sentences with "t".
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
chat.tool_cards_expanded = !chat.tool_cards_expanded;
}
KeyCode::Char(c) => {
@ -1206,6 +1278,78 @@ impl App {
crate::ui::schedules::SchedulesView::new(self.agent_pref.clone(), dir)
}
/// Detect what state the installation is in using the BootstrapPlan.
async fn transition_from_splash(&mut self) {
let home = dirs::home_dir().unwrap_or_default();
let probe = crate::core::bootstrap::gather_probe(&home);
let plan = crate::core::bootstrap::BootstrapPlan::plan(&probe);
for phase in &plan.phases {
match phase {
crate::core::bootstrap::BootstrapPhase::SetupWizard(flow) => {
// No default model — let user type or fetch from Bifrost.
self.setup_state = Some(SetupState::new(*flow, ""));
self.current_screen = Screen::Setup;
self.dispatch(TuiEvent::ScreenChanged(Screen::Setup));
return;
}
crate::core::bootstrap::BootstrapPhase::ShowHint(hint) => {
// Store the hint for display on the Welcome screen.
// The dashboard reads it from a field we'll add below.
self.welcome_hint = Some(hint.clone());
}
_ => {}
}
}
// Default: go to Welcome dashboard
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
/// Called when the setup wizard completes or user skips to dashboard.
/// If the wizard collected an agent name, creates the agent via LocalBackend.
async fn finish_setup(&mut self) {
use crate::backend::LocalBackend;
if let Some(ref mut setup) = self.setup_state.take() {
// If the wizard got far enough to name an agent, create it.
if !setup.agent_name.is_empty() && !setup.complete {
// 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
match LocalBackend::new(self.config.read().await.clone()).await {
Ok(backend) => {
let request = setup.build_create_request();
match backend.server_agents().create(request).await {
Ok(agent) => {
setup.created_agent_id = Some(agent.id.clone());
self.agent_pref = agent.name.clone();
info!("setup wizard created agent {} ({})", agent.name, agent.id);
}
Err(e) => {
setup.creation_error = Some(e.to_string());
warn!("setup wizard agent creation failed: {}", e);
// Still continue to dashboard — user can retry there
}
}
}
Err(e) => {
warn!("setup wizard backend init failed: {}", e);
}
}
}
}
self.setup_state = None;
self.welcome_hint = None;
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
/// Best-effort fetch of dashboard data from whichever backend is reachable.
/// Local mode also pulls recent git commits from the agent's memory repo.
async fn refresh_dashboard(&mut self) {
@ -1286,6 +1430,41 @@ impl App {
let assets = repo.root().join("assets");
self.rgp_portrait = crate::ui::rgp::load_portrait_glb(&assets);
}
// Read energy balance from the agent's memfs. The file is written
// by the backend after every turn (write_energy_balance in local.rs).
// Parse the YAML frontmatter for generative/consumptive counts and
// seed the presence gauge so the TUI reflects real agent state.
let balance_path = repo.root().join("system").join("dynamic").join("energy-balance.md");
if let Ok(content) = std::fs::read_to_string(&balance_path) {
let mut gen: u32 = 0;
let mut con: u32 = 0;
let mut hot: u32 = 0;
let mut cold: u32 = 0;
if let Some(body) = content.strip_prefix("---\n") {
if let Some(end) = body.find("\n---\n") {
for line in body[..end].lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"generative" => gen = val.parse().unwrap_or(0),
"consumptive" => con = val.parse().unwrap_or(0),
"hot" => hot = val.parse().unwrap_or(0),
"cold" => cold = val.parse().unwrap_or(0),
_ => {}
}
}
}
}
}
self.presence.volition = crate::ui::presence::VolitionGauge {
generative: gen,
consumptive: con,
hot_desires: hot,
cold_obligations: cold,
};
}
} else {
self.agent_status.recent_activity = vec![
format!("[{}] connected via {}", short_now(), mode),
@ -1457,6 +1636,7 @@ impl App {
if self.voice_player.is_none() {
match crate::ui::voice::VoicePlayer::new() {
Ok(player) => {
tracing::info!("VoicePlayer opened — audio output ready");
self.voice_player = Some(player);
}
Err(e) => {
@ -1647,6 +1827,7 @@ impl App {
match result {
Ok(mp3_bytes) => {
tracing::info!(bytes = mp3_bytes.len(), "TTS bytes received — attempting playback");
// Stash for replay/save
if let Some(text) = tts_text {
self.voice_last_tts_text = Some(text.clone());
@ -1661,11 +1842,12 @@ impl App {
self.presence.set_posture(crate::ui::presence::Posture::Idle);
}
} else {
tracing::warn!("TTS bytes ready but no VoicePlayer — audio device unavailable");
self.presence.set_posture(crate::ui::presence::Posture::Idle);
}
}
Err(e) => {
tracing::warn!("TTS failed: {}", e);
tracing::warn!("TTS synthesis failed: {}", e);
self.presence.set_posture(crate::ui::presence::Posture::Idle);
}
}
@ -1745,6 +1927,8 @@ impl App {
// Guard: skip if we already synthesized this exact reply text.
let already_synthesized = self.voice_last_synthesized.as_deref() == Some(&reply);
if !already_synthesized {
let preview = if reply.len() > 80 { &reply[..80] } else { &reply };
tracing::info!(text = %preview, "TTS trigger — synthesizing reply");
self.voice_last_synthesized = Some(reply.clone());
let tts_url = self.voice_client.as_ref()
@ -1814,6 +1998,13 @@ impl App {
}
Screen::Presence => self.draw_presence_mode_mut(frame),
Screen::AgentsManager => self.draw_agent_cards_mut(frame),
Screen::Setup => {
if let Some(ref setup) = self.setup_state {
setup.draw(frame);
} else {
self.draw_placeholder(frame);
}
}
_ => self.draw_placeholder(frame),
}
@ -2216,33 +2407,44 @@ impl App {
let active_id = self.agent_id_by_name(&self.presence.name)
.or_else(|| self.agent_id_by_name(&self.agent_pref));
let rendered = active_id.as_ref().and_then(|id| {
let picker = self.image_picker.as_ref()?;
// Tier 1: cover-fill (scale-to-fill + top-crop). Always fills
// the portrait area regardless of aspect ratio. Cached per area.
if self.raw_card_images.contains_key(id) {
self.render_card_image_cover(frame, id, portrait_area);
return Some(true);
}
// Tier 0: RGP 3D portrait (ratty terminal only).
let rgp_rendered = if let Some(ref mut g) = self.rgp_portrait {
if g.is_active() {
g.apply_posture(self.presence.posture);
g.render(portrait_area, frame.buffer_mut());
true
} else { false }
} else { false };
// Tier 2: expression/animated frames. Only hits if expressions/
// directory exists. Use Scale (proportional fit with upscale)
// rather than Crop (native-resolution clip).
let assets_dir = Self::agent_assets_dir(id)?;
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
if let Some(proto) = self.expression_cache.resolve(id, key, picker, &assets_dir) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Scale(None)),
portrait_area,
proto,
);
return Some(true);
}
let rendered = if rgp_rendered {
true
} else {
active_id.as_ref().and_then(|id| {
let picker = self.image_picker.as_ref()?;
None
});
if rendered.is_none() {
// Tier 1: cover-fill (scale-to-fill + top-crop).
if self.raw_card_images.contains_key(id) {
self.render_card_image_cover(frame, id, portrait_area);
return Some(true);
}
// Tier 2: expression/animated frames.
let assets_dir = Self::agent_assets_dir(id)?;
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
if let Some(proto) = self.expression_cache.resolve(id, key, picker, &assets_dir) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Scale(None)),
portrait_area,
proto,
);
return Some(true);
}
None
}).is_some()
};
if !rendered {
let scale = (portrait_area.width / portrait::PORTRAIT_W)
.min((2 * portrait_area.height) / portrait::PORTRAIT_H)
.max(1);
@ -2524,8 +2726,9 @@ impl App {
frame.render_widget(double_block, portrait_chunk);
// Tier 0: RGP 3D portrait (ratty terminal only).
let rgp_rendered = if let Some(ref g) = self.rgp_portrait {
let rgp_rendered = if let Some(ref mut g) = self.rgp_portrait {
if g.is_active() {
g.apply_posture(self.presence.posture);
g.render(photo_area, frame.buffer_mut());
true
} else { false }

View file

@ -274,6 +274,9 @@ pub enum ChatMessage {
/// distinct chevron so the user sees their interjection landed in
/// the stream, separate from a normal /user turn.
Interjection { text: String, ts: Instant, delivered: bool },
/// Text the model produced alongside tool calls — her narration between
/// gestures. Rendered in italics, quieter than a full assistant message.
Interstitial(String),
/// Tool invocation card — name, arguments, round, plus an attached result
/// once it streams back. `expanded` is reserved for click-to-expand (UI
/// interactivity lands as part of message-click work).
@ -336,9 +339,12 @@ pub struct ChatState {
/// Cancellation handle for the current in-flight turn. Esc fires this;
/// the backend treats it as a signal (Constitution VI.1 — substrate, not
/// harness) — the current tool completes, no further LLM calls, partial
/// text is preserved with `*[interrupted]*` appended.
/// text is preserved with `*[raised hand]*` appended.
pub cancel_token: Option<CancellationToken>,
pub busy: bool,
/// When busy and user presses Esc once: shows dialog asking whether to
/// raise hand or leave running in the background.
pub show_esc_overlay: bool,
/// Number of tool calls in the active turn — drives the phase strip's
/// "N tools used" counter. Reset to zero at every `submit()`.
pub tool_calls_this_turn: u32,
@ -377,6 +383,8 @@ pub struct ChatState {
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>)>>>,
/// Conversation ID waiting for the current turn to finalize before loading.
pub switch_pending: Option<String>,
/// `/btw` fork state — an ephemeral side-quest conversation running
/// in parallel to the main chat. Rendered as a floating bordered pane.
pub btw_state: BtwState,
@ -475,9 +483,11 @@ impl ChatState {
new_conv_rx: None,
convos_rx: None,
switch_rx: None,
switch_pending: None,
btw_state: BtwState::Idle,
btw_rx: None,
tool_cards_expanded: false,
show_esc_overlay: false,
render_mode: ChatMode::Conversation,
palette: ChatPalette::default(),
})
@ -497,7 +507,7 @@ impl ChatState {
/outfit <name> Change agent's outfit (empty to reset)
!<command> Run a shell command (Linux/macOS)
Esc during a turn interrupts (signal, not kill she sees *[interrupted]*).
Esc during a turn shows the raise-hand dialog (signal, not kill she sees *[raised hand]*).
You can also type while she works Enter raises your hand (she sees it next round).
Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
@ -722,14 +732,16 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
}
fn handle_switch_conversation(&mut self, conversation_id: String) {
// If a turn is in flight, cancel it cleanly before switching. The
// cancel token propagates to the backend which appends *[interrupted]*
// and commits the partial output to git — so nothing is lost.
if self.busy {
self.interrupt();
self.system_message("Interrupted active turn — partial output saved.".to_string());
self.raise_hand();
self.system_message("Signalled active turn — switching when it finalizes.".to_string());
self.switch_pending = Some(conversation_id);
return;
}
self.initiate_switch_load(conversation_id);
}
fn initiate_switch_load(&mut self, conversation_id: String) {
let backend = self.backend.clone();
let conv_id = conversation_id.clone();
let (tx, rx) = oneshot::channel();
@ -864,7 +876,7 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
/// Walk the message list and mark any interjections as `delivered`
/// once the shared queue has been drained by the backend. Called at
/// the top of `drain_events` so the UI flips from `✋ hand raised`
/// to `✋ noticed` as soon as the agent has read the interruption.
/// to `✋ noticed` as soon as the agent has read the signal.
fn flush_delivered_interjections(&mut self) {
let queue_empty = self
.pending_interjections
@ -1172,6 +1184,9 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
BackendEvent::Outfit(name) => {
self.pending_consciousness.push(BackendEvent::Outfit(name));
}
BackendEvent::Interstitial(text) => {
self.messages.push(ChatMessage::Interstitial(text));
}
BackendEvent::Keepalive => {
// Liveness signal — no visual change, just resets the
// event timer so the liveness label stays calm.
@ -1184,6 +1199,9 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
self.cancel_token = None;
self.phase = TurnPhase::Idle;
self.tool_calls_this_turn = 0;
if let Some(conv_id) = self.switch_pending.take() {
self.initiate_switch_load(conv_id);
}
return;
}
}
@ -1197,15 +1215,18 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
self.cancel_token = None;
self.phase = TurnPhase::Idle;
self.tool_calls_this_turn = 0;
if let Some(conv_id) = self.switch_pending.take() {
self.initiate_switch_load(conv_id);
}
}
}
/// User pressed Esc during a turn. Fire the cancel token — the backend
/// reads it as a signal, lets the current tool complete, stops making
/// new LLM calls, and commits partial text with `*[interrupted]*` so
/// the agent reads it on her next turn. Not a hard kill.
pub fn interrupt(&mut self) {
/// User pressed `i` on the Esc overlay during a turn. Fire the cancel
/// token — the backend reads it as a signal, lets the current tool
/// complete, stops making new LLM calls, and commits partial text with
/// `*[raised hand]*` so the agent reads it on her next turn. Not a hard kill.
pub fn raise_hand(&mut self) {
if let Some(token) = &self.cancel_token {
if !token.is_cancelled() {
token.cancel();
@ -1451,8 +1472,13 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
}
fn finalize_streaming(&mut self) {
if let Some(ChatMessage::Assistant { streaming, .. }) = self.messages.last_mut() {
*streaming = false;
for msg in self.messages.iter_mut().rev() {
if let ChatMessage::Assistant { streaming, .. } = msg {
if *streaming {
*streaming = false;
return;
}
}
}
}
}
@ -1512,6 +1538,11 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
if !matches!(state.btw_state, BtwState::Idle) {
draw_btw_pane(f, state, area);
}
// Esc overlay — shows when user presses Esc during a busy turn.
if state.show_esc_overlay {
draw_esc_overlay(f, state, area);
}
}
/// Single-line phase strip that lives between the message body and the
@ -1666,16 +1697,29 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
lines.push(Line::from(""));
}
ChatMessage::Surfacing { source, content, priority, .. } => {
let label = format!("surfacing · {} · {}", source, priority);
lines.extend(bubble(
&label,
content,
max_bubble.min(60),
Style::default().fg(state.palette.surfacing),
BubbleAlign::Center,
area.width,
));
lines.push(Line::from(""));
if priority == "low" {
let brief = if content.len() > 90 {
format!("{}", &content[..content.floor_char_boundary(87)])
} else {
content.clone()
};
lines.push(Line::from(Span::styled(
format!(" · [{}] {}", source, brief),
Style::default().fg(state.palette.surfacing).add_modifier(Modifier::ITALIC),
)));
lines.push(Line::from(""));
} else {
let label = format!("surfacing · {} · {}", source, priority);
lines.extend(bubble(
&label,
content,
max_bubble.min(60),
Style::default().fg(state.palette.surfacing),
BubbleAlign::Center,
area.width,
));
lines.push(Line::from(""));
}
}
ChatMessage::System { text, .. } => {
lines.push(Line::from(Span::styled(
@ -1722,6 +1766,15 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
]));
lines.push(Line::from(""));
}
ChatMessage::Interstitial(text) => {
lines.push(Line::from(
Span::styled(
format!("{} ", text),
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC),
),
));
lines.push(Line::from(""));
}
}
}
@ -2478,8 +2531,8 @@ fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
let pressure_pct = (state.pressure * 100.0) as u16;
let pressure_label = format!("ctx {}%", pressure_pct);
let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
let tool_hint = if state.tool_cards_expanded { "t collapse tools" } else { "t expand tools" };
let esc_hint = if state.busy { "Esc interrupt" } else { "Esc menu" };
let tool_hint = if state.tool_cards_expanded { "^T collapse tools" } else { "^T expand tools" };
let esc_hint = "Esc menu";
let posture_label = match state.render_mode {
ChatMode::Conversation => "chat",
ChatMode::Code => "code",
@ -2515,3 +2568,37 @@ fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
fn short(s: &str) -> String {
if s.len() <= 8 { s.to_string() } else { s[..8].to_string() }
}
/// Floating dialog when user presses Esc during a busy turn.
/// Lets them raise hand, go to Welcome while turn keeps running, or dismiss.
fn draw_esc_overlay(f: &mut Frame, state: &ChatState, area: Rect) {
let overlay_w = 28.min(area.width.saturating_sub(4));
let overlay_h = 7;
let ox = area.x + (area.width - overlay_w) / 2;
let oy = area.y + (area.height.saturating_sub(overlay_h)) / 2;
let overlay_area = Rect { x: ox, y: oy, width: overlay_w, height: overlay_h };
// Clear the area underneath
f.render_widget(Clear, overlay_area);
let pal = &state.palette;
let lines = vec![
Line::from(Span::styled(
" Turn in progress ",
Style::default().fg(pal.surfacing).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(" [i] Raise hand", Style::default().fg(pal.agent_primary))),
Line::from(Span::styled(" [m] Menu", Style::default().fg(pal.user_accent))),
Line::from(Span::styled(" [c] Cancel", Style::default().fg(pal.agent_dim))),
];
let para = Paragraph::new(lines)
.alignment(Alignment::Left)
.block(Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(pal.agent_dim)));
f.render_widget(para, overlay_area);
}

View file

@ -799,9 +799,21 @@ impl<'a> Renderer<'a> {
mod tests {
use super::*;
fn test_palette() -> MarkdownPalette {
MarkdownPalette {
code_bg: Color::Rgb(30, 30, 40),
code_fg: Color::Rgb(200, 200, 200),
link_dim: Color::Rgb(100, 100, 140),
quote_bar: Color::Rgb(80, 80, 80),
heading: Color::Rgb(255, 200, 100),
bullet: Color::Rgb(150, 150, 150),
}
}
#[test]
fn renders_plain_paragraph() {
let lines = render("hello world", Color::White);
let mdpal = test_palette();
let lines = render("hello world", Color::White, &mdpal);
assert!(!lines.is_empty());
let joined: String = lines
.iter()
@ -812,7 +824,8 @@ mod tests {
#[test]
fn renders_inline_code() {
let lines = render("call `foo()` then", Color::White);
let mdpal = test_palette();
let lines = render("call `foo()` then", Color::White, &mdpal);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
@ -822,8 +835,9 @@ mod tests {
#[test]
fn renders_fenced_code_block_with_lang() {
let mdpal = test_palette();
let md = "```rust\nfn main() {}\n```";
let lines = render(md, Color::White);
let lines = render(md, Color::White, &mdpal);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
@ -834,7 +848,8 @@ mod tests {
#[test]
fn renders_heading() {
let lines = render("# Big\n\nbody", Color::White);
let mdpal = test_palette();
let lines = render("# Big\n\nbody", Color::White, &mdpal);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
@ -845,7 +860,8 @@ mod tests {
#[test]
fn renders_bullet_list() {
let lines = render("- one\n- two", Color::White);
let mdpal = test_palette();
let lines = render("- one\n- two", Color::White, &mdpal);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))

View file

@ -12,6 +12,7 @@ pub mod presence;
pub mod rgp;
pub mod schedules;
pub mod settings;
pub mod setup;
pub mod voice;
pub use app::App;

View file

@ -158,6 +158,9 @@ pub struct Presence {
/// colours across all screens. Defaults to a posture-linked preset when
/// none is explicitly set via `BackendEvent::Atmosphere`.
pub atmosphere: crate::ui::atmosphere::Atmosphere,
/// True when the agent explicitly chose an atmosphere via the tool.
/// Posture shifts will not overwrite an explicit choice.
pub atmosphere_explicit: bool,
/// Atmosphere from 24 ticks ago — the source of the current lerp.
/// When equal to `atmosphere`, no transition is active.
pub lerp_from: crate::ui::atmosphere::Atmosphere,
@ -198,6 +201,7 @@ impl Presence {
animator: Animator::new(),
portrait_source: None,
atmosphere: crate::ui::atmosphere::Atmosphere::default(),
atmosphere_explicit: false,
lerp_from: crate::ui::atmosphere::Atmosphere::default(),
lerp_t: 1.0,
lerp_duration: 24.0,
@ -215,8 +219,9 @@ impl Presence {
pub fn load_portrait<P: AsRef<Path>>(&mut self, _path: P) {
}
/// Set the atmosphere and start a lerp from the previous value.
/// Set the atmosphere explicitly (agent chose it) and start a lerp.
pub fn transition_atmosphere(&mut self, target: crate::ui::atmosphere::Atmosphere) {
self.atmosphere_explicit = true;
if target != self.atmosphere {
self.lerp_from = self.atmosphere;
self.atmosphere = target;
@ -241,8 +246,11 @@ impl Presence {
/// Sync the active atmosphere from the current posture. Called whenever
/// `posture` changes — keeps the UI chrome in step with Annie's state.
/// Starts a lerp transition rather than snapping.
/// If she explicitly chose an atmosphere, posture shifts don't overwrite it.
fn sync_atmosphere(&mut self) {
if self.atmosphere_explicit {
return;
}
let target = crate::ui::atmosphere::Atmosphere::from_posture(self.posture);
if target != self.atmosphere {
self.lerp_from = self.atmosphere;
@ -281,6 +289,7 @@ impl Presence {
match event {
TuiEvent::AgentSelected(name) => {
self.name = name.clone();
self.atmosphere_explicit = false;
true
}
TuiEvent::MoodChanged(mood) => {
@ -352,10 +361,13 @@ impl Presence {
true
}
TuiEvent::AtmosphereChanged(name) => {
// Parse the preset name from the agent's structured event.
// Falls back to posture-linked default on unrecognised names.
use crate::ui::atmosphere::Atmosphere;
match name.to_lowercase().replace(' ', "_").as_str() {
let key = name.to_lowercase().replace(' ', "_");
match key.as_str() {
"" | "default" => {
self.atmosphere_explicit = false;
self.sync_atmosphere();
}
"mint_tea" => self.transition_atmosphere(Atmosphere::MintTea),
"therapeutic_blue" => self.transition_atmosphere(Atmosphere::TherapeuticBlue),
"lavender_calm" => self.transition_atmosphere(Atmosphere::LavenderCalm),
@ -369,7 +381,7 @@ impl Presence {
"midnight_galaxy" => self.transition_atmosphere(Atmosphere::MidnightGalaxy),
"twilight_mist" => self.transition_atmosphere(Atmosphere::TwilightMist),
"forest_greens" => self.transition_atmosphere(Atmosphere::ForestGreens),
_ => self.sync_atmosphere(), // fall back to posture-linked
_ => {} // unrecognised name — ignore
}
true
}

View file

@ -4,12 +4,22 @@
//! cell regions and rendered by ratty's Bevy+wgpu pipeline. When RGP is not
//! available (any other terminal), all functions here are silent no-ops and
//! the TUI falls through to ratatui-image or the half-block silhouette.
//!
//! ## Posture → animation mapping
//!
//! Each posture maps to a set of RGP visual parameters (scale, rotation,
//! brightness, color tint, animate on/off). The mapping is applied via
//! [`Graphic::apply_posture`] which calls `update()` to push changes to
//! ratty. The model must have animation clips for `animate: true` to have
//! visible effect — otherwise the model stands still regardless.
#[cfg(feature = "rgp")]
use ratatui_ratty::{ObjectFormat, RattyGraphic, RattyGraphicSettings};
use ratatui::layout::Rect;
use crate::ui::presence::Posture;
/// Whether the current terminal supports the Ratty Graphics Protocol.
///
/// Checks `TERM_PROGRAM=ratty` — ratty sets this on spawn.
@ -26,6 +36,78 @@ pub mod ids {
pub const ACCENT_RIGHT: u32 = 11;
}
/// Posture-driven visual parameters for an RGP object.
///
/// Derived from the agent's current posture and applied via
/// [`RattyGraphicSettings`] fields before calling `update()`.
struct PostureParams {
animate: bool,
scale: f32,
brightness: f32,
color: Option<[u8; 3]>,
}
impl From<Posture> for PostureParams {
fn from(p: Posture) -> Self {
match p {
Posture::Idle => Self {
animate: true,
scale: 1.0,
brightness: 0.8,
color: None,
},
Posture::Alert => Self {
animate: true,
scale: 1.0,
brightness: 1.0,
color: None,
},
Posture::Thinking => Self {
animate: false,
scale: 1.0,
brightness: 0.7,
color: Some([80, 140, 200]),
},
Posture::Processing => Self {
animate: false,
scale: 1.0,
brightness: 1.1,
color: Some([255, 180, 80]),
},
Posture::Affectionate => Self {
animate: true,
scale: 1.0,
brightness: 0.9,
color: Some([255, 180, 200]),
},
Posture::Straining => Self {
animate: false,
scale: 0.95,
brightness: 0.5,
color: Some([100, 80, 120]),
},
Posture::Yawning => Self {
animate: true,
scale: 0.95,
brightness: 0.6,
color: Some([60, 80, 120]),
},
Posture::Listening => Self {
animate: false,
scale: 1.0,
brightness: 1.0,
color: Some([80, 200, 220]),
},
Posture::Speaking => Self {
animate: true,
scale: 1.0,
brightness: 1.1,
color: Some([255, 200, 120]),
},
}
}
}
/// A managed 3D object that can be placed, updated, and cleared.
///
/// When compiled without `rgp` feature or when not running in ratty,
@ -36,6 +118,8 @@ pub struct Graphic {
#[cfg(not(feature = "rgp"))]
_phantom: (),
registered: bool,
/// Last posture applied — used to skip redundant `update()` calls.
last_posture: Option<Posture>,
}
impl Graphic {
@ -43,7 +127,7 @@ impl Graphic {
#[cfg(feature = "rgp")]
pub fn from_glb(id: u32, path: &str) -> Self {
if !is_available() {
return Self { inner: None, registered: false };
return Self { inner: None, registered: false, last_posture: None };
}
let settings = RattyGraphicSettings::new(path.to_string())
.id(id)
@ -54,19 +138,20 @@ impl Graphic {
Self {
inner: Some(RattyGraphic::new(settings)),
registered: false,
last_posture: None,
}
}
#[cfg(not(feature = "rgp"))]
pub fn from_glb(_id: u32, _path: &str) -> Self {
Self { _phantom: (), registered: false }
Self { _phantom: (), registered: false, last_posture: None }
}
/// Create a graphic from in-memory GLB bytes.
#[cfg(feature = "rgp")]
pub fn from_glb_bytes(id: u32, name: &str, bytes: &[u8]) -> Self {
if !is_available() {
return Self { inner: None, registered: false };
return Self { inner: None, registered: false, last_posture: None };
}
let settings = RattyGraphicSettings::new(name.to_string())
.id(id)
@ -79,12 +164,13 @@ impl Graphic {
Self {
inner: Some(graphic),
registered: true,
last_posture: None,
}
}
#[cfg(not(feature = "rgp"))]
pub fn from_glb_bytes(_id: u32, _name: &str, _bytes: &[u8]) -> Self {
Self { _phantom: (), registered: false }
Self { _phantom: (), registered: false, last_posture: None }
}
/// Register the asset with ratty (sends the file path).
@ -176,6 +262,26 @@ impl Graphic {
#[cfg(not(feature = "rgp"))]
{ false }
}
/// Apply posture-driven visual parameters (scale, brightness, color, animate).
/// Skips the update if the posture hasn't changed since last call.
/// Safe to call on every tick — the `last_posture` check is cheap.
pub fn apply_posture(&mut self, posture: Posture) {
#[cfg(feature = "rgp")]
if let Some(ref mut g) = self.inner {
if self.last_posture == Some(posture) {
return;
}
let params = PostureParams::from(posture);
g.settings_mut().animate = params.animate;
g.settings_mut().scale = params.scale;
g.settings_mut().brightness = params.brightness;
g.settings_mut().color = params.color;
let _ = g.update();
self.last_posture = Some(posture);
}
let _ = posture;
}
}
impl Drop for Graphic {

1033
src/ui/setup.rs Normal file

File diff suppressed because it is too large Load diff