WIP: context doctor session + audit-fixes accum
This commit is contained in:
parent
aed114f998
commit
93d4baa98d
32 changed files with 2723 additions and 221 deletions
125
.skills/context-doctor/SKILL.md
Normal file
125
.skills/context-doctor/SKILL.md
Normal 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.
|
||||
144
.skills/feature-dev/SKILL.md
Normal file
144
.skills/feature-dev/SKILL.md
Normal 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
1052
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
19
Cargo.toml
19
Cargo.toml
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::server::SouveraineServer;
|
||||
use axum::{
|
||||
middleware,
|
||||
routing::{get, post, patch, delete},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
|
|
|||
|
|
@ -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(¶ms.parent_agent_id);
|
||||
let _conv_id = self.server.sessions.create(¶ms.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,
|
||||
|
|
|
|||
|
|
@ -6,4 +6,3 @@ pub mod bifrost;
|
|||
pub mod model_router;
|
||||
|
||||
pub use bifrost::BifrostClient;
|
||||
pub use model_router::ModelRouter;
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
pub mod commands;
|
||||
|
||||
pub use commands::{run_model_command, ModelListReport, ModelReport};
|
||||
pub use commands::run_model_command;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
pub mod event;
|
||||
pub mod store;
|
||||
|
||||
pub use event::{ConversationEvent, EventSender};
|
||||
pub use store::{ConversationRecord, ConversationStore};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
193
src/core/nervous/turn_dispatcher.rs
Normal file
193
src/core/nervous/turn_dispatcher.rs
Normal 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)));
|
||||
}
|
||||
}
|
||||
190
src/core/sensorium/matrix/client.rs
Normal file
190
src/core/sensorium/matrix/client.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
280
src/core/sensorium/matrix/mod.rs
Normal file
280
src/core/sensorium/matrix/mod.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tracing::debug;
|
||||
|
||||
/// Role of a message participant
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue