Store now has internal Mutex for capnp appends and AtomicU64 for size tracking. All methods take &self. The external Arc<Mutex<Store>> is replaced with Arc<Store>. - Store::append_lock protects file appends - local.rs functions take &Store (not &mut Store) - access_local() returns Arc<Store> - All .lock().await calls removed from callers Co-Authored-By: Proof of Concept <poc@bcachefs.org>
711 lines
27 KiB
Rust
711 lines
27 KiB
Rust
// mind/ — Cognitive layer
|
||
//
|
||
// Mind state machine, DMN, identity, observation socket.
|
||
// Everything about how the mind operates, separate from the
|
||
// user interface (TUI, CLI) and the agent execution (tools, API).
|
||
|
||
pub mod subconscious;
|
||
pub mod unconscious;
|
||
pub mod identity;
|
||
pub mod log;
|
||
|
||
// consciousness.rs — Mind state machine and event loop
|
||
//
|
||
// The core runtime for the consciousness binary. Mind manages turns,
|
||
// DMN state, compaction, scoring, and slash commands. The event loop
|
||
// bridges Mind (cognitive state) with App (TUI rendering).
|
||
//
|
||
// The event loop uses biased select! so priorities are deterministic:
|
||
// keyboard events > turn results > render ticks > DMN timer > UI messages.
|
||
|
||
use anyhow::Result;
|
||
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
use tokio::sync::mpsc;
|
||
use crate::agent::{Agent, TurnResult};
|
||
use crate::agent::api::ApiClient;
|
||
use crate::config::{AppConfig, SessionConfig};
|
||
use crate::subconscious::learn;
|
||
use crate::hippocampus::access_local;
|
||
|
||
pub use subconscious::{SubconsciousSnapshot, Subconscious};
|
||
pub use unconscious::{UnconsciousSnapshot, Unconscious};
|
||
|
||
use crate::agent::context::{AstNode, NodeBody, Section, Ast, ContextState};
|
||
|
||
fn load_memory_scores(ctx: &mut ContextState, path: &std::path::Path) {
|
||
let data = match std::fs::read_to_string(path) {
|
||
Ok(d) => d,
|
||
Err(_) => return,
|
||
};
|
||
let scores: std::collections::BTreeMap<String, f64> = match serde_json::from_str(&data) {
|
||
Ok(s) => s,
|
||
Err(_) => return,
|
||
};
|
||
let mut applied = 0;
|
||
for i in 0..ctx.conversation().len() {
|
||
if let AstNode::Leaf(leaf) = &ctx.conversation()[i] {
|
||
if let NodeBody::Memory { key, .. } = leaf.body() {
|
||
if let Some(&s) = scores.get(key.as_str()) {
|
||
ctx.set_score(Section::Conversation, i, Some(s));
|
||
applied += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if applied > 0 {
|
||
dbglog!("[scoring] loaded {} scores from {}", applied, path.display());
|
||
}
|
||
}
|
||
|
||
/// Collect scored memory keys from conversation entries.
|
||
fn collect_memory_scores(ctx: &ContextState) -> std::collections::BTreeMap<String, f64> {
|
||
ctx.conversation().iter()
|
||
.filter_map(|node| {
|
||
if let AstNode::Leaf(leaf) = node {
|
||
if let NodeBody::Memory { key, score: Some(s), .. } = leaf.body() {
|
||
return Some((key.clone(), *s));
|
||
}
|
||
}
|
||
None
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Save memory scores to disk.
|
||
fn save_memory_scores(scores: &std::collections::BTreeMap<String, f64>, path: &std::path::Path) {
|
||
if let Ok(json) = serde_json::to_string_pretty(scores) {
|
||
let _ = std::fs::write(path, json);
|
||
dbglog!("[scoring] saved {} scores to {}", scores.len(), path.display());
|
||
}
|
||
}
|
||
|
||
/// Which pane streaming text should go to.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum StreamTarget {
|
||
/// User-initiated turn — text goes to conversation pane.
|
||
Conversation,
|
||
/// DMN-initiated turn — text goes to autonomous pane.
|
||
Autonomous,
|
||
}
|
||
|
||
/// Compaction threshold — context is rebuilt when prompt tokens exceed this.
|
||
fn compaction_threshold(app: &AppConfig) -> u32 {
|
||
(crate::agent::context::context_window() as u32) * app.compaction.hard_threshold_pct / 100
|
||
}
|
||
|
||
/// Shared state between Mind and UI.
|
||
pub struct MindState {
|
||
/// Pending user input — UI pushes, Mind consumes after turn completes.
|
||
pub input: Vec<String>,
|
||
/// True while a turn is in progress.
|
||
pub turn_active: bool,
|
||
/// DMN state
|
||
pub dmn: subconscious::State,
|
||
pub dmn_turns: u32,
|
||
pub max_dmn_turns: u32,
|
||
/// Whether memory scoring is running.
|
||
pub scoring_in_flight: bool,
|
||
/// Whether compaction is running.
|
||
pub compaction_in_flight: bool,
|
||
/// Per-turn tracking
|
||
pub last_user_input: Instant,
|
||
pub consecutive_errors: u32,
|
||
pub last_turn_had_tools: bool,
|
||
/// Handle to the currently running turn task.
|
||
pub turn_handle: Option<tokio::task::JoinHandle<()>>,
|
||
/// Unconscious agent idle state — true when 60s timer has expired.
|
||
pub unc_idle: bool,
|
||
/// When the unconscious idle timer will fire (for UI display).
|
||
pub unc_idle_deadline: Instant,
|
||
}
|
||
|
||
impl Clone for MindState {
|
||
fn clone(&self) -> Self {
|
||
Self {
|
||
input: self.input.clone(),
|
||
turn_active: self.turn_active,
|
||
dmn: self.dmn.clone(),
|
||
dmn_turns: self.dmn_turns,
|
||
max_dmn_turns: self.max_dmn_turns,
|
||
scoring_in_flight: self.scoring_in_flight,
|
||
compaction_in_flight: self.compaction_in_flight,
|
||
last_user_input: self.last_user_input,
|
||
consecutive_errors: self.consecutive_errors,
|
||
last_turn_had_tools: self.last_turn_had_tools,
|
||
turn_handle: None, // Not cloned — only Mind's loop uses this
|
||
unc_idle: self.unc_idle,
|
||
unc_idle_deadline: self.unc_idle_deadline,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What should happen after a state transition.
|
||
pub enum MindCommand {
|
||
/// Run compaction check
|
||
Compact,
|
||
/// Run incremental memory scoring (auto, after turns)
|
||
Score,
|
||
/// Run full N×M memory scoring matrix (/score command)
|
||
ScoreFull,
|
||
/// Abort current turn, kill processes
|
||
Interrupt,
|
||
/// Reset session
|
||
NewSession,
|
||
/// Nothing to do
|
||
None,
|
||
}
|
||
|
||
impl MindState {
|
||
pub fn new(max_dmn_turns: u32) -> Self {
|
||
Self {
|
||
input: Vec::new(),
|
||
turn_active: false,
|
||
dmn: if subconscious::is_off() { subconscious::State::Off }
|
||
else { subconscious::State::Resting { since: Instant::now() } },
|
||
dmn_turns: 0,
|
||
max_dmn_turns,
|
||
scoring_in_flight: false,
|
||
compaction_in_flight: false,
|
||
last_user_input: Instant::now(),
|
||
consecutive_errors: 0,
|
||
last_turn_had_tools: false,
|
||
turn_handle: None,
|
||
unc_idle: false,
|
||
unc_idle_deadline: Instant::now() + std::time::Duration::from_secs(60),
|
||
}
|
||
}
|
||
|
||
/// Is there pending user input waiting?
|
||
fn has_pending_input(&self) -> bool {
|
||
!self.turn_active && !self.input.is_empty()
|
||
}
|
||
|
||
/// Consume pending user input if no turn is active.
|
||
/// Returns the text to send; caller is responsible for pushing it
|
||
/// into the Agent's context and starting the turn.
|
||
fn take_pending_input(&mut self) -> Option<String> {
|
||
if self.turn_active || self.input.is_empty() {
|
||
return None;
|
||
}
|
||
let text = self.input.join("\n");
|
||
self.input.clear();
|
||
self.dmn_turns = 0;
|
||
self.consecutive_errors = 0;
|
||
self.last_user_input = Instant::now();
|
||
self.dmn = subconscious::State::Engaged;
|
||
Some(text)
|
||
}
|
||
|
||
/// Process turn completion, return model switch name if requested.
|
||
fn complete_turn(&mut self, result: &Result<TurnResult>, target: StreamTarget) -> Option<String> {
|
||
self.turn_active = false;
|
||
match result {
|
||
Ok(turn_result) => {
|
||
if turn_result.tool_errors > 0 {
|
||
self.consecutive_errors += turn_result.tool_errors;
|
||
} else {
|
||
self.consecutive_errors = 0;
|
||
}
|
||
self.last_turn_had_tools = turn_result.had_tool_calls;
|
||
self.dmn = subconscious::transition(
|
||
&self.dmn,
|
||
turn_result.yield_requested,
|
||
turn_result.had_tool_calls,
|
||
target == StreamTarget::Conversation,
|
||
);
|
||
if turn_result.dmn_pause {
|
||
self.dmn = subconscious::State::Paused;
|
||
self.dmn_turns = 0;
|
||
}
|
||
turn_result.model_switch.clone()
|
||
}
|
||
Err(_) => {
|
||
self.consecutive_errors += 1;
|
||
self.dmn = subconscious::State::Resting { since: Instant::now() };
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// DMN tick — returns a prompt and target if we should run a turn.
|
||
fn dmn_tick(&mut self) -> Option<(String, StreamTarget)> {
|
||
if matches!(self.dmn, subconscious::State::Paused | subconscious::State::Off) {
|
||
return None;
|
||
}
|
||
|
||
self.dmn_turns += 1;
|
||
if self.dmn_turns > self.max_dmn_turns {
|
||
self.dmn = subconscious::State::Resting { since: Instant::now() };
|
||
self.dmn_turns = 0;
|
||
return None;
|
||
}
|
||
|
||
let dmn_ctx = subconscious::DmnContext {
|
||
user_idle: self.last_user_input.elapsed(),
|
||
consecutive_errors: self.consecutive_errors,
|
||
last_turn_had_tools: self.last_turn_had_tools,
|
||
};
|
||
let prompt = self.dmn.prompt(&dmn_ctx);
|
||
Some((prompt, StreamTarget::Autonomous))
|
||
}
|
||
|
||
fn interrupt(&mut self) {
|
||
self.input.clear();
|
||
self.dmn = subconscious::State::Resting { since: Instant::now() };
|
||
}
|
||
}
|
||
|
||
/// Background task completion events.
|
||
enum BgEvent {
|
||
ScoringDone,
|
||
}
|
||
|
||
// --- Mind: cognitive state machine ---
|
||
|
||
pub type SharedMindState = std::sync::Mutex<MindState>;
|
||
|
||
pub struct Mind {
|
||
pub agent: Arc<Agent>,
|
||
pub shared: Arc<SharedMindState>,
|
||
pub config: SessionConfig,
|
||
pub subconscious: Arc<crate::Mutex<Subconscious>>,
|
||
pub unconscious: Arc<crate::Mutex<Unconscious>>,
|
||
turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>,
|
||
turn_watch: tokio::sync::watch::Sender<bool>,
|
||
/// Signals conscious activity to the unconscious loop.
|
||
/// true = active, false = idle opportunity.
|
||
conscious_active: tokio::sync::watch::Sender<bool>,
|
||
bg_tx: mpsc::UnboundedSender<BgEvent>,
|
||
bg_rx: std::sync::Mutex<Option<mpsc::UnboundedReceiver<BgEvent>>>,
|
||
_supervisor: crate::thalamus::supervisor::Supervisor,
|
||
}
|
||
|
||
impl Mind {
|
||
pub async fn new(
|
||
config: SessionConfig,
|
||
turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>,
|
||
) -> Self {
|
||
let client = ApiClient::new(&config.api_base, &config.api_key, &config.model);
|
||
let conversation_log = log::ConversationLog::new(
|
||
config.session_dir.join("conversation.jsonl"),
|
||
).ok();
|
||
|
||
let agent = Agent::new(
|
||
client,
|
||
config.context_parts.clone(),
|
||
config.app.clone(),
|
||
config.prompt_file.clone(),
|
||
conversation_log,
|
||
crate::agent::tools::ActiveTools::new(),
|
||
crate::agent::tools::tools(),
|
||
).await;
|
||
|
||
let shared = Arc::new(std::sync::Mutex::new(MindState::new(config.app.dmn.max_turns)));
|
||
let (turn_watch, _) = tokio::sync::watch::channel(false);
|
||
let (conscious_active, _) = tokio::sync::watch::channel(false);
|
||
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
|
||
|
||
let mut sup = crate::thalamus::supervisor::Supervisor::new();
|
||
sup.load_config();
|
||
sup.ensure_running();
|
||
|
||
let subconscious = Arc::new(crate::Mutex::new(Subconscious::new()));
|
||
subconscious.lock().await.init_output_tool(subconscious.clone());
|
||
|
||
let unconscious = Arc::new(crate::Mutex::new(Unconscious::new()));
|
||
|
||
// Spawn the unconscious loop on its own task
|
||
if !config.no_agents {
|
||
let unc = unconscious.clone();
|
||
let shared_for_unc = shared.clone();
|
||
let mut unc_rx = conscious_active.subscribe();
|
||
tokio::spawn(async move {
|
||
const IDLE_DELAY: std::time::Duration = std::time::Duration::from_secs(60);
|
||
loop {
|
||
// Wait for conscious side to go inactive
|
||
if *unc_rx.borrow() {
|
||
if unc_rx.changed().await.is_err() { break; }
|
||
continue;
|
||
}
|
||
// Conscious is inactive — wait 60s before starting
|
||
let deadline = tokio::time::Instant::now() + IDLE_DELAY;
|
||
{
|
||
let mut s = shared_for_unc.lock().unwrap();
|
||
s.unc_idle = false;
|
||
s.unc_idle_deadline = Instant::now() + IDLE_DELAY;
|
||
}
|
||
let went_active = tokio::select! {
|
||
_ = tokio::time::sleep_until(deadline) => false,
|
||
r = unc_rx.changed() => r.is_ok(),
|
||
};
|
||
if went_active { continue; }
|
||
|
||
// Idle period reached — run agents until conscious goes active
|
||
{
|
||
let mut s = shared_for_unc.lock().unwrap();
|
||
s.unc_idle = true;
|
||
}
|
||
loop {
|
||
// Phase 0: health check outside lock (slow I/O)
|
||
let needs_health = unc.lock().await.needs_health_refresh();
|
||
if needs_health {
|
||
if let Ok(store_arc) = access_local() {
|
||
let health = crate::subconscious::daemon::compute_graph_health(&store_arc);
|
||
unc.lock().await.set_health(health);
|
||
}
|
||
}
|
||
// Phase 1: quick work under lock
|
||
let to_spawn = {
|
||
let mut guard = unc.lock().await;
|
||
guard.reap_finished();
|
||
guard.select_to_spawn()
|
||
};
|
||
// Phase 2: slow work outside lock
|
||
for (idx, name, auto) in to_spawn {
|
||
match crate::mind::unconscious::prepare_spawn(&name, auto).await {
|
||
Ok(result) => unc.lock().await.complete_spawn(idx, result),
|
||
Err(auto) => unc.lock().await.abort_spawn(idx, auto),
|
||
}
|
||
}
|
||
// Check if conscious became active
|
||
if *unc_rx.borrow() { break; }
|
||
// Brief yield to not starve other tasks
|
||
tokio::task::yield_now().await;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
Self { agent, shared, config,
|
||
subconscious, unconscious,
|
||
turn_tx, turn_watch, conscious_active, bg_tx,
|
||
bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup }
|
||
}
|
||
|
||
/// Initialize — restore log, start daemons and background agents.
|
||
pub async fn subconscious_snapshots(&self) -> Vec<SubconsciousSnapshot> {
|
||
// Lock ordering: subconscious → store (store is bottom-most).
|
||
let sub = self.subconscious.lock().await;
|
||
let store_arc = crate::hippocampus::access_local().ok();
|
||
let store_guard = match &store_arc {
|
||
Some(s) => Some(&**s),
|
||
None => None,
|
||
};
|
||
sub.snapshots(store_guard.as_deref())
|
||
}
|
||
|
||
pub async fn subconscious_walked(&self) -> Vec<String> {
|
||
self.subconscious.lock().await.walked()
|
||
}
|
||
|
||
pub async fn unconscious_snapshots(&self) -> Vec<UnconsciousSnapshot> {
|
||
let unc = self.unconscious.lock().await;
|
||
let store_arc = crate::hippocampus::access_local().ok();
|
||
let store_guard = match &store_arc {
|
||
Some(s) => Some(&**s),
|
||
None => None,
|
||
};
|
||
unc.snapshots(store_guard.as_deref())
|
||
}
|
||
|
||
pub async fn init(&self) {
|
||
// Restore conversation
|
||
self.agent.restore_from_log().await;
|
||
|
||
// Restore persisted memory scores
|
||
let scores_path = self.config.session_dir.join("memory-scores.json");
|
||
load_memory_scores(&mut *self.agent.context.lock().await, &scores_path);
|
||
|
||
self.agent.state.lock().await.changed.notify_one();
|
||
|
||
// Load persistent subconscious state
|
||
let state_path = self.config.session_dir.join("subconscious-state.json");
|
||
self.subconscious.lock().await.set_state_path(state_path);
|
||
}
|
||
|
||
pub fn turn_watch(&self) -> tokio::sync::watch::Receiver<bool> {
|
||
self.turn_watch.subscribe()
|
||
}
|
||
|
||
/// Execute an Action from a MindState method.
|
||
async fn run_commands(&self, cmds: Vec<MindCommand>) {
|
||
for cmd in cmds {
|
||
match cmd {
|
||
MindCommand::None => {}
|
||
MindCommand::Compact => {
|
||
let threshold = compaction_threshold(&self.config.app) as usize;
|
||
if self.agent.context.lock().await.tokens() > threshold {
|
||
self.agent.compact().await;
|
||
self.agent.state.lock().await.notify("compacted");
|
||
}
|
||
}
|
||
MindCommand::Score => {
|
||
let mut s = self.shared.lock().unwrap();
|
||
if !s.scoring_in_flight {
|
||
s.scoring_in_flight = true;
|
||
drop(s);
|
||
self.start_memory_scoring();
|
||
} else {
|
||
dbglog!("[scoring] skipped: scoring_in_flight=true");
|
||
}
|
||
}
|
||
MindCommand::ScoreFull => {
|
||
let mut s = self.shared.lock().unwrap();
|
||
if !s.scoring_in_flight {
|
||
s.scoring_in_flight = true;
|
||
drop(s);
|
||
self.start_full_scoring();
|
||
} else {
|
||
dbglog!("[scoring-full] skipped: scoring_in_flight=true");
|
||
}
|
||
}
|
||
MindCommand::Interrupt => {
|
||
self.shared.lock().unwrap().interrupt();
|
||
self.agent.state.lock().await.active_tools.abort_all();
|
||
if let Some(h) = self.shared.lock().unwrap().turn_handle.take() { h.abort(); }
|
||
self.shared.lock().unwrap().turn_active = false;
|
||
let _ = self.turn_watch.send(false);
|
||
}
|
||
MindCommand::NewSession => {
|
||
{
|
||
let mut s = self.shared.lock().unwrap();
|
||
s.dmn = subconscious::State::Resting { since: Instant::now() };
|
||
s.dmn_turns = 0;
|
||
}
|
||
let new_log = log::ConversationLog::new(
|
||
self.config.session_dir.join("conversation.jsonl"),
|
||
).ok();
|
||
{
|
||
let mut ctx = self.agent.context.lock().await;
|
||
ctx.clear(Section::Conversation);
|
||
ctx.conversation_log = new_log;
|
||
}
|
||
{
|
||
let mut st = self.agent.state.lock().await;
|
||
st.generation += 1;
|
||
st.last_prompt_tokens = 0;
|
||
}
|
||
self.agent.compact().await;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn start_memory_scoring(&self) {
|
||
let agent = self.agent.clone();
|
||
let bg_tx = self.bg_tx.clone();
|
||
let scores_path = self.config.session_dir.join("memory-scores.json");
|
||
let cfg = crate::config::get();
|
||
let max_age = cfg.scoring_interval_secs;
|
||
let response_window = cfg.scoring_response_window;
|
||
tokio::spawn(async move {
|
||
let (context, client) = {
|
||
let mut st = agent.state.lock().await;
|
||
if st.memory_scoring_in_flight {
|
||
dbglog!("[scoring] skipped: memory_scoring_in_flight=true");
|
||
return;
|
||
}
|
||
st.memory_scoring_in_flight = true;
|
||
drop(st);
|
||
let ctx = agent.context.lock().await.clone();
|
||
(ctx, agent.client.clone())
|
||
};
|
||
let _result = learn::score_memories_incremental(
|
||
&context, max_age as i64, response_window, &client, &agent,
|
||
|key: String, score: f64| {
|
||
let agent = agent.clone();
|
||
let path = scores_path.clone();
|
||
async move {
|
||
let scores_snapshot = {
|
||
let mut ctx = agent.context.lock().await;
|
||
for i in 0..ctx.conversation().len() {
|
||
if let AstNode::Leaf(leaf) = &ctx.conversation()[i] {
|
||
if let NodeBody::Memory { key: k, .. } = leaf.body() {
|
||
if *k == key {
|
||
ctx.set_score(Section::Conversation, i, Some(score));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let snapshot = collect_memory_scores(&ctx);
|
||
drop(ctx);
|
||
agent.state.lock().await.changed.notify_one();
|
||
snapshot
|
||
};
|
||
save_memory_scores(&scores_snapshot, &path);
|
||
}
|
||
},
|
||
).await;
|
||
{
|
||
agent.state.lock().await.memory_scoring_in_flight = false;
|
||
}
|
||
let _ = bg_tx.send(BgEvent::ScoringDone);
|
||
});
|
||
}
|
||
|
||
/// Run full N×M scoring matrix — scores every memory against every response.
|
||
pub fn start_full_scoring(&self) {
|
||
let agent = self.agent.clone();
|
||
let bg_tx = self.bg_tx.clone();
|
||
tokio::spawn(async move {
|
||
{
|
||
let mut st = agent.state.lock().await;
|
||
if st.memory_scoring_in_flight {
|
||
dbglog!("[scoring-full] skipped: memory_scoring_in_flight=true");
|
||
return;
|
||
}
|
||
st.memory_scoring_in_flight = true;
|
||
}
|
||
let client = agent.client.clone();
|
||
match learn::score_memories(&client, &agent).await {
|
||
Ok(()) => { let _ = bg_tx.send(BgEvent::ScoringDone); }
|
||
Err(e) => { dbglog!("[scoring-full] FAILED: {:#}", e); }
|
||
}
|
||
agent.state.lock().await.memory_scoring_in_flight = false;
|
||
});
|
||
}
|
||
|
||
async fn start_turn(&self, text: &str, target: StreamTarget) {
|
||
{
|
||
match target {
|
||
StreamTarget::Conversation => {
|
||
self.agent.push_node(AstNode::user_msg(text)).await;
|
||
}
|
||
StreamTarget::Autonomous => {
|
||
self.agent.push_node(AstNode::dmn(text)).await;
|
||
}
|
||
}
|
||
|
||
// Compact if over budget before sending
|
||
let threshold = compaction_threshold(&self.config.app) as usize;
|
||
if self.agent.context.lock().await.tokens() > threshold {
|
||
self.agent.compact().await;
|
||
self.agent.state.lock().await.notify("compacted");
|
||
}
|
||
}
|
||
self.shared.lock().unwrap().turn_active = true;
|
||
let _ = self.turn_watch.send(true);
|
||
let _ = self.conscious_active.send(true);
|
||
let agent = self.agent.clone();
|
||
let result_tx = self.turn_tx.clone();
|
||
self.shared.lock().unwrap().turn_handle = Some(tokio::spawn(async move {
|
||
let result = Agent::turn(agent).await;
|
||
let _ = result_tx.send((result, target)).await;
|
||
}));
|
||
}
|
||
|
||
pub async fn shutdown(&self) {
|
||
if let Some(handle) = self.shared.lock().unwrap().turn_handle.take() { handle.abort(); }
|
||
}
|
||
|
||
/// Mind event loop — locks MindState, calls state methods, executes actions.
|
||
pub async fn run(
|
||
&self,
|
||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<MindCommand>,
|
||
mut turn_rx: mpsc::Receiver<(Result<TurnResult>, StreamTarget)>,
|
||
) {
|
||
// Spawn lock stats logger
|
||
tokio::spawn(async {
|
||
let path = dirs::home_dir().unwrap_or_default()
|
||
.join(".consciousness/lock-stats.json");
|
||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||
loop {
|
||
interval.tick().await;
|
||
let stats = crate::locks::lock_stats();
|
||
if stats.is_empty() { continue; }
|
||
let json: Vec<serde_json::Value> = stats.iter()
|
||
.map(|(loc, s)| serde_json::json!({
|
||
"location": loc,
|
||
"count": s.count,
|
||
"total_ms": s.total_ns as f64 / 1_000_000.0,
|
||
"avg_ms": s.avg_ns as f64 / 1_000_000.0,
|
||
"max_ms": s.max_ns as f64 / 1_000_000.0,
|
||
}))
|
||
.collect();
|
||
let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap_or_default());
|
||
}
|
||
});
|
||
|
||
let mut bg_rx = self.bg_rx.lock().unwrap().take()
|
||
.expect("Mind::run() called twice");
|
||
let mut sub_handle: Option<tokio::task::JoinHandle<()>> = None;
|
||
loop {
|
||
let (timeout, has_input) = {
|
||
let me = self.shared.lock().unwrap();
|
||
(me.dmn.interval(), me.has_pending_input())
|
||
};
|
||
|
||
let mut cmds = Vec::new();
|
||
let mut dmn_expired = false;
|
||
|
||
tokio::select! {
|
||
biased;
|
||
|
||
cmd = input_rx.recv() => {
|
||
match cmd {
|
||
Some(cmd) => cmds.push(cmd),
|
||
None => break, // UI shut down
|
||
}
|
||
}
|
||
|
||
Some(bg) = bg_rx.recv() => {
|
||
match bg {
|
||
BgEvent::ScoringDone => {
|
||
self.shared.lock().unwrap().scoring_in_flight = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
Some((result, target)) = turn_rx.recv() => {
|
||
let _ = self.conscious_active.send(false);
|
||
let model_switch = {
|
||
let mut s = self.shared.lock().unwrap();
|
||
s.turn_handle = None;
|
||
s.complete_turn(&result, target)
|
||
};
|
||
let _ = self.turn_watch.send(false);
|
||
|
||
if let Some(name) = model_switch {
|
||
crate::user::chat::cmd_switch_model(&self.agent, &name).await;
|
||
}
|
||
|
||
cmds.push(MindCommand::Compact);
|
||
if !self.config.no_agents {
|
||
cmds.push(MindCommand::Score);
|
||
}
|
||
}
|
||
|
||
_ = tokio::time::sleep(timeout), if !has_input => dmn_expired = true,
|
||
}
|
||
|
||
if !self.config.no_agents {
|
||
if sub_handle.as_ref().map_or(true, |h| h.is_finished()) {
|
||
let sub = self.subconscious.clone();
|
||
let agent = self.agent.clone();
|
||
sub_handle = Some(tokio::spawn(async move {
|
||
let mut s = sub.lock().await;
|
||
s.collect_results(&agent).await;
|
||
s.trigger(&agent).await;
|
||
}));
|
||
}
|
||
}
|
||
|
||
// Check for pending user input → push to agent context and start turn
|
||
let pending = self.shared.lock().unwrap().take_pending_input();
|
||
if let Some(text) = pending {
|
||
self.start_turn(&text, StreamTarget::Conversation).await;
|
||
}
|
||
/*
|
||
else if dmn_expired {
|
||
let tick = self.shared.lock().unwrap().dmn_tick();
|
||
if let Some((prompt, target)) = tick {
|
||
self.start_turn(&prompt, target).await;
|
||
}
|
||
}
|
||
*/
|
||
|
||
self.run_commands(cmds).await;
|
||
}
|
||
}
|
||
}
|