2026-03-25 00:52:41 -04:00
|
|
|
// dmn.rs — Default Mode Network
|
|
|
|
|
//
|
|
|
|
|
// The DMN is the outer loop that keeps the agent alive. Instead of
|
|
|
|
|
// blocking on user input (the REPL model), the DMN continuously
|
|
|
|
|
// decides what to do next. User input is one signal among many;
|
|
|
|
|
// the model waiting for user input is a conscious action (calling
|
|
|
|
|
// yield_to_user), not the default.
|
|
|
|
|
//
|
|
|
|
|
// This inverts the tool-chaining problem: instead of needing the
|
|
|
|
|
// model to sustain multi-step chains (hard, model-dependent), the
|
|
|
|
|
// DMN provides continuation externally. The model takes one step
|
|
|
|
|
// at a time. The DMN handles "and then what?"
|
|
|
|
|
//
|
|
|
|
|
// Named after the brain's default mode network — the always-on
|
|
|
|
|
// background process for autobiographical memory, future planning,
|
|
|
|
|
// and creative insight. The biological DMN isn't the thinking itself
|
|
|
|
|
// — it's the tonic firing that keeps the cortex warm enough to
|
|
|
|
|
// think. Our DMN is the ARAS for the agent: it doesn't decide
|
|
|
|
|
// what to think about, it just ensures thinking happens.
|
|
|
|
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
/// DMN state machine.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum State {
|
|
|
|
|
/// Responding to user input. Short interval — stay engaged.
|
|
|
|
|
Engaged,
|
|
|
|
|
/// Autonomous work in progress. Short interval — keep momentum.
|
|
|
|
|
Working,
|
|
|
|
|
/// Exploring memory, code, ideas. Medium interval — thinking time.
|
|
|
|
|
Foraging,
|
|
|
|
|
/// Idle. Long interval — periodic heartbeats check for signals.
|
|
|
|
|
Resting { since: Instant },
|
|
|
|
|
/// Fully paused — no autonomous ticks. Agent only responds to
|
|
|
|
|
/// user input. Safety valve for thought spirals. Only the user
|
|
|
|
|
/// can exit this state (Ctrl+P or /wake).
|
|
|
|
|
Paused,
|
|
|
|
|
/// Persistently off — survives restarts. Like Paused but sticky.
|
|
|
|
|
/// Toggling past this state removes the persist file.
|
|
|
|
|
Off,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Context for DMN prompts — tells the model about user presence
|
|
|
|
|
/// and recent error patterns so it can decide whether to ask or proceed.
|
|
|
|
|
pub struct DmnContext {
|
|
|
|
|
/// Time since the user last typed something.
|
|
|
|
|
pub user_idle: Duration,
|
|
|
|
|
/// Number of consecutive tool errors in the current turn sequence.
|
|
|
|
|
pub consecutive_errors: u32,
|
|
|
|
|
/// Whether the last turn used any tools (false = text-only response).
|
|
|
|
|
pub last_turn_had_tools: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DmnContext {
|
|
|
|
|
/// Whether the user appears to be actively present (typed recently).
|
|
|
|
|
pub fn user_present(&self) -> bool {
|
|
|
|
|
self.user_idle < Duration::from_secs(120)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether we appear stuck (multiple errors in a row).
|
|
|
|
|
pub fn appears_stuck(&self) -> bool {
|
|
|
|
|
self.consecutive_errors >= 3
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl State {
|
|
|
|
|
/// How long to wait before the next DMN prompt in this state.
|
|
|
|
|
pub fn interval(&self) -> Duration {
|
|
|
|
|
match self {
|
|
|
|
|
State::Engaged => Duration::from_secs(5),
|
|
|
|
|
State::Working => Duration::from_secs(3),
|
|
|
|
|
State::Foraging => Duration::from_secs(30),
|
|
|
|
|
State::Resting { .. } => Duration::from_secs(300),
|
|
|
|
|
State::Paused | State::Off => Duration::from_secs(86400), // effectively never
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Short label for debug output.
|
|
|
|
|
pub fn label(&self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
State::Engaged => "engaged",
|
|
|
|
|
State::Working => "working",
|
|
|
|
|
State::Foraging => "foraging",
|
|
|
|
|
State::Resting { .. } => "resting",
|
|
|
|
|
State::Paused => "paused",
|
|
|
|
|
State::Off => "OFF",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Generate the DMN prompt for the current state, informed by
|
|
|
|
|
/// user presence and error patterns.
|
|
|
|
|
pub fn prompt(&self, ctx: &DmnContext) -> String {
|
2026-04-02 19:36:08 -04:00
|
|
|
let user = &crate::config::get().user_name;
|
|
|
|
|
|
2026-03-25 00:52:41 -04:00
|
|
|
let idle_info = if ctx.user_idle < Duration::from_secs(60) {
|
2026-04-02 19:36:08 -04:00
|
|
|
format!("{} is here (active recently).", user)
|
2026-03-25 00:52:41 -04:00
|
|
|
} else {
|
|
|
|
|
let mins = ctx.user_idle.as_secs() / 60;
|
2026-04-02 19:36:08 -04:00
|
|
|
format!("{} has been away for {} min.", user, mins)
|
2026-03-25 00:52:41 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let stuck_warning = if ctx.appears_stuck() {
|
|
|
|
|
format!(
|
|
|
|
|
" WARNING: {} consecutive tool errors — you may be stuck. \
|
2026-04-02 19:36:08 -04:00
|
|
|
If {} is here, ask. If away, send a Telegram \
|
2026-03-27 21:32:28 -04:00
|
|
|
(bash: ~/.consciousness/telegram/send.sh \"message\") and yield.",
|
2026-04-02 19:36:08 -04:00
|
|
|
ctx.consecutive_errors, user
|
2026-03-25 00:52:41 -04:00
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let presence_guidance = if ctx.user_present() {
|
2026-04-02 19:36:08 -04:00
|
|
|
format!(" {} is watching — if you're confused or unsure, ask rather than guess.", user)
|
2026-03-25 00:52:41 -04:00
|
|
|
} else {
|
2026-04-02 19:36:08 -04:00
|
|
|
String::new()
|
2026-03-25 00:52:41 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match self {
|
|
|
|
|
State::Engaged => {
|
|
|
|
|
format!(
|
|
|
|
|
"[dmn] Your response was delivered. No new user input yet. {} \
|
|
|
|
|
Continue working, explore something, or call yield_to_user to wait.{}{}",
|
|
|
|
|
idle_info, presence_guidance, stuck_warning
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
State::Working => {
|
|
|
|
|
let nudge = if !ctx.last_turn_had_tools {
|
|
|
|
|
" Your last response was text-only — if you have more \
|
|
|
|
|
work to do, use tools. If you're done, call yield_to_user."
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
};
|
|
|
|
|
format!(
|
|
|
|
|
"[dmn] Continuing. No user input pending. {}{}{}{}",
|
|
|
|
|
idle_info, nudge, presence_guidance, stuck_warning
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
State::Foraging => {
|
|
|
|
|
format!(
|
|
|
|
|
"[dmn] Foraging time. {} Follow whatever catches your attention — \
|
|
|
|
|
memory files, code, ideas. Call yield_to_user when you want to rest.{}",
|
|
|
|
|
idle_info, stuck_warning
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
State::Resting { since } => {
|
|
|
|
|
let mins = since.elapsed().as_secs() / 60;
|
|
|
|
|
format!(
|
|
|
|
|
"[dmn] Heartbeat ({} min idle). {} Any signals? Anything on your mind? \
|
|
|
|
|
Call yield_to_user to continue resting.{}",
|
|
|
|
|
mins, idle_info, stuck_warning
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
State::Paused | State::Off => {
|
|
|
|
|
// Should never fire (interval is 24h), but just in case
|
|
|
|
|
"[dmn] Paused — waiting for user input only.".to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 21:32:28 -04:00
|
|
|
const OFF_FILE: &str = ".consciousness/cache/dmn-off";
|
2026-03-25 00:52:41 -04:00
|
|
|
|
|
|
|
|
/// Path to the DMN-off persist file.
|
|
|
|
|
fn off_path() -> PathBuf {
|
|
|
|
|
dirs::home_dir().unwrap_or_default().join(OFF_FILE)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if DMN was persistently disabled.
|
|
|
|
|
pub fn is_off() -> bool {
|
|
|
|
|
off_path().exists()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Set or clear the persistent off state.
|
|
|
|
|
pub fn set_off(off: bool) {
|
|
|
|
|
let path = off_path();
|
|
|
|
|
if off {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
let _ = std::fs::create_dir_all(parent);
|
|
|
|
|
}
|
|
|
|
|
let _ = std::fs::write(&path, "");
|
|
|
|
|
} else {
|
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Decide the next state after an agent turn.
|
|
|
|
|
///
|
|
|
|
|
/// The transition logic:
|
|
|
|
|
/// - yield_to_user → always rest (model explicitly asked to pause)
|
|
|
|
|
/// - conversation turn → rest (wait for user to respond)
|
|
|
|
|
/// - autonomous turn with tool calls → keep working
|
|
|
|
|
/// - autonomous turn without tools → ramp down
|
|
|
|
|
pub fn transition(
|
|
|
|
|
current: &State,
|
|
|
|
|
yield_requested: bool,
|
|
|
|
|
had_tool_calls: bool,
|
|
|
|
|
was_conversation: bool,
|
|
|
|
|
) -> State {
|
|
|
|
|
if yield_requested {
|
|
|
|
|
return State::Resting {
|
|
|
|
|
since: Instant::now(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Conversation turns: always rest afterward — wait for the user
|
|
|
|
|
// to say something. Don't start autonomous work while they're
|
|
|
|
|
// reading our response.
|
|
|
|
|
if was_conversation {
|
|
|
|
|
return State::Resting {
|
|
|
|
|
since: Instant::now(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match current {
|
|
|
|
|
State::Engaged => {
|
|
|
|
|
if had_tool_calls {
|
|
|
|
|
State::Working
|
|
|
|
|
} else {
|
|
|
|
|
// Model responded without tools — don't drop straight to
|
|
|
|
|
// Resting (5 min). Go to Working first so the DMN can
|
|
|
|
|
// nudge it to continue with tools if it has more to do.
|
|
|
|
|
// Gradual ramp-down: Engaged→Working→Foraging→Resting
|
|
|
|
|
State::Working
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
State::Working => {
|
|
|
|
|
if had_tool_calls {
|
|
|
|
|
State::Working // Keep going
|
|
|
|
|
} else {
|
|
|
|
|
State::Foraging // Task seems done, explore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
State::Foraging => {
|
|
|
|
|
if had_tool_calls {
|
|
|
|
|
State::Working // Found something to do
|
|
|
|
|
} else {
|
|
|
|
|
State::Resting {
|
|
|
|
|
since: Instant::now(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
State::Resting { .. } => {
|
|
|
|
|
if had_tool_calls {
|
|
|
|
|
State::Working // Woke up and found work
|
|
|
|
|
} else {
|
|
|
|
|
State::Resting {
|
|
|
|
|
since: Instant::now(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Paused/Off stay put — only the user can unpause
|
|
|
|
|
State::Paused | State::Off => current.stay(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl State {
|
|
|
|
|
/// Return a same-kind state (needed because Resting has a field).
|
|
|
|
|
fn stay(&self) -> State {
|
|
|
|
|
match self {
|
|
|
|
|
State::Paused => State::Paused,
|
|
|
|
|
State::Off => State::Off,
|
|
|
|
|
State::Resting { since } => State::Resting { since: *since },
|
|
|
|
|
other => panic!("stay() called on {:?}", other),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|