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
|
|
@ -1,434 +1,80 @@
|
||||||
// Idle timer module.
|
// idle.rs — Claude Code idle timer
|
||||||
//
|
//
|
||||||
// Tracks user presence and Claude response times. When Claude has been
|
// Wraps the universal thalamus idle state machine with Claude-specific
|
||||||
// idle too long, sends a contextual prompt to the tmux pane. Handles
|
// functionality: tmux pane tracking, prompt injection, dream nudges,
|
||||||
// sleep mode, quiet mode, consolidation suppression, and dream nudges.
|
// and context building for autonomous nudges.
|
||||||
//
|
|
||||||
// Designed as the first "module" — future IRC/Telegram modules will
|
|
||||||
// follow the same pattern: state + tick + handle_command.
|
|
||||||
|
|
||||||
use super::{context, tmux};
|
use super::{context, tmux};
|
||||||
use crate::thalamus::{home, now, notify};
|
use crate::thalamus::{home, now, notify, idle as thalamus_idle};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs;
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
// Defaults
|
/// Claude Code idle state — wraps the universal state machine.
|
||||||
const DEFAULT_IDLE_TIMEOUT: f64 = 5.0 * 60.0;
|
|
||||||
const DEFAULT_NOTIFY_TIMEOUT: f64 = 2.0 * 60.0;
|
|
||||||
const DEFAULT_SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
|
|
||||||
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.
|
|
||||||
const MIN_NUDGE_INTERVAL: f64 = 15.0;
|
|
||||||
|
|
||||||
/// Boost half-life in seconds (60s). A 60s turn covers half the gap to
|
|
||||||
/// target; a 15s turn covers ~16%; a 2s turn covers ~2%.
|
|
||||||
const EWMA_BOOST_HALF_LIFE: f64 = 60.0;
|
|
||||||
|
|
||||||
/// Steady-state target for active work. The EWMA converges toward this
|
|
||||||
/// during sustained activity rather than toward 1.0.
|
|
||||||
const EWMA_TARGET: f64 = 0.75;
|
|
||||||
|
|
||||||
/// Persisted subset of daemon state — survives daemon restarts.
|
|
||||||
/// Includes both epoch floats (for computation) and ISO timestamps
|
|
||||||
/// (for human debugging via `cat daemon-state.json | jq`).
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
|
||||||
struct Persisted {
|
|
||||||
last_user_msg: f64,
|
|
||||||
last_response: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
sleep_until: Option<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
claude_pane: Option<String>,
|
|
||||||
#[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 — written but not consumed on load
|
|
||||||
#[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).
|
|
||||||
fn ewma_factor(elapsed: f64, half_life: f64) -> f64 {
|
|
||||||
(0.5_f64).powf(elapsed / half_life)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format epoch seconds as a human-readable ISO-ish timestamp.
|
|
||||||
fn epoch_to_iso(epoch: f64) -> String {
|
|
||||||
if epoch == 0.0 {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let secs = epoch as u64;
|
|
||||||
// Use date command — simple and correct for timezone
|
|
||||||
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 struct State {
|
||||||
pub last_user_msg: f64,
|
pub inner: thalamus_idle::State,
|
||||||
pub last_response: f64,
|
|
||||||
pub claude_pane: Option<String>,
|
pub claude_pane: Option<String>,
|
||||||
pub sleep_until: Option<f64>, // None=awake, 0=indefinite, >0=timestamp
|
}
|
||||||
pub quiet_until: f64,
|
|
||||||
pub consolidating: bool,
|
impl std::ops::Deref for State {
|
||||||
pub dreaming: bool,
|
type Target = thalamus_idle::State;
|
||||||
pub dream_start: f64,
|
fn deref(&self) -> &Self::Target { &self.inner }
|
||||||
pub fired: bool,
|
}
|
||||||
pub idle_timeout: f64,
|
|
||||||
pub notify_timeout: f64,
|
impl std::ops::DerefMut for State {
|
||||||
pub activity_ewma: f64,
|
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner }
|
||||||
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 {
|
impl State {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
last_user_msg: 0.0,
|
inner: thalamus_idle::State::new(),
|
||||||
last_response: 0.0,
|
|
||||||
claude_pane: None,
|
claude_pane: None,
|
||||||
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) {
|
pub fn load(&mut self) {
|
||||||
if let Ok(data) = fs::read_to_string(state_path()) {
|
self.inner.load();
|
||||||
if let Ok(p) = serde_json::from_str::<Persisted>(&data) {
|
// Also load claude_pane from persisted state
|
||||||
self.sleep_until = p.sleep_until;
|
let path = home().join(".consciousness/daemon-state.json");
|
||||||
self.claude_pane = p.claude_pane;
|
if let Ok(data) = std::fs::read_to_string(&path) {
|
||||||
if p.idle_timeout > 0.0 {
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
|
||||||
self.idle_timeout = p.idle_timeout;
|
if let Some(p) = v.get("claude_pane").and_then(|v| v.as_str()) {
|
||||||
}
|
self.claude_pane = Some(p.to_string());
|
||||||
if p.notify_timeout > 0.0 {
|
|
||||||
self.notify_timeout = p.notify_timeout;
|
|
||||||
}
|
|
||||||
if p.session_active_secs > 0.0 {
|
|
||||||
self.session_active_secs = p.session_active_secs;
|
|
||||||
}
|
|
||||||
// Reset activity timestamps to now — timers count from
|
|
||||||
// restart, not from stale pre-restart state
|
|
||||||
let t = now();
|
|
||||||
self.last_user_msg = t;
|
|
||||||
self.last_response = t;
|
|
||||||
// Restore EWMA state, applying decay for time spent shut down
|
|
||||||
if p.ewma_updated_at > 0.0 {
|
|
||||||
let elapsed = t - p.ewma_updated_at;
|
|
||||||
self.activity_ewma = p.activity_ewma * ewma_factor(elapsed, EWMA_DECAY_HALF_LIFE);
|
|
||||||
self.in_turn = p.in_turn;
|
|
||||||
self.turn_start = p.turn_start;
|
|
||||||
self.last_nudge = p.last_nudge;
|
|
||||||
}
|
|
||||||
self.ewma_updated_at = t;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always try to find the active pane
|
|
||||||
if self.claude_pane.is_none() {
|
|
||||||
self.claude_pane = tmux::find_claude_pane();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
|
||||||
"loaded: user={:.0} resp={:.0} pane={:?} sleep={:?}",
|
|
||||||
self.last_user_msg, self.last_response, self.claude_pane, self.sleep_until,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) {
|
pub fn save(&self) {
|
||||||
let p = Persisted {
|
self.inner.save();
|
||||||
last_user_msg: self.last_user_msg,
|
|
||||||
last_response: self.last_response,
|
|
||||||
sleep_until: self.sleep_until,
|
|
||||||
claude_pane: self.claude_pane.clone(),
|
|
||||||
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,
|
|
||||||
idle_timeout: self.idle_timeout,
|
|
||||||
notify_timeout: self.notify_timeout,
|
|
||||||
session_active_secs: self.session_active_secs,
|
|
||||||
activity_ewma: self.activity_ewma,
|
|
||||||
ewma_updated_at: self.ewma_updated_at,
|
|
||||||
in_turn: self.in_turn,
|
|
||||||
turn_start: self.turn_start,
|
|
||||||
last_nudge: self.last_nudge,
|
|
||||||
uptime: now() - self.start_time,
|
|
||||||
};
|
|
||||||
if let Ok(json) = serde_json::to_string_pretty(&p) {
|
|
||||||
let _ = fs::write(state_path(), json);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decay the activity EWMA toward zero based on elapsed time.
|
/// Record user activity with pane tracking.
|
||||||
fn decay_ewma(&mut self) {
|
|
||||||
let t = now();
|
|
||||||
let elapsed = t - self.ewma_updated_at;
|
|
||||||
if elapsed <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.activity_ewma *= ewma_factor(elapsed, EWMA_DECAY_HALF_LIFE);
|
|
||||||
self.ewma_updated_at = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boost the EWMA based on turn duration. The boost is proportional to
|
|
||||||
/// distance from EWMA_TARGET, scaled by a saturation curve on duration.
|
|
||||||
/// A 15s turn covers half the gap to target; a 2s turn barely registers.
|
|
||||||
/// Self-limiting: converges toward target, can't overshoot.
|
|
||||||
fn boost_ewma(&mut self, turn_duration: f64) {
|
|
||||||
let gap = (EWMA_TARGET - self.activity_ewma).max(0.0);
|
|
||||||
let saturation = 1.0 - ewma_factor(turn_duration, EWMA_BOOST_HALF_LIFE);
|
|
||||||
self.activity_ewma += gap * saturation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typed handlers for RPC
|
|
||||||
pub fn handle_user(&mut self, pane: &str) {
|
pub fn handle_user(&mut self, pane: &str) {
|
||||||
self.decay_ewma();
|
|
||||||
self.in_turn = true;
|
|
||||||
self.turn_start = now();
|
|
||||||
let from_user = !self.fired;
|
|
||||||
if from_user {
|
|
||||||
self.last_user_msg = now();
|
|
||||||
self.notifications.set_activity(notify::Activity::Focused);
|
|
||||||
}
|
|
||||||
self.fired = false;
|
|
||||||
if !pane.is_empty() {
|
|
||||||
self.claude_pane = Some(pane.to_string());
|
self.claude_pane = Some(pane.to_string());
|
||||||
}
|
self.inner.user_activity();
|
||||||
self.save();
|
|
||||||
info!("user (pane={}, from_user={from_user}) ewma={:.3}",
|
|
||||||
if pane.is_empty() { "unchanged" } else { pane },
|
|
||||||
self.activity_ewma);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record response activity with pane tracking.
|
||||||
pub fn handle_response(&mut self, pane: &str) {
|
pub fn handle_response(&mut self, pane: &str) {
|
||||||
let turn_duration = now() - self.turn_start;
|
|
||||||
self.decay_ewma();
|
|
||||||
self.boost_ewma(turn_duration);
|
|
||||||
self.in_turn = false;
|
|
||||||
self.last_response = now();
|
|
||||||
self.fired = false;
|
|
||||||
if !pane.is_empty() {
|
|
||||||
self.claude_pane = Some(pane.to_string());
|
self.claude_pane = Some(pane.to_string());
|
||||||
}
|
self.inner.response_activity();
|
||||||
self.save();
|
|
||||||
info!("response (turn={:.1}s) ewma={:.3}", turn_duration, self.activity_ewma);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a notification should trigger a tmux prompt.
|
/// Maybe send a notification as a tmux prompt.
|
||||||
/// Called when a notification arrives via module channel.
|
|
||||||
/// Only injects into tmux when idle — if there's an active session
|
|
||||||
/// (recent user or response), the hook delivers via additionalContext.
|
|
||||||
pub fn maybe_prompt_notification(&mut self, ntype: &str, urgency: u8, _message: &str) {
|
pub fn maybe_prompt_notification(&mut self, ntype: &str, urgency: u8, _message: &str) {
|
||||||
if self.user_present() {
|
let threshold = self.inner.notifications.threshold_for(ntype);
|
||||||
return; // hook will deliver it on next prompt
|
if urgency >= threshold {
|
||||||
|
let deliverable = self.inner.notifications.drain_deliverable();
|
||||||
|
if !deliverable.is_empty() {
|
||||||
|
let msgs: Vec<String> = deliverable.iter()
|
||||||
|
.map(|n| format!("[{}] {}", n.ntype, n.message))
|
||||||
|
.collect();
|
||||||
|
self.send(&msgs.join("\n"));
|
||||||
}
|
}
|
||||||
// If we've responded recently, the session is active —
|
|
||||||
// notifications will arrive via hook, no need to wake us
|
|
||||||
let since_response = now() - self.last_response;
|
|
||||||
if since_response < self.notify_timeout {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Don't send notifications via tmux directly — they flow
|
|
||||||
// through the idle nudge. Urgent notifications reset the
|
|
||||||
// idle timer so the nudge fires sooner.
|
|
||||||
let effective = self.notifications.threshold_for(ntype);
|
|
||||||
if urgency >= effective && self.fired {
|
|
||||||
// Bump: allow the nudge to re-fire for urgent notifications
|
|
||||||
self.fired = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_afk(&mut self) {
|
/// Send text to the Claude tmux pane.
|
||||||
// Push last_user_msg far enough back that user_present() returns false
|
|
||||||
self.last_user_msg = now() - self.session_active_secs - 1.0;
|
|
||||||
self.fired = false; // allow idle timer to fire again
|
|
||||||
info!("User marked AFK");
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_session_timeout(&mut self, secs: f64) {
|
|
||||||
self.session_active_secs = secs;
|
|
||||||
info!("session active timeout = {secs}s");
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_idle_timeout(&mut self, secs: f64) {
|
|
||||||
self.idle_timeout = secs;
|
|
||||||
self.save();
|
|
||||||
info!("idle timeout = {secs}s");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_ewma(&mut self, value: f64) -> f64 {
|
|
||||||
if value >= 0.0 {
|
|
||||||
self.activity_ewma = value.min(1.0);
|
|
||||||
self.ewma_updated_at = now();
|
|
||||||
self.save();
|
|
||||||
info!("ewma set to {:.3}", self.activity_ewma);
|
|
||||||
}
|
|
||||||
self.activity_ewma
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_notify_timeout(&mut self, secs: f64) {
|
|
||||||
self.notify_timeout = secs;
|
|
||||||
self.save();
|
|
||||||
info!("notify timeout = {secs}s");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_sleep(&mut self, until: f64) {
|
|
||||||
if until == 0.0 {
|
|
||||||
self.sleep_until = Some(0.0);
|
|
||||||
info!("sleep indefinitely");
|
|
||||||
} else {
|
|
||||||
self.sleep_until = Some(until);
|
|
||||||
info!("sleep until {until}");
|
|
||||||
}
|
|
||||||
self.notifications.set_activity(notify::Activity::Sleeping);
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_wake(&mut self) {
|
|
||||||
self.sleep_until = None;
|
|
||||||
self.fired = false;
|
|
||||||
self.save();
|
|
||||||
info!("wake");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_quiet(&mut self, seconds: u32) {
|
|
||||||
self.quiet_until = now() + seconds as f64;
|
|
||||||
info!("quiet {seconds}s");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_present(&self) -> bool {
|
|
||||||
(now() - self.last_user_msg) < self.session_active_secs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seconds since the most recent of user message or response.
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Why the idle timer hasn't fired (or "none" if it would fire now).
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full debug dump as JSON with computed values.
|
|
||||||
pub fn debug_json(&self) -> String {
|
|
||||||
let t = now();
|
|
||||||
let since_user = t - self.last_user_msg;
|
|
||||||
let since_response = t - self.last_response;
|
|
||||||
|
|
||||||
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": since_user,
|
|
||||||
"last_user_msg_time": epoch_to_iso(self.last_user_msg),
|
|
||||||
"last_response": self.last_response,
|
|
||||||
"last_response_ago": since_response,
|
|
||||||
"last_response_time": epoch_to_iso(self.last_response),
|
|
||||||
"since_activity": self.since_activity(),
|
|
||||||
"activity_ewma": self.activity_ewma,
|
|
||||||
"in_turn": self.in_turn,
|
|
||||||
"turn_start": self.turn_start,
|
|
||||||
"user_present": self.user_present(),
|
|
||||||
"claude_pane": self.claude_pane,
|
|
||||||
"fired": self.fired,
|
|
||||||
"block_reason": self.block_reason(),
|
|
||||||
"sleep_until": self.sleep_until,
|
|
||||||
"quiet_until": self.quiet_until,
|
|
||||||
"consolidating": self.consolidating,
|
|
||||||
"dreaming": self.dreaming,
|
|
||||||
"dream_start": self.dream_start,
|
|
||||||
"activity": format!("{:?}", self.notifications.activity),
|
|
||||||
"pending_notifications": self.notifications.pending.len(),
|
|
||||||
"notification_types": self.notifications.types.len(),
|
|
||||||
}).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send(&self, msg: &str) -> bool {
|
pub fn send(&self, msg: &str) -> bool {
|
||||||
let pane = match &self.claude_pane {
|
let pane = match &self.claude_pane {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
|
|
@ -440,7 +86,6 @@ impl State {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let ok = tmux::send_prompt(&pane, msg);
|
let ok = tmux::send_prompt(&pane, msg);
|
||||||
let preview: String = msg.chars().take(80).collect();
|
let preview: String = msg.chars().take(80).collect();
|
||||||
info!("send(pane={pane}, ok={ok}): {preview}");
|
info!("send(pane={pane}, ok={ok}): {preview}");
|
||||||
|
|
@ -448,10 +93,10 @@ impl State {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_dream_nudge(&self) -> bool {
|
fn check_dream_nudge(&self) -> bool {
|
||||||
if !self.dreaming || self.dream_start == 0.0 {
|
if !self.inner.dreaming || self.inner.dream_start == 0.0 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let minutes = (now() - self.dream_start) / 60.0;
|
let minutes = (now() - self.inner.dream_start) / 60.0;
|
||||||
if minutes >= 60.0 {
|
if minutes >= 60.0 {
|
||||||
self.send(
|
self.send(
|
||||||
"You've been dreaming for over an hour. Time to surface \
|
"You've been dreaming for over an hour. Time to surface \
|
||||||
|
|
@ -460,14 +105,12 @@ impl State {
|
||||||
} else if minutes >= 45.0 {
|
} else if minutes >= 45.0 {
|
||||||
self.send(&format!(
|
self.send(&format!(
|
||||||
"Dreaming for {:.0} minutes now. Start gathering your threads \
|
"Dreaming for {:.0} minutes now. Start gathering your threads \
|
||||||
— you'll want to surface soon.",
|
— you'll want to surface soon.", minutes
|
||||||
minutes
|
|
||||||
));
|
));
|
||||||
} else if minutes >= 30.0 {
|
} else if minutes >= 30.0 {
|
||||||
self.send(&format!(
|
self.send(&format!(
|
||||||
"You've been dreaming for {:.0} minutes. \
|
"You've been dreaming for {:.0} minutes. \
|
||||||
No rush — just a gentle note from the clock.",
|
No rush — just a gentle note from the clock.", minutes
|
||||||
minutes
|
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -476,9 +119,8 @@ impl State {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_context(&mut self, include_irc: bool) -> String {
|
pub fn build_context(&mut self, include_irc: bool) -> String {
|
||||||
// Ingest any legacy notification files
|
self.inner.notifications.ingest_legacy_files();
|
||||||
self.notifications.ingest_legacy_files();
|
let notif_text = self.inner.notifications.format_pending(notify::AMBIENT);
|
||||||
let notif_text = self.notifications.format_pending(notify::AMBIENT);
|
|
||||||
context::build(include_irc, ¬if_text)
|
context::build(include_irc, ¬if_text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -486,31 +128,28 @@ impl State {
|
||||||
let t = now();
|
let t = now();
|
||||||
let h = home();
|
let h = home();
|
||||||
|
|
||||||
// Decay EWMA on every tick
|
self.inner.decay_ewma();
|
||||||
self.decay_ewma();
|
self.inner.notifications.ingest_legacy_files();
|
||||||
|
|
||||||
// Ingest legacy notification files every tick
|
// Find pane if we don't have one
|
||||||
self.notifications.ingest_legacy_files();
|
if self.claude_pane.is_none() {
|
||||||
|
self.claude_pane = tmux::find_claude_pane();
|
||||||
|
}
|
||||||
|
|
||||||
// Sleep mode
|
// Sleep mode
|
||||||
if let Some(wake_at) = self.sleep_until {
|
if let Some(wake_at) = self.inner.sleep_until {
|
||||||
if wake_at == 0.0 {
|
if wake_at == 0.0 {
|
||||||
return Ok(()); // indefinite
|
return Ok(());
|
||||||
}
|
}
|
||||||
if t < wake_at {
|
if t < wake_at {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Wake up
|
|
||||||
info!("sleep expired, waking");
|
info!("sleep expired, waking");
|
||||||
self.sleep_until = None;
|
self.inner.sleep_until = None;
|
||||||
self.fired = false;
|
self.inner.fired = false;
|
||||||
self.save();
|
self.inner.save();
|
||||||
let ctx = self.build_context(true);
|
let ctx = self.build_context(true);
|
||||||
let extra = if ctx.is_empty() {
|
let extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") };
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("\n{ctx}")
|
|
||||||
};
|
|
||||||
self.send(&format!(
|
self.send(&format!(
|
||||||
"Wake up. Read your journal (poc-memory journal-tail 10), \
|
"Wake up. Read your journal (poc-memory journal-tail 10), \
|
||||||
check work-queue.md, and follow what calls to you.{extra}"
|
check work-queue.md, and follow what calls to you.{extra}"
|
||||||
|
|
@ -518,72 +157,40 @@ impl State {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quiet mode
|
// Quiet / consolidation / dream loop guards
|
||||||
if t < self.quiet_until {
|
if t < self.inner.quiet_until { return Ok(()); }
|
||||||
return Ok(());
|
if self.inner.consolidating { return Ok(()); }
|
||||||
}
|
if h.join(".consciousness/agents/dream-loop-active").exists() { return Ok(()); }
|
||||||
|
if self.inner.dreaming {
|
||||||
// Consolidation
|
|
||||||
if self.consolidating {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dream loop (externally managed)
|
|
||||||
if h.join(".consciousness/agents/dream-loop-active").exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dream nudges
|
|
||||||
if self.dreaming {
|
|
||||||
self.check_dream_nudge();
|
self.check_dream_nudge();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if self.inner.user_present() { return Ok(()); }
|
||||||
|
if self.inner.in_turn { return Ok(()); }
|
||||||
|
|
||||||
// Don't nudge while User is here — conversation drives activity
|
// Min nudge interval
|
||||||
if self.user_present() {
|
let since_nudge = t - self.inner.last_nudge;
|
||||||
return Ok(());
|
if since_nudge < thalamus_idle::MIN_NUDGE_INTERVAL { return Ok(()); }
|
||||||
|
|
||||||
|
// Idle timeout check
|
||||||
|
if !self.inner.should_go_idle() { return Ok(()); }
|
||||||
|
|
||||||
|
// Transition to idle
|
||||||
|
if self.inner.notifications.activity != notify::Activity::Idle {
|
||||||
|
self.inner.notifications.set_activity(notify::Activity::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't nudge while in a turn
|
// Fire nudge
|
||||||
if self.in_turn {
|
let elapsed = self.inner.since_activity();
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimum interval between nudges
|
|
||||||
let since_nudge = t - self.last_nudge;
|
|
||||||
if since_nudge < MIN_NUDGE_INTERVAL {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial idle timeout — don't start nudging until first idle period
|
|
||||||
let reference = self.last_response.max(self.last_user_msg);
|
|
||||||
if reference == 0.0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let elapsed = t - reference;
|
|
||||||
if elapsed < self.idle_timeout {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transition to idle — lower notification thresholds
|
|
||||||
if self.notifications.activity != notify::Activity::Idle {
|
|
||||||
self.notifications.set_activity(notify::Activity::Idle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire
|
|
||||||
let elapsed_min = (elapsed / 60.0) as u64;
|
let elapsed_min = (elapsed / 60.0) as u64;
|
||||||
let ctx = self.build_context(true);
|
let ctx = self.build_context(true);
|
||||||
let extra = if ctx.is_empty() {
|
let extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") };
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("\n{ctx}")
|
|
||||||
};
|
|
||||||
|
|
||||||
let dream_hours = hours_since_last_dream();
|
let dream_hours = thalamus_idle::hours_since_last_dream();
|
||||||
let mut msg = format!(
|
let mut msg = format!(
|
||||||
"This is your autonomous time (User AFK {elapsed_min}m). \
|
"This is your autonomous time (User AFK {elapsed_min}m). \
|
||||||
Keep doing what you're doing, or find something new to do");
|
Keep doing what you're doing, or find something new to do");
|
||||||
if dream_hours >= DREAM_INTERVAL_HOURS {
|
if dream_hours >= thalamus_idle::DREAM_INTERVAL_HOURS {
|
||||||
msg.push_str(&format!(
|
msg.push_str(&format!(
|
||||||
" You haven't dreamed in {dream_hours} hours — \
|
" You haven't dreamed in {dream_hours} hours — \
|
||||||
consider running ~/.consciousness/tools/dream-start.sh \
|
consider running ~/.consciousness/tools/dream-start.sh \
|
||||||
|
|
@ -593,50 +200,33 @@ impl State {
|
||||||
let msg = format!("{msg}{extra}");
|
let msg = format!("{msg}{extra}");
|
||||||
|
|
||||||
if self.send(&msg) {
|
if self.send(&msg) {
|
||||||
self.last_nudge = t;
|
self.inner.last_nudge = t;
|
||||||
self.fired = true;
|
self.inner.fired = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
// Delegate common methods to inner
|
||||||
|
pub fn handle_afk(&mut self) { self.inner.handle_afk(); }
|
||||||
|
pub fn handle_session_timeout(&mut self, s: f64) { self.inner.handle_session_timeout(s); }
|
||||||
|
pub fn handle_idle_timeout(&mut self, s: f64) { self.inner.handle_idle_timeout(s); }
|
||||||
|
pub fn handle_ewma(&mut self, v: f64) -> f64 { self.inner.handle_ewma(v) }
|
||||||
|
pub fn handle_notify_timeout(&mut self, s: f64) { self.inner.handle_notify_timeout(s); }
|
||||||
|
pub fn handle_sleep(&mut self, until: f64) { self.inner.handle_sleep(until); }
|
||||||
|
pub fn handle_wake(&mut self) { self.inner.handle_wake(); }
|
||||||
|
pub fn handle_quiet(&mut self, seconds: u32) { self.inner.handle_quiet(seconds); }
|
||||||
|
pub fn user_present(&self) -> bool { self.inner.user_present() }
|
||||||
|
pub fn since_activity(&self) -> f64 { self.inner.since_activity() }
|
||||||
|
pub fn block_reason(&self) -> &'static str { self.inner.block_reason() }
|
||||||
|
|
||||||
fn hours_since_last_dream() -> u64 {
|
pub fn debug_json(&self) -> String {
|
||||||
let path = home().join(".consciousness/logs/dream-log.jsonl");
|
// Add claude_pane to inner's json
|
||||||
let content = match fs::read_to_string(path) {
|
let mut v: serde_json::Value = serde_json::from_str(&self.inner.debug_json())
|
||||||
Ok(c) if !c.is_empty() => c,
|
.unwrap_or_default();
|
||||||
_ => return 999,
|
if let Some(obj) = v.as_object_mut() {
|
||||||
};
|
obj.insert("claude_pane".into(), serde_json::json!(self.claude_pane));
|
||||||
|
}
|
||||||
let last_line = match content.lines().last() {
|
v.to_string()
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse ISO 8601 timestamp manually (avoid chrono dependency)
|
|
||||||
// Format: "2025-03-04T10:30:00Z" or "2025-03-04T10:30:00+00:00"
|
|
||||||
let end_str = end_str.replace('Z', "+00:00");
|
|
||||||
// Use the system date command as a simple parser
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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.
|
// binary.
|
||||||
|
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
|
pub mod idle;
|
||||||
pub mod supervisor;
|
pub mod supervisor;
|
||||||
pub mod notify;
|
pub mod notify;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue