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:
parent
c489aa4bb3
commit
35534f1c73
22 changed files with 2077 additions and 79 deletions
16
Cargo.toml
16
Cargo.toml
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
55
src/core/credentials.rs
Normal 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
3
src/core/identity/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod seed;
|
||||
|
||||
pub use seed::SeedId;
|
||||
146
src/core/identity/seed.rs
Normal file
146
src/core/identity/seed.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
292
src/core/nervous/cron.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
163
src/core/nervous/event_log.rs
Normal file
163
src/core/nervous/event_log.rs
Normal 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)
|
||||
}
|
||||
55
src/core/nervous/handler.rs
Normal file
55
src/core/nervous/handler.rs
Normal 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
108
src/core/nervous/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
279
src/core/tools/schedule.rs
Normal 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(())
|
||||
}
|
||||
450
src/main.rs
450
src/main.rs
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue