split idle state: thalamus (universal) + claude (tmux wrapper)
thalamus/idle.rs: pure state machine — activity tracking, EWMA, timers, sleep/quiet/dream state, notifications. No tmux, no Claude Code dependencies. claude/idle.rs: wraps thalamus state via Deref, adds claude_pane tracking, tmux prompt injection, dream nudges, context building. The Claude-specific tick() loop stays here. The consciousness binary can now use thalamus::idle::State directly, fed by TUI key events instead of tmux pane scraping. Co-Developed-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
parent
dd7f1e3f86
commit
fae44ad2d8
3 changed files with 497 additions and 514 deletions
392
src/thalamus/idle.rs
Normal file
392
src/thalamus/idle.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
// idle.rs — Universal idle state machine
|
||||
//
|
||||
// Tracks user presence and response times. Manages sleep mode,
|
||||
// quiet mode, activity EWMA, and notification thresholds.
|
||||
// No Claude Code or tmux dependencies — pure state machine.
|
||||
//
|
||||
// The consciousness binary feeds events directly (key presses,
|
||||
// turn completions). Claude Code feeds events via the daemon RPC.
|
||||
|
||||
use crate::thalamus::{home, now, notify};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use tracing::info;
|
||||
|
||||
// Defaults
|
||||
pub const DEFAULT_IDLE_TIMEOUT: f64 = 5.0 * 60.0;
|
||||
pub const DEFAULT_NOTIFY_TIMEOUT: f64 = 2.0 * 60.0;
|
||||
pub const DEFAULT_SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
|
||||
pub const DREAM_INTERVAL_HOURS: u64 = 18;
|
||||
|
||||
/// EWMA decay half-life in seconds (5 minutes).
|
||||
const EWMA_DECAY_HALF_LIFE: f64 = 5.0 * 60.0;
|
||||
|
||||
/// Minimum seconds between autonomous nudges.
|
||||
pub const MIN_NUDGE_INTERVAL: f64 = 15.0;
|
||||
|
||||
/// Boost half-life in seconds (60s).
|
||||
const EWMA_BOOST_HALF_LIFE: f64 = 60.0;
|
||||
|
||||
/// Steady-state target for active work.
|
||||
const EWMA_TARGET: f64 = 0.75;
|
||||
|
||||
/// Persisted subset — survives restarts.
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct Persisted {
|
||||
last_user_msg: f64,
|
||||
last_response: f64,
|
||||
#[serde(default)]
|
||||
sleep_until: Option<f64>,
|
||||
#[serde(default)]
|
||||
idle_timeout: f64,
|
||||
#[serde(default)]
|
||||
notify_timeout: f64,
|
||||
#[serde(default)]
|
||||
activity_ewma: f64,
|
||||
#[serde(default)]
|
||||
ewma_updated_at: f64,
|
||||
#[serde(default)]
|
||||
session_active_secs: f64,
|
||||
#[serde(default)]
|
||||
in_turn: bool,
|
||||
#[serde(default)]
|
||||
turn_start: f64,
|
||||
#[serde(default)]
|
||||
last_nudge: f64,
|
||||
// Human-readable mirrors
|
||||
#[serde(default, skip_deserializing)]
|
||||
last_user_msg_time: String,
|
||||
#[serde(default, skip_deserializing)]
|
||||
last_response_time: String,
|
||||
#[serde(default, skip_deserializing)]
|
||||
saved_at: String,
|
||||
#[serde(default, skip_deserializing)]
|
||||
fired: bool,
|
||||
#[serde(default, skip_deserializing)]
|
||||
uptime: f64,
|
||||
}
|
||||
|
||||
fn state_path() -> std::path::PathBuf {
|
||||
home().join(".consciousness/daemon-state.json")
|
||||
}
|
||||
|
||||
/// Compute EWMA decay factor: 0.5^(elapsed / half_life).
|
||||
pub fn ewma_factor(elapsed: f64, half_life: f64) -> f64 {
|
||||
(0.5_f64).powf(elapsed / half_life)
|
||||
}
|
||||
|
||||
/// Format epoch seconds as ISO timestamp.
|
||||
pub fn epoch_to_iso(epoch: f64) -> String {
|
||||
if epoch == 0.0 {
|
||||
return String::new();
|
||||
}
|
||||
let secs = epoch as u64;
|
||||
std::process::Command::new("date")
|
||||
.args(["-d", &format!("@{secs}"), "+%Y-%m-%dT%H:%M:%S%z"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct State {
|
||||
pub last_user_msg: f64,
|
||||
pub last_response: f64,
|
||||
pub sleep_until: Option<f64>,
|
||||
pub quiet_until: f64,
|
||||
pub consolidating: bool,
|
||||
pub dreaming: bool,
|
||||
pub dream_start: f64,
|
||||
pub fired: bool,
|
||||
pub idle_timeout: f64,
|
||||
pub notify_timeout: f64,
|
||||
pub activity_ewma: f64,
|
||||
pub ewma_updated_at: f64,
|
||||
pub session_active_secs: f64,
|
||||
pub in_turn: bool,
|
||||
pub turn_start: f64,
|
||||
pub last_nudge: f64,
|
||||
#[serde(skip)]
|
||||
pub running: bool,
|
||||
#[serde(skip)]
|
||||
pub start_time: f64,
|
||||
#[serde(skip)]
|
||||
pub notifications: notify::NotifyState,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_user_msg: 0.0,
|
||||
last_response: 0.0,
|
||||
sleep_until: None,
|
||||
quiet_until: 0.0,
|
||||
consolidating: false,
|
||||
dreaming: false,
|
||||
dream_start: 0.0,
|
||||
fired: false,
|
||||
idle_timeout: DEFAULT_IDLE_TIMEOUT,
|
||||
notify_timeout: DEFAULT_NOTIFY_TIMEOUT,
|
||||
session_active_secs: DEFAULT_SESSION_ACTIVE_SECS,
|
||||
activity_ewma: 0.0,
|
||||
ewma_updated_at: now(),
|
||||
in_turn: false,
|
||||
turn_start: 0.0,
|
||||
last_nudge: 0.0,
|
||||
running: true,
|
||||
start_time: now(),
|
||||
notifications: notify::NotifyState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self) {
|
||||
if let Ok(data) = fs::read_to_string(state_path()) {
|
||||
if let Ok(p) = serde_json::from_str::<Persisted>(&data) {
|
||||
self.sleep_until = p.sleep_until;
|
||||
if p.idle_timeout > 0.0 {
|
||||
self.idle_timeout = p.idle_timeout;
|
||||
}
|
||||
if p.notify_timeout > 0.0 {
|
||||
self.notify_timeout = p.notify_timeout;
|
||||
}
|
||||
if p.activity_ewma > 0.0 {
|
||||
self.activity_ewma = p.activity_ewma;
|
||||
self.ewma_updated_at = p.ewma_updated_at;
|
||||
}
|
||||
if p.session_active_secs > 0.0 {
|
||||
self.session_active_secs = p.session_active_secs;
|
||||
}
|
||||
self.last_user_msg = p.last_user_msg;
|
||||
self.last_response = p.last_response;
|
||||
self.in_turn = p.in_turn;
|
||||
self.turn_start = p.turn_start;
|
||||
self.last_nudge = p.last_nudge;
|
||||
info!("loaded idle state");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let p = Persisted {
|
||||
last_user_msg: self.last_user_msg,
|
||||
last_response: self.last_response,
|
||||
sleep_until: self.sleep_until,
|
||||
idle_timeout: self.idle_timeout,
|
||||
notify_timeout: self.notify_timeout,
|
||||
activity_ewma: self.activity_ewma,
|
||||
ewma_updated_at: self.ewma_updated_at,
|
||||
session_active_secs: self.session_active_secs,
|
||||
in_turn: self.in_turn,
|
||||
turn_start: self.turn_start,
|
||||
last_nudge: self.last_nudge,
|
||||
last_user_msg_time: epoch_to_iso(self.last_user_msg),
|
||||
last_response_time: epoch_to_iso(self.last_response),
|
||||
saved_at: epoch_to_iso(now()),
|
||||
fired: self.fired,
|
||||
uptime: now() - self.start_time,
|
||||
..Default::default()
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string_pretty(&p) {
|
||||
let _ = fs::write(state_path(), json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decay_ewma(&mut self) {
|
||||
let t = now();
|
||||
let elapsed = t - self.ewma_updated_at;
|
||||
if elapsed > 0.0 {
|
||||
let factor = ewma_factor(elapsed, EWMA_DECAY_HALF_LIFE);
|
||||
self.activity_ewma *= factor;
|
||||
self.ewma_updated_at = t;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn boost_ewma(&mut self, turn_duration: f64) {
|
||||
self.decay_ewma();
|
||||
let gap = EWMA_TARGET - self.activity_ewma;
|
||||
let boost = gap * (1.0 - ewma_factor(turn_duration, EWMA_BOOST_HALF_LIFE));
|
||||
self.activity_ewma += boost.max(0.0);
|
||||
}
|
||||
|
||||
/// Record user activity.
|
||||
pub fn user_activity(&mut self) {
|
||||
let t = now();
|
||||
self.last_user_msg = t;
|
||||
self.fired = false;
|
||||
if self.notifications.activity != notify::Activity::Focused {
|
||||
self.notifications.set_activity(notify::Activity::Focused);
|
||||
}
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// Record response activity.
|
||||
pub fn response_activity(&mut self) {
|
||||
let t = now();
|
||||
if self.in_turn {
|
||||
self.boost_ewma(t - self.turn_start);
|
||||
}
|
||||
self.last_response = t;
|
||||
self.in_turn = false;
|
||||
self.fired = false;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn handle_afk(&mut self) {
|
||||
self.last_user_msg = 0.0;
|
||||
self.fired = false;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn handle_session_timeout(&mut self, secs: f64) {
|
||||
self.session_active_secs = secs;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn handle_idle_timeout(&mut self, secs: f64) {
|
||||
self.idle_timeout = secs;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn handle_ewma(&mut self, value: f64) -> f64 {
|
||||
if value >= 0.0 {
|
||||
self.decay_ewma();
|
||||
self.activity_ewma = value;
|
||||
self.save();
|
||||
}
|
||||
self.activity_ewma
|
||||
}
|
||||
|
||||
pub fn handle_notify_timeout(&mut self, secs: f64) {
|
||||
self.notify_timeout = secs;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn handle_sleep(&mut self, until: f64) {
|
||||
if until == 0.0 {
|
||||
self.sleep_until = Some(0.0); // indefinite
|
||||
} else {
|
||||
self.sleep_until = Some(now() + until);
|
||||
}
|
||||
self.fired = false;
|
||||
self.notifications.set_activity(notify::Activity::Sleeping);
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn handle_wake(&mut self) {
|
||||
self.sleep_until = None;
|
||||
self.fired = false;
|
||||
self.notifications.set_activity(notify::Activity::Idle);
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn handle_quiet(&mut self, seconds: u32) {
|
||||
self.quiet_until = now() + seconds as f64;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn user_present(&self) -> bool {
|
||||
(now() - self.last_user_msg) < self.session_active_secs
|
||||
}
|
||||
|
||||
pub fn since_activity(&self) -> f64 {
|
||||
let reference = self.last_response.max(self.last_user_msg);
|
||||
if reference > 0.0 { now() - reference } else { 0.0 }
|
||||
}
|
||||
|
||||
pub fn block_reason(&self) -> &'static str {
|
||||
let t = now();
|
||||
if self.fired {
|
||||
"already fired"
|
||||
} else if self.sleep_until.is_some() {
|
||||
"sleeping"
|
||||
} else if t < self.quiet_until {
|
||||
"quiet mode"
|
||||
} else if self.consolidating {
|
||||
"consolidating"
|
||||
} else if self.dreaming {
|
||||
"dreaming"
|
||||
} else if self.user_present() {
|
||||
"user present"
|
||||
} else if self.in_turn {
|
||||
"in turn"
|
||||
} else if self.last_response.max(self.last_user_msg) == 0.0 {
|
||||
"no activity yet"
|
||||
} else if self.since_activity() < self.idle_timeout {
|
||||
"not idle long enough"
|
||||
} else {
|
||||
"none — would fire"
|
||||
}
|
||||
}
|
||||
|
||||
/// Should we transition to idle? Call on every tick.
|
||||
pub fn should_go_idle(&self) -> bool {
|
||||
if self.sleep_until.is_some() { return false; }
|
||||
if now() < self.quiet_until { return false; }
|
||||
if self.consolidating { return false; }
|
||||
if self.dreaming { return false; }
|
||||
if self.user_present() { return false; }
|
||||
if self.in_turn { return false; }
|
||||
let reference = self.last_response.max(self.last_user_msg);
|
||||
if reference == 0.0 { return false; }
|
||||
self.since_activity() >= self.idle_timeout
|
||||
}
|
||||
|
||||
pub fn debug_json(&self) -> String {
|
||||
let t = now();
|
||||
serde_json::json!({
|
||||
"now": t,
|
||||
"uptime": t - self.start_time,
|
||||
"idle_timeout": self.idle_timeout,
|
||||
"notify_timeout": self.notify_timeout,
|
||||
"last_user_msg": self.last_user_msg,
|
||||
"last_user_msg_ago": t - self.last_user_msg,
|
||||
"last_response": self.last_response,
|
||||
"last_response_ago": t - self.last_response,
|
||||
"since_activity": self.since_activity(),
|
||||
"activity_ewma": self.activity_ewma,
|
||||
"in_turn": self.in_turn,
|
||||
"user_present": self.user_present(),
|
||||
"fired": self.fired,
|
||||
"block_reason": self.block_reason(),
|
||||
"sleep_until": self.sleep_until,
|
||||
"quiet_until": self.quiet_until,
|
||||
"consolidating": self.consolidating,
|
||||
"dreaming": self.dreaming,
|
||||
"activity": format!("{:?}", self.notifications.activity),
|
||||
"pending_notifications": self.notifications.pending.len(),
|
||||
}).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hours_since_last_dream() -> u64 {
|
||||
let path = home().join(".consciousness/logs/dream-log.jsonl");
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) if !c.is_empty() => c,
|
||||
_ => return 999,
|
||||
};
|
||||
let last_line = match content.lines().last() {
|
||||
Some(l) => l,
|
||||
None => return 999,
|
||||
};
|
||||
let parsed: serde_json::Value = match serde_json::from_str(last_line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return 999,
|
||||
};
|
||||
let end_str = match parsed.get("end").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return 999,
|
||||
};
|
||||
let end_str = end_str.replace('Z', "+00:00");
|
||||
let out = std::process::Command::new("date")
|
||||
.args(["-d", &end_str, "+%s"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|s| s.trim().parse::<f64>().ok());
|
||||
match out {
|
||||
Some(end_epoch) => ((now() - end_epoch) / 3600.0) as u64,
|
||||
None => 999,
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
// binary.
|
||||
|
||||
pub mod channels;
|
||||
pub mod idle;
|
||||
pub mod supervisor;
|
||||
pub mod notify;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue