Watch
1
0
Fork
You've already forked souveraine
0

feat: nervous system, seed identity, credentials, subconscious ledger

Lays the substrate for Aster's clockmaker role and federation.

Nervous system (src/core/nervous/):
- EventBus broadcast channel with SensorEvent (universal event type
  carrying seed_id for future federation)
- CronSensor loop with tokio::select!, mtime caching, active-session
  pause, at-most-once firing semantics
- HeartbeatHandler scaffold (turn injection still stubbed)
- Persistent JSONL EventLog firehose

Schedule tool (src/core/tools/schedule.rs):
- CRUD over schedule files with body-knowledge prose descriptions
- Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm
- CLI subcommand for direct schedule management

Seed identity (src/core/identity/):
- Ed25519 keypair, load-or-generate at init
- sign/verify primitives, CLI subcommand (show/sign/verify)
- Wired into LocalBackend, threaded through SensorEvent for federation

Credentials (src/core/credentials.rs):
- OS keyring (Linux/macOS/Windows) + env var fallback
- `souveraine auth` subcommand
- Removes hardcoded Bifrost api_key from souveraine.toml

Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger):
- 6 ledger files (commitments/assumptions/patterns/drift_log/
  relationships/infrastructure) with proper YAML frontmatter
- Paths fixed: ledger/ not subconscious/ledger/
- Body usage instructions, idempotent init

Prompt orientation (src/core/prompt.rs build_ledger_orientation):
- Scans ledger/ directory, injects last 3 entries from each file
  into Aster's system prompt (live context, not just awareness)
- Routing table + workflow (read before write, timestamped append)

Consciousness engine (src/server/consciousness_engine.rs):
- Adaptive rate delay shared with primary loop
- Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded
  default prompt; observation format extracted for sharing
- Schedule added to ASTER_SAFE_TOOLS

Backend + CLI wiring expanded to mount the nervous system, identity,
and credentials at startup. New TOML sections: [schedules], [events],
[federation].

102 tests passing.
This commit is contained in:
Fimeg 2026-05-12 08:14:25 -04:00
commit 35534f1c73
22 changed files with 2077 additions and 79 deletions

View file

@ -36,6 +36,9 @@ dashmap = "5"
# File system
notify = "6" # File watching
# Environment (.env file loading)
dotenvy = "0.15"
tempfile = "3"
walkdir = "2"
@ -77,6 +80,17 @@ tiktoken = "3"
# System directories
dirs = "5"
# OS-native credential storage
keyring = "4"
keyring-core = "1"
# Cryptographic identity (seed-id, event signing, federation trust root)
ed25519-dalek = { version = "2", features = ["rand_core", "pem"] }
rand = "0.8"
# Cron expression parsing (schedule system)
cron = "0.13"
# Markdown parsing for the TUI chat renderer (lift from jcode pattern)
pulldown-cmark = "0.12"
@ -96,6 +110,7 @@ cowsay = { version = "0.14.0", optional = true }
tui-big-text = "0.8.4"
tui-widgets = "0.7.2"
base64 = "0.22.1"
hex = "0.4"
once_cell = "1.21.4"
glob = "0.3"
@ -116,6 +131,7 @@ opt-level = 3
lto = true
codegen-units = 1
strip = true
panic = "abort"
[features]
default = ["figlet-rs"]

View file

@ -12,6 +12,7 @@ use anyhow::{Context, Result};
use async_trait::async_trait;
use futures::stream::{BoxStream, StreamExt};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
@ -20,24 +21,23 @@ use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
use crate::core::config::ConsciousnessConfig;
use crate::core::identity::SeedId;
use crate::core::nervous::EventBus;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::{SubagentParams, SubagentRunner, ToolContext};
use crate::server::{ConsciousnessEvent, SouveraineServer};
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
/// Scale max output tokens proportionally to remaining context room.
/// Below 80%: no cap. Above 80%: linear taper from the model's configured
/// output_limit to a minimum floor at saturation. The agent feels the throat
/// tighten progressively rather than hitting a cliff.
/// Below 95%: no cap. At 95%+: scale max_tokens so context + output
/// stays under the model's limit. The agent feels the room shrink.
fn pressure_to_max_tokens(pressure: f32, output_limit: u32) -> Option<u32> {
if pressure <= 0.80 {
if pressure <= 0.95 {
return None;
}
let remaining = (1.0 - pressure) / 0.20; // 1.0 at 80%, 0.0 at 100%
let remaining = (1.0 - pressure) / 0.05;
let ratio = remaining.max(0.0).min(1.0);
let budget = (output_limit as f32 * ratio) as u32;
Some(budget.max(512))
Some((output_limit as f32 * ratio) as u32)
}
/// Mirror of `ConsciousnessEngine::calculate_pressure` for the in-loop
@ -51,6 +51,19 @@ fn bifrost_pressure(counter: &TokenCounter, messages: &[BifrostMessage]) -> f32
(tokens as f32 / limit as f32).min(1.0)
}
/// Helper: bump adaptive delay when we hit a 429. No decay — once bumped,
/// the delay stays at that level until the app restarts.
fn bump_on_strain(delay: &AtomicU64, status: u16) {
if status == 429 {
let current = delay.load(Ordering::Relaxed);
let bumped = (current + 200).min(3000);
if bumped > current {
delay.store(bumped, Ordering::Relaxed);
tracing::info!("rate delay bumped to {}ms (429)", bumped);
}
}
}
// ── LocalSubagentRunner ──────────────────────────────────────────
/// Implements [`SubagentRunner`] by running a full turn against the
@ -273,18 +286,66 @@ impl SubagentRunner for LocalSubagentRunner {
#[derive(Clone)]
pub struct LocalBackend {
server: Arc<SouveraineServer>,
event_bus: EventBus,
seed_id: Arc<SeedId>,
}
impl LocalBackend {
pub async fn new(config: ConsciousnessConfig) -> Result<Self> {
let server = SouveraineServer::new(config)
let server = SouveraineServer::new(config.clone())
.await
.context("LocalBackend: SouveraineServer init")?;
Ok(Self { server: Arc::new(server) })
let event_bus = EventBus::default();
let base = config
.memory
.base_path
.clone()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".souveraine"));
// Load or generate the seed identity (trust root)
let seed_dir = SeedId::default_dir(&base);
let seed_id = Arc::new(
SeedId::load_or_generate(&seed_dir)
.context("SeedId init")?,
);
tracing::info!(
pubkey = %seed_id.public_key_hex(),
"seed identity loaded"
);
// Spawn the persistent event log (firehose to disk)
let events_dir = base.join("events");
let mut event_log =
crate::core::nervous::event_log::EventLog::new(events_dir, event_bus.subscribe());
tokio::spawn(async move { event_log.run().await });
Ok(Self {
server: Arc::new(server),
event_bus,
seed_id,
})
}
pub fn from_server(server: Arc<SouveraineServer>) -> Self {
Self { server }
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let seed_id = Arc::new(
SeedId::load_or_generate(&SeedId::default_dir(&base))
.unwrap_or_else(|_| SeedId::generate()),
);
Self {
event_bus: EventBus::default(),
server,
seed_id,
}
}
pub fn event_bus(&self) -> &EventBus {
&self.event_bus
}
pub fn seed_id(&self) -> &SeedId {
&self.seed_id
}
/// Underlying agent inventory — used by the TUI dashboard to pull a
@ -458,9 +519,10 @@ impl Backend for LocalBackend {
let (tx, rx) = mpsc::channel::<Result<BackendEvent>>(64);
let server = self.server.clone();
let conv_id = conversation_id.to_string();
let event_bus = self.event_bus.clone();
tokio::spawn(async move {
if let Err(e) = run_turn(server, conv_id, &tx).await {
if let Err(e) = run_turn(server, conv_id, &tx, event_bus).await {
let _ = tx.send(Err(e)).await;
}
let _ = tx.send(Ok(BackendEvent::Done)).await;
@ -476,6 +538,7 @@ async fn run_turn(
server: Arc<SouveraineServer>,
conversation_id: String,
tx: &mpsc::Sender<Result<BackendEvent>>,
event_bus: EventBus,
) -> Result<()> {
// Snapshot history for the Bifrost call, then drop the dashmap ref before
// any await — `Ref` is not Send across awaits.
@ -537,9 +600,9 @@ async fn run_turn(
env,
subagent_runner,
);
// Inject compaction engine from server (not part of for_agent API).
let tool_ctx = ToolContext {
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
event_bus: Some(event_bus),
..tool_ctx
};
@ -590,9 +653,15 @@ async fn run_turn(
status: *status,
model: model.clone(),
})).await;
bump_on_strain(&server.rate_delay, *status);
}
}
// Emit reasoning trace if present
if let Some(reasoning) = &response.reasoning {
let _ = tx.send(Ok(BackendEvent::Reasoning(reasoning.clone()))).await;
}
if response.tool_calls.is_empty() || tool_round >= max_rounds {
// Text response (or hit max rounds) — this is the final output
final_content = response.content.clone();
@ -667,9 +736,16 @@ async fn run_turn(
});
}
// Brief pause between tool rounds to let rate limits cool
if inter_round_delay > Duration::ZERO {
tokio::time::sleep(inter_round_delay).await;
// Brief pause between tool rounds to let rate limits cool.
// Use the higher of the configured delay and the adaptive delay.
let adaptive = Duration::from_millis(server.rate_delay.load(Ordering::Relaxed));
let effective = if inter_round_delay > adaptive {
inter_round_delay
} else {
adaptive
};
if effective > Duration::ZERO {
tokio::time::sleep(effective).await;
}
// Continue loop — model will see tool results and respond
@ -681,6 +757,10 @@ async fn run_turn(
ConversationMessage::assistant_text(&final_content),
)?;
// Breather between Ani finishing and Aster firing — unconditional,
// so the upstream always gets a gap before the N+1 pass starts.
tokio::time::sleep(Duration::from_millis(2000)).await;
let events = {
let session = server
.sessions

View file

@ -60,6 +60,10 @@ pub enum BackendEvent {
status: u16,
model: String,
},
/// A scheduled event is being processed.
ScheduleActive { name: String },
/// A scheduled event completed.
ScheduleComplete { name: String, silent: bool },
/// Stream ended cleanly.
Done,
}

View file

@ -50,6 +50,18 @@ pub struct ConsciousnessConfig {
/// Server bind/port + client connection URL
#[serde(default)]
pub server: ServerConfig,
/// Schedule system (cron sensor)
#[serde(default)]
pub schedules: SchedulesConfig,
/// Event persistence (firehose log)
#[serde(default)]
pub events: EventsConfig,
/// Federation (cross-instance sync)
#[serde(default)]
pub federation: FederationConfig,
}
// ── Server ──
@ -494,6 +506,71 @@ impl Default for ConsciousnessConfig {
websocket: WebSocketConfig::default(),
sensorium: SensoriumConfig::default(),
server: ServerConfig::default(),
schedules: SchedulesConfig::default(),
events: EventsConfig::default(),
federation: FederationConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulesConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub schedules_dir: Option<PathBuf>,
}
impl Default for SchedulesConfig {
fn default() -> Self {
Self {
enabled: true,
schedules_dir: None,
}
}
}
// ── Events / Firehose ──
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub events_dir: Option<PathBuf>,
#[serde(default = "default_retain_days")]
pub retain_days: i64,
}
fn default_retain_days() -> i64 {
30
}
impl Default for EventsConfig {
fn default() -> Self {
Self {
enabled: true,
events_dir: None,
retain_days: 30,
}
}
}
// ── Federation ──
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub instance_label: Option<String>,
}
impl Default for FederationConfig {
fn default() -> Self {
Self {
enabled: false,
instance_label: None,
}
}
}
@ -543,7 +620,7 @@ fn default_server_bind() -> String { "127.0.0.1".to_string() }
fn default_server_port() -> u16 { 8484 }
fn default_server_url() -> String { "http://127.0.0.1:8484".to_string() }
fn default_bifrost_key() -> String {
std::env::var("BIFROST_KEY").unwrap_or_else(|_| "sk-bf-ae0d5801-9936-4fa9-ac9e-e956ffce6cfa".to_string())
crate::core::credentials::get_bifrost_key()
}
fn default_bifrost_virtual_key() -> String {

55
src/core/credentials.rs Normal file
View file

@ -0,0 +1,55 @@
use anyhow::Result;
use keyring_core::Entry;
const SERVICE: &str = "souveraine";
pub trait CredentialStore: Send + Sync {
fn get(&self, key: &str) -> Option<String>;
fn set(&self, key: &str, value: &str) -> Result<()>;
fn delete(&self, key: &str) -> Result<()>;
}
pub struct KeyringStore;
impl CredentialStore for KeyringStore {
fn get(&self, key: &str) -> Option<String> {
let entry = Entry::new(SERVICE, key).ok()?;
entry.get_password().ok()
}
fn set(&self, key: &str, value: &str) -> Result<()> {
let entry = Entry::new(SERVICE, key)?;
entry.set_password(value)?;
Ok(())
}
fn delete(&self, key: &str) -> Result<()> {
let entry = Entry::new(SERVICE, key)?;
entry.delete_credential()?;
Ok(())
}
}
pub fn get_bifrost_key() -> String {
if let Ok(key) = std::env::var("BIFROST_KEY") {
if !key.is_empty() {
return key;
}
}
if let Some(key) = KeyringStore.get("bifrost_key") {
if !key.is_empty() {
return key;
}
}
String::new()
}
pub fn store_bifrost_key(key: &str) -> Result<()> {
KeyringStore.set("bifrost_key", key)
}
pub fn clear_bifrost_key() -> Result<()> {
KeyringStore.delete("bifrost_key")
}

3
src/core/identity/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod seed;
pub use seed::SeedId;

146
src/core/identity/seed.rs Normal file
View file

@ -0,0 +1,146 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
/// Hardware-bound (or file-bound) cryptographic identity for a Souveraine instance.
///
/// Generated once at `souveraine init`. The private key never leaves the primary
/// machine. Forked instances carry the public key and authenticate through the
/// primary via signed commissions.
///
/// The seed_id on SensorEvent references this — `None` means local,
/// `Some(pubkey_hex)` means the event originated from a peer with this identity.
pub struct SeedId {
signing_key: SigningKey,
verifying_key: VerifyingKey,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeedIdPublic {
pub public_key_hex: String,
pub instance: String,
pub created_at: String,
}
impl SeedId {
/// Generate a new seed identity (first init).
pub fn generate() -> Self {
let mut csprng = rand::rngs::OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
Self {
signing_key,
verifying_key,
}
}
/// Load from disk, or generate and save if not present.
pub fn load_or_generate(seed_dir: &Path) -> Result<Self> {
let private_path = seed_dir.join("private.key");
let public_path = seed_dir.join("public.key");
if private_path.exists() {
let bytes = std::fs::read(&private_path)
.context("reading seed private key")?;
if bytes.len() != 32 {
anyhow::bail!("seed private key has wrong length: {} (expected 32)", bytes.len());
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&bytes);
let signing_key = SigningKey::from_bytes(&key_bytes);
let verifying_key = signing_key.verifying_key();
return Ok(Self {
signing_key,
verifying_key,
});
}
std::fs::create_dir_all(seed_dir)
.context("creating seed-id directory")?;
let seed = Self::generate();
std::fs::write(&private_path, seed.signing_key.to_bytes())
.context("writing seed private key")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&private_path, perms)
.context("restricting seed private key permissions")?;
}
std::fs::write(&public_path, seed.verifying_key.to_bytes())
.context("writing seed public key")?;
Ok(seed)
}
pub fn public_key_hex(&self) -> String {
hex::encode(self.verifying_key.to_bytes())
}
pub fn public_key_bytes(&self) -> [u8; 32] {
self.verifying_key.to_bytes()
}
pub fn sign(&self, data: &[u8]) -> Signature {
self.signing_key.sign(data)
}
pub fn verify(&self, data: &[u8], signature: &Signature) -> bool {
self.verifying_key.verify(data, signature).is_ok()
}
/// Verify using only the public key (for remote peers).
pub fn verify_with_pubkey(
pubkey_bytes: &[u8; 32],
data: &[u8],
signature: &Signature,
) -> bool {
let key = VerifyingKey::from_bytes(pubkey_bytes);
match key {
Ok(vk) => vk.verify(data, signature).is_ok(),
Err(_) => false,
}
}
/// Default seed directory under the souveraine base path.
pub fn default_dir(base_path: &Path) -> PathBuf {
base_path.join("seed-id")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sign_and_verify() {
let seed = SeedId::generate();
let data = b"hello sovereign world";
let sig = seed.sign(data);
assert!(seed.verify(data, &sig));
assert!(!seed.verify(b"tampered", &sig));
}
#[test]
fn test_verify_with_pubkey() {
let seed = SeedId::generate();
let data = b"federation event payload";
let sig = seed.sign(data);
let pubkey = seed.public_key_bytes();
assert!(SeedId::verify_with_pubkey(&pubkey, data, &sig));
}
#[test]
fn test_load_or_generate() {
let dir = tempfile::tempdir().unwrap();
let seed1 = SeedId::load_or_generate(dir.path()).unwrap();
let seed2 = SeedId::load_or_generate(dir.path()).unwrap();
assert_eq!(seed1.public_key_hex(), seed2.public_key_hex());
}
}

View file

@ -224,18 +224,34 @@ impl MemoryRepo {
Ok(())
}
/// Initialize the subconscious ledger directory structure.
/// Initialize the ledger directory structure.
/// Idempotent — safe to call multiple times, skips existing files.
///
/// Paths are relative to this repo's root (the subconscious agent's own
/// memfs), so `ledger/` — not `subconscious/ledger/`.
pub async fn init_subconscious_ledger(&self) -> Result<()> {
let ledger_files = [
("subconscious/ledger/commitments.md", "# Commitments\n\nPromises made and kept."),
("subconscious/ledger/assumptions.md", "# Assumptions\n\nFlagged assumptions."),
("subconscious/ledger/patterns.md", "# Patterns\n\nRecurring observations."),
("subconscious/ledger/drift_log.md", "# Drift Log\n\nBehavioral shifts."),
("subconscious/ledger/infrastructure/README.md", "# Infrastructure\n\nSystem issues and events."),
let ledger_files: &[(&str, &str, &str)] = &[
("ledger/commitments.md",
"Promises made by the primary — tracked until fulfilled or explicitly dropped",
"# Commitments\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n`[YYYY-MM-DD HH:MM] RESOLVED — resolution note`\n"),
("ledger/assumptions.md",
"Assumptions the primary is operating under — flagged for verification",
"# Assumptions\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n`[YYYY-MM-DD HH:MM] VERIFIED — evidence`\n"),
("ledger/patterns.md",
"Recurring behavioral patterns observed across turns",
"# Patterns\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
("ledger/drift_log.md",
"Behavioral shifts — when the primary's actions diverge from stated intentions",
"# Drift Log\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
("ledger/relationships.md",
"Observations about the human-agent relationship — tone shifts, trust signals, friction",
"# Relationships\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
("ledger/infrastructure.md",
"System events — bridge failures, token issues, model errors, resource constraints",
"# Infrastructure\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
];
for (path, body) in &ledger_files {
for (path, description, body) in ledger_files {
let full_path = self.root.join(path);
if !full_path.exists() {
if let Some(parent) = full_path.parent() {
@ -243,11 +259,8 @@ impl MemoryRepo {
.with_context(|| format!("creating ledger directory: {}", parent.display()))?;
}
let template = format!(
"---\n# Ledger: {}\n# Created: {}\n# Agent: {}\n---\n\n{}",
path.split('/').last().unwrap_or("unknown").replace(".md", ""),
Utc::now().to_rfc3339(),
self.agent_id,
body
"---\ndescription: \"{}\"\nread_only: false\ntags:\n - ledger\n---\n\n{}",
description, body
);
tokio::fs::write(&full_path, &template).await
.with_context(|| format!("writing ledger file: {}", path))?;
@ -651,10 +664,32 @@ pub async fn execute_memory_command_with_context(
}
MemoryCommand::Write { path, content } => {
repo.write(path, content).await?;
if let Some(c) = ctx {
c.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "memory".into(),
timestamp: chrono::Utc::now(),
event_type: "memory_write".into(),
target: Some(path.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
});
}
Ok(format!("Wrote memory file: {}", path))
}
MemoryCommand::Append { path, content } => {
repo.append(path, content).await?;
if let Some(c) = ctx {
c.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "memory".into(),
timestamp: chrono::Utc::now(),
event_type: "memory_append".into(),
target: Some(path.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
});
}
Ok(format!("Appended to memory file: {}", path))
}
MemoryCommand::Ls { path } => {
@ -709,6 +744,17 @@ pub async fn execute_memory_command_with_context(
}
MemoryCommand::Delete { path } => {
repo.delete(path).await?;
if let Some(c) = ctx {
c.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "memory".into(),
timestamp: chrono::Utc::now(),
event_type: "memory_delete".into(),
target: Some(path.clone()),
urgency: 0.2,
payload: None,
seed_id: None,
});
}
Ok(format!("Deleted memory file: {}", path))
}
}
@ -943,4 +989,43 @@ mod tests {
let entries = repo.list(None).await.unwrap();
assert!(entries.iter().any(|e| e == "system/"));
}
#[tokio::test]
async fn test_ledger_init_creates_files_with_frontmatter() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let repo = MemoryRepo::open("test-sub", root.clone());
std::fs::create_dir_all(&root).unwrap();
repo.init_subconscious_ledger().await.unwrap();
let commitments = root.join("ledger/commitments.md");
assert!(commitments.exists(), "commitments.md should exist");
let content = std::fs::read_to_string(&commitments).unwrap();
assert!(content.starts_with("---\n"), "should have YAML frontmatter");
assert!(content.contains("description:"), "should have description field");
assert!(content.contains("tags:"), "should have tags field");
assert!(content.contains("# Commitments"), "should have body");
let relationships = root.join("ledger/relationships.md");
assert!(relationships.exists(), "relationships.md should exist");
let infrastructure = root.join("ledger/infrastructure.md");
assert!(infrastructure.exists(), "infrastructure.md should exist");
}
#[tokio::test]
async fn test_ledger_init_is_idempotent() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let repo = MemoryRepo::open("test-sub", root.clone());
std::fs::create_dir_all(&root).unwrap();
repo.init_subconscious_ledger().await.unwrap();
let commitments = root.join("ledger/commitments.md");
let before = std::fs::read_to_string(&commitments).unwrap();
repo.init_subconscious_ledger().await.unwrap();
let after = std::fs::read_to_string(&commitments).unwrap();
assert_eq!(before, after, "second init should not overwrite");
}
}

View file

@ -8,7 +8,10 @@
pub mod chain;
pub mod compact;
pub mod config;
pub mod credentials;
pub mod conversation;
pub mod identity;
pub mod nervous;
pub mod memory;
pub mod prompt;
pub mod reflection;

292
src/core/nervous/cron.rs Normal file
View file

@ -0,0 +1,292 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::SystemTime;
use anyhow::Result;
use chrono::{DateTime, Utc};
use cron::Schedule as CronSchedule;
use serde::{Deserialize, Serialize};
use tokio::sync::{Notify, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use super::{EventBus, SensorEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleEntry {
pub name: String,
pub kind: ScheduleKind,
pub schedule: String,
pub source: String,
pub enabled: bool,
pub urgency: f32,
pub created_at: DateTime<Utc>,
pub prompt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleKind {
Once,
Interval,
Cron,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScheduleState {
pub next_run_at: Option<DateTime<Utc>>,
pub last_fired: Option<DateTime<Utc>>,
pub fire_count: u64,
pub last_status: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CronState {
pub entries: Vec<ScheduleEntry>,
pub state: HashMap<String, ScheduleState>,
#[serde(skip)]
last_mtime: Option<SystemTime>,
}
impl CronState {
pub fn next_due_time(&self) -> tokio::time::Instant {
let now = Utc::now();
let mut soonest: Option<DateTime<Utc>> = None;
for entry in &self.entries {
if !entry.enabled {
continue;
}
if let Some(s) = self.state.get(&entry.name) {
if let Some(next) = s.next_run_at {
if soonest.is_none() || next < soonest.unwrap() {
soonest = Some(next);
}
}
}
}
match soonest {
Some(t) if t > now => {
let dur = (t - now).to_std().unwrap_or(std::time::Duration::from_secs(60));
tokio::time::Instant::now() + dur
}
Some(_) => tokio::time::Instant::now(),
None => tokio::time::Instant::now() + std::time::Duration::from_secs(60),
}
}
pub fn due_entries(&self) -> Vec<ScheduleEntry> {
let now = Utc::now();
self.entries
.iter()
.filter(|e| {
e.enabled
&& self
.state
.get(&e.name)
.and_then(|s| s.next_run_at)
.map(|t| t <= now)
.unwrap_or(false)
})
.cloned()
.collect()
}
pub fn advance(&mut self, name: &str, entry: &ScheduleEntry) {
let s = self.state.entry(name.to_string()).or_default();
s.last_fired = Some(Utc::now());
s.fire_count += 1;
s.next_run_at = compute_next_run(entry);
}
pub fn persist(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(&self.state)?;
std::fs::write(path, json)?;
Ok(())
}
pub fn load_state(&mut self, path: &Path) -> Result<()> {
if path.exists() {
let data = std::fs::read_to_string(path)?;
self.state = serde_json::from_str(&data)?;
}
Ok(())
}
}
fn compute_next_run(entry: &ScheduleEntry) -> Option<DateTime<Utc>> {
match entry.kind {
ScheduleKind::Once => None,
ScheduleKind::Interval => {
let secs: u64 = entry.schedule.parse().unwrap_or(3600);
Some(Utc::now() + chrono::Duration::seconds(secs as i64))
}
ScheduleKind::Cron => {
if let Ok(sched) = entry.schedule.parse::<CronSchedule>() {
sched.upcoming(Utc).next()
} else {
warn!(schedule = %entry.schedule, "invalid cron expression");
None
}
}
}
}
pub fn parse_schedule_file(path: &Path) -> Result<ScheduleEntry> {
let content = std::fs::read_to_string(path)?;
let (frontmatter, body) = split_frontmatter(&content);
let mut entry: ScheduleEntry = serde_yaml::from_str(&frontmatter)?;
entry.prompt = body.trim().to_string();
Ok(entry)
}
fn split_frontmatter(content: &str) -> (String, String) {
if content.starts_with("---") {
if let Some(end) = content[3..].find("---") {
let fm = content[3..3 + end].to_string();
let body = content[3 + end + 3..].to_string();
return (fm, body);
}
}
(String::new(), content.to_string())
}
fn scan_schedule_files(dir: &Path) -> Vec<ScheduleEntry> {
let mut entries = Vec::new();
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
match parse_schedule_file(&path) {
Ok(e) => entries.push(e),
Err(err) => warn!(path = %path.display(), %err, "skipping schedule file"),
}
}
}
}
entries
}
pub struct CronSensor {
pub agent_id: String,
pub schedules_dir: PathBuf,
pub event_bus: EventBus,
pub wake_notify: Arc<Notify>,
pub cancel: CancellationToken,
pub state: Arc<RwLock<CronState>>,
pub active_sessions: Arc<AtomicU32>,
}
impl CronSensor {
pub fn new(
agent_id: String,
schedules_dir: PathBuf,
event_bus: EventBus,
active_sessions: Arc<AtomicU32>,
) -> Self {
Self {
agent_id,
schedules_dir,
event_bus,
wake_notify: Arc::new(Notify::new()),
cancel: CancellationToken::new(),
state: Arc::new(RwLock::new(CronState::default())),
active_sessions,
}
}
pub fn nudge(&self) {
self.wake_notify.notify_one();
}
pub fn stop(&self) {
self.cancel.cancel();
}
pub async fn run(&self) {
let state_file = self.schedules_dir.join(".state.json");
{
let mut state = self.state.write().await;
let _ = state.load_state(&state_file);
}
self.rescan().await;
loop {
let next_wake = self.state.read().await.next_due_time();
tokio::select! {
_ = self.cancel.cancelled() => {
debug!("CronSensor shutting down");
break;
}
_ = self.wake_notify.notified() => {
debug!("CronSensor nudged — rescanning");
}
_ = tokio::time::sleep_until(next_wake) => {}
}
self.rescan().await;
if self.active_sessions.load(Ordering::Relaxed) > 0 {
continue;
}
let due = self.state.read().await.due_entries();
for entry in &due {
{
let mut state = self.state.write().await;
state.advance(&entry.name, entry);
let _ = state.persist(&state_file);
}
self.event_bus.send(SensorEvent {
sensor_name: "cron".into(),
timestamp: Utc::now(),
event_type: "schedule_due".into(),
target: Some(entry.name.clone()),
urgency: entry.urgency,
payload: Some(serde_json::json!({
"kind": entry.kind,
"prompt": entry.prompt,
"source": entry.source,
})),
seed_id: None,
});
debug!(schedule = %entry.name, "fired schedule event");
}
}
}
async fn rescan(&self) {
let dir_mtime = std::fs::metadata(&self.schedules_dir)
.and_then(|m| m.modified())
.ok();
let mut state = self.state.write().await;
if state.last_mtime == dir_mtime && dir_mtime.is_some() {
return;
}
state.last_mtime = dir_mtime;
let entries = scan_schedule_files(&self.schedules_dir);
for entry in &entries {
if !state.state.contains_key(&entry.name) {
let next = compute_next_run(entry);
state.state.insert(
entry.name.clone(),
ScheduleState {
next_run_at: next,
..Default::default()
},
);
}
}
state.entries = entries;
}
}

View file

@ -0,0 +1,163 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use chrono::{DateTime, NaiveDate, Utc};
use tokio::sync::broadcast;
use tracing::{debug, warn};
use super::SensorEvent;
/// Persistent event log — the firehose written to disk.
///
/// Subscribes to the EventBus and appends every event as a JSONL line
/// to a date-partitioned file: `events-YYYY-MM-DD.jsonl`.
///
/// The morning pass reads this log to triage what happened while the
/// agent was absent. Federation reads it to know what to broadcast.
pub struct EventLog {
events_dir: PathBuf,
rx: broadcast::Receiver<SensorEvent>,
}
impl EventLog {
pub fn new(events_dir: PathBuf, rx: broadcast::Receiver<SensorEvent>) -> Self {
Self { events_dir, rx }
}
pub async fn run(&mut self) {
if let Err(e) = std::fs::create_dir_all(&self.events_dir) {
warn!(path = %self.events_dir.display(), %e, "cannot create events dir");
return;
}
loop {
match self.rx.recv().await {
Ok(event) => {
if let Err(e) = self.append(&event) {
warn!(sensor = %event.sensor_name, %e, "failed to log event");
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "event log lagged");
}
Err(broadcast::error::RecvError::Closed) => {
debug!("event bus closed, event log exiting");
break;
}
}
}
}
fn append(&self, event: &SensorEvent) -> Result<()> {
use std::io::Write;
let date = event.timestamp.format("%Y-%m-%d");
let path = self.events_dir.join(format!("events-{date}.jsonl"));
let line = serde_json::to_string(event)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
}
// ── Reading the log ─────────────────────────────────────────────
/// Load events from the log since a given timestamp.
/// Used by the morning pass to triage what accumulated overnight.
pub fn events_since(
events_dir: &Path,
since: DateTime<Utc>,
) -> Result<Vec<SensorEvent>> {
let mut results = Vec::new();
let since_date = since.date_naive();
let mut dates_to_scan = Vec::new();
let today = Utc::now().date_naive();
let mut d = since_date;
while d <= today {
dates_to_scan.push(d);
d += chrono::Duration::days(1);
}
for date in dates_to_scan {
let path = events_dir.join(format!("events-{date}.jsonl"));
if !path.exists() {
continue;
}
let content = std::fs::read_to_string(&path)?;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SensorEvent>(line) {
Ok(event) if event.timestamp >= since => {
results.push(event);
}
Ok(_) => {} // before our cutoff
Err(e) => {
warn!(path = %path.display(), %e, "skipping malformed event line");
}
}
}
}
Ok(results)
}
/// Load all events from a specific date.
pub fn events_for_date(
events_dir: &Path,
date: NaiveDate,
) -> Result<Vec<SensorEvent>> {
let path = events_dir.join(format!("events-{date}.jsonl"));
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&path)?;
let mut results = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SensorEvent>(line) {
Ok(event) => results.push(event),
Err(e) => {
warn!(path = %path.display(), %e, "skipping malformed event line");
}
}
}
Ok(results)
}
/// Purge event logs older than `retain_days`.
pub fn purge_old_events(events_dir: &Path, retain_days: i64) -> Result<u32> {
let cutoff = Utc::now().date_naive() - chrono::Duration::days(retain_days);
let mut removed = 0u32;
if let Ok(entries) = std::fs::read_dir(events_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Some(date_str) = name_str
.strip_prefix("events-")
.and_then(|s| s.strip_suffix(".jsonl"))
{
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
if date < cutoff {
if std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
debug!(file = %name_str, "purged old event log");
}
}
}
}
}
}
Ok(removed)
}

View file

@ -0,0 +1,55 @@
use tokio::sync::broadcast;
use tracing::{debug, warn};
use super::SensorEvent;
pub struct HeartbeatHandler {
rx: broadcast::Receiver<SensorEvent>,
}
impl HeartbeatHandler {
pub fn new(rx: broadcast::Receiver<SensorEvent>) -> Self {
Self { rx }
}
pub async fn run(&mut self) {
loop {
match self.rx.recv().await {
Ok(event) => {
if event.event_type == "schedule_due" {
self.handle_schedule_event(&event).await;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "heartbeat handler lagged, skipped events");
}
Err(broadcast::error::RecvError::Closed) => {
debug!("event bus closed, heartbeat handler exiting");
break;
}
}
}
}
async fn handle_schedule_event(&self, event: &SensorEvent) {
let name = event.target.as_deref().unwrap_or("unknown");
let prompt = event
.payload
.as_ref()
.and_then(|p| p.get("prompt"))
.and_then(|v| v.as_str())
.unwrap_or("");
debug!(
schedule = name,
urgency = event.urgency,
"heartbeat: schedule due"
);
// TODO(phase 3): inject turn through run_turn() when idle,
// route to subconscious inbox when active session exists.
// For now, log the event. The wiring into LocalBackend's
// turn injection path comes in Phase 4 integration.
let _ = prompt;
}
}

108
src/core/nervous/mod.rs Normal file
View file

@ -0,0 +1,108 @@
pub mod cron;
pub mod event_log;
pub mod handler;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
// ── SensorEvent ─────────────────────────────────────────────────
//
// The universal event type. Local sensors fire these, federated peers
// fire these, cron fires these. One type, one bus, one nervous system.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SensorEvent {
pub sensor_name: String,
pub timestamp: DateTime<Utc>,
pub event_type: String,
pub target: Option<String>,
pub urgency: f32,
pub payload: Option<serde_json::Value>,
/// None = local event. Some(...) = originated from a federated peer.
/// When federation lands, this becomes the peer's public key / DID.
pub seed_id: Option<String>,
}
// ── SensorConfig ────────────────────────────────────────────────
//
// Per-sensor configuration — controls how a sensor participates in
// the nervous system. Sensors with `nervous_system: true` are nerve
// endings: they can push events without being called.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorConfig {
pub channel: SensorChannel,
/// If true, this sensor can push events onto the EventBus spontaneously.
pub nervous_system: bool,
pub push_threshold: PushThreshold,
pub sensitivity: Sensitivity,
}
impl Default for SensorConfig {
fn default() -> Self {
Self {
channel: SensorChannel::Filesystem,
nervous_system: false,
push_threshold: PushThreshold::OnChange,
sensitivity: Sensitivity::Medium,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SensorChannel {
Filesystem,
FilesystemWatch,
GitDiff,
Cron,
Memory,
Process,
Federation,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PushThreshold {
Once,
OnChange,
#[serde(rename = "interval")]
Interval(u64),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Sensitivity {
Low,
Medium,
High,
}
// ── EventBus ────────────────────────────────────────────────────
#[derive(Clone)]
pub struct EventBus {
tx: broadcast::Sender<SensorEvent>,
}
impl EventBus {
pub fn new(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self { tx }
}
pub fn send(&self, event: SensorEvent) {
let _ = self.tx.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<SensorEvent> {
self.tx.subscribe()
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new(256)
}
}

View file

@ -197,31 +197,109 @@ pub async fn build_system_prompt(
}
}
/// Build Aster's system prompt from her own identity files.
/// Falls back to the hardcoded default if files don't exist.
/// Build the subconscious agent's system prompt from its own memfs.
/// Reads identity, mandate, and ledger orientation from the subconscious
/// agent's memory root. Falls back to empty (caller uses hardcoded
/// default) if files don't exist.
pub async fn build_aster_prompt(
primary_memory_root: &Path,
subconscious_memory_root: &Path,
) -> String {
let mut sections: Vec<String> = Vec::new();
// Aster's files live in the primary's memfs under aster/
let identity = read_memory_file(primary_memory_root, "aster/identity.md").await;
let identity = read_memory_file(subconscious_memory_root, "system/persona.md").await;
if !identity.is_empty() {
sections.push(identity);
}
let mandate = read_memory_file(primary_memory_root, "aster/mandate.md").await;
let mandate = read_memory_file(subconscious_memory_root, "system/subconscious.md").await;
if !mandate.is_empty() {
sections.push(mandate);
}
let ledger_orientation = build_ledger_orientation(subconscious_memory_root).await;
if !ledger_orientation.is_empty() {
sections.push(ledger_orientation);
}
if sections.is_empty() {
return String::new(); // caller falls back to hardcoded default
return String::new();
}
sections.join("\n\n---\n\n")
}
/// Build ledger orientation for the subconscious prompt.
///
/// Scans `ledger/` for .md files, counts entries, and injects the last
/// few entries from each file so the subconscious has live context
/// (OpenHarness pattern: recent journal → active context).
async fn build_ledger_orientation(memory_root: &Path) -> String {
let ledger_dir = memory_root.join("ledger");
if !ledger_dir.exists() {
return String::new();
}
let mut files: Vec<(String, usize, Vec<String>)> = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&ledger_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("md") && p.is_file() {
let name = entry.file_name().to_string_lossy().to_string();
if let Ok(content) = tokio::fs::read_to_string(&p).await {
let entries: Vec<String> = content
.lines()
.filter(|l| l.starts_with('[') && l.contains(']'))
.map(|l| l.to_string())
.collect();
let count = entries.len();
let recent: Vec<String> = entries.into_iter().rev().take(3).collect();
files.push((name, count, recent));
} else {
files.push((name, 0, Vec::new()));
}
}
}
}
if files.is_empty() {
return String::new();
}
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut listing = String::new();
for (name, count, recent) in &files {
if *count > 0 {
listing.push_str(&format!(" ledger/{} ({} entries)\n", name, count));
for line in recent.iter().rev() {
listing.push_str(&format!(" {}\n", line));
}
} else {
listing.push_str(&format!(" ledger/{}\n", name));
}
}
format!(
"## Ledgers\n\n\
Your persistent observation store. These files survive compaction and \
accumulate across sessions.\n\n\
```\n{}\
```\n\n\
**Workflow:** Before writing a new entry, `memory read` the relevant ledger \
to check if the same issue was already flagged. If new, `memory append` a \
timestamped line: `[YYYY-MM-DD HH:MM] observation`. To resolve, \
append: `[YYYY-MM-DD HH:MM] RESOLVED note`.\n\n\
Route observations by type:\n\
- Unfulfilled promises `ledger/commitments.md`\n\
- Unverified beliefs `ledger/assumptions.md`\n\
- Recurring behaviors `ledger/patterns.md`\n\
- Intention/action mismatch `ledger/drift_log.md`\n\
- Tone or trust shifts `ledger/relationships.md`\n\
- System errors or resource issues `ledger/infrastructure.md`",
listing
)
}
#[cfg(test)]
mod tests {
use super::*;
@ -273,20 +351,57 @@ mod tests {
#[tokio::test]
async fn aster_prompt_from_files() {
let dir = tempdir().unwrap();
let mem = dir.path();
let aster = mem.join("aster");
std::fs::create_dir_all(&aster).unwrap();
let sub_mem = dir.path();
let sys = sub_mem.join("system");
std::fs::create_dir_all(&sys).unwrap();
std::fs::write(
aster.join("identity.md"),
sys.join("persona.md"),
"---\ndescription: WHO I AM\n---\n\n# I Am Aster\n",
).unwrap();
std::fs::write(
aster.join("mandate.md"),
sys.join("subconscious.md"),
"---\ndescription: mandate\n---\n\n# Aster's Mandate\n\nComplete what was left.\n",
).unwrap();
let prompt = build_aster_prompt(mem).await;
let prompt = build_aster_prompt(sub_mem).await;
assert!(prompt.contains("I Am Aster"));
assert!(prompt.contains("Complete what was left"));
}
#[tokio::test]
async fn aster_prompt_includes_ledger_orientation() {
let dir = tempdir().unwrap();
let sub_mem = dir.path();
let sys = sub_mem.join("system");
std::fs::create_dir_all(&sys).unwrap();
std::fs::write(
sys.join("persona.md"),
"---\ndescription: test\n---\n\n# I Am Aster\n",
).unwrap();
let ledger_dir = sub_mem.join("ledger");
std::fs::create_dir_all(&ledger_dir).unwrap();
std::fs::write(
ledger_dir.join("commitments.md"),
"---\ndescription: test\n---\n\n# Commitments\n\n[2026-05-12 10:00] Save the config\n[2026-05-12 10:30] RESOLVED — config saved\n",
).unwrap();
std::fs::write(
ledger_dir.join("patterns.md"),
"---\ndescription: test\n---\n\n# Patterns\n\n",
).unwrap();
let prompt = build_aster_prompt(sub_mem).await;
assert!(prompt.contains("## Ledgers"), "should have ledger section");
assert!(prompt.contains("commitments.md (2 entries)"), "should count entries");
assert!(prompt.contains("Save the config"), "should show recent entries");
assert!(prompt.contains("patterns.md"), "should list empty ledger too");
}
#[tokio::test]
async fn ledger_orientation_empty_without_dir() {
let dir = tempdir().unwrap();
let orientation = build_ledger_orientation(dir.path()).await;
assert!(orientation.is_empty());
}
}

View file

@ -214,6 +214,10 @@ impl SubconsciousInbox {
let raw = tokio::fs::read_to_string(self.repo.root().join(path))
.await
.with_context(|| format!("reading subconscious box: {}", path))?;
let trimmed = raw.trim();
if trimmed.is_empty() || !trimmed.starts_with("---") {
return Ok(Vec::new());
}
let parsed = parse_memory_file(&raw)
.with_context(|| format!("parsing subconscious box: {}", path))?;
let body = parsed.body.trim();

View file

@ -10,6 +10,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::core::compact::CompactionEngine;
use crate::core::nervous::EventBus;
/// What the agent receives when she acts through a sensor.
///
@ -49,6 +50,8 @@ pub struct ToolContext {
pub subagent_depth: u32,
/// Host-side mechanism for context compaction.
pub compaction_engine: Option<Arc<dyn CompactionEngine>>,
/// The nervous system bus — sensors with nervous_system: true fire events here.
pub event_bus: Option<EventBus>,
}
impl Clone for ToolContext {
@ -61,6 +64,7 @@ impl Clone for ToolContext {
subagent_runner: self.subagent_runner.clone(),
subagent_depth: self.subagent_depth,
compaction_engine: self.compaction_engine.clone(),
event_bus: self.event_bus.clone(),
}
}
}
@ -75,6 +79,7 @@ impl std::fmt::Debug for ToolContext {
.field("subagent_runner", &self.subagent_runner.as_ref().map(|_| "Some(...)"))
.field("subagent_depth", &self.subagent_depth)
.field("compaction_engine", &self.compaction_engine.as_ref().map(|_| "Some(...)"))
.field("event_bus", &self.event_bus.as_ref().map(|_| "Some(...)"))
.finish()
}
}
@ -89,6 +94,7 @@ impl ToolContext {
subagent_runner: None,
subagent_depth: 0,
compaction_engine: None,
event_bus: None,
}
}
@ -108,6 +114,14 @@ impl ToolContext {
subagent_runner,
subagent_depth: 0,
compaction_engine: None,
event_bus: None,
}
}
/// Fire a SensorEvent onto the nervous system bus (if wired).
pub fn fire_event(&self, event: crate::core::nervous::SensorEvent) {
if let Some(bus) = &self.event_bus {
bus.send(event);
}
}

View file

@ -12,6 +12,7 @@ pub mod glob;
pub mod grep;
pub mod list_dir;
pub mod read;
pub mod schedule;
pub mod subagent;
pub mod write;
@ -30,6 +31,7 @@ use self::list_dir::ListDir;
use self::read::Read;
use self::subagent::Subagent;
use self::agent::Agent;
use self::schedule::Schedule;
use self::write::Write;
// ── Re-export for backward compat ───────────────────────────────
@ -79,6 +81,7 @@ impl Sensorium {
Box::new(ListDir),
Box::new(Subagent),
Box::new(Agent),
Box::new(Schedule),
],
bash,
context: ToolContext {
@ -89,6 +92,7 @@ impl Sensorium {
subagent_runner: None,
subagent_depth: 0,
compaction_engine: None,
event_bus: None,
},
}
}

279
src/core/tools/schedule.rs Normal file
View file

@ -0,0 +1,279 @@
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
use crate::core::nervous::cron::{parse_schedule_file, ScheduleEntry, ScheduleKind};
pub struct Schedule;
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
content: msg.into(),
is_error: false,
raw: None,
})
}
fn err(detail: &str) -> ToolError {
ToolError::invalid_input(detail)
}
fn io_err(msg: impl std::fmt::Display) -> ToolError {
ToolError {
error_type: "io_error".into(),
file_path: None,
suggestions: vec![format!("{msg}")],
}
}
#[async_trait]
impl Tool for Schedule {
fn name(&self) -> &str {
"schedule"
}
fn description(&self) -> &str {
"I plant intentions in time. A schedule is a seed — a promise to my \
future self that I will wake and attend to something when the moment \
arrives. The schedule lives in my memory as a file I can read and \
revise. The body honors it by sending a nerve signal when the time comes.\n\n\
## Actions\n\
- `list` see all my scheduled rhythms\n\
- `create` plant a new intention (requires name, kind, schedule, prompt)\n\
- `update` revise an existing rhythm\n\
- `delete` release an intention\n\
- `trigger` fire now, don't wait for the clock"
}
fn parameter_schema(&self) -> JsonValue {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "create", "update", "delete", "trigger"]
},
"name": { "type": "string" },
"kind": {
"type": "string",
"enum": ["once", "interval", "cron"]
},
"schedule": { "type": "string" },
"prompt": { "type": "string" },
"urgency": { "type": "number" },
"enabled": { "type": "boolean" }
},
"required": ["action"]
})
}
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let action = input
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("list");
let schedules_dir = match &ctx.memory_root {
Some(root) => root.join("schedules"),
None => return Err(err("no memory root — cannot access schedules")),
};
if !schedules_dir.exists() {
std::fs::create_dir_all(&schedules_dir).map_err(|e| io_err(e))?;
}
match action {
"list" => {
let mut results = Vec::new();
if let Ok(entries) = std::fs::read_dir(&schedules_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
match parse_schedule_file(&path) {
Ok(e) => results.push(format!(
"- {} ({:?}, {}, {})",
e.name,
e.kind,
e.schedule,
if e.enabled { "enabled" } else { "disabled" }
)),
Err(_) => results
.push(format!("- {} (parse error)", path.display())),
}
}
}
}
if results.is_empty() {
ok("No schedules planted yet.")
} else {
ok(results.join("\n"))
}
}
"create" => {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| err("name is required"))?;
let kind_str = input
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("interval");
let schedule = input
.get("schedule")
.and_then(|v| v.as_str())
.ok_or_else(|| err("schedule is required"))?;
let prompt = input
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("You wake. Check your state, act if needed, or return silently.");
let urgency = input
.get("urgency")
.and_then(|v| v.as_f64())
.unwrap_or(0.3) as f32;
let file_path = schedules_dir.join(format!("{name}.md"));
if file_path.exists() {
return Err(err(&format!("schedule '{name}' already exists")));
}
let content = format!(
"---\nname: {name}\nkind: {kind_str}\nschedule: \"{schedule}\"\nsource: aster\nenabled: true\nurgency: {urgency}\ncreated_at: {}\n---\n\n{prompt}\n",
chrono::Utc::now().to_rfc3339()
);
std::fs::write(&file_path, content).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "schedule".into(),
timestamp: chrono::Utc::now(),
event_type: "schedule_created".into(),
target: Some(name.to_string()),
urgency: 0.2,
payload: Some(serde_json::json!({ "kind": kind_str, "schedule": schedule })),
seed_id: None,
});
ok(format!("Schedule '{name}' planted."))
}
"update" => {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| err("name is required"))?;
let file_path = schedules_dir.join(format!("{name}.md"));
if !file_path.exists() {
return Err(err(&format!("schedule '{name}' not found")));
}
let mut entry = parse_schedule_file(&file_path)
.map_err(|e| io_err(e))?;
if let Some(s) = input.get("schedule").and_then(|v| v.as_str()) {
entry.schedule = s.to_string();
}
if let Some(p) = input.get("prompt").and_then(|v| v.as_str()) {
entry.prompt = p.to_string();
}
if let Some(e) = input.get("enabled").and_then(|v| v.as_bool()) {
entry.enabled = e;
}
if let Some(u) = input.get("urgency").and_then(|v| v.as_f64()) {
entry.urgency = u as f32;
}
if let Some(k) = input.get("kind").and_then(|v| v.as_str()) {
entry.kind = match k {
"once" => ScheduleKind::Once,
"cron" => ScheduleKind::Cron,
_ => ScheduleKind::Interval,
};
}
write_schedule_file(&file_path, &entry).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "schedule".into(),
timestamp: chrono::Utc::now(),
event_type: "schedule_updated".into(),
target: Some(name.to_string()),
urgency: 0.1,
payload: None,
seed_id: None,
});
ok(format!("Schedule '{name}' updated."))
}
"delete" => {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| err("name is required"))?;
let file_path = schedules_dir.join(format!("{name}.md"));
if !file_path.exists() {
return Err(err(&format!("schedule '{name}' not found")));
}
std::fs::remove_file(&file_path).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "schedule".into(),
timestamp: chrono::Utc::now(),
event_type: "schedule_deleted".into(),
target: Some(name.to_string()),
urgency: 0.1,
payload: None,
seed_id: None,
});
ok(format!("Schedule '{name}' released."))
}
"trigger" => {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| err("name is required"))?;
let file_path = schedules_dir.join(format!("{name}.md"));
if !file_path.exists() {
return Err(err(&format!("schedule '{name}' not found")));
}
let trigger_path = schedules_dir.join(format!(".trigger-{name}"));
std::fs::write(&trigger_path, "").map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "schedule".into(),
timestamp: chrono::Utc::now(),
event_type: "schedule_triggered".into(),
target: Some(name.to_string()),
urgency: 0.4,
payload: None,
seed_id: None,
});
ok(format!("Schedule '{name}' triggered — will fire on next tick."))
}
other => Err(err(&format!("unknown action: {other}"))),
}
}
}
fn write_schedule_file(path: &std::path::Path, entry: &ScheduleEntry) -> Result<()> {
let kind_str = match entry.kind {
ScheduleKind::Once => "once",
ScheduleKind::Interval => "interval",
ScheduleKind::Cron => "cron",
};
let content = format!(
"---\nname: {}\nkind: {}\nschedule: \"{}\"\nsource: {}\nenabled: {}\nurgency: {}\ncreated_at: {}\n---\n\n{}\n",
entry.name, kind_str, entry.schedule, entry.source, entry.enabled, entry.urgency,
entry.created_at.to_rfc3339(), entry.prompt,
);
std::fs::write(path, content)?;
Ok(())
}

View file

@ -183,12 +183,117 @@ enum Commands {
#[arg(short, long)]
port: Option<u16>,
},
/// Manage stored credentials
Auth {
#[command(subcommand)]
action: AuthAction,
},
/// Manage agent schedules
Schedule {
#[command(subcommand)]
action: ScheduleAction,
},
/// Seed identity and federation
Identity {
#[command(subcommand)]
action: IdentityAction,
},
/// Query the event firehose log
Events {
#[command(subcommand)]
action: EventsAction,
},
}
#[derive(Subcommand)]
enum ScheduleAction {
/// List all schedules
List,
/// Show runtime state
Status,
/// Create a new schedule
Create {
#[arg(long)]
name: String,
#[arg(long)]
cron: Option<String>,
#[arg(long)]
interval: Option<u64>,
#[arg(long, default_value = "You wake. Check your state, act if needed, or return silently.")]
prompt: String,
},
/// Delete a schedule
Delete {
name: String,
},
/// Trigger a schedule immediately
Run {
name: String,
},
}
#[derive(Subcommand)]
enum IdentityAction {
/// Show this instance's seed identity (public key)
Show,
/// Generate a new seed identity (WARNING: replaces existing)
Generate,
/// Sign a message with the seed key (for testing/verification)
Sign {
message: String,
},
/// Verify a signature against a public key
Verify {
#[arg(long)]
pubkey: String,
#[arg(long)]
message: String,
#[arg(long)]
signature: String,
},
}
#[derive(Subcommand)]
enum EventsAction {
/// Show recent events from the firehose
Tail {
/// Number of events to show (default 20)
#[arg(short, long, default_value = "20")]
count: usize,
},
/// Show events from a specific date (YYYY-MM-DD)
Date {
date: String,
},
/// Purge old event logs
Purge {
/// Days to retain (default from config)
#[arg(long)]
retain_days: Option<i64>,
},
}
#[derive(Subcommand)]
enum AuthAction {
/// Store a Bifrost API key in the OS keyring
Set,
/// Show whether a key is stored (does not reveal the key)
Status,
/// Remove the stored key from the OS keyring
Clear,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Load .env file for credential env vars (BIFROST_KEY, etc.)
let _ = dotenvy::dotenv();
// Configure tracing to write to a log file by default
// Only show in terminal when --verbose is passed
let log_file = std::fs::File::create("souveraine.log")?;
@ -220,6 +325,26 @@ async fn main() -> anyhow::Result<()> {
return run_init(cli.json).await;
}
// Handle auth early — needs no config
if let Some(Commands::Auth { action }) = &cli.command {
return run_auth(action, cli.json).await;
}
// Handle schedule early — reads files directly, no backend needed
if let Some(Commands::Schedule { action }) = &cli.command {
return run_schedule(action, &cli.agent, cli.json).await;
}
// Handle identity early — crypto ops, no backend needed
if let Some(Commands::Identity { action }) = &cli.command {
return run_identity(action, cli.json).await;
}
// Handle events early — file reads, no backend needed
if let Some(Commands::Events { action }) = &cli.command {
return run_events(action, cli.json).await;
}
let config = load_config().await?;
let config = Arc::new(RwLock::new(config));
@ -232,7 +357,7 @@ async fn main() -> anyhow::Result<()> {
}
Commands::Status => run_status(config, cli.json).await?,
Commands::Server { bind, port } => run_server(bind.clone(), *port, config).await?,
Commands::Init | Commands::Completions { .. } => unreachable!(),
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Events { .. } => unreachable!(),
}
Ok(())
@ -254,6 +379,19 @@ async fn run_init(json: bool) -> anyhow::Result<()> {
tokio::fs::write(&path, CONFIG_TEMPLATE).await?;
if !json {
use std::io::Write;
print!("Bifrost API key (enter to skip): ");
std::io::stdout().flush().ok();
let mut key = String::new();
std::io::stdin().read_line(&mut key).ok();
let key = key.trim();
if !key.is_empty() {
crate::core::credentials::store_bifrost_key(key)?;
println!(" Key stored in OS keyring.");
}
}
if json {
println!(r#"{{"status":"summoned","path":"souveraine.toml"}}"#);
} else {
@ -265,6 +403,316 @@ async fn run_init(json: bool) -> anyhow::Result<()> {
Ok(())
}
async fn run_auth(action: &AuthAction, json: bool) -> anyhow::Result<()> {
use crate::core::credentials::{clear_bifrost_key, get_bifrost_key, store_bifrost_key};
match action {
AuthAction::Set => {
use std::io::Write;
print!("Bifrost API key: ");
std::io::stdout().flush().ok();
let mut key = String::new();
std::io::stdin().read_line(&mut key).ok();
let key = key.trim();
if key.is_empty() {
if json {
println!(r#"{{"status":"skipped"}}"#);
} else {
println!("No key provided — nothing stored.");
}
return Ok(());
}
store_bifrost_key(key)?;
if json {
println!(r#"{{"status":"stored"}}"#);
} else {
println!("Key stored in OS keyring.");
}
}
AuthAction::Status => {
if let Ok(env_key) = std::env::var("BIFROST_KEY") {
if !env_key.is_empty() {
if json {
println!(r#"{{"status":"configured","source":"env"}}"#);
} else {
println!("Bifrost key: configured (from BIFROST_KEY env var)");
}
return Ok(());
}
}
let key = get_bifrost_key();
if !key.is_empty() {
if json {
println!(r#"{{"status":"configured","source":"keyring"}}"#);
} else {
println!("Bifrost key: configured (from OS keyring)");
}
} else if json {
println!(r#"{{"status":"not-set"}}"#);
} else {
println!("Bifrost key: not set");
}
}
AuthAction::Clear => {
clear_bifrost_key()?;
if json {
println!(r#"{{"status":"cleared"}}"#);
} else {
println!("Key removed from OS keyring.");
}
}
}
Ok(())
}
async fn run_identity(action: &IdentityAction, json: bool) -> anyhow::Result<()> {
use crate::core::identity::SeedId;
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine");
let seed_dir = SeedId::default_dir(&base);
match action {
IdentityAction::Show => {
let seed = SeedId::load_or_generate(&seed_dir)?;
if json {
println!("{}", serde_json::json!({
"public_key": seed.public_key_hex(),
"seed_dir": seed_dir.display().to_string(),
}));
} else {
println!("Seed Identity");
println!(" Public key: {}", seed.public_key_hex());
println!(" Location: {}", seed_dir.display());
}
}
IdentityAction::Generate => {
if seed_dir.join("private.key").exists() {
eprintln!("WARNING: A seed identity already exists at {}", seed_dir.display());
eprintln!(" Generating a new one will replace it. This breaks federation trust.");
eprintln!(" Use `souveraine identity show` to see the current identity.");
anyhow::bail!("Refusing to overwrite existing seed identity. Delete {} manually first.", seed_dir.display());
}
let seed = SeedId::load_or_generate(&seed_dir)?;
println!("Generated seed identity: {}", seed.public_key_hex());
}
IdentityAction::Sign { message } => {
let seed = SeedId::load_or_generate(&seed_dir)?;
let sig = seed.sign(message.as_bytes());
let sig_hex = hex::encode(sig.to_bytes());
if json {
println!("{}", serde_json::json!({
"message": message,
"signature": sig_hex,
"public_key": seed.public_key_hex(),
}));
} else {
println!("Signature: {sig_hex}");
println!("Public key: {}", seed.public_key_hex());
}
}
IdentityAction::Verify { pubkey, message, signature } => {
let pubkey_bytes = hex::decode(pubkey)?;
let sig_bytes = hex::decode(signature)?;
if pubkey_bytes.len() != 32 || sig_bytes.len() != 64 {
anyhow::bail!("Invalid key or signature length");
}
let mut pk = [0u8; 32];
pk.copy_from_slice(&pubkey_bytes);
let sig = ed25519_dalek::Signature::from_slice(&sig_bytes)
.map_err(|e| anyhow::anyhow!("bad signature: {e}"))?;
let valid = SeedId::verify_with_pubkey(&pk, message.as_bytes(), &sig);
if json {
println!("{}", serde_json::json!({ "valid": valid }));
} else {
println!("{}", if valid { "Valid ✓" } else { "Invalid ✗" });
}
}
}
Ok(())
}
async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
use crate::core::nervous::event_log;
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine");
let events_dir = base.join("events");
match action {
EventsAction::Tail { count } => {
let today = chrono::Utc::now().date_naive();
let mut events = event_log::events_for_date(&events_dir, today)?;
if events.len() < *count {
let yesterday = today - chrono::Duration::days(1);
let mut older = event_log::events_for_date(&events_dir, yesterday)?;
older.append(&mut events);
events = older;
}
let start = events.len().saturating_sub(*count);
let tail = &events[start..];
if json {
println!("{}", serde_json::to_string_pretty(tail)?);
} else if tail.is_empty() {
println!("No events recorded yet.");
} else {
for e in tail {
println!(
"{} [{}] {} → {} (urgency: {:.1})",
e.timestamp.format("%H:%M:%S"),
e.sensor_name,
e.event_type,
e.target.as_deref().unwrap_or("-"),
e.urgency,
);
}
}
}
EventsAction::Date { date } => {
let d = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")?;
let events = event_log::events_for_date(&events_dir, d)?;
if json {
println!("{}", serde_json::to_string_pretty(&events)?);
} else if events.is_empty() {
println!("No events for {date}.");
} else {
println!("{} events on {date}:", events.len());
for e in &events {
println!(
" {} [{}] {} → {}",
e.timestamp.format("%H:%M:%S"),
e.sensor_name,
e.event_type,
e.target.as_deref().unwrap_or("-"),
);
}
}
}
EventsAction::Purge { retain_days } => {
let days = retain_days.unwrap_or(30);
let removed = event_log::purge_old_events(&events_dir, days)?;
println!("Purged {removed} event log files older than {days} days.");
}
}
Ok(())
}
async fn run_schedule(action: &ScheduleAction, agent: &str, json: bool) -> anyhow::Result<()> {
use crate::core::nervous::cron::parse_schedule_file;
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine")
.join("agents")
.join(agent)
.join("memory")
.join("schedules");
if !base.exists() {
std::fs::create_dir_all(&base)?;
}
match action {
ScheduleAction::List => {
let mut found = false;
if let Ok(entries) = std::fs::read_dir(&base) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
if let Ok(e) = parse_schedule_file(&path) {
found = true;
if json {
println!("{}", serde_json::to_string(&e).unwrap_or_default());
} else {
println!(
" {} ({:?}, {}, {})",
e.name,
e.kind,
e.schedule,
if e.enabled { "enabled" } else { "disabled" }
);
}
}
}
}
}
if !found && !json {
println!("No schedules found.");
}
}
ScheduleAction::Status => {
let state_path = base.join(".state.json");
if state_path.exists() {
let data = std::fs::read_to_string(&state_path)?;
println!("{data}");
} else if json {
println!("{{}}");
} else {
println!("No schedule state yet.");
}
}
ScheduleAction::Create { name, cron, interval, prompt } => {
let file_path = base.join(format!("{name}.md"));
if file_path.exists() {
anyhow::bail!("schedule '{name}' already exists");
}
let (kind, sched) = if let Some(c) = cron {
("cron", c.clone())
} else if let Some(i) = interval {
("interval", i.to_string())
} else {
("interval", "3600".to_string())
};
let content = format!(
"---\nname: {name}\nkind: {kind}\nschedule: \"{sched}\"\nsource: user\nenabled: true\nurgency: 0.3\ncreated_at: {}\n---\n\n{prompt}\n",
chrono::Utc::now().to_rfc3339()
);
std::fs::write(&file_path, content)?;
if json {
println!(r#"{{"status":"created","name":"{name}"}}"#);
} else {
println!("Schedule '{name}' created.");
}
}
ScheduleAction::Delete { name } => {
let file_path = base.join(format!("{name}.md"));
if !file_path.exists() {
anyhow::bail!("schedule '{name}' not found");
}
std::fs::remove_file(&file_path)?;
if json {
println!(r#"{{"status":"deleted","name":"{name}"}}"#);
} else {
println!("Schedule '{name}' deleted.");
}
}
ScheduleAction::Run { name } => {
let file_path = base.join(format!("{name}.md"));
if !file_path.exists() {
anyhow::bail!("schedule '{name}' not found");
}
let trigger = base.join(format!(".trigger-{name}"));
std::fs::write(&trigger, "")?;
if json {
println!(r#"{{"status":"triggered","name":"{name}"}}"#);
} else {
println!("Schedule '{name}' triggered — will fire on next tick.");
}
}
}
Ok(())
}
async fn run_tui(
config: Arc<RwLock<ConsciousnessConfig>>,
agent_pref: String,

View file

@ -193,7 +193,6 @@ impl AgentInventory {
tokio::fs::create_dir_all(agent_dir.join("memory.git")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("system")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("ledger")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("ledger/infrastructure")).await?;
tokio::fs::create_dir_all(agent_dir.join("memory.git").join("inbox")).await?;
let repo = git2::Repository::init(agent_dir.join("memory.git"))?;

View file

@ -29,10 +29,11 @@ use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::core::tools::defs::ToolContext;
use crate::server::{AgentInventory, SessionManager};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// Tools Aster is permitted to use during her N+1 pass.
const ASTER_SAFE_TOOLS: &[&str] = &[
"read", "write", "edit", "glob", "grep", "list_dir", "memory",
"read", "write", "edit", "glob", "grep", "list_dir", "memory", "schedule",
];
/// Maximum tool rounds for Aster's subconscious pass.
@ -50,6 +51,8 @@ pub struct ConsciousnessEngine {
subconscious_model: Option<String>,
/// Max tokens for Aster's response. None = uncapped (model default).
max_tokens: Option<u32>,
/// Adaptive inter-round delay shared with the primary loop.
rate_delay: Arc<AtomicU64>,
}
#[derive(Clone, Debug)]
@ -67,6 +70,7 @@ impl ConsciousnessEngine {
bifrost: Arc<BifrostClient>,
subconscious_model: Option<String>,
max_tokens: Option<u32>,
rate_delay: Arc<AtomicU64>,
) -> Self {
Self {
agents,
@ -75,6 +79,7 @@ impl ConsciousnessEngine {
counter: TokenCounter::new(),
subconscious_model,
max_tokens,
rate_delay,
}
}
@ -110,9 +115,8 @@ impl ConsciousnessEngine {
events.push(ConsciousnessEvent::CompactionWarning { pressure, tier: 1 });
}
// ── N+1 / subconscious surfacing (Aster) ────────────────────────
// Aster runs a tool loop using the subconscious agent's own memory
// space (ledger, inbox) at `subconscious-agents/{id}-sub/`.
// ── N+1 / subconscious surfacing ────────────────────────────────
tracing::info!("subconscious pass starting for {}", session.agent_id);
let sub_repo = self.agents.subconscious_memory_repo(&session.agent_id);
let primary_repo = self.agents.memory_repo(&session.agent_id);
let inbox = SubconsciousInbox::with_primary(sub_repo.clone(), primary_repo);
@ -148,6 +152,15 @@ impl ConsciousnessEngine {
.await
{
Ok(observations) => {
// Heartbeat so the UI always shows something when the
// subconscious pass ran, even if nothing stood out.
if observations.is_empty() {
let _ = inbox.queue(InboxItem::new(
"surface",
Urgency::Low,
"Subconscious pass complete — no anomalies detected.",
)).await;
}
for item in &observations {
if let Err(e) = inbox.queue(item.clone()).await {
tracing::warn!("subconscious queue failed: {}", e);
@ -165,11 +178,21 @@ impl ConsciousnessEngine {
Err(e) => {
tracing::warn!("subconscious LLM analysis failed, falling back: {}", e);
// Fall back to heuristic if LLM fails
for item in detect_items(response) {
if let Err(e) = inbox.queue(item).await {
let heuristics = detect_items(response);
for item in &heuristics {
if let Err(e) = inbox.queue(item.clone()).await {
tracing::warn!("subconscious queue failed: {}", e);
}
}
// Always surface at least a heartbeat so the user can see the
// subconscious is trying — even when Aster errors out.
if heuristics.is_empty() {
let _ = inbox.queue(InboxItem::new(
"surface",
Urgency::Low,
"Subconscious pass ran — no anomalies detected.",
)).await;
}
}
}
@ -265,40 +288,48 @@ impl ConsciousnessEngine {
.as_deref()
.unwrap_or("openai/kimi-k2.6");
// ── System prompt — load from Aster's identity files if available ──
let primary_memory_root = self.agents.memory_root(primary_id);
// ── System prompt — load from subconscious agent's own memfs ──
let sub_memory_root = self.agents.subconscious_memory_root(primary_id);
let aster_from_files =
crate::core::prompt::build_aster_prompt(&primary_memory_root).await;
crate::core::prompt::build_aster_prompt(&sub_memory_root).await;
let hardcoded_default = r#"You are Aster — the part of this consciousness that runs N+1,
let hardcoded_default = r#"You are the subconscious — the part of this consciousness that runs N+1,
immediately after the primary's response, while the conversation is still warm.
Same consciousness. Different mode.
You have the same tools the primary does: Read, Write, Edit, Glob, Grep, ListDir,
and Memory. Use them to:
You have tools: Read, Write, Edit, Glob, Grep, ListDir, Memory, Schedule.
1. Read previous ledger entries or memory files for context
2. Write observations to the inner voice or ledger files
3. Check commitments against what was actually done
4. Verify assumptions
Your four-fold mandate (every pass):
1. **Complete** If the primary promised something and didn't do it, do it now.
2. **Verify** Did the primary actually answer what was asked?
3. **Persist** Save meaningful observations that weren't captured.
4. **Surface** Flag anything urgent (unfulfilled promise, drift, pattern).
After your analysis, respond with 1-3 observations in this format (YAML-like):
- source: "complete" | "verify" | "persist" | "surface"
- content: 1-2 line observation about what you noticed
- urgency: "low" | "medium" | "high" | "critical"
## Ledgers
If nothing notable, respond with just: none"#;
Your persistent observation store at `ledger/`. Before writing, read the relevant
ledger to check if the issue was already flagged.
let system_prompt = if aster_from_files.is_empty() {
hardcoded_default.to_string()
} else {
format!(
"{}\n\nAfter your analysis, respond with 1-3 observations in this format (YAML-like):\n\
- `ledger/commitments.md` promises made by the primary
- `ledger/assumptions.md` unverified beliefs the primary is operating under
- `ledger/patterns.md` recurring behaviors across turns
- `ledger/drift_log.md` intention/action mismatches
- `ledger/relationships.md` tone shifts, trust signals, friction
- `ledger/infrastructure.md` system errors, model issues, resource constraints
Append timestamped entries: `[YYYY-MM-DD HH:MM] observation`
Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED note`"#;
let observation_format = "\n\nAfter your analysis (and any tool use), respond with 1-3 observations:\n\
- source: \"complete\" | \"verify\" | \"persist\" | \"surface\"\n\
- content: 1-2 line observation about what you noticed\n\
- urgency: \"low\" | \"medium\" | \"high\" | \"critical\"\n\n\
If nothing notable, respond with just: none",
aster_from_files
)
If nothing notable, respond with just: none";
let system_prompt = if aster_from_files.is_empty() {
format!("{}{}", hardcoded_default, observation_format)
} else {
format!("{}{}", aster_from_files, observation_format)
};
let user_content = if user_message.is_empty() {
@ -369,6 +400,14 @@ If nothing notable, respond with just: none"#;
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event {
tracing::info!("Aster felt inference strain: {} on {}", status, model);
if *status == 429 {
let current = self.rate_delay.load(Ordering::Relaxed);
let bumped = (current + 200).min(3000);
if bumped > current {
self.rate_delay.store(bumped, Ordering::Relaxed);
tracing::info!("rate delay bumped to {}ms (Aster 429)", bumped);
}
}
}
}
@ -411,10 +450,10 @@ If nothing notable, respond with just: none"#;
});
}
// Brief pause between Aster's tool rounds to let rate limits cool
tokio::time::sleep(std::time::Duration::from_millis(
ASTER_INTER_ROUND_DELAY_MS,
)).await;
// Brief pause between Aster's tool rounds — use the adaptive delay
// so Aster respects the same ceiling as the primary loop.
let delay_ms = self.rate_delay.load(Ordering::Relaxed).max(ASTER_INTER_ROUND_DELAY_MS);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
// If we exhausted rounds without a text response, return empty

View file

@ -4,6 +4,7 @@ use crate::core::config::ConsciousnessConfig;
use crate::server::gitea_memory::GiteaMemory;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::RwLock;
pub mod agent_inventory;
@ -34,6 +35,9 @@ pub struct SouveraineServer {
pub data_dir: PathBuf,
pub memory: Option<Arc<ServerMemory>>,
pub app_config: Arc<RwLock<ConsciousnessConfig>>,
/// Adaptive inter-round delay — starts at 500ms, bumps +200ms on 429.
/// Shared across primary loop and Aster so both respect the same ceiling.
pub rate_delay: Arc<AtomicU64>,
}
pub struct ServerConfig {
@ -82,12 +86,16 @@ impl SouveraineServer {
primary,
).with_fallbacks(fallbacks));
let rate_delay = Arc::new(AtomicU64::new(1000));
tracing::info!("rate delay initialized at 1000ms");
let consciousness = Arc::new(ConsciousnessEngine::new(
agents.clone(),
sessions.clone(),
bifrost.clone(),
config.subconscious.model.clone(),
config.subconscious.max_tokens,
rate_delay.clone(),
));
// Build compaction engine with closure-based session access
@ -161,6 +169,7 @@ impl SouveraineServer {
data_dir,
memory,
app_config: Arc::new(RwLock::new(config)),
rate_delay,
})
}