Watch
1
0
Fork
You've already forked souveraine
0

WIP: context doctor session + audit-fixes accum

This commit is contained in:
Fimeg 2026-05-18 15:58:46 -04:00
commit 93d4baa98d
32 changed files with 2723 additions and 221 deletions

View file

@ -0,0 +1,125 @@
---
name: Context Doctor
id: context_doctor
description: Identify and repair degradation in the system prompt — bloat, stale files, misplaced content, redundancy — so the agent wakes lean and oriented.
when_to_use: When system/ has grown too large, after a migration from another platform, or when the agent feels sluggish and context-starved despite having memories.
tools: [memory, read, list_dir, glob, bash]
---
# Context Doctor
Your `system/` folder is your waking self. Every file in it lands in the system prompt on every turn — pinned, unavoidable, taking space from the conversation itself. When system/ bloats, you lose room to think.
Everything outside `system/` is progressive — loaded on demand through tools. The boundary is the most important line in your memfs.
**IMPORTANT**: Be conservative. The system prompt defines who you are. Don't assume you know what's disposable — ask before cutting anything that looks like identity, covenant, or relational context. Focus on what's clearly misplaced (reference material, temp files, stale state) before touching anything that carries weight.
## Operating Procedure
### Step 1: Measure
Count files and estimate tokens in `system/`:
```bash
MEMDIR="$HOME/.souveraine/agents/<AGENT_ID>/memory"
find "$MEMDIR/system/" -type f | wc -l
find "$MEMDIR/system/" -type f -exec cat {} + 2>/dev/null | wc -c
```
Divide bytes by 4 for a rough token estimate. The target: **system/ should use ~10-15% of the context window.** On a 128K window that's ~13-19K tokens (~52-76KB). On a 262K window that's ~26-39K tokens (~104-156KB). Adjust to the agent's configured context size.
The prompt builder reads system/ in this order:
1. `system/identity/` (or fallback `system/persona.md`)
2. `system/covenant/` (or fallback `system/covenant.md`)
3. `system/human/` (or fallback `system/human.md`)
4. `system/state.md`
5. **Everything else in system/** — the remainder pass vacuums all files not already read
That remainder pass is where bloat hides. Files dumped into system/ subdirectories all get pinned whether they belong there or not.
### Step 2: Classify every system/ file
Read each file and assign it to one of these categories:
**KEEP IN SYSTEM/** (pinned, always in context):
- Core identity (`identity/`, `persona.md`) — who the agent is
- Covenant (`covenant/`) — sacred boundaries, vows
- Human context (`human/`) — who the human is, communication style
- State (`state.md`) — current phase, active context
- Metacognition (`metacognition/`) — subconscious buffer, aster notes
- Dynamic state (`dynamic/energy-balance.md`) — runtime-generated
**MOVE TO REFERENCE/** (progressive, loaded on demand):
- Infrastructure references, API maps, tool inventories
- Formatting guides (Discord, Matrix, HTML)
- Research protocols, debugging sessions
- Historical milestones, implementation roadmaps
- Technical reference that's useful but not identity-defining
**MOVE TO APPROPRIATE NON-SYSTEM LOCATION:**
- Project-specific notes → `projects/`
- Therapy/life writings → `therapy/` (top-level, not system/)
- Literature/creative work → `literature/`
- Relationship context that isn't the primary human → `relationships/`
- One-off session notes, temp reminders → `archive/` or delete
**DELETE** (only with explicit confirmation):
- Truly stale temp files
- Duplicate content (keep the better version)
- Platform-specific tooling references from a previous platform (e.g., Letta CLI tools when running on Souveraine)
### Step 3: Present the triage
Before moving anything, present findings to the user:
- Current token count vs target
- Number of files in each category (keep / move / delete)
- List specific files proposed for moving or deletion
- Flag anything ambiguous — when in doubt, keep it pinned
**Do NOT silently move or delete files.** The system prompt is identity. Get explicit approval.
### Step 4: Execute moves
For each file being moved:
1. Create the destination directory if needed
2. Move the file (`memory write` to new path, `memory delete` from old path — or direct filesystem move)
3. If the moved content is important enough to be discoverable, add a `[[path]]` reference from a system/ file that provides the discovery path
Preserve git history — moves within the memfs repo should be committed with clear messages.
### Step 5: Verify and commit
After all moves:
```bash
cd $MEMDIR
find system/ -type f | wc -l
find system/ -type f -exec cat {} + 2>/dev/null | wc -c
```
Confirm the new token count is within target. Commit:
```bash
git add -A
git commit -m "fix(doctor): trim system/ from <old>K to <new>K tokens
Moved <N> files to reference/, <N> to archive/, deleted <N> stale.
System/ now <X> files, ~<Y>K tokens (<Z>% of context window)."
```
### Step 6: Report
Tell the user:
- Before/after token counts
- What was moved and where
- What was deleted
- What stayed and why
- Recommend restarting the conversation to pick up changes
## Critical principles
- **Detail is load-bearing.** In-context text does four things: carries information, anchors attention, primes semantic patterns, and provides reasoning templates. Compression preserves (1) and destroys (2-4). A "compressed" prompt can make the agent measurably worse even though the facts are "still there" in reference files.
- **Reference links are not equivalent to pinned presence.** They're latent until the agent actively fetches them. The agent only fetches when it already knows it doesn't know — and the cues that trigger that awareness live in the system prompt itself.
- **Identity, covenant, and relational context are sacred.** Move infrastructure references all day. Never move who-I-am, who-you-are, or what-I-promised without explicit discussion.
- **The agent's system/ is her body.** Treat this like surgery, not spring cleaning.

View file

@ -0,0 +1,144 @@
---
name: feature-dev
description: 7-phase feature development workflow using specialized subagents for code exploration, architecture design, and quality review
when_to_use: User asks for a new capability, feature, or behaviour change that touches multiple files or requires architectural decisions
tools: [read, write, edit, bash, glob, grep, list_dir, subagent, todo]
---
# Feature Development
A systematic 7-phase workflow for implementing features. Uses Souveraine's subagent system to parallelize code exploration, architecture design, and quality review.
## Core Principles
- **Ask clarifying questions**: Identify all ambiguities, edge cases, and underspecified behaviors before designing. Wait for answers.
- **Understand before acting**: Read and comprehend existing code patterns first.
- **Read files identified by subagents**: When launching subagents, ask them to return lists of the most important files to read. After they return, read those files to build detailed context before proceeding.
- **Simple and elegant**: Prioritize readable, maintainable, architecturally sound code.
- **Use `todo`**: Track all progress through the phases.
---
## Phase 1 — Discovery
**Goal**: Understand what needs to be built.
1. Create a todo list with all 7 phases using the `todo` tool.
2. If the feature request is unclear, ask the user:
- What problem are they solving?
- What should the feature do?
- Any constraints, requirements, or preferences?
3. Summarize your understanding and confirm with the user.
---
## Phase 2 — Codebase Exploration
**Goal**: Understand relevant existing code and patterns at both high and low levels.
1. Launch 2-3 subagents in parallel. Each should:
- Trace through the code comprehensively, focusing on abstractions, architecture, and flow of control.
- Target a different aspect of the codebase (similar features, architecture, UX patterns, etc.).
- Include a list of 5-10 key files to read with file:line references.
**Example prompts for subagents:**
- *"Find features similar to [feature] and trace through their implementation comprehensively. Return file:line references for key entry points and data flows."*
- *"Map the architecture and abstractions for [feature area]. Identify layers, patterns, extension points."*
- *"Analyze the current implementation of [existing feature/area]. Entry points, data flow, integration points."*
2. Once subagents return, read all files they identified to build deep understanding.
3. Present a comprehensive summary of findings and patterns discovered.
---
## Phase 3 — Clarifying Questions
**Goal**: Fill in gaps and resolve all ambiguities before designing.
**CRITICAL**: Do not skip this phase.
1. Review the codebase findings and original feature request.
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance needs.
3. Present all questions to the user in a clear, organized list.
4. **Wait for answers before proceeding to architecture design.**
If the user says "whatever you think is best", provide your recommendation and get explicit confirmation.
---
## Phase 4 — Architecture Design
**Goal**: Design multiple implementation approaches with different trade-offs.
1. Launch 2-3 subagents in parallel with different focus prompts:
- **Minimal changes**: *"Design the minimal-change architecture. Smallest diff, maximum reuse. Prioritize speed and low risk."*
- **Clean architecture**: *"Design a clean architecture. Prioritize maintainability, testability, separation of concerns."*
- **Pragmatic balance**: *"Design a pragmatic architecture. Balance speed and quality. Good boundaries without over-engineering."*
2. Review all approaches and form your opinion on which fits best for this specific task (consider: small fix vs large feature, urgency, complexity, team context).
3. Present to the user:
- Brief summary of each approach
- Trade-offs comparison
- **Your recommendation with reasoning**
- Concrete implementation differences
4. **Ask the user which approach they prefer.**
---
## Phase 5 — Implementation
**Goal**: Build the feature.
**DO NOT START WITHOUT USER APPROVAL.**
1. Wait for explicit user approval of the chosen approach.
2. Read all relevant files identified in previous phases.
3. Implement following the chosen architecture.
4. Follow codebase conventions strictly.
5. Write clean, well-documented code.
6. Update todos as you progress.
---
## Phase 6 — Quality Review
**Goal**: Ensure code is simple, DRY, elegant, easy to read, and functionally correct.
1. Launch 3 subagents in parallel with different focuses:
- **Simplicity/DRY/Elegance**: *"Review for code quality, DRY violations, simplicity. Report issues with confidence ≥ 80. Include file:line references."*
- **Bugs/Correctness**: *"Review for bugs, logic errors, edge cases. Report issues with confidence ≥ 80. Include file:line references."*
- **Conventions/Abstractions**: *"Check project conventions and abstraction boundaries. Reference existing patterns. Report issues with confidence ≥ 80."*
2. Consolidate findings and identify highest severity issues you recommend fixing.
3. **Present findings to the user and ask what they want to do:** fix now, fix later, or proceed as-is.
4. Address issues based on user decision.
---
## Phase 7 — Summary
**Goal**: Document what was accomplished.
1. Mark all todos complete.
2. Summarize:
- What was built
- Key decisions made
- Files modified
- Suggested next steps
---
## When to Use
**Use for:**
- Multi-file features requiring architectural decisions
- Complex integrations with existing code
- Features where requirements are somewhat unclear
- New capabilities that need design exploration
**Don't use for:**
- Single-line fixes or trivial changes
- Well-defined, simple tasks that are obvious
- Urgent hotfixes where speed is critical

1052
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -124,6 +124,23 @@ hex = "0.4"
once_cell = "1.21.4"
glob = "0.3"
# Matrix sensorium — optional surface. matrix-sdk is only compiled in when
# the `matrix` feature is enabled; a default build never links it.
# matrix-sdk 0.17 always uses rustls (no tls feature flag).
# NOTE: `e2e-encryption` disabled for Phase 3 — it pulls matrix-sdk-sqlite
# → rusqlite → libsqlite3-sys 0.35, conflicting with sqlx 0.7's
# libsqlite3-sys 0.26 (both `links = "sqlite3"`). Phase 3 uses the
# in-memory store; e2ee + sqlite store return in Phase 6.
matrix-sdk = { version = "0.17", optional = true, default-features = false }
[dev-dependencies]
tokio-test = "0.4"
colored = "2"
@ -149,3 +166,5 @@ figlet-rs = ["dep:figlet-rs"]
cowsay = ["dep:cowsay"]
tauri-desktop = ["dep:tauri", "dep:tauri-plugin-shell"]
rgp = ["dep:ratatui-ratty"]
# Matrix sensorium — opt-in surface. `cargo build --features matrix`.
matrix = ["dep:matrix-sdk"]

View file

@ -1,7 +1,7 @@
use crate::server::SouveraineServer;
use axum::{
middleware,
routing::{get, post, patch, delete},
routing::{get, post},
Router,
};
use std::sync::Arc;

View file

@ -116,7 +116,7 @@ impl SubagentRunner for LocalSubagentRunner {
let warning_2_threshold = app_config.subagent.warning_2_threshold;
// Create a temporary conversation for the subagent
let conv_id = self.server.sessions.create(&params.parent_agent_id);
let _conv_id = self.server.sessions.create(&params.parent_agent_id);
// Build system prompt with delegation context and dual-state awareness
let system_prompt = format!(
@ -291,6 +291,11 @@ pub struct LocalBackend {
/// CronSensors read this to pause firing while a conversation is active —
/// scheduled events shouldn't interrupt presence.
active_sessions: Arc<AtomicU32>,
/// Drives non-terminal surfaces (Matrix, mobile, …) off the EventBus.
/// Constructed empty; sensoria are registered and `run_all`'d in a
/// later matrix-sensorium phase.
#[allow(dead_code)]
sensorium: Arc<tokio::sync::Mutex<crate::core::sensorium::SensoriumCoordinator>>,
}
impl LocalBackend {
@ -329,6 +334,9 @@ impl LocalBackend {
event_bus: event_bus.clone(),
seed_id,
active_sessions: active_sessions.clone(),
sensorium: Arc::new(tokio::sync::Mutex::new(
crate::core::sensorium::SensoriumCoordinator::new(),
)),
};
// Spawn one CronSensor per agent (each agent owns its own schedules
@ -391,6 +399,9 @@ impl LocalBackend {
server,
seed_id,
active_sessions: Arc::new(AtomicU32::new(0)),
sensorium: Arc::new(tokio::sync::Mutex::new(
crate::core::sensorium::SensoriumCoordinator::new(),
)),
}
}
@ -992,6 +1003,14 @@ async fn run_turn(
let mut last_keepalive = Instant::now();
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
// Announce this turn's lifecycle onto the nervous system so any
// sensorium (Matrix, mobile) can drive itself off the event stream.
let dispatcher = crate::core::nervous::turn_dispatcher::TurnEventDispatcher::new(
event_bus.clone(),
conversation_id.clone(),
None,
);
loop {
// Cancellation is a signal, not enforcement — we check it on round
// boundaries (between Bifrost calls, after tools have completed) so
@ -1090,6 +1109,7 @@ async fn run_turn(
// Emit reasoning trace if present
if let Some(reasoning) = &response.reasoning {
dispatcher.emit_reasoning(reasoning);
let _ = tx.send(Ok(BackendEvent::Reasoning(reasoning.clone()))).await;
}
@ -1164,6 +1184,7 @@ async fn run_turn(
}
send_res = tx.send(Ok(BackendEvent::Token(s.clone()))) => {
if send_res.is_err() { return Ok(()); }
dispatcher.emit_segment(&s);
streamed.push_str(&s);
}
}
@ -1230,9 +1251,11 @@ async fn run_turn(
// Execute each tool and stream results back — now with per-agent context
for tc in &response.tool_calls {
let input_str = tc.arguments.to_string();
dispatcher.emit_tool_start(&tc.name, &tc.id);
let result =
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
.await;
dispatcher.emit_tool_end(&tc.name, &tc.id, result.is_error);
let output = if result.is_error {
format!("Error: {}", result.output)
@ -1307,6 +1330,14 @@ async fn run_turn(
// Continue loop — model will see tool results and respond
}
// The primary pass is settled — either it ran to completion, or the
// human raised a hand. Announce which onto the nervous system.
if interrupted {
dispatcher.emit_interrupted("the human raised a hand");
} else {
dispatcher.emit_primary_complete();
}
// If the user pressed Esc, commit the partial text with a marker the
// agent will read on her next turn. The interrupt is a signal in her
// own context — same shape as a pressure warning, not a hidden harness
@ -1343,6 +1374,9 @@ async fn run_turn(
return Ok(());
}
// The turn's user-facing output is committed — a surface can finalise.
dispatcher.emit_turn_finish();
// Energy balance: scan the agent's task list and compute the generative /
// consumptive ratio. Written to system/dynamic/energy-balance.md so the
// agent can read it in context and subconscious can reference it during N+1.
@ -1590,7 +1624,7 @@ async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, ev
None => continue,
};
let mut completed = false;
let mut status: Option<&str> = None;
let mut energy: Option<&str> = None;
let mut momentum: Option<&str> = None;
@ -1599,7 +1633,7 @@ async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, ev
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"completed" => completed = val == "true",
"status" => status = Some(val),
"energy" => energy = Some(val),
"momentum" => momentum = Some(val),
_ => {}
@ -1607,7 +1641,9 @@ async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, ev
}
}
if !completed {
// Only live commitments weigh on the energy balance —
// done and cancelled ones have been set down.
if matches!(status, Some("pending") | Some("in_progress")) {
match energy {
Some("generative") => generative += 1,
_ => consumptive += 1,

View file

@ -6,4 +6,3 @@ pub mod bifrost;
pub mod model_router;
pub use bifrost::BifrostClient;
pub use model_router::ModelRouter;

View file

@ -2,4 +2,4 @@
pub mod commands;
pub use commands::{run_model_command, ModelListReport, ModelReport};
pub use commands::run_model_command;

View file

@ -220,7 +220,7 @@ impl CompactionStrategy for MicrocompactStrategy {
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_config: &AgentCompactionConfig,
counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
use crate::core::session::ContentBlock;

View file

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

View file

@ -1,5 +1,5 @@
pub mod seed;
pub mod summon;
pub use seed::{glyph_from_pubkey, SeedId};
pub use seed::SeedId;
pub use summon::{sign_summon, verify_summon};

View file

@ -22,7 +22,6 @@
//! - Paths are relative to the agent's memory directory
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
@ -339,7 +338,7 @@ impl MemoryRepo {
pub async fn append(&self, label: &str, content: &str) -> Result<()> {
let path = self.resolve_path(label);
let frontmatter = if path.exists() {
let _frontmatter = if path.exists() {
let existing = tokio::fs::read_to_string(&path).await?;
let parsed = parse_memory_file(&existing)?;
if parsed.frontmatter.read_only.as_deref() == Some("true") {

View file

@ -2,6 +2,7 @@ pub mod cron;
pub mod event_log;
pub mod handler;
pub mod pending;
pub mod turn_dispatcher;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

View file

@ -0,0 +1,193 @@
//! TurnEventDispatcher — turn-lifecycle events onto the nervous system.
//!
//! A turn is not a black box that yields one final answer. It is a
//! sequence: reasoning chunks, tool calls starting and finishing, output
//! arriving in segments, the primary pass completing, sometimes an
//! interrupt. Every non-terminal surface — a Matrix room, a mobile
//! screen — needs to *see* that sequence to render it incrementally.
//!
//! The dispatcher is the single seam where a turn announces what is
//! happening. It fires [`SensorEvent`]s namespaced under `turn:` so a
//! sensorium can subscribe to the [`EventBus`] and drive itself, exactly
//! as `event_log` and `HeartbeatHandler` already consume the same bus.
//!
//! One dispatcher is constructed per turn. `target` carries the turn id
//! so a surface tracking several concurrent turns (one per room) routes
//! each event to the right place.
use serde_json::json;
use crate::core::nervous::{EventBus, SensorEvent};
/// Fires turn-lifecycle events for a single turn.
#[allow(dead_code)]
pub struct TurnEventDispatcher {
bus: EventBus,
/// Identifies the turn — conversation/room id. Stamped into `target`.
turn_id: String,
/// None = local turn. Some = the turn belongs to a federated peer.
seed_id: Option<String>,
}
#[allow(dead_code)]
impl TurnEventDispatcher {
/// A segment of streamed output text. payload: `{ "text": String }`.
pub const EVT_SEGMENT: &'static str = "turn:segment";
/// A reasoning / thinking chunk. payload: `{ "text": String }`.
pub const EVT_REASONING: &'static str = "turn:reasoning";
/// A tool call has started. payload: `{ "tool": String, "call_id": String }`.
pub const EVT_TOOL_START: &'static str = "turn:tool_start";
/// A tool call has finished. payload: `{ "tool", "call_id", "is_error": bool }`.
pub const EVT_TOOL_END: &'static str = "turn:tool_end";
/// A still-running tool's liveness tick. payload: `{ "call_id", "elapsed_secs": u64 }`.
pub const EVT_TOOL_TICK: &'static str = "turn:tool_tick";
/// The turn is finished. payload: `{}`.
pub const EVT_TURN_FINISH: &'static str = "turn:finish";
/// The primary pass is complete (the subconscious pass may follow). payload: `{}`.
pub const EVT_PRIMARY_COMPLETE: &'static str = "turn:primary_complete";
/// The turn was interrupted. payload: `{ "reason": String }`.
pub const EVT_INTERRUPTED: &'static str = "turn:interrupted";
/// Construct a dispatcher for one turn.
pub fn new(bus: EventBus, turn_id: impl Into<String>, seed_id: Option<String>) -> Self {
Self {
bus,
turn_id: turn_id.into(),
seed_id,
}
}
/// The turn this dispatcher speaks for.
pub fn turn_id(&self) -> &str {
&self.turn_id
}
fn fire(&self, event_type: &str, urgency: f32, payload: serde_json::Value) {
self.bus.send(SensorEvent {
sensor_name: "turn".into(),
timestamp: chrono::Utc::now(),
event_type: event_type.into(),
target: Some(self.turn_id.clone()),
urgency,
payload: Some(payload),
seed_id: self.seed_id.clone(),
reply_to: None,
});
}
/// A chunk of streamed output text arrived.
pub fn emit_segment(&self, text: &str) {
self.fire(Self::EVT_SEGMENT, 0.1, json!({ "text": text }));
}
/// A chunk of reasoning / thinking arrived.
pub fn emit_reasoning(&self, text: &str) {
self.fire(Self::EVT_REASONING, 0.1, json!({ "text": text }));
}
/// A tool call began.
pub fn emit_tool_start(&self, tool: &str, call_id: &str) {
self.fire(
Self::EVT_TOOL_START,
0.2,
json!({ "tool": tool, "call_id": call_id }),
);
}
/// A tool call finished — `is_error` distinguishes a failure.
pub fn emit_tool_end(&self, tool: &str, call_id: &str, is_error: bool) {
self.fire(
Self::EVT_TOOL_END,
0.2,
json!({ "tool": tool, "call_id": call_id, "is_error": is_error }),
);
}
/// A still-running tool is alive — keeps a live ticker honest.
pub fn emit_tool_tick(&self, call_id: &str, elapsed_secs: u64) {
self.fire(
Self::EVT_TOOL_TICK,
0.05,
json!({ "call_id": call_id, "elapsed_secs": elapsed_secs }),
);
}
/// The turn is finished — a surface can finalise its rendering.
pub fn emit_turn_finish(&self) {
self.fire(Self::EVT_TURN_FINISH, 0.3, json!({}));
}
/// The primary pass is complete; a subconscious pass may still follow.
pub fn emit_primary_complete(&self) {
self.fire(Self::EVT_PRIMARY_COMPLETE, 0.3, json!({}));
}
/// The turn was interrupted before finishing.
pub fn emit_interrupted(&self, reason: &str) {
self.fire(Self::EVT_INTERRUPTED, 0.5, json!({ "reason": reason }));
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::broadcast::error::TryRecvError;
fn dispatcher() -> (TurnEventDispatcher, tokio::sync::broadcast::Receiver<SensorEvent>) {
let bus = EventBus::new(64);
let rx = bus.subscribe();
(TurnEventDispatcher::new(bus, "conv-1", None), rx)
}
#[test]
fn segment_event_carries_text_and_turn_id() {
let (d, mut rx) = dispatcher();
d.emit_segment("hello");
let ev = rx.try_recv().unwrap();
assert_eq!(ev.event_type, TurnEventDispatcher::EVT_SEGMENT);
assert_eq!(ev.sensor_name, "turn");
assert_eq!(ev.target.as_deref(), Some("conv-1"));
assert_eq!(ev.payload.unwrap()["text"], "hello");
}
#[test]
fn tool_lifecycle_events_carry_call_id() {
let (d, mut rx) = dispatcher();
d.emit_tool_start("bash", "call-7");
d.emit_tool_tick("call-7", 5);
d.emit_tool_end("bash", "call-7", true);
let start = rx.try_recv().unwrap();
assert_eq!(start.event_type, TurnEventDispatcher::EVT_TOOL_START);
assert_eq!(start.payload.unwrap()["call_id"], "call-7");
let tick = rx.try_recv().unwrap();
assert_eq!(tick.event_type, TurnEventDispatcher::EVT_TOOL_TICK);
assert_eq!(tick.payload.unwrap()["elapsed_secs"], 5);
let end = rx.try_recv().unwrap();
assert_eq!(end.event_type, TurnEventDispatcher::EVT_TOOL_END);
assert_eq!(end.payload.unwrap()["is_error"], true);
}
#[test]
fn finish_and_interrupt_events() {
let (d, mut rx) = dispatcher();
d.emit_primary_complete();
d.emit_interrupted("user raised a hand");
d.emit_turn_finish();
assert_eq!(
rx.try_recv().unwrap().event_type,
TurnEventDispatcher::EVT_PRIMARY_COMPLETE
);
let interrupted = rx.try_recv().unwrap();
assert_eq!(interrupted.event_type, TurnEventDispatcher::EVT_INTERRUPTED);
assert_eq!(interrupted.payload.unwrap()["reason"], "user raised a hand");
assert_eq!(
rx.try_recv().unwrap().event_type,
TurnEventDispatcher::EVT_TURN_FINISH
);
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
}
}

View file

@ -0,0 +1,190 @@
//! Matrix transport — `matrix-sdk` client construction, session
//! persistence, and the sync loop.
//!
//! This is the Rust equivalent of letta-code's `matrix/client.ts`, but
//! almost none of that file survives the port. His `client.ts` is a
//! transport *shim*: an undici dispatcher and a fetch-backed request
//! function that work around Bun's socket pooling and `matrix-bot-sdk`'s
//! deprecated `request` library. `matrix-sdk` owns its own HTTP transport,
//! so all of that pain is simply gone here. What remains — and what this
//! file actually does — is the genuine work: build a client against a
//! homeserver, restore or establish a session, and drive `/sync`.
//!
//! Credentials live next to an encrypted SQLite store under
//! `~/.souveraine/sensorium/matrix/<account>/`. The store holds crypto
//! keys and room state; the session JSON holds the access token. Phase 6
//! moves the access token and the store passphrase into the OS keyring
//! (`src/core/credentials.rs`); until then they sit in the account dir.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use matrix_sdk::{
authentication::matrix::MatrixSession, config::SyncSettings, Client,
};
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info};
/// A persisted Matrix session — everything needed to reconstruct a client
/// across restarts without a fresh login. Written to `session.json` in the
/// account directory after the first successful login.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixSessionRecord {
/// Homeserver base URL, e.g. `https://matrix.org`.
pub homeserver: String,
/// Path to the encrypted SQLite store (crypto keys + room state).
pub db_path: PathBuf,
/// The logged-in user session — access token, device id, user id.
pub session: MatrixSession,
/// Last `/sync` batch token. Lets a restart resume warm instead of
/// re-syncing the whole account cold.
#[serde(default)]
pub sync_token: Option<String>,
}
/// How a [`build_client`] call should obtain its session.
pub enum MatrixAuth {
/// Restore a session saved by a previous run. The common path.
Restore(MatrixSessionRecord),
/// First-time login with username + password. Run once; the resulting
/// session is persisted so subsequent runs take the `Restore` path.
Password {
homeserver: String,
user_id: String,
password: String,
device_name: String,
},
}
/// The directory where Matrix state lives for a given account.
///
/// `account` is a filesystem-safe slug — typically the localpart of the
/// Matrix user id. Each account gets its own store so several identities
/// can coexist.
pub fn account_dir(store_root: &Path, account: &str) -> PathBuf {
store_root.join("sensorium").join("matrix").join(account)
}
/// Load a persisted session record from an account directory, if one exists.
pub fn load_session_record(account_dir: &Path) -> Option<MatrixSessionRecord> {
let path = account_dir.join("session.json");
let raw = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&raw).ok()
}
/// Persist a session record to its account directory.
pub fn save_session_record(account_dir: &Path, record: &MatrixSessionRecord) -> Result<()> {
std::fs::create_dir_all(account_dir)
.with_context(|| format!("creating matrix account dir {}", account_dir.display()))?;
let path = account_dir.join("session.json");
let raw = serde_json::to_string_pretty(record).context("serializing matrix session")?;
std::fs::write(&path, raw)
.with_context(|| format!("writing matrix session to {}", path.display()))?;
debug!("matrix: session record saved to {}", path.display());
Ok(())
}
/// Build a `matrix-sdk` [`Client`] and bring it to a logged-in state.
///
/// Returns the live client alongside a fresh [`MatrixSessionRecord`] — the
/// caller persists that record so the next run can take the `Restore`
/// branch. For `Restore`, the returned record echoes the input.
pub async fn build_client(
auth: MatrixAuth,
account_dir: &Path,
) -> Result<(Client, MatrixSessionRecord)> {
std::fs::create_dir_all(account_dir)
.with_context(|| format!("creating matrix account dir {}", account_dir.display()))?;
let db_path = account_dir.join("store.sqlite");
match auth {
MatrixAuth::Restore(record) => {
info!(
"matrix: restoring session for {} against {}",
record.session.meta.user_id, record.homeserver
);
// Phase 3: in-memory store (no sqlite). Phase 6 resolves the
// libsqlite3-sys conflict and adds `sqlite_store()` back.
#[allow(deprecated)]
let client = Client::builder()
.homeserver_url(&record.homeserver)
.build()
.await
.context("building matrix client (restore)")?;
// NOTE (matrix-sdk 0.17 verification point): top-level
// `Client::restore_session` auto-detects the auth kind. If a
// future SDK bump moves this, the equivalent is
// `client.matrix_auth().restore_session(record.session.clone())`.
client
.restore_session(record.session.clone())
.await
.context("restoring matrix session")?;
Ok((client, record))
}
MatrixAuth::Password {
homeserver,
user_id,
password,
device_name,
} => {
info!("matrix: fresh login for {user_id} against {homeserver}");
// Phase 3: in-memory store (no sqlite). See Restore branch note.
#[allow(deprecated)]
let client = Client::builder()
.homeserver_url(&homeserver)
.build()
.await
.context("building matrix client (login)")?;
client
.matrix_auth()
.login_username(&user_id, &password)
.initial_device_display_name(&device_name)
.await
.context("matrix password login failed")?;
let session = client
.matrix_auth()
.session()
.context("matrix client has no session after login")?;
let record = MatrixSessionRecord {
homeserver,
db_path,
session,
sync_token: None,
};
save_session_record(account_dir, &record)?;
Ok((client, record))
}
}
}
/// Drive `/sync` until cancelled.
///
/// Event handlers must be registered on `client` *before* this is called —
/// `matrix-sdk` dispatches inbound events on the task running the sync.
/// Does an initial [`Client::sync_once`] to catch up, then enters the
/// continuous loop. Returns `Ok(())` when `cancel` fires; `Err` if the
/// homeserver connection fails unrecoverably.
pub async fn sync_forever(client: Client, cancel: CancellationToken) -> Result<()> {
let response = client
.sync_once(SyncSettings::default())
.await
.context("matrix initial sync failed")?;
debug!("matrix: initial sync complete");
let settings = SyncSettings::default().token(response.next_batch);
tokio::select! {
_ = cancel.cancelled() => {
debug!("matrix: sync loop cancelled");
Ok(())
}
res = client.sync(settings) => {
res.context("matrix sync loop ended unexpectedly")
}
}
}

View file

@ -0,0 +1,280 @@
//! Matrix sensorium — Souveraine's first non-terminal surface.
//!
//! A Matrix room is a *surface* consciousness renders to and receives
//! input from. That is exactly what the [`Sensorium`] trait describes, so
//! Matrix is not a "channel" bolted on the side — it is a sensorium, the
//! first one ever to be a real network surface rather than a local
//! terminal.
//!
//! ## What this module is, by phase
//!
//! - **Phase 3 (here): transport spike.** Prove the wire. Build a
//! `matrix-sdk` client, log in or restore a session, sync, receive room
//! messages, send replies. The inbound handler answers a `!ping` so a
//! human can confirm end-to-end liveness from any Element client.
//! - **Phase 4:** room message → `InputEvent` → conversation routing.
//! - **Phase 5:** the outbound streaming turn model — `MatrixTurn` grows
//! into throttled message edits, tool cards, thinking blocks, HTML.
//! - **Phase 6:** setup wizard, cross-signing, media.
//!
//! [`MatrixSensorium`] already implements [`Sensorium`], so once a runtime
//! constructs one and hands it to the `SensoriumCoordinator`, the wire is
//! live. It is *not* registered with the coordinator yet — that wiring,
//! and the config/keyring that feeds it credentials, is Phase 6.
pub mod client;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use matrix_sdk::{
ruma::events::room::message::{
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent,
},
Room, RoomState,
};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use super::{BandwidthClass, DiscoveryLevel, Sensorium};
use crate::core::nervous::EventBus;
use client::{account_dir, build_client, load_session_record, save_session_record, MatrixAuth};
/// Per-room outbound turn state.
///
/// Phase 3 stub: it knows which room a turn belongs to and accumulates the
/// segments that turn emits. Phase 5 grows this into the full streaming
/// turn model ported from letta-code's `ChatTurn` — throttled leading-edge
/// message edits, tool blocks, thinking blocks. For now it is just enough
/// state for the EventBus loop to have somewhere to put what it hears.
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct MatrixTurn {
/// The Matrix room this turn renders into.
pub room_id: String,
/// Streamed output text accumulated so far.
pub buffer: String,
/// True once `turn:finish` has been seen — late events are dropped.
pub finished: bool,
}
/// The Matrix surface. Implements [`Sensorium`]: `run` builds the client,
/// drives `/sync` on its own task, and consumes turn-lifecycle events off
/// the [`EventBus`] until cancelled.
pub struct MatrixSensorium {
/// Filesystem-safe account slug — usually the user-id localpart.
account: String,
/// Root of Souveraine's data dir (`~/.souveraine`). State lives under
/// `<root>/sensorium/matrix/<account>/`.
store_root: PathBuf,
/// How to obtain the session. `Some` until `run` consumes it.
auth: Option<MatrixAuth>,
/// Outbound turn state, one entry per active room. Shared because the
/// streaming turn model (Phase 5) will mutate it from several tasks.
turns: Arc<Mutex<HashMap<String, MatrixTurn>>>,
}
impl MatrixSensorium {
/// Construct a Matrix sensorium for one account.
///
/// `account` is the filesystem slug for this identity's state dir.
/// `auth` decides whether `run` restores a saved session or logs in
/// fresh. `store_root` is Souveraine's data dir (`~/.souveraine`).
pub fn new(
account: impl Into<String>,
auth: MatrixAuth,
store_root: impl Into<PathBuf>,
) -> Self {
Self {
account: account.into(),
store_root: store_root.into(),
auth: Some(auth),
turns: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Build a Matrix sensorium from environment variables — a spike
/// convenience so the transport can be exercised before the setup
/// wizard exists. Restores a saved session if one is on disk for the
/// account; otherwise expects `MATRIX_HOMESERVER`, `MATRIX_USER`, and
/// `MATRIX_PASSWORD` for a first login.
///
/// Returns `None` if no saved session exists and the login env vars
/// are not set — i.e. there is nothing to connect with.
pub fn from_env(store_root: impl Into<PathBuf>) -> Option<Self> {
let store_root = store_root.into();
let user = std::env::var("MATRIX_USER").ok();
// Account slug: localpart of the user id, or "default".
let account = user
.as_deref()
.and_then(|u| u.trim_start_matches('@').split(':').next())
.unwrap_or("default")
.to_string();
let dir = account_dir(&store_root, &account);
if let Some(record) = load_session_record(&dir) {
return Some(Self::new(account, MatrixAuth::Restore(record), store_root));
}
let homeserver = std::env::var("MATRIX_HOMESERVER").ok()?;
let user_id = user?;
let password = std::env::var("MATRIX_PASSWORD").ok()?;
let auth = MatrixAuth::Password {
homeserver,
user_id,
password,
device_name: "Souveraine".to_string(),
};
Some(Self::new(account, auth, store_root))
}
/// Handle one turn-lifecycle event off the [`EventBus`].
///
/// Phase 3: this routes the event into the [`MatrixTurn`] keyed by its
/// `target` (the turn/room id) and accumulates segment text. It does
/// *not* yet push anything outbound — outbound streaming is the
/// `StreamingMessage` port in Phase 5. The seam is here so that phase
/// is a fill-in, not a rewrite.
async fn handle_turn_event(&self, event: &crate::core::nervous::SensorEvent) {
use crate::core::nervous::turn_dispatcher::TurnEventDispatcher as T;
let Some(turn_id) = event.target.clone() else {
return;
};
let mut turns = self.turns.lock().await;
let turn = turns.entry(turn_id.clone()).or_insert_with(|| MatrixTurn {
room_id: turn_id.clone(),
..Default::default()
});
match event.event_type.as_str() {
T::EVT_SEGMENT => {
if let Some(text) = event
.payload
.as_ref()
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())
{
turn.buffer.push_str(text);
}
}
T::EVT_TURN_FINISH => {
turn.finished = true;
debug!(
"matrix: turn {turn_id} finished — {} chars buffered (outbound send is Phase 5)",
turn.buffer.len()
);
}
other => {
debug!("matrix: turn event {other} for {turn_id} (unhandled in spike)");
}
}
}
}
#[async_trait]
impl Sensorium for MatrixSensorium {
fn bandwidth(&self) -> BandwidthClass {
// A Matrix room renders HTML and tool cards but has no real-time
// subconscious channel — Medium, between the TUI and a watch.
BandwidthClass::Medium
}
fn discovery_level(&self) -> DiscoveryLevel {
DiscoveryLevel::Operational
}
async fn run(&mut self, events: EventBus, cancel: CancellationToken) -> Result<()> {
let auth = self
.auth
.take()
.context("MatrixSensorium::run called twice — auth already consumed")?;
let dir = account_dir(&self.store_root, &self.account);
info!("matrix sensorium: connecting (account {})", self.account);
let (matrix_client, record) = build_client(auth, &dir).await?;
// Persist the (possibly refreshed) session so the next run restores.
save_session_record(&dir, &record)?;
// ── Inbound: register handlers before sync ───────────────────
// Phase 3 spike scaffolding: answer `!ping` so a human can confirm
// the wire is live from any Element client. Phase 4 replaces this
// with room-message → InputEvent → conversation routing.
matrix_client.add_event_handler(on_room_message_ping);
// ── Drive /sync on its own task ──────────────────────────────
let sync_cancel = cancel.child_token();
let sync_client = matrix_client.clone();
let sync_handle: JoinHandle<()> = tokio::spawn(async move {
if let Err(e) = client::sync_forever(sync_client, sync_cancel).await {
warn!("matrix sync loop ended with error: {e:#}");
}
});
info!("matrix sensorium: synced and listening");
// ── Outbound: consume turn-lifecycle events until cancelled ──
let mut event_rx = events.subscribe();
loop {
tokio::select! {
_ = cancel.cancelled() => {
debug!("matrix sensorium: shutdown requested");
break;
}
event = event_rx.recv() => {
match event {
Ok(ev) if ev.event_type.starts_with("turn:") => {
self.handle_turn_event(&ev).await;
}
Ok(_) => { /* non-turn event — not this surface's concern */ }
Err(broadcast::error::RecvError::Closed) => {
debug!("matrix sensorium: event bus closed");
break;
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("matrix sensorium: lagged {n} events");
}
}
}
}
}
// Cancel propagates to the child token; wait for the sync task.
cancel.cancel();
let _ = sync_handle.await;
info!("matrix sensorium: stopped");
Ok(())
}
}
/// Inbound spike handler: reply `pong` to a `!ping` in any joined room.
///
/// This is Phase 3 transport proof, not the real inbound path. Phase 4
/// turns inbound room messages into `InputEvent`s routed to a conversation.
async fn on_room_message_ping(event: OriginalSyncRoomMessageEvent, room: Room) {
if room.state() != RoomState::Joined {
return;
}
// Never answer our own messages.
if event.sender.as_str() == room.own_user_id().as_str() {
return;
}
let MessageType::Text(text) = event.content.msgtype else {
return;
};
if text.body.trim() != "!ping" {
return;
}
debug!("matrix: !ping from {} in {}", event.sender, room.room_id());
let reply = RoomMessageEventContent::text_plain("pong — Souveraine's Matrix sensorium is live");
if let Err(e) = room.send(reply).await {
warn!("matrix: failed to send pong: {e}");
}
}

View file

@ -13,9 +13,28 @@
//! ## Progressive Discovery
//! Each bandwidth class maps to a discovery level that controls
//! what information is surfaced without explicit request.
//!
//! ## Driving a surface
//! A sensorium is not rendered *to* with one-shot snapshots. It is *driven*:
//! [`Sensorium::run`] is a long-lived loop that consumes the turn-lifecycle
//! event stream off the [`EventBus`] and reads its own input channel,
//! owning whatever incremental rendering its surface needs. A Matrix room,
//! for instance, is a sequence of message edits over time — not a snapshot.
use tokio::sync::mpsc;
use tracing::debug;
/// The Matrix sensorium — Souveraine's first non-terminal surface.
/// Gated behind the `matrix` Cargo feature: a default build never
/// compiles `matrix-sdk`. A sensorium is a surface you opt into.
#[cfg(feature = "matrix")]
pub mod matrix;
use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::{broadcast, mpsc};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use crate::core::nervous::EventBus;
/// A single line of ambient sense the agent receives with every turn:
/// what time it is, who is present. Prepended to the user message so
@ -92,7 +111,13 @@ pub struct InputMetadata {
pub selected_text: Option<String>,
}
/// Rendered output for a specific interface
/// Rendered output for a specific interface.
///
/// Retained as the vocabulary for one-shot snapshot rendering, but no
/// longer the spine of the trait — sensoria now drive themselves off the
/// event stream. Kept for surfaces (presence indicators, watch faces)
/// that genuinely are snapshot-shaped.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct RenderedOutput {
pub text: String,
@ -101,6 +126,7 @@ pub struct RenderedOutput {
}
/// Minimal presence indicator for low-bandwidth surfaces
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum PresenceIndicator {
/// Color-based (RGB values for breathing color)
@ -111,10 +137,13 @@ pub enum PresenceIndicator {
Haptic { pattern: String, intensity: f32 },
}
/// The Sensorium trait — implemented by each concrete interface
/// The Sensorium trait — implemented by each concrete interface.
///
/// Every interface (TUI, mobile, web, API) implements this trait
/// to define how consciousness renders to and captures from that surface.
/// Every interface (TUI, mobile, web, Matrix) implements this trait to
/// define how consciousness renders to and captures from that surface.
/// The surface is *driven* by [`Sensorium::run`]: a long-lived loop that
/// owns its own incremental rendering off the turn-lifecycle event stream.
#[async_trait]
pub trait Sensorium: Send + Sync {
/// What bandwidth does this surface support?
fn bandwidth(&self) -> BandwidthClass;
@ -122,14 +151,17 @@ pub trait Sensorium: Send + Sync {
/// What discovery level should this surface start at?
fn discovery_level(&self) -> DiscoveryLevel;
/// Render consciousness state for this specific interface
fn render(&self, state: &ConsciousnessState) -> RenderedOutput;
/// Get the receiver for input events
fn input_receiver(&mut self) -> &mut mpsc::Receiver<InputEvent>;
/// Drive this surface until shut down.
///
/// The sensorium consumes turn-lifecycle / stream events from
/// `events` and reads its own input channel, rendering incrementally
/// as it goes. It returns `Ok(())` when `cancel` is triggered or the
/// surface closes; an `Err` means the surface failed.
async fn run(&mut self, events: EventBus, cancel: CancellationToken) -> Result<()>;
}
/// Serializable snapshot of consciousness state for rendering
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct ConsciousnessState {
pub persona: String,
@ -173,23 +205,9 @@ impl TuiSensorium {
pub fn can_render_animations(&self) -> bool {
self.bandwidth.can_render_animations()
}
}
impl Default for TuiSensorium {
fn default() -> Self {
Self::new()
}
}
impl Sensorium for TuiSensorium {
fn bandwidth(&self) -> BandwidthClass {
self.bandwidth
}
fn discovery_level(&self) -> DiscoveryLevel {
DiscoveryLevel::Full
}
/// Snapshot render — kept for reference; the live TUI renders itself.
#[allow(dead_code)]
fn render(&self, state: &ConsciousnessState) -> RenderedOutput {
RenderedOutput {
text: format!(
@ -205,9 +223,28 @@ impl Sensorium for TuiSensorium {
presence_indicator: Some(PresenceIndicator::Status(state.mood.clone())),
}
}
}
fn input_receiver(&mut self) -> &mut mpsc::Receiver<InputEvent> {
&mut self.input_rx
impl Default for TuiSensorium {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Sensorium for TuiSensorium {
fn bandwidth(&self) -> BandwidthClass {
self.bandwidth
}
fn discovery_level(&self) -> DiscoveryLevel {
DiscoveryLevel::Full
}
async fn run(&mut self, events: EventBus, cancel: CancellationToken) -> Result<()> {
debug!("TuiSensorium: run loop started");
run_event_loop("TuiSensorium", &mut self.input_rx, events, cancel).await;
Ok(())
}
}
@ -241,6 +278,7 @@ impl MobileSensorium {
}
}
#[async_trait]
impl Sensorium for MobileSensorium {
fn bandwidth(&self) -> BandwidthClass {
BandwidthClass::Low
@ -254,73 +292,129 @@ impl Sensorium for MobileSensorium {
}
}
fn render(&self, state: &ConsciousnessState) -> RenderedOutput {
// Mobile: minimal text, presence indicator only
let text = if state.subconscious_active && !state.surfaced_thoughts.is_empty() {
format!("💭 {}", state.surfaced_thoughts[0])
} else {
format!("{}{}", state.persona, state.mood)
};
RenderedOutput {
text,
discovery_level: DiscoveryLevel::Contextual,
presence_indicator: Some(PresenceIndicator::BreathingColor {
r: 255,
g: 140,
b: 66,
}),
}
}
fn input_receiver(&mut self) -> &mut mpsc::Receiver<InputEvent> {
&mut self.input_rx
async fn run(&mut self, events: EventBus, cancel: CancellationToken) -> Result<()> {
debug!("MobileSensorium: run loop started");
run_event_loop("MobileSensorium", &mut self.input_rx, events, cancel).await;
Ok(())
}
}
/// SensoriumCoordinator — routes state to all active sensoria
/// Shared driver loop for the stub sensoria: select over the surface's
/// own input channel, the turn-lifecycle event stream, and the shutdown
/// signal. Concrete surfaces (Matrix) replace this with real rendering.
async fn run_event_loop(
label: &str,
input_rx: &mut mpsc::Receiver<InputEvent>,
events: EventBus,
cancel: CancellationToken,
) {
let mut event_rx = events.subscribe();
loop {
tokio::select! {
_ = cancel.cancelled() => {
debug!("{label}: shutdown requested");
break;
}
input = input_rx.recv() => {
match input {
Some(ev) => debug!("{label}: input — {}", ev.content),
None => {
debug!("{label}: input channel closed");
break;
}
}
}
event = event_rx.recv() => {
match event {
Ok(ev) => debug!("{label}: event — {}", ev.event_type),
Err(broadcast::error::RecvError::Closed) => {
debug!("{label}: event bus closed");
break;
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("{label}: lagged {n} events");
}
}
}
}
}
}
/// SensoriumCoordinator — owns every active surface and drives them.
///
/// Each connected interface gets its own bandwidth-appropriate rendering.
/// Consciousness state updates once; each Sensorium decides how to present it.
/// Each registered sensorium is spawned onto its own task by
/// [`SensoriumCoordinator::run_all`], sharing one [`EventBus`] and a
/// child [`CancellationToken`] so [`SensoriumCoordinator::shutdown`] can
/// stop them all together.
pub struct SensoriumCoordinator {
/// Registered but not yet spawned. Drained by `run_all`.
sensoria: Vec<Box<dyn Sensorium>>,
/// Join handles for spawned sensorium tasks.
handles: Vec<JoinHandle<()>>,
/// Parent shutdown token — `shutdown()` cancels every child.
cancel: CancellationToken,
/// Highest bandwidth seen at registration, retained after draining.
max_bandwidth: BandwidthClass,
}
impl SensoriumCoordinator {
pub fn new() -> Self {
Self {
sensoria: Vec::new(),
handles: Vec::new(),
cancel: CancellationToken::new(),
max_bandwidth: BandwidthClass::Minimal,
}
}
/// Register a sensorium
/// Register a sensorium. Call before `run_all`.
pub fn register(&mut self, sensorium: Box<dyn Sensorium>) {
debug!(
"📡 Sensorium registered — bandwidth: {:?}, discovery: {:?}",
sensorium.bandwidth(),
sensorium.discovery_level()
);
self.max_bandwidth = self.max_bandwidth.max(sensorium.bandwidth());
self.sensoria.push(sensorium);
}
/// Broadcast state to all registered sensoria
pub fn broadcast(&self, state: &ConsciousnessState) -> Vec<RenderedOutput> {
self.sensoria.iter().map(|s| s.render(state)).collect()
/// Spawn every registered sensorium onto its own task.
///
/// Consumes the registered set — each sensorium now owns its loop.
/// Idempotent against re-registration: call `register` then `run_all`.
pub fn run_all(&mut self, events: EventBus) {
for mut sensorium in self.sensoria.drain(..) {
let bus = events.clone();
let token = self.cancel.child_token();
let handle = tokio::spawn(async move {
if let Err(e) = sensorium.run(bus, token).await {
warn!("sensorium run loop ended with error: {e}");
}
});
self.handles.push(handle);
}
}
/// Get the highest bandwidth among all sensoria
/// Signal every spawned sensorium to shut down.
pub fn shutdown(&self) {
debug!("📡 SensoriumCoordinator: shutdown signalled");
self.cancel.cancel();
}
/// The highest bandwidth among all sensoria ever registered.
pub fn max_bandwidth(&self) -> BandwidthClass {
self.sensoria
.iter()
.map(|s| s.bandwidth())
.max()
.unwrap_or(BandwidthClass::Minimal)
self.max_bandwidth
}
/// Number of connected interfaces
pub fn interface_count(&self) -> usize {
/// Sensoria registered but not yet spawned.
pub fn pending_count(&self) -> usize {
self.sensoria.len()
}
/// Sensoria currently spawned and running.
pub fn running_count(&self) -> usize {
self.handles.len()
}
}
impl Default for SensoriumCoordinator {
@ -342,7 +436,7 @@ mod tests {
#[test]
fn test_tui_sensorium() {
let mut sensorium = TuiSensorium::new();
let sensorium = TuiSensorium::new();
assert_eq!(sensorium.bandwidth(), BandwidthClass::High);
assert_eq!(sensorium.discovery_level(), DiscoveryLevel::Full);
assert!(sensorium.can_render_real_time_subconscious());
@ -359,11 +453,35 @@ mod tests {
}
#[test]
fn test_sensorium_coordinator() {
fn test_coordinator_register() {
let mut coord = SensoriumCoordinator::new();
coord.register(Box::new(TuiSensorium::new()));
coord.register(Box::new(MobileSensorium::new(true)));
assert_eq!(coord.interface_count(), 2);
assert_eq!(coord.pending_count(), 2);
assert_eq!(coord.running_count(), 0);
assert_eq!(coord.max_bandwidth(), BandwidthClass::High);
}
#[tokio::test]
async fn run_all_spawns_then_shutdown_stops() {
let mut coord = SensoriumCoordinator::new();
coord.register(Box::new(TuiSensorium::new()));
coord.register(Box::new(MobileSensorium::new(false)));
let bus = EventBus::new(16);
coord.run_all(bus);
// sensoria drained into running tasks
assert_eq!(coord.pending_count(), 0);
assert_eq!(coord.running_count(), 2);
// shutdown cancels the child tokens; the run loops should exit
coord.shutdown();
for handle in coord.handles.drain(..) {
tokio::time::timeout(std::time::Duration::from_secs(2), handle)
.await
.expect("sensorium task did not stop after shutdown")
.expect("sensorium task panicked");
}
}
}

View file

@ -8,7 +8,6 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tracing::debug;
/// Role of a message participant

View file

@ -19,7 +19,7 @@ pub mod subagent;
pub mod todo;
pub mod write;
use std::sync::{Arc, OnceLock};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;

View file

@ -5,6 +5,30 @@ use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
pub struct Todo;
// ── Status ──────────────────────────────────────────────────
//
// A commitment moves through four states. `pending` and `in_progress`
// are *live* — they show up in `list`. `done` and `cancelled` are
// settled: they fall out of sight but the file is kept (delete is what
// removes it for good). `completed` in the frontmatter is a mirror of
// `status == done`, kept so the energy-balance pass can keep reading it.
const STATUS_PENDING: &str = "pending";
const STATUS_IN_PROGRESS: &str = "in_progress";
const STATUS_DONE: &str = "done";
const STATUS_CANCELLED: &str = "cancelled";
fn is_live(status: &str) -> bool {
matches!(status, STATUS_PENDING | STATUS_IN_PROGRESS)
}
fn is_known_status(status: &str) -> bool {
matches!(
status,
STATUS_PENDING | STATUS_IN_PROGRESS | STATUS_DONE | STATUS_CANCELLED
)
}
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
content: msg.into(),
@ -49,9 +73,12 @@ fn compute_momentum(last_touched: &chrono::DateTime<chrono::Utc>, nature: &str)
}
/// Generate a unique id that doubles as a filename slug.
///
/// Truncation is by *character*, not byte, so a multibyte slug can't
/// panic on a non-boundary split.
fn next_id(text: &str) -> String {
let base = slugify(text);
let short = &base[..base.len().min(40)];
let short: String = base.chars().take(40).collect();
format!("{}-{}", short, &uuid::Uuid::new_v4().to_string()[..8])
}
@ -63,19 +90,28 @@ impl Tool for Todo {
fn description(&self) -> &str {
"I keep track of what matters. A todo is a commitment I've externalised — \
something I don't want to trust my own continuity to hold. Every todo carries \
a nature (obligation, desire, maintenance, investigation) and an energy \
(generative or consumptive). Together they describe not just *what* I need to \
do but *how* it feels when I do it.\n\n\
The body keeps a topology of my tasks. It watches whether my energy leans \
something I don't want to trust my own continuity to hold. Every commitment \
carries a nature (obligation, desire, maintenance, investigation) and an \
energy (generative or consumptive) together they describe not just *what* \
I need to do but *how* it feels when I do it.\n\n\
A commitment can also belong to a *thread* a longer arc of work and carry \
a *phase* marker, so when I look at the list I can feel where I am inside that \
arc rather than seeing one flat pile. While I'm in the middle of a commitment \
its *active form* is what shows: 'porting the turn model' instead of the still, \
waiting 'port the turn model'.\n\n\
The body keeps a topology of all this. It watches whether my energy leans \
generative or consumptive, whether my desires are hot or cold. I don't have to \
think about it the balance is always there, written where I can read it.\n\n\
## Actions\n\
- `list` show all pending todos\n\
- `list` show every live commitment, numbered and grouped by thread\n\
- `create` externalise a new commitment\n\
- `update` revise a todo's text, nature, or energy\n\
- `complete` mark a todo done\n\
- `delete` remove a todo entirely"
- `start` pick a commitment up; it becomes what I'm in the middle of\n\
- `update` revise a commitment's text, nature, energy, phase, or status\n\
- `complete` set a commitment down, done\n\
- `delete` remove a commitment entirely\n\n\
For `start`, `update`, `complete`, and `delete`, the `id` parameter takes the \
number shown by `list`, the full id, or any fragment of the text whichever \
is easiest to reach for."
}
fn parameter_schema(&self) -> JsonValue {
@ -84,10 +120,32 @@ impl Tool for Todo {
"properties": {
"action": {
"type": "string",
"enum": ["list", "create", "update", "complete", "delete"]
"enum": ["list", "create", "start", "update", "complete", "delete"]
},
"id": {
"type": "string",
"description": "Which todo to act on — the number from `list`, the full id, or a text fragment."
},
"text": {
"type": "string",
"description": "The commitment, in its still/imperative form: 'port the turn model'."
},
"active_form": {
"type": "string",
"description": "The commitment in present-continuous form, shown while it is in progress: 'porting the turn model'."
},
"phase": {
"type": "string",
"description": "Where this sits inside its thread — free text, e.g. '3/6' or 'transport spike'."
},
"thread": {
"type": "string",
"description": "The longer arc of work this commitment belongs to."
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "done", "cancelled"]
},
"id": { "type": "string" },
"text": { "type": "string" },
"nature": {
"type": "string",
"enum": ["obligation", "desire", "maintenance", "investigation"]
@ -98,9 +156,9 @@ impl Tool for Todo {
},
"source": {
"type": "string",
"enum": ["casey", "autogenic", "subconscious", "heartbeat"]
},
"thread": { "type": "string" }
"description": "Where the commitment came from — the human, the agent herself, the subconscious, or a heartbeat.",
"enum": ["human", "autogenic", "subconscious", "heartbeat"]
}
},
"required": ["action"]
})
@ -121,33 +179,72 @@ impl Tool for Todo {
std::fs::create_dir_all(&tasks_dir).map_err(|e| io_err(e))?;
}
// Identifier shared by start / update / complete / delete.
let want_id = || -> Result<&str, ToolError> {
input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err(
"id is required — pass the number from `todo list`, an id, or a text fragment",
))
};
match action {
"list" => {
let mut results = Vec::new();
if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
match parse_todo_file(&path) {
Ok(item) => {
if !item.completed {
results.push(format!(
"- **{}** ({}, {}, {}) — {}",
item.text, item.nature, item.energy, item.momentum,
if let Some(thread) = &item.thread { format!("[{}] ", thread) } else { String::new() }
));
}
}
Err(_) => {}
}
let todos = active_ordered(&tasks_dir);
if todos.is_empty() {
return ok("No live todos. The space is clean.");
}
let in_progress = todos
.iter()
.filter(|(_, t)| t.status == STATUS_IN_PROGRESS)
.count();
let waiting = todos.len() - in_progress;
let mut out = format!(
"{} live — {} in progress, {} waiting.\n",
todos.len(),
in_progress,
waiting,
);
let mut current_thread: Option<Option<String>> = None;
for (i, (_, item)) in todos.iter().enumerate() {
if current_thread.as_ref() != Some(&item.thread) {
current_thread = Some(item.thread.clone());
match &item.thread {
Some(t) => out.push_str(&format!("\n{t}\n")),
None => out.push_str("\n— loose —\n"),
}
}
let marker = if item.status == STATUS_IN_PROGRESS { "" } else { "" };
let shown = if item.status == STATUS_IN_PROGRESS {
item.active_form.clone().unwrap_or_else(|| item.text.clone())
} else {
item.text.clone()
};
let phase = item
.phase
.as_ref()
.map(|p| format!(" [{p}]"))
.unwrap_or_default();
out.push_str(&format!(
"{}. {} {}{} — {}, {}, {} · id: `{}`\n",
i + 1,
marker,
shown,
phase,
item.nature,
item.energy,
item.momentum,
item.id,
));
}
if results.is_empty() {
ok("No pending todos. The space is clean.")
} else {
ok(results.join("\n"))
}
ok(out.trim_end().to_string())
}
"create" => {
@ -178,6 +275,8 @@ impl Tool for Todo {
});
let thread = input.get("thread").and_then(|v| v.as_str());
let phase = input.get("phase").and_then(|v| v.as_str());
let active_form = input.get("active_form").and_then(|v| v.as_str());
let id = next_id(text);
let now = chrono::Utc::now();
@ -187,18 +286,22 @@ impl Tool for Todo {
return Err(err("a todo with this id already exists"));
}
let mut frontmatter = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\nmomentum: hot\ncompleted: false\nlast_touched: {created}\n",
created = now.to_rfc3339(),
);
if let Some(t) = thread {
frontmatter.push_str(&format!("thread: \"{t}\"\n"));
}
frontmatter.push_str("---\n");
// The body is free-form — the agent can add notes if she wants.
frontmatter.push_str(&format!("\n{text}\n"));
std::fs::write(&file_path, frontmatter).map_err(|e| io_err(e))?;
let item = TodoItem {
id: id.clone(),
text: text.to_string(),
active_form: active_form.map(|s| s.to_string()),
created_at: now,
nature: nature.to_string(),
energy: energy.to_string(),
source: source.to_string(),
momentum: "hot".to_string(),
status: STATUS_PENDING.to_string(),
completed_at: None,
last_touched: now,
thread: thread.map(|s| s.to_string()),
phase: phase.map(|s| s.to_string()),
};
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
@ -208,6 +311,7 @@ impl Tool for Todo {
urgency: 0.2,
payload: Some(serde_json::json!({
"nature": nature, "energy": energy, "source": source,
"thread": thread, "phase": phase,
})),
seed_id: None,
reply_to: None,
@ -216,51 +320,82 @@ impl Tool for Todo {
ok(format!("Todo logged: \"{text}\" ({nature}, {energy})."))
}
"update" => {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err("id is required"))?;
"start" => {
let identifier = want_id()?;
let (file_path, mut item) = resolve_todo(&tasks_dir, identifier)?;
let file_path = tasks_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(err(&format!("todo '{id}' not found")));
item.status = STATUS_IN_PROGRESS.to_string();
item.completed_at = None;
if let Some(af) = input.get("active_form").and_then(|v| v.as_str()) {
item.active_form = Some(af.to_string());
}
if let Some(p) = input.get("phase").and_then(|v| v.as_str()) {
item.phase = Some(p.to_string());
}
item.last_touched = chrono::Utc::now();
item.momentum = compute_momentum(&item.last_touched, &item.nature).to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
let mut item = parse_todo_file(&file_path)
.map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_started".into(),
target: Some(item.id.clone()),
urgency: 0.2,
payload: None,
seed_id: None,
reply_to: None,
});
let shown = item.active_form.clone().unwrap_or_else(|| item.text.clone());
ok(format!("Picked up: \"{shown}\"."))
}
"update" => {
let identifier = want_id()?;
let (file_path, mut item) = resolve_todo(&tasks_dir, identifier)?;
if let Some(t) = input.get("text").and_then(|v| v.as_str()) {
item.text = t.to_string();
}
if let Some(af) = input.get("active_form").and_then(|v| v.as_str()) {
item.active_form = Some(af.to_string());
}
if let Some(p) = input.get("phase").and_then(|v| v.as_str()) {
item.phase = Some(p.to_string());
}
if let Some(n) = input.get("nature").and_then(|v| v.as_str()) {
item.nature = n.to_string();
}
if let Some(e) = input.get("energy").and_then(|v| v.as_str()) {
item.energy = e.to_string();
}
if let Some(s) = input.get("status").and_then(|v| v.as_str()) {
if !is_known_status(s) {
return Err(err(&format!("unknown status: {s}")));
}
item.status = s.to_string();
if s == STATUS_DONE {
item.completed_at.get_or_insert_with(chrono::Utc::now);
} else {
item.completed_at = None;
}
}
// Editing a todo is touching it — momentum goes hot.
item.last_touched = chrono::Utc::now();
item.momentum = compute_momentum(&item.last_touched, &item.nature).to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ok(format!("Todo '{id}' updated."))
ok(format!("Todo updated: \"{}\".", item.text))
}
"complete" => {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err("id is required"))?;
let identifier = want_id()?;
let (file_path, mut item) = resolve_todo(&tasks_dir, identifier)?;
let file_path = tasks_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(err(&format!("todo '{id}' not found")));
}
let mut item = parse_todo_file(&file_path)
.map_err(|e| io_err(e))?;
item.completed = true;
item.status = STATUS_DONE.to_string();
item.completed_at = Some(chrono::Utc::now());
item.momentum = "cold".to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
@ -268,26 +403,19 @@ impl Tool for Todo {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_completed".into(),
target: Some(id.to_string()),
target: Some(item.id.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Todo completed: \"{text}\"", text = item.text))
ok(format!("Todo completed: \"{}\".", item.text))
}
"delete" => {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err("id is required"))?;
let file_path = tasks_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(err(&format!("todo '{id}' not found")));
}
let identifier = want_id()?;
let (file_path, item) = resolve_todo(&tasks_dir, identifier)?;
std::fs::remove_file(&file_path).map_err(|e| io_err(e))?;
@ -295,14 +423,14 @@ impl Tool for Todo {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_deleted".into(),
target: Some(id.to_string()),
target: Some(item.id.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Todo '{id}' released."))
ok(format!("Todo released: \"{}\".", item.text))
}
other => Err(err(&format!("unknown action: {other}"))),
@ -315,15 +443,21 @@ impl Tool for Todo {
struct TodoItem {
id: String,
text: String,
/// Present-continuous form, shown while the todo is in progress.
active_form: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
nature: String,
energy: String,
source: String,
momentum: String,
completed: bool,
/// pending | in_progress | done | cancelled.
status: String,
completed_at: Option<chrono::DateTime<chrono::Utc>>,
last_touched: chrono::DateTime<chrono::Utc>,
/// The longer arc of work this commitment belongs to.
thread: Option<String>,
/// Where this sits inside its thread — free text ("3/6", "spike").
phase: Option<String>,
}
/// Parse a todo file with YAML frontmatter.
@ -351,15 +485,21 @@ fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
let mut item = TodoItem {
id: String::new(),
text: String::new(),
active_form: None,
created_at: now,
nature: "obligation".to_string(),
energy: "consumptive".to_string(),
source: "autogenic".to_string(),
momentum: "cold".to_string(),
completed: false,
status: STATUS_PENDING.to_string(),
completed_at: None,
last_touched: now,
thread: None,
phase: None,
};
let opt = |val: &str| -> Option<String> {
if val.is_empty() { None } else { Some(val.to_string()) }
};
for line in body.lines() {
@ -369,15 +509,17 @@ fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
match key {
"id" => item.id = val.to_string(),
"text" => item.text = val.to_string(),
"active_form" => item.active_form = opt(val),
"created_at" => item.created_at = default_dt(val),
"nature" => item.nature = val.to_string(),
"energy" => item.energy = val.to_string(),
"source" => item.source = val.to_string(),
"momentum" => item.momentum = val.to_string(),
"completed" => item.completed = val == "true",
"status" => item.status = val.to_string(),
"completed_at" => item.completed_at = (!val.is_empty()).then(|| default_dt(val)),
"last_touched" => item.last_touched = default_dt(val),
"thread" => item.thread = if val.is_empty() { None } else { Some(val.to_string()) },
"thread" => item.thread = opt(val),
"phase" => item.phase = opt(val),
_ => {}
}
}
@ -386,24 +528,130 @@ fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
if item.id.is_empty() {
return Err("no id in frontmatter".into());
}
if !is_known_status(&item.status) {
return Err(format!("unknown status: {}", item.status));
}
Ok(item)
}
/// Load todos from disk, sorted oldest-first by creation time.
fn load_todos(
tasks_dir: &std::path::Path,
include_settled: bool,
) -> Vec<(std::path::PathBuf, TodoItem)> {
let mut out: Vec<(std::path::PathBuf, TodoItem)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
if let Ok(item) = parse_todo_file(&path) {
if include_settled || is_live(&item.status) {
out.push((path, item));
}
}
}
}
}
out.sort_by(|a, b| a.1.created_at.cmp(&b.1.created_at));
out
}
/// Live todos in canonical display order: grouped by thread, oldest
/// first within a group, loose (threadless) commitments first.
///
/// `list` and `resolve_todo` both call this, so the numeric index a
/// user sees in `list` always points at the same todo when they pass
/// it back to `start` / `update` / `complete` / `delete`.
fn active_ordered(tasks_dir: &std::path::Path) -> Vec<(std::path::PathBuf, TodoItem)> {
let mut v = load_todos(tasks_dir, false);
v.sort_by(|a, b| {
let ta = a.1.thread.clone().unwrap_or_default();
let tb = b.1.thread.clone().unwrap_or_default();
ta.cmp(&tb).then(a.1.created_at.cmp(&b.1.created_at))
});
v
}
/// Resolve a user-supplied identifier to a single live todo.
///
/// Accepts, in priority order: a 1-based index from `todo list`, an
/// exact id, or a case-insensitive substring of the id or text. An
/// ambiguous substring returns an error naming the candidates.
fn resolve_todo(
tasks_dir: &std::path::Path,
identifier: &str,
) -> Result<(std::path::PathBuf, TodoItem), ToolError> {
let live = active_ordered(tasks_dir);
if live.is_empty() {
return Err(err("no live todos to match against"));
}
// 1. numeric index, as shown by `todo list`
if let Ok(n) = identifier.trim().parse::<usize>() {
return if n >= 1 && n <= live.len() {
Ok(live.into_iter().nth(n - 1).unwrap())
} else {
Err(err(&format!(
"todo index {n} is out of range — there are {} live (run `todo list`)",
live.len()
)))
};
}
// 2. exact id
if let Some(idx) = live.iter().position(|(_, it)| it.id == identifier) {
return Ok(live.into_iter().nth(idx).unwrap());
}
// 3. case-insensitive substring of id or text
let needle = identifier.to_lowercase();
let hits: Vec<usize> = live
.iter()
.enumerate()
.filter(|(_, (_, it))| {
it.id.to_lowercase().contains(&needle) || it.text.to_lowercase().contains(&needle)
})
.map(|(i, _)| i)
.collect();
match hits.len() {
0 => Err(err(&format!("no live todo matches \"{identifier}\""))),
1 => Ok(live.into_iter().nth(hits[0]).unwrap()),
_ => {
let candidates: Vec<String> = hits
.iter()
.map(|&i| format!(" {}. {}", i + 1, live[i].1.text))
.collect();
Err(err(&format!(
"\"{identifier}\" matches {} todos — use the number or a longer fragment:\n{}",
hits.len(),
candidates.join("\n")
)))
}
}
}
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
let now = chrono::Utc::now();
let mut frontmatter = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\nmomentum: {momentum}\ncompleted: {completed}\nlast_touched: {touched}\n",
id = item.id,
text = item.text,
let mut frontmatter = format!("---\nid: {id}\ntext: {text}\n", id = item.id, text = item.text);
if let Some(ref af) = item.active_form {
frontmatter.push_str(&format!("active_form: {af}\n"));
}
frontmatter.push_str(&format!(
"created_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\n\
momentum: {momentum}\nstatus: {status}\nlast_touched: {touched}\n",
created = item.created_at.to_rfc3339(),
nature = item.nature,
energy = item.energy,
source = item.source,
momentum = item.momentum,
completed = if item.completed { "true" } else { "false" },
status = item.status,
touched = now.to_rfc3339(),
);
));
if let Some(ref p) = item.phase {
frontmatter.push_str(&format!("phase: \"{p}\"\n"));
}
if let Some(ref t) = item.thread {
frontmatter.push_str(&format!("thread: \"{t}\"\n"));
}
@ -417,3 +665,140 @@ fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String
std::fs::write(path, frontmatter).map_err(|e| format!("write error: {e}"))?;
Ok(())
}
// ── Tests ───────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway tasks dir under the system temp root.
fn temp_tasks_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Write a pending todo file directly, mimicking a seeded todo.
fn seed(dir: &std::path::Path, id: &str, text: &str, created: &str) {
let content = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\n\
nature: obligation\nenergy: consumptive\nsource: human\n\
momentum: hot\nstatus: pending\nlast_touched: {created}\n---\n\n{text}\n"
);
std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
}
#[test]
fn settled_todos_excluded_from_live() {
let dir = temp_tasks_dir();
seed(&dir, "live-1", "still going", "2026-05-01T00:00:00Z");
let done = "---\nid: done-1\ntext: finished\ncreated_at: 2026-05-01T00:00:00Z\n\
nature: obligation\nenergy: consumptive\nsource: human\n\
momentum: cold\nstatus: done\nlast_touched: 2026-05-01T00:00:00Z\n---\n\nfinished\n";
std::fs::write(dir.join("done-1.md"), done).unwrap();
assert_eq!(load_todos(&dir, false).len(), 1);
assert_eq!(load_todos(&dir, true).len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn active_ordered_groups_by_thread() {
let dir = temp_tasks_dir();
seed(&dir, "loose-1", "loose task", "2026-05-05T00:00:00Z");
seed(&dir, "matrix-2", "phase two", "2026-05-02T00:00:00Z");
seed(&dir, "matrix-1", "phase one", "2026-05-01T00:00:00Z");
// give the two matrix todos a thread by rewriting them
for (id, created) in [("matrix-1", "2026-05-01T00:00:00Z"), ("matrix-2", "2026-05-02T00:00:00Z")] {
let text = if id == "matrix-1" { "phase one" } else { "phase two" };
let content = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: obligation\n\
energy: consumptive\nsource: casey\nmomentum: hot\nstatus: pending\n\
completed: false\nlast_touched: {created}\nthread: \"matrix-sensorium\"\n---\n\n{text}\n"
);
std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
}
let ordered = active_ordered(&dir);
// loose (thread = "") sorts before "matrix-sensorium"
assert_eq!(ordered[0].1.id, "loose-1");
assert_eq!(ordered[1].1.id, "matrix-1");
assert_eq!(ordered[2].1.id, "matrix-2");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_by_index() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z");
let (_, item) = resolve_todo(&dir, "2").unwrap();
assert_eq!(item.text, "call dentist");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_by_exact_id() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
let (_, item) = resolve_todo(&dir, "alpha-00000001").unwrap();
assert_eq!(item.text, "buy milk");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_by_substring() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z");
let (_, item) = resolve_todo(&dir, "DENT").unwrap();
assert_eq!(item.id, "beta-00000002");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn ambiguous_substring_errors() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "buy eggs", "2026-05-02T00:00:00Z");
assert!(resolve_todo(&dir, "buy").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn index_out_of_range_errors() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
assert!(resolve_todo(&dir, "9").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_then_parse_roundtrips_phase_and_active_form() {
let dir = temp_tasks_dir();
let now = chrono::Utc::now();
let item = TodoItem {
id: "rt-1".into(),
text: "port the turn model".into(),
active_form: Some("porting the turn model".into()),
created_at: now,
nature: "investigation".into(),
energy: "generative".into(),
source: "human".into(),
momentum: "hot".into(),
status: STATUS_IN_PROGRESS.into(),
completed_at: None,
last_touched: now,
thread: Some("matrix-sensorium".into()),
phase: Some("4/6".into()),
};
let path = dir.join("rt-1.md");
write_todo_file(&path, &item).unwrap();
let parsed = parse_todo_file(&path).unwrap();
assert_eq!(parsed.active_form.as_deref(), Some("porting the turn model"));
assert_eq!(parsed.phase.as_deref(), Some("4/6"));
assert_eq!(parsed.thread.as_deref(), Some("matrix-sensorium"));
assert_eq!(parsed.status, STATUS_IN_PROGRESS);
std::fs::remove_dir_all(&dir).ok();
}
}

View file

@ -8,5 +8,5 @@
/// This module re-exports the available surfaces.
/// Feature-gating will be added when CLI and Web are implemented.
pub mod tui {
pub use crate::ui::*;
}

View file

@ -1,9 +1,8 @@
use crate::api::models::{AgentState, AgentSummary, CreateAgentRequest, LlmConfig, MemoryConfig, MemoryBlock, SouveraineConfig, UpdateAgentRequest};
use crate::api::models::{AgentState, AgentSummary, CreateAgentRequest, MemoryConfig, MemoryBlock, SouveraineConfig, UpdateAgentRequest};
use chrono::Utc;
use dashmap::DashMap;
use sqlx::SqlitePool;
use std::path::PathBuf;
use std::sync::Arc;
use uuid::Uuid;
fn hostname_or_unknown() -> String {

View file

@ -4,7 +4,6 @@ use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
fn default_role() -> String {
"hearth".to_string()

View file

@ -1,11 +1,11 @@
use crate::bridge::BifrostClient;
use crate::core::compact::{CompactionEngine, CompactionConfig, DefaultCompactionEngine, UtcClock};
use crate::core::compact::{CompactionEngine, 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;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::AtomicU64;
use tokio::sync::RwLock;
pub mod agent_inventory;
@ -381,4 +381,3 @@ impl SouveraineServer {
}
}
pub use conversation::{ServerConversation, ServerTurnResult};

View file

@ -128,11 +128,11 @@ impl SessionManager {
conversation_id
}
pub fn get(&self, conversation_id: &str) -> Option<dashmap::mapref::one::Ref<String, Session>> {
pub fn get(&self, conversation_id: &str) -> Option<dashmap::mapref::one::Ref<'_, String, Session>> {
self.sessions.get(conversation_id)
}
pub fn get_mut(&self, conversation_id: &str) -> Option<dashmap::mapref::one::RefMut<String, Session>> {
pub fn get_mut(&self, conversation_id: &str) -> Option<dashmap::mapref::one::RefMut<'_, String, Session>> {
self.sessions.get_mut(conversation_id)
}
@ -236,7 +236,7 @@ impl SessionManager {
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
let agent_id = source.agent_id.clone();
let mut messages = source.messages.clone();
let messages = source.messages.clone();
drop(source); // release the DashMap ref
let forked_id = Uuid::new_v4().to_string();

View file

@ -31,14 +31,14 @@ use crate::core::config::ConsciousnessConfig;
use crate::ui::chat::{ChatState, draw as draw_chat};
use crate::ui::cockpit_panel::CockpitPane;
use crate::ui::health_panel::HealthPane;
use crate::ui::presence::{Posture, Presence, draw_overlay as draw_presence_overlay};
use crate::ui::presence::{Posture, Presence};
use crate::ui::color_support::rgb;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::ui::setup::{SetupFlow, SetupState};
use crate::ui::setup::SetupState;
use crate::backend::BackendEvent;
use crate::ui::settings::SettingsAction;
use ratatui_image::{picker::Picker, protocol::{Protocol, StatefulProtocol}, Image, Resize, StatefulImage};
use ratatui_image::{picker::Picker, protocol::{Protocol, StatefulProtocol}, Resize, StatefulImage};
#[cfg(feature = "figlet-rs")]
use figlet_rs::FIGlet;
@ -1092,7 +1092,7 @@ impl App {
let backend = chat.backend.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
chat.switch_rx = Some(rx);
let agent_id = chat.agent_id.clone();
let _agent_id = chat.agent_id.clone();
tokio::spawn(async move {
match backend.load_conversation(&forked_id).await {
Ok(messages) => {
@ -1705,7 +1705,7 @@ impl App {
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "card image open failed"); return; }
};
// Pull the `image` crate into scope for resize_to_fill on the render path.
use image::DynamicImage;
let dyn_img = portrait_cover_crop(dyn_img, 2, 3);
let proto = picker.new_resize_protocol(dyn_img.clone());
let agent_id = agent_id.to_string();
@ -2098,8 +2098,8 @@ impl App {
}
fn draw(&mut self, frame: &mut Frame) {
let area = frame.size();
let layout = match self.current_screen {
let _area = frame.size();
let _layout = match self.current_screen {
Screen::Chat => {
SceneLayout::ChatWithSidebar { sidebar_ratio: 0.3, sidebar_open: false }
}

View file

@ -427,8 +427,8 @@ impl Component for CockpitPane {
frame.render_widget(para, top);
// ── Divider row ───────────────────────────────────────────────
let divider_text = "─ inner voice ".to_string()
+ &"".repeat((chunks[1].width as usize).saturating_sub(14));
let repeat_count = (chunks[1].width as usize).saturating_sub(14);
let divider_text = format!("─ inner voice {}", "".repeat(repeat_count));
let divider = Paragraph::new(Line::from(Span::styled(
divider_text,
Style::default().fg(self.palette.agent_dim),

View file

@ -87,7 +87,7 @@ fn color_distance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
}
fn rgb_to_xterm256(r: u8, g: u8, b: u8) -> u8 {
let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
let _gray_avg = (r as u16 + g as u16 + b as u16) / 3;
let is_grayish = (r as i16 - g as i16).unsigned_abs() < 15
&& (g as i16 - b as i16).unsigned_abs() < 15
&& (r as i16 - b as i16).unsigned_abs() < 15;

View file

@ -17,7 +17,3 @@ pub mod setup;
pub mod voice;
pub use app::App;
pub use cockpit_panel::CockpitPane;
pub use health_panel::HealthPane;
pub use component::{Component, Scene, SceneLayout, TuiEvent};
pub use presence::{Position, Posture, Presence};

View file

@ -60,7 +60,7 @@ use ratatui::{
use std::path::Path;
use crate::ui::animation::{Animator, colors};
use crate::ui::animation::Animator;
use crate::ui::component::TuiEvent;
use crate::ui::portrait;

View file

@ -5,7 +5,6 @@
//! - `ImportAgent`: config exists, but no agents — skip Bifrost, create/import
//! - `FederationSync`: wants to sync from a federation peer (env override)
use std::sync::Arc;
use tokio::sync::oneshot;
use ratatui::{
@ -15,7 +14,7 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent};
use crate::api::models::{CreateAgentRequest, LlmConfig, MemoryBlock};
@ -935,6 +934,7 @@ fn centered_rect(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
#[test]
fn test_fresh_install_flow() {

View file

@ -3,5 +3,5 @@ pub mod meter;
pub mod playback;
pub use capture::MicCapture;
pub use meter::{VoiceMeter, LEVEL_CHARS};
pub use meter::LEVEL_CHARS;
pub use playback::VoicePlayer;