From 0857c98bfb340d6e7bc4ab1d6dd73e1c02f0b9e8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 31 May 2026 01:00:45 -0700 Subject: [PATCH 01/20] refactor(subconscious): replace task evaluator with agent-per-tick model Strip the task/escalation/execution system from the subconscious engine. The engine now runs a periodic agent that observes the user's state via the situation report and produces structured thoughts (reflections). Each tick creates a conversation thread so the user can view the agent's reasoning by clicking on any thought in the UI. Key changes: - types.rs: remove SubconsciousTask, TaskSource, TaskRecurrence, TickDecision, TaskEvaluation, Escalation, and related types - store.rs: strip task/escalation/log CRUD; keep state KV + reflection tables (legacy DDL retained for backward compat) - engine.rs: rewrite tick() to run agent via chat provider, parse thoughts, create thread, store reflections with thread_id - schemas.rs: remove 8 task/escalation endpoints, keep status + trigger + 3 reflection endpoints - reflection.rs: add thread_id field to Reflection struct - reflection_store.rs: add thread_id column + migration - prompt.rs: new agent system prompt for the summarizer - executor.rs, decision_log.rs: gutted (no longer needed) - All test files updated for the new architecture --- src/openhuman/heartbeat/engine.rs | 4 +- src/openhuman/subconscious/decision_log.rs | 212 +---- src/openhuman/subconscious/engine.rs | 860 ++++-------------- src/openhuman/subconscious/engine_tests.rs | 251 +---- src/openhuman/subconscious/executor.rs | 546 +---------- src/openhuman/subconscious/global.rs | 44 +- .../subconscious/integration_tests.rs | 319 ++----- src/openhuman/subconscious/mod.rs | 9 +- src/openhuman/subconscious/prompt.rs | 257 +----- src/openhuman/subconscious/reflection.rs | 6 + .../subconscious/reflection_store.rs | 28 +- .../subconscious/reflection_store_tests.rs | 2 +- .../subconscious/reflection_tests.rs | 2 +- src/openhuman/subconscious/schemas.rs | 402 +------- src/openhuman/subconscious/schemas_tests.rs | 242 +---- .../subconscious/situation_report/mod.rs | 19 +- src/openhuman/subconscious/store.rs | 612 +------------ src/openhuman/subconscious/store_tests.rs | 335 +------ src/openhuman/subconscious/types.rs | 152 +--- 19 files changed, 439 insertions(+), 3863 deletions(-) diff --git a/src/openhuman/heartbeat/engine.rs b/src/openhuman/heartbeat/engine.rs index 2c8247873f..9ba763e4e3 100644 --- a/src/openhuman/heartbeat/engine.rs +++ b/src/openhuman/heartbeat/engine.rs @@ -108,8 +108,8 @@ impl HeartbeatEngine { match engine.tick().await { Ok(result) => { info!( - "[heartbeat] tick: executed={} escalated={} duration={}ms", - result.executed, result.escalated, result.duration_ms + "[heartbeat] tick: thoughts={} thread={:?} duration={}ms", + result.thoughts_count, result.thread_id, result.duration_ms ); } Err(e) => { diff --git a/src/openhuman/subconscious/decision_log.rs b/src/openhuman/subconscious/decision_log.rs index 18c18063bb..322d610808 100644 --- a/src/openhuman/subconscious/decision_log.rs +++ b/src/openhuman/subconscious/decision_log.rs @@ -1,209 +1,3 @@ -//! Decision log for tracking what the subconscious has already surfaced. -//! Prevents re-escalating the same state changes across ticks. - -use super::types::TickDecision; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; - -/// TTL for decision records before auto-expiry (24 hours). -const RECORD_TTL_SECS: f64 = 24.0 * 60.0 * 60.0; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DecisionRecord { - pub tick_at: f64, - pub decision: TickDecision, - pub source_doc_ids: Vec, - pub reason: String, - pub acknowledged: bool, - pub expires_at: f64, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct DecisionLog { - records: Vec, -} - -impl DecisionLog { - pub fn new() -> Self { - Self { - records: Vec::new(), - } - } - - pub fn was_already_surfaced(&self, doc_ids: &[String]) -> bool { - let now = now_secs(); - self.records.iter().any(|r| { - !r.acknowledged - && r.expires_at > now - && r.decision != TickDecision::Noop - && r.source_doc_ids.iter().any(|id| doc_ids.contains(id)) - }) - } - - pub fn filter_unsurfaced(&self, doc_ids: &[String]) -> Vec { - let surfaced: HashSet<&str> = self - .records - .iter() - .filter(|r| { - !r.acknowledged && r.expires_at > now_secs() && r.decision != TickDecision::Noop - }) - .flat_map(|r| r.source_doc_ids.iter().map(|s| s.as_str())) - .collect(); - - doc_ids - .iter() - .filter(|id| !surfaced.contains(id.as_str())) - .cloned() - .collect() - } - - pub fn record( - &mut self, - tick_at: f64, - decision: TickDecision, - reason: &str, - source_doc_ids: Vec, - ) { - self.records.push(DecisionRecord { - tick_at, - decision, - source_doc_ids, - reason: reason.to_string(), - acknowledged: false, - expires_at: tick_at + RECORD_TTL_SECS, - }); - } - - pub fn mark_acknowledged(&mut self, doc_ids: &[String]) { - for record in &mut self.records { - if record.source_doc_ids.iter().any(|id| doc_ids.contains(id)) { - record.acknowledged = true; - } - } - } - - pub fn prune_expired(&mut self) { - let now = now_secs(); - self.records.retain(|r| r.expires_at > now); - } - - pub fn active_count(&self) -> usize { - let now = now_secs(); - self.records.iter().filter(|r| r.expires_at > now).count() - } - - pub fn records(&self) -> &[DecisionRecord] { - &self.records - } - - pub fn to_json(&self) -> Result { - serde_json::to_string(self).map_err(|e| format!("serialize decision log: {e}")) - } - - pub fn from_json(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| format!("deserialize decision log: {e}")) - } -} - -fn now_secs() -> f64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs_f64()) - .unwrap_or(0.0) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn now() -> f64 { - now_secs() - } - - #[test] - fn empty_log_surfaces_nothing() { - let log = DecisionLog::new(); - assert!(!log.was_already_surfaced(&["doc-1".into()])); - } - - #[test] - fn recorded_escalation_is_surfaced() { - let mut log = DecisionLog::new(); - log.record( - now(), - TickDecision::Escalate, - "deadline", - vec!["doc-1".into()], - ); - assert!(log.was_already_surfaced(&["doc-1".into()])); - assert!(!log.was_already_surfaced(&["doc-2".into()])); - } - - #[test] - fn noop_decisions_are_not_surfaced() { - let mut log = DecisionLog::new(); - log.record(now(), TickDecision::Noop, "nothing", vec!["doc-1".into()]); - assert!(!log.was_already_surfaced(&["doc-1".into()])); - } - - #[test] - fn acknowledged_records_are_not_surfaced() { - let mut log = DecisionLog::new(); - log.record( - now(), - TickDecision::Escalate, - "deadline", - vec!["doc-1".into()], - ); - log.mark_acknowledged(&["doc-1".into()]); - assert!(!log.was_already_surfaced(&["doc-1".into()])); - } - - #[test] - fn expired_records_are_not_surfaced() { - let mut log = DecisionLog::new(); - let old_time = now() - RECORD_TTL_SECS - 1.0; - log.record( - old_time, - TickDecision::Escalate, - "old", - vec!["doc-1".into()], - ); - assert!(!log.was_already_surfaced(&["doc-1".into()])); - } - - #[test] - fn prune_removes_expired() { - let mut log = DecisionLog::new(); - let old_time = now() - RECORD_TTL_SECS - 1.0; - log.record( - old_time, - TickDecision::Escalate, - "old", - vec!["doc-1".into()], - ); - log.record(now(), TickDecision::Act, "new", vec!["doc-2".into()]); - assert_eq!(log.records().len(), 2); - log.prune_expired(); - assert_eq!(log.records().len(), 1); - assert_eq!(log.records()[0].source_doc_ids, vec!["doc-2".to_string()]); - } - - #[test] - fn filter_unsurfaced_returns_new_docs() { - let mut log = DecisionLog::new(); - log.record(now(), TickDecision::Escalate, "seen", vec!["doc-1".into()]); - let unsurfaced = log.filter_unsurfaced(&["doc-1".into(), "doc-2".into(), "doc-3".into()]); - assert_eq!(unsurfaced, vec!["doc-2".to_string(), "doc-3".to_string()]); - } - - #[test] - fn roundtrip_json() { - let mut log = DecisionLog::new(); - log.record(now(), TickDecision::Escalate, "test", vec!["doc-1".into()]); - let json = log.to_json().unwrap(); - let restored = DecisionLog::from_json(&json).unwrap(); - assert_eq!(restored.records().len(), 1); - assert_eq!(restored.records()[0].reason, "test"); - } -} +//! Legacy decision log — retained as an empty module for backward +//! compatibility. The task-based decision tracking was removed when +//! the subconscious switched to an agent-per-tick model. diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index 9202b52555..26ca890fb8 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -1,31 +1,20 @@ -//! Subconscious engine — SQLite-backed task evaluation and execution loop. +//! Subconscious engine — periodic agent loop that produces thoughts. //! -//! On each tick: load due tasks from SQLite → log as in_progress → -//! evaluate with local model → execute "act" tasks → create escalations -//! for ambiguous tasks → update log entries in place. -//! -//! Overlap guard: each tick gets a generation counter. If a new tick starts -//! while the old one is in-flight, the old tick's in_progress entries are -//! marked as cancelled and its results are discarded. +//! On each tick: build situation report → run subconscious agent → +//! parse thoughts from output → create thread → store reflections. -use super::executor; use super::prompt; use super::reflection::{apply_cap, hydrate_draft, Reflection, ReflectionDraft}; use super::reflection_store; use super::situation_report::build_situation_report; use super::source_chunk::resolve_chunks; use super::store; -use super::types::{ - EscalationPriority, EvaluationResponse, SubconsciousStatus, SubconsciousTask, TaskEvaluation, - TaskRecurrence, TaskSource, TickDecision, TickResult, -}; +use super::types::{SubconsciousStatus, TickResult}; use crate::openhuman::config::Config; use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; use crate::openhuman::memory::chat::{build_chat_provider, ChatPrompt, ChatProvider}; use crate::openhuman::memory_store::MemoryClientRef; use anyhow::Result; -use executor::ExecutionOutcome; -use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -39,8 +28,6 @@ pub struct SubconsciousEngine { enabled: bool, memory: Option, state: Mutex, - /// Monotonically increasing tick generation. A tick checks this before - /// writing results — if it has been bumped, the tick was superseded. tick_generation: AtomicU64, } @@ -49,7 +36,6 @@ struct EngineState { total_ticks: u64, consecutive_failures: u64, provider_unavailable_reason: Option, - seeded: bool, } impl SubconsciousEngine { @@ -62,32 +48,11 @@ impl SubconsciousEngine { workspace_dir: PathBuf, memory: Option, ) -> Self { - // Seed default system tasks eagerly so they show in the UI immediately, - // without waiting for the first tick. - let seeded = match store::with_connection(&workspace_dir, store::seed_default_tasks) { - Ok(count) => { - if count > 0 { - info!("[subconscious] seeded {count} tasks on init"); - } - true - } - Err(e) => { - warn!("[subconscious] seed on init failed: {e}"); - false - } - }; - - // Restore `last_tick_at` from `subconscious_state` so the - // situation-report cutoff survives process restarts. Without - // this every restart cold-starts the LLM, which sees the same - // memory-tree rows again and re-emits near-duplicate reflections - // (no insert-time dedupe in `persist_and_surface_reflections`). - // 0.0 on first run / load failure mirrors the previous default. let last_tick_at = match store::with_connection(&workspace_dir, store::get_last_tick_at) { Ok(v) => { if v > 0.0 { info!( - "[subconscious] resumed last_tick_at={v} from disk (situation report will only emit memory-tree rows newer than this)" + "[subconscious] resumed last_tick_at={v} from disk" ); } v @@ -109,17 +74,11 @@ impl SubconsciousEngine { total_ticks: 0, consecutive_failures: 0, provider_unavailable_reason: None, - seeded, }), tick_generation: AtomicU64::new(0), } } - /// Start the subconscious loop (runs until cancelled). - /// - /// Uses `sleep` after each tick (not `interval`) so ticks never stack up. - /// If a tick takes longer than the interval, the next tick starts immediately - /// after the previous one finishes — no overlap. pub async fn run(&self) -> Result<()> { if !self.enabled { info!("[subconscious] disabled, exiting"); @@ -137,8 +96,8 @@ impl SubconsciousEngine { match self.tick().await { Ok(result) => { info!( - "[subconscious] tick: executed={} escalated={} duration={}ms", - result.executed, result.escalated, result.duration_ms + "[subconscious] tick: thoughts={} thread={:?} duration={}ms", + result.thoughts_count, result.thread_id, result.duration_ms ); } Err(e) => { @@ -148,124 +107,58 @@ impl SubconsciousEngine { } } - /// Execute a single tick. Public for manual triggering via RPC. pub async fn tick(&self) -> Result { let started = std::time::Instant::now(); let tick_at = now_secs(); - // Bump generation — any in-flight tick with an older generation is stale. let my_generation = self.tick_generation.fetch_add(1, Ordering::SeqCst) + 1; - let mut state = self.state.lock().await; - - // Seed default tasks on first tick (fallback if init seeding failed) - if !state.seeded { - self.seed_tasks(); - state.seeded = true; - } - - // Cancel any stale in_progress log entries from previous ticks - let _ = store::with_connection(&self.workspace_dir, |conn| { - let cancelled = store::cancel_stale_in_progress(conn)?; - if cancelled > 0 { - info!("[subconscious] cancelled {cancelled} stale in_progress entries"); - } - Ok(()) - }); - - let last_tick_at = state.last_tick_at; - - // 1. Load due tasks from SQLite - let due_tasks = - store::with_connection(&self.workspace_dir, |conn| store::due_tasks(conn, tick_at))?; - - if due_tasks.is_empty() { - debug!("[subconscious] no due tasks"); - state.last_tick_at = tick_at; - persist_last_tick_at(&self.workspace_dir, tick_at); - state.total_ticks += 1; - return Ok(TickResult { - tick_at, - evaluations: vec![], - executed: 0, - escalated: 0, - duration_ms: started.elapsed().as_millis() as u64, - }); - } - - debug!("[subconscious] {} due tasks", due_tasks.len()); - - let config_for_report = match Config::load_or_init().await { + let config = match Config::load_or_init().await { Ok(c) => c, Err(e) => { - warn!("[subconscious] config load for situation report failed: {e}"); - let reason = format!("Config unavailable: {e}"); - state.provider_unavailable_reason = Some(reason); + warn!("[subconscious] config load failed: {e}"); + let mut state = self.state.lock().await; + state.provider_unavailable_reason = Some(format!("Config unavailable: {e}")); state.consecutive_failures += 1; state.total_ticks += 1; return Ok(TickResult { tick_at, - evaluations: vec![], - executed: 0, - escalated: 0, + thoughts_count: 0, + thread_id: None, duration_ms: started.elapsed().as_millis() as u64, }); } }; - if let Some(reason) = subconscious_provider_unavailable_reason(&config_for_report) { + if let Some(reason) = subconscious_provider_unavailable_reason(&config) { info!("[subconscious] provider unavailable, skipping tick: {reason}"); + let mut state = self.state.lock().await; state.provider_unavailable_reason = Some(reason); state.consecutive_failures += 1; state.total_ticks += 1; return Ok(TickResult { tick_at, - evaluations: vec![], - executed: 0, - escalated: 0, + thoughts_count: 0, + thread_id: None, duration_ms: started.elapsed().as_millis() as u64, }); } - state.provider_unavailable_reason = None; - // 2. Insert in_progress log entries for each due task - let log_ids: HashMap = - store::with_connection(&self.workspace_dir, |conn| { - let mut ids = HashMap::new(); - for task in &due_tasks { - match store::add_log_entry( - conn, - &task.id, - tick_at, - "in_progress", - Some("Evaluating..."), - None, - ) { - Ok(entry) => { - ids.insert(task.id.clone(), entry.id); - } - Err(e) => { - warn!( - "[subconscious] failed to log in_progress for '{}': {e}", - task.title - ); - } - } - } - Ok(ids) - })?; + let mut state = self.state.lock().await; + state.provider_unavailable_reason = None; + let last_tick_at = state.last_tick_at; + drop(state); - // 3. Build situation report — memory-tree-derived sections (#623). - // Fetch last 8 reflections for anti-double-emit context. - let recent_reflections = super::store::with_connection(&self.workspace_dir, |conn| { - super::reflection_store::list_recent(conn, 8, None) + // 1. Build situation report + let recent_reflections = store::with_connection(&self.workspace_dir, |conn| { + reflection_store::list_recent(conn, 8, None) }) .unwrap_or_else(|e| { warn!("[subconscious] recent reflections load failed: {e}"); Vec::new() }); let report = build_situation_report( - &config_for_report, + &config, &self.workspace_dir, last_tick_at, self.context_budget_tokens, @@ -273,142 +166,62 @@ impl SubconsciousEngine { ) .await; - // 4. Load identity context + // 2. Load identity context let identity = prompt::load_identity_context(&self.workspace_dir); - // Release lock during LLM calls - drop(state); - - // 5. Evaluate tasks + emit reflections via cloud chat (#623). - let (evaluations, reflection_drafts) = self - .evaluate_tasks_and_reflections(&config_for_report, &due_tasks, &report, &identity) - .await; + // 3. Run the subconscious agent + let agent_prompt = prompt::build_agent_prompt(&report, &identity); + let drafts = self.run_agent(&config, &agent_prompt).await; - // Check if we were superseded by a newer tick + // 4. Check if superseded if self.tick_generation.load(Ordering::SeqCst) != my_generation { - info!("[subconscious] tick superseded by newer tick, discarding results"); - // Cancel our in_progress entries - let _ = store::with_connection(&self.workspace_dir, |conn| { - store::cancel_stale_in_progress(conn) - }); - // Don't advance last_tick_at — next tick should re-fetch from - // the same point so nothing is missed. + info!("[subconscious] tick superseded by newer tick, discarding"); let mut state = self.state.lock().await; state.total_ticks += 1; return Ok(TickResult { tick_at, - evaluations: vec![], - executed: 0, - escalated: 0, + thoughts_count: 0, + thread_id: None, duration_ms: started.elapsed().as_millis() as u64, }); } - // 6. Check if the evaluation itself failed (all tasks defaulted to noop - // due to LLM error). Individual task execution failures are tracked - // per-task and don't block the tick from advancing. - let evaluation_failed = evaluations.iter().all(|e| { - e.decision == TickDecision::Noop && e.reason.starts_with("Evaluation failed:") - }) && !evaluations.is_empty(); - - // 6a. Persist reflections + post Notify ones (#623). Skipped on - // evaluation failure since the LLM didn't produce useful - // output anyway. We do NOT advance `last_tick_at` on - // failure, so the next tick sees the same window. - if !evaluation_failed && !reflection_drafts.is_empty() { - // Reuse the same `config_for_report` we built for the situation - // report — the source-chunk resolver reads the same memory-tree - // tables, so a single load is enough. - persist_and_surface_reflections( - &self.workspace_dir, - &config_for_report, - reflection_drafts, - tick_at, - ) - .await; - } + // 5. Create thread and persist reflections + let thread_id = if !drafts.is_empty() { + let tid = self.create_tick_thread(&config, tick_at, &agent_prompt, &drafts); + Some(tid) + } else { + None + }; - // 7. Execute based on decisions, updating log entries in place - let mut executed = 0; - let mut escalated = 0; - - for eval in &evaluations { - let task = match due_tasks.iter().find(|t| t.id == eval.task_id) { - Some(t) => t, - None => continue, - }; - let log_id = log_ids.get(&task.id).map(|s| s.as_str()); - - match eval.decision { - TickDecision::Act => { - self.handle_act(task, &report, &identity, tick_at, eval, log_id) - .await; - executed += 1; - } - TickDecision::Escalate => { - self.handle_escalate(task, tick_at, eval, log_id).await; - escalated += 1; - } - TickDecision::Noop => { - self.handle_noop(task, tick_at, eval, log_id).await; - self.advance_task_schedule(task, tick_at); - } - } - } + let reflections = persist_reflections( + &self.workspace_dir, + &config, + drafts, + tick_at, + thread_id.as_deref(), + ) + .await; - // 8. Mark any tasks that didn't get an evaluation as noop. - // This happens when the LLM returns results for only a subset of tasks. - let evaluated_task_ids: std::collections::HashSet<&str> = - evaluations.iter().map(|e| e.task_id.as_str()).collect(); - for task in &due_tasks { - if !evaluated_task_ids.contains(task.id.as_str()) { - if let Some(lid) = log_ids.get(&task.id) { - let _ = store::with_connection(&self.workspace_dir, |conn| { - store::update_log_entry( - conn, - lid, - "noop", - Some("No evaluation returned by model"), - None, - ) - }); - } - } - } + let thoughts_count = reflections.len(); - // 9. Update state + // 6. Update state let mut state = self.state.lock().await; state.total_ticks += 1; - if evaluation_failed { - state.consecutive_failures += 1; - // Don't advance last_tick_at — the LLM couldn't evaluate anything, - // so the next tick should re-fetch from the same point. - } else { - state.consecutive_failures = 0; - state.last_tick_at = tick_at; - persist_last_tick_at(&self.workspace_dir, tick_at); - } + state.consecutive_failures = 0; + state.last_tick_at = tick_at; + persist_last_tick_at(&self.workspace_dir, tick_at); Ok(TickResult { tick_at, - evaluations, - executed, - escalated, + thoughts_count, + thread_id, duration_ms: started.elapsed().as_millis() as u64, }) } - /// Get current status. pub async fn status(&self) -> SubconsciousStatus { let state = self.state.lock().await; - let (task_count, pending_escalations) = - store::with_connection(&self.workspace_dir, |conn| { - Ok(( - store::task_count(conn).unwrap_or(0), - store::pending_escalation_count(conn).unwrap_or(0), - )) - }) - .unwrap_or((0, 0)); SubconsciousStatus { enabled: self.enabled, @@ -421,372 +234,118 @@ impl SubconsciousEngine { None }, total_ticks: state.total_ticks, - task_count, - pending_escalations, consecutive_failures: state.consecutive_failures, } } - /// Add a new task. All tasks are evaluated on every tick — no scheduling needed. - pub async fn add_task(&self, title: &str, source: TaskSource) -> Result { - let task = store::with_connection(&self.workspace_dir, |conn| { - store::add_task(conn, title, source, TaskRecurrence::Pending) - })?; - info!("[subconscious] added task: {}", title); - Ok(task) - } - - /// Approve an escalation — execute the task then mark approved. - pub async fn approve_escalation(&self, escalation_id: &str) -> Result<()> { - let (escalation, task) = store::with_connection(&self.workspace_dir, |conn| { - let esc = store::get_escalation(conn, escalation_id)?; - let task = store::get_task(conn, &esc.task_id)?; - Ok((esc, task)) - })?; - - info!( - "[subconscious] approved escalation '{}' for task '{}'", - escalation.title, task.title - ); - - // Execute the task - let identity = prompt::load_identity_context(&self.workspace_dir); - let config_for_report = match crate::openhuman::config::Config::load_or_init().await { - Ok(c) => c, - Err(e) => { - warn!("[subconscious] approve_escalation: config load failed: {e}"); - crate::openhuman::config::Config::default() - } - }; - let recent_reflections = super::store::with_connection(&self.workspace_dir, |conn| { - super::reflection_store::list_recent(conn, 8, None) - }) - .unwrap_or_default(); - let report = build_situation_report( - &config_for_report, - &self.workspace_dir, - 0.0, // fresh report for execution - self.context_budget_tokens, - &recent_reflections, - ) - .await; - - let tick_at = now_secs(); - let result = executor::execute_approved_write(&task, &report, &identity).await; - let (result_text, duration) = match &result { - Ok(r) => (r.output.clone(), Some(r.duration_ms as i64)), - Err(e) => (format!("Execution failed: {e}"), None), - }; - - store::with_connection(&self.workspace_dir, |conn| { - store::add_log_entry(conn, &task.id, tick_at, "act", Some(&result_text), duration)?; - store::resolve_escalation( - conn, - escalation_id, - &super::types::EscalationStatus::Approved, - )?; - if task.recurrence == TaskRecurrence::Once { - store::mark_task_completed(conn, &task.id)?; - } else { - self.advance_task_schedule_in_conn(conn, &task, tick_at); - } - Ok(()) - })?; - - Ok(()) - } - - /// Dismiss an escalation — log and don't execute. - pub async fn dismiss_escalation(&self, escalation_id: &str) -> Result<()> { - store::with_connection(&self.workspace_dir, |conn| { - let esc = store::get_escalation(conn, escalation_id)?; - store::add_log_entry( - conn, - &esc.task_id, - now_secs(), - "dismissed", - Some("Dismissed by user"), - None, - )?; - store::resolve_escalation( - conn, - escalation_id, - &super::types::EscalationStatus::Dismissed, - )?; - Ok(()) - }) - } - - // ── Internal methods ───────────────────────────────────────────────────── - - fn seed_tasks(&self) { - match store::with_connection(&self.workspace_dir, store::seed_default_tasks) { - Ok(count) => { - if count > 0 { - info!("[subconscious] seeded {count} default tasks"); - } - } - Err(e) => warn!("[subconscious] seed failed: {e}"), - } - } - - /// Run the per-tick LLM call. Routes via `subconscious_provider` - /// while reusing the memory LLM adapter over unified inference routing. - /// On failure returns - /// `(empty_evaluations, empty_drafts)` so `last_tick_at` is NOT - /// advanced — the next tick re-fetches from the same point. - async fn evaluate_tasks_and_reflections( - &self, - config: &Config, - tasks: &[SubconsciousTask], - report: &str, - identity: &str, - ) -> (Vec, Vec) { - let prompt_text = prompt::build_evaluation_prompt(tasks, report, identity); - - // Build the chat provider. The subconscious tick uses - // `ChatConsumer::Summarise` because the per-tick payload is - // closer in shape to a structured-summary call than a per-chunk - // entity extraction. + /// Run the subconscious agent and parse thoughts from the output. + async fn run_agent(&self, config: &Config, prompt_text: &str) -> Vec { let provider: Arc = match build_subconscious_chat_provider(config) { Ok(p) => p, Err(e) => { warn!("[subconscious] chat provider init failed: {e}"); - return ( - tasks - .iter() - .map(|t| TaskEvaluation { - task_id: t.id.clone(), - decision: TickDecision::Noop, - reason: format!("Evaluation failed: provider init: {e}"), - }) - .collect(), - vec![], - ); + return vec![]; } }; let chat_prompt = ChatPrompt { - system: prompt_text, - user: "Evaluate the due tasks and surface reflections. Reply with JSON only." + system: prompt_text.to_string(), + user: "Observe the current state and surface your thoughts. Reply with JSON only." .to_string(), temperature: 0.0, kind: "subconscious_tick", }; debug!( - "[subconscious] cloud chat call provider={} tasks={}", - provider.name(), - tasks.len() + "[subconscious] running agent via provider={}", + provider.name() ); match provider.chat_for_json(&chat_prompt).await { Ok(raw) => { - let (evals, drafts) = parse_response(&raw, tasks); - debug!( - "[subconscious] cloud chat parsed evals={} drafts={}", - evals.len(), - drafts.len() - ); - (evals, drafts) + let drafts = parse_thoughts(&raw); + debug!("[subconscious] agent produced {} thoughts", drafts.len()); + drafts } Err(e) => { - warn!("[subconscious] cloud chat failed (no local fallback): {e}"); - ( - tasks - .iter() - .map(|t| TaskEvaluation { - task_id: t.id.clone(), - decision: TickDecision::Noop, - reason: format!("Evaluation failed: cloud chat: {e}"), - }) - .collect(), - vec![], - ) + warn!("[subconscious] agent call failed: {e}"); + vec![] } } } - /// Handle an "act" decision. Individual execution failures are logged - /// per-task but don't block the tick from advancing. - async fn handle_act( + /// Create a conversation thread for this tick so the user can view the + /// agent's reasoning by clicking on any thought. + fn create_tick_thread( &self, - task: &SubconsciousTask, - report: &str, - identity: &str, + config: &Config, tick_at: f64, - eval: &TaskEvaluation, - log_id: Option<&str>, - ) { - info!( - "[subconscious] executing task '{}': {}", - task.title, eval.reason + _agent_prompt: &str, + drafts: &[ReflectionDraft], + ) -> String { + let thread_id = uuid::Uuid::new_v4().to_string(); + let dt = chrono::DateTime::from_timestamp(tick_at as i64, 0) + .unwrap_or_else(|| chrono::Utc::now()); + let thread_title = format!( + "Subconscious — {}", + dt.format("%b %d, %H:%M") ); - - let result = executor::execute_task(task, report, identity).await; - - match &result { - Ok(ExecutionOutcome::Completed(r)) => { - let _ = store::with_connection(&self.workspace_dir, |conn| { - let duration = Some(r.duration_ms as i64); - if let Some(lid) = log_id { - store::update_log_entry(conn, lid, "act", Some(&r.output), duration)?; - } else { - store::add_log_entry( - conn, - &task.id, - tick_at, - "act", - Some(&r.output), - duration, - )?; - } - if task.recurrence == TaskRecurrence::Once { - store::mark_task_completed(conn, &task.id)?; - info!("[subconscious] one-off task '{}' completed", task.title); - } else { - self.advance_task_schedule_in_conn(conn, task, tick_at); - } - Ok(()) - }); - } - Ok(ExecutionOutcome::UnapprovedWrite { - recommendation, - duration_ms, - }) => { - // agentic-v1 wants to take a write action the user didn't ask for. - // Create an escalation so the user can approve or dismiss. - info!( - "[subconscious] unapproved write for '{}': {}", - task.title, recommendation - ); - let _ = store::with_connection(&self.workspace_dir, |conn| { - let duration = Some(*duration_ms as i64); - let effective_log_id = if let Some(lid) = log_id { - store::update_log_entry( - conn, - lid, - "escalate", - Some(recommendation), - duration, - )?; - lid.to_string() - } else { - let entry = store::add_log_entry( - conn, - &task.id, - tick_at, - "escalate", - Some(recommendation), - duration, - )?; - entry.id - }; - store::add_escalation( - conn, - &task.id, - Some(&effective_log_id), - &task.title, - recommendation, - &EscalationPriority::Important, - )?; - Ok(()) - }); - } - Err(e) => { - let msg = format!("Execution failed: {e}"); - let _ = store::with_connection(&self.workspace_dir, |conn| { - if let Some(lid) = log_id { - store::update_log_entry(conn, lid, "failed", Some(&msg), None)?; - } else { - store::add_log_entry(conn, &task.id, tick_at, "failed", Some(&msg), None)?; - } - Ok(()) - }); - } + let now_iso = chrono::Utc::now().to_rfc3339(); + + if let Err(e) = crate::openhuman::memory_conversations::ensure_thread( + config.workspace_dir.clone(), + crate::openhuman::memory_conversations::CreateConversationThread { + id: thread_id.clone(), + title: thread_title, + created_at: now_iso.clone(), + parent_thread_id: None, + labels: Some(vec!["subconscious_tick".to_string()]), + personality_id: None, + }, + ) { + warn!("[subconscious] failed to create tick thread: {e}"); + return thread_id; } - } - async fn handle_escalate( - &self, - task: &SubconsciousTask, - tick_at: f64, - eval: &TaskEvaluation, - log_id: Option<&str>, - ) { - info!( - "[subconscious] escalating task '{}': {}", - task.title, eval.reason - ); - - let _ = store::with_connection(&self.workspace_dir, |conn| { - let effective_log_id = if let Some(lid) = log_id { - store::update_log_entry(conn, lid, "escalate", Some(&eval.reason), None)?; - lid.to_string() - } else { - let entry = store::add_log_entry( - conn, - &task.id, - tick_at, - "escalate", - Some(&eval.reason), - None, - )?; - entry.id - }; - store::add_escalation( - conn, - &task.id, - Some(&effective_log_id), - &task.title, - &eval.reason, - &EscalationPriority::Important, - )?; - Ok(()) - }); - } - - async fn handle_noop( - &self, - task: &SubconsciousTask, - tick_at: f64, - eval: &TaskEvaluation, - log_id: Option<&str>, - ) { - debug!("[subconscious] noop for '{}': {}", task.title, eval.reason); - let _ = store::with_connection(&self.workspace_dir, |conn| { - if let Some(lid) = log_id { - store::update_log_entry(conn, lid, "noop", Some(&eval.reason), None)?; - } else { - store::add_log_entry(conn, &task.id, tick_at, "noop", Some(&eval.reason), None)?; - } - Ok(()) - }); - } - - fn advance_task_schedule(&self, task: &SubconsciousTask, tick_at: f64) { - let _ = store::with_connection(&self.workspace_dir, |conn| { - self.advance_task_schedule_in_conn(conn, task, tick_at); - Ok(()) - }); - } - - fn advance_task_schedule_in_conn( - &self, - conn: &rusqlite::Connection, - task: &SubconsciousTask, - tick_at: f64, - ) { - if let TaskRecurrence::Cron(ref expr) = task.recurrence { - let next = store::compute_next_run(expr); - let _ = store::update_task_run_times(conn, &task.id, tick_at, next); - } else if task.recurrence == TaskRecurrence::Pending { - // Pending tasks run on every tick until classified - let next = tick_at + (f64::from(self.interval_minutes) * 60.0); - let _ = store::update_task_run_times(conn, &task.id, tick_at, Some(next)); + // Seed thread with a summary of the thoughts as the assistant message + let body = drafts + .iter() + .map(|d| { + let action = d + .proposed_action + .as_deref() + .map(|a| format!("\n\n_Proposed action_: {a}")) + .unwrap_or_default(); + format!("**{}** — {}{}", d.kind.as_str().replace('_', " "), d.body, action) + }) + .collect::>() + .join("\n\n---\n\n"); + + let seed_message = crate::openhuman::memory_conversations::ConversationMessage { + id: uuid::Uuid::new_v4().to_string(), + content: body, + message_type: "text".to_string(), + extra_metadata: serde_json::json!({ + "origin": "subconscious_tick", + "tick_at": tick_at, + "thoughts_count": drafts.len(), + }), + sender: "assistant".to_string(), + created_at: now_iso, + }; + if let Err(e) = crate::openhuman::memory_conversations::append_message( + config.workspace_dir.clone(), + &thread_id, + seed_message, + ) { + warn!("[subconscious] failed to seed tick thread: {e}"); } + + thread_id } } +// ── Provider routing ──────────────────────────────────────────────────────── + #[derive(Clone, Debug, Eq, PartialEq)] enum SubconsciousProviderRoute { LocalOllama { model: String }, @@ -854,72 +413,74 @@ fn build_subconscious_chat_provider(config: &Config) -> Result (Vec, Vec) { +// ── Thought parsing ───────────────────────────────────────────────────────── + +/// Response envelope for the agent's JSON output. +#[derive(Debug, Clone, serde::Deserialize)] +struct ThoughtsResponse { + #[serde(default)] + thoughts: Vec, + // Backward compat: also accept "reflections" key + #[serde(default)] + reflections: Vec, +} + +fn parse_thoughts(text: &str) -> Vec { let json_text = extract_json(text); - // 1. Full envelope (preferred). - if let Ok(response) = serde_json::from_str::(json_text) { - let evals = if response.evaluations.is_empty() { - // The LLM returned only reflections — fall through to the - // default-noop branch for tasks but keep reflections. - tasks - .iter() - .map(|t| TaskEvaluation { - task_id: t.id.clone(), - decision: TickDecision::Noop, - reason: "No evaluation returned by model".to_string(), - }) - .collect() - } else { - response.evaluations - }; - return (evals, response.reflections); + // Try full envelope + if let Ok(response) = serde_json::from_str::(json_text) { + let mut drafts = response.thoughts; + if drafts.is_empty() { + drafts = response.reflections; + } + if !drafts.is_empty() { + return drafts; + } } - // 2. Bare evaluations array (legacy shape pre-#623). - if let Ok(evals) = serde_json::from_str::>(json_text) { - if !evals.is_empty() { - return (evals, vec![]); + // Try bare array + if let Ok(drafts) = serde_json::from_str::>(json_text) { + if !drafts.is_empty() { + return drafts; } } - warn!("[subconscious] could not parse LLM response, defaulting all tasks to noop"); - let evals = tasks - .iter() - .map(|t| TaskEvaluation { - task_id: t.id.clone(), - decision: TickDecision::Noop, - reason: "Unparseable evaluation response".to_string(), - }) - .collect(); - (evals, vec![]) + warn!("[subconscious] could not parse agent output for thoughts"); + vec![] } -/// Persist a batch of LLM-emitted reflection drafts. -/// -/// Caps to `MAX_REFLECTIONS_PER_TICK`. Failures on individual writes -/// are logged but do not abort the rest — the tick must finish even if -/// one row trips an I/O error. -/// -/// Note: prior versions of this function also auto-posted `Notify`- -/// disposition reflections into a `system:subconscious` conversation -/// thread. That auto-post path is removed — reflections live exclusively -/// on the Intelligence tab. The user can spawn a fresh conversation from -/// any reflection via the `reflections_act` RPC (drives the action button). -async fn persist_and_surface_reflections( +fn extract_json(text: &str) -> &str { + let trimmed = text.trim(); + let obj_start = trimmed.find('{'); + let arr_start = trimmed.find('['); + let start = match (obj_start, arr_start) { + (Some(o), Some(a)) => o.min(a), + (Some(o), None) => o, + (None, Some(a)) => a, + (None, None) => return trimmed, + }; + let end = if trimmed.as_bytes().get(start) == Some(&b'[') { + trimmed.rfind(']').map(|i| i + 1) + } else { + trimmed.rfind('}').map(|i| i + 1) + }; + let end = end.unwrap_or(trimmed.len()); + if start < end { + &trimmed[start..end] + } else { + trimmed + } +} + +// ── Reflection persistence ────────────────────────────────────────────────── + +async fn persist_reflections( workspace_dir: &std::path::Path, - config: &crate::openhuman::config::Config, + config: &Config, drafts: Vec, now: f64, + thread_id: Option<&str>, ) -> Vec { let (drafts, dropped) = apply_cap(drafts); if dropped > 0 { @@ -933,25 +494,20 @@ async fn persist_and_surface_reflections( return vec![]; } - // Hydrate drafts into full reflections with fresh ids. For each draft, - // resolve its `source_refs` against the live memory-tree data NOW so - // the snapshot freezes the LLM's actual context. The chunks ride - // alongside the reflection row and feed both the Intelligence-tab - // "Sources" disclosure and the orchestrator's system-prompt memory- - // context injection for any chat turn in a thread spawned from this - // reflection. Resolver failures degrade per-chunk to empty content - // (see `source_chunk::resolve_chunks`). let reflections: Vec = drafts .into_iter() .map(|d| { let chunks = resolve_chunks(config, &d.source_refs); - hydrate_draft(d, uuid::Uuid::new_v4().to_string(), now, chunks) + hydrate_draft( + d, + uuid::Uuid::new_v4().to_string(), + now, + chunks, + thread_id.map(String::from), + ) }) .collect(); - // Persist all reflections in one connection. Idempotent inserts — - // duplicate ids cannot occur here because we just generated them, - // but the IGNORE clause makes a future retry safe. if let Err(e) = store::with_connection(workspace_dir, |conn| { for r in &reflections { if let Err(e) = reflection_store::add_reflection(conn, r) { @@ -966,34 +522,6 @@ async fn persist_and_surface_reflections( reflections } -fn extract_json(text: &str) -> &str { - let trimmed = text.trim(); - let obj_start = trimmed.find('{'); - let arr_start = trimmed.find('['); - let start = match (obj_start, arr_start) { - (Some(o), Some(a)) => o.min(a), - (Some(o), None) => o, - (None, Some(a)) => a, - (None, None) => return trimmed, - }; - let end = if trimmed.as_bytes().get(start) == Some(&b'[') { - trimmed.rfind(']').map(|i| i + 1) - } else { - trimmed.rfind('}').map(|i| i + 1) - }; - let end = end.unwrap_or(trimmed.len()); - if start < end { - &trimmed[start..end] - } else { - trimmed - } -} - -/// Best-effort durability for the in-memory `last_tick_at` advance. -/// SQLite write failures are downgraded to a warning — the in-memory -/// value still advances and the current process keeps deduping -/// correctly. The next restart would just cold-start as before, which -/// is the pre-fix behaviour. fn persist_last_tick_at(workspace_dir: &std::path::Path, tick_at: f64) { if let Err(e) = store::with_connection(workspace_dir, |conn| store::set_last_tick_at(conn, tick_at)) diff --git a/src/openhuman/subconscious/engine_tests.rs b/src/openhuman/subconscious/engine_tests.rs index a7e123c0a3..120348a228 100644 --- a/src/openhuman/subconscious/engine_tests.rs +++ b/src/openhuman/subconscious/engine_tests.rs @@ -1,243 +1,58 @@ use super::*; -struct EnvVarGuard { - key: &'static str, - old: Option, -} - -impl EnvVarGuard { - fn set_to_path(key: &'static str, path: &std::path::Path) -> Self { - let old = std::env::var_os(key); - std::env::set_var(key, path); - Self { key, old } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.old { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } -} - -fn test_tasks() -> Vec { - vec![ - SubconsciousTask { - id: "t1".into(), - title: "Check email".into(), - source: TaskSource::User, - recurrence: TaskRecurrence::Cron("0 8 * * *".into()), - enabled: true, - last_run_at: None, - next_run_at: None, - completed: false, - created_at: 0.0, - }, - SubconsciousTask { - id: "t2".into(), - title: "Monitor skills".into(), - source: TaskSource::System, - recurrence: TaskRecurrence::Pending, - enabled: true, - last_run_at: None, - next_run_at: None, - completed: false, - created_at: 0.0, - }, - ] -} - -#[tokio::test] -async fn tick_skips_unavailable_provider_without_activity_log_spam() { - let _env_lock = crate::openhuman::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let tmp = tempfile::tempdir().expect("tempdir"); - let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); - let config = Config::load_or_init().await.expect("load test config"); - let engine = SubconsciousEngine::from_heartbeat_config( - &config.heartbeat, - config.workspace_dir.clone(), - None, - ); - - let result = engine.tick().await.expect("tick should skip cleanly"); - - assert!(result.evaluations.is_empty()); - let logs = store::with_connection(&config.workspace_dir, |conn| { - store::list_log_entries(conn, None, 20) - }) - .expect("list logs"); - assert!( - logs.is_empty(), - "provider skip must not append per-task failure log entries" - ); - - let status = engine.status().await; - assert_eq!(status.consecutive_failures, 1); - assert!(!status.provider_available); - assert!(status - .provider_unavailable_reason - .as_deref() - .unwrap_or_default() - .contains("Sign in")); - - let _second = engine.tick().await.expect("repeat skip should be clean"); - let logs = store::with_connection(&config.workspace_dir, |conn| { - store::list_log_entries(conn, None, 20) - }) - .expect("list logs after repeat"); - assert!(logs.is_empty(), "repeat skips must not spam activity log"); -} - -#[test] -fn local_subconscious_provider_is_available() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut config = Config::default(); - config.config_path = tmp.path().join("config.toml"); - config.workspace_dir = tmp.path().join("workspace"); - config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into()); - - assert!(subconscious_provider_unavailable_reason(&config).is_none()); -} - -#[test] -fn local_subconscious_route_preserves_ollama_model() { - let mut config = Config::default(); - config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into()); - - assert_eq!( - resolve_subconscious_route(&config), - SubconsciousProviderRoute::LocalOllama { - model: "qwen2.5:0.5b".into(), - } - ); -} - #[test] -fn local_subconscious_provider_does_not_require_legacy_endpoint() { - let mut config = Config::default(); - config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into()); - config.memory_tree.llm_summariser_endpoint = None; - - assert!(subconscious_provider_unavailable_reason(&config).is_none()); -} - -#[test] -fn openhuman_subconscious_alias_uses_cloud_route() { - let mut config = Config::default(); - config.subconscious_provider = Some("openhuman:summarization".into()); - - assert_eq!( - resolve_subconscious_route(&config), - SubconsciousProviderRoute::OpenHumanCloud - ); -} - -#[test] -fn explicit_subconscious_provider_uses_other_route() { - let mut config = Config::default(); - config.subconscious_provider = Some("custom-provider".into()); - - assert_eq!( - resolve_subconscious_route(&config), - SubconsciousProviderRoute::Other("custom-provider".into()) - ); - assert!(subconscious_provider_unavailable_reason(&config).is_none()); -} - -#[test] -fn parse_evaluation_response() { - let json = r#"{"evaluations": [ - {"task_id": "t1", "decision": "act", "reason": "3 new urgent emails"}, - {"task_id": "t2", "decision": "noop", "reason": "All skills healthy"} +fn parse_thoughts_from_envelope() { + let json = r#"{"thoughts": [ + {"kind": "hotness_spike", "body": "Phoenix surged", "source_refs": ["entity:phoenix"]}, + {"kind": "risk", "body": "Deadline approaching"} ]}"#; - let (evals, drafts) = parse_response(json, &test_tasks()); - assert_eq!(evals.len(), 2); - assert_eq!(evals[0].decision, TickDecision::Act); - assert_eq!(evals[1].decision, TickDecision::Noop); - assert!(drafts.is_empty()); + let drafts = parse_thoughts(json); + assert_eq!(drafts.len(), 2); + assert_eq!(drafts[0].kind, reflection::ReflectionKind::HotnessSpike); + assert_eq!(drafts[1].kind, reflection::ReflectionKind::Risk); } #[test] -fn parse_evaluation_bare_array() { - let json = r#"[ - {"task_id": "t1", "decision": "escalate", "reason": "Deadline conflict"} - ]"#; - let (evals, drafts) = parse_response(json, &test_tasks()); - assert_eq!(evals.len(), 1); - assert_eq!(evals[0].decision, TickDecision::Escalate); - assert!(drafts.is_empty()); +fn parse_thoughts_from_reflections_key() { + let json = r#"{"reflections": [ + {"kind": "opportunity", "body": "New connection available"} + ]}"#; + let drafts = parse_thoughts(json); + assert_eq!(drafts.len(), 1); } #[test] -fn parse_evaluation_in_markdown() { - let json = "```json\n{\"evaluations\": [{\"task_id\": \"t1\", \"decision\": \"act\", \"reason\": \"Found items\"}]}\n```"; - let (evals, _) = parse_response(json, &test_tasks()); - assert_eq!(evals.len(), 1); - assert_eq!(evals[0].decision, TickDecision::Act); +fn parse_thoughts_from_bare_array() { + let json = r#"[{"kind": "daily_digest", "body": "Summary of the day"}]"#; + let drafts = parse_thoughts(json); + assert_eq!(drafts.len(), 1); } #[test] -fn parse_evaluation_garbage_falls_back_to_noop() { - let (evals, drafts) = parse_response("Not JSON at all", &test_tasks()); - assert_eq!(evals.len(), 2); - assert!(evals.iter().all(|e| e.decision == TickDecision::Noop)); +fn parse_thoughts_returns_empty_on_garbage() { + let drafts = parse_thoughts("not json at all"); assert!(drafts.is_empty()); } #[test] -fn parse_response_extracts_reflections() { - let json = r#"{ - "evaluations": [{"task_id": "t1", "decision": "noop", "reason": "nothing"}], - "reflections": [ - { - "kind": "hotness_spike", - "body": "Phoenix surge", - "disposition": "notify", - "proposed_action": "Pull mentions", - "source_refs": ["entity:phoenix"] - }, - { - "kind": "daily_digest", - "body": "New digest", - "disposition": "observe" - } - ] - }"#; - let (evals, drafts) = parse_response(json, &test_tasks()); - assert_eq!(evals.len(), 1); - assert_eq!(drafts.len(), 2); - assert_eq!(drafts[0].body, "Phoenix surge"); - assert_eq!(drafts[1].body, "New digest"); -} - -#[test] -fn parse_response_handles_only_reflections() { - // LLM emitted reflections but no per-task evaluations. - let json = r#"{ - "evaluations": [], - "reflections": [ - {"kind": "risk", "body": "Concerning pattern", "disposition": "notify"} - ] - }"#; - let (evals, drafts) = parse_response(json, &test_tasks()); - // Tasks default to Noop so the existing tick loop still updates log entries. - assert_eq!(evals.len(), 2); - assert!(evals.iter().all(|e| e.decision == TickDecision::Noop)); +fn parse_thoughts_handles_markdown_wrapper() { + let json = "```json\n{\"thoughts\": [{\"kind\": \"risk\", \"body\": \"test\"}]}\n```"; + let drafts = parse_thoughts(json); assert_eq!(drafts.len(), 1); } #[test] -fn extract_json_object() { - assert_eq!(extract_json(r#"{"key": "val"}"#), r#"{"key": "val"}"#); +fn extract_json_finds_object() { + let text = "Here's the JSON: {\"a\": 1} done."; + let extracted = extract_json(text); + assert!(extracted.starts_with('{')); + assert!(extracted.ends_with('}')); } #[test] -fn extract_json_from_text() { - let input = "Here's the result: {\"evaluations\": []} done."; - assert!(extract_json(input).starts_with('{')); - assert!(extract_json(input).ends_with('}')); +fn extract_json_finds_array() { + let text = "Result: [1, 2, 3] end."; + let extracted = extract_json(text); + assert!(extracted.starts_with('[')); + assert!(extracted.ends_with(']')); } diff --git a/src/openhuman/subconscious/executor.rs b/src/openhuman/subconscious/executor.rs index 6382adf4f8..d0393bc9c7 100644 --- a/src/openhuman/subconscious/executor.rs +++ b/src/openhuman/subconscious/executor.rs @@ -1,542 +1,4 @@ -//! Task execution — dispatches tasks to either the local Ollama model (text-only) -//! or the full agentic loop (tool-required). -//! -//! When agentic-v1 is used for a task that didn't have explicit write intent, -//! it runs in analysis-only mode. If it recommends a write action, execution -//! is paused and an `UnapprovedWrite` result is returned so the engine can -//! create an escalation for user approval. - -use super::prompt; -use super::types::{ExecutionResult, SubconsciousTask}; -use tracing::{debug, info, warn}; - -#[cfg(test)] -mod test_mocks { - use std::sync::atomic::{AtomicU8, Ordering}; - - const MODE_REAL: u8 = 0; - const MODE_LOCAL_FAIL: u8 = 1; - const MODE_AGENT_FAIL: u8 = 2; - - static MODE: AtomicU8 = AtomicU8::new(MODE_REAL); - - pub fn mock_local() { - MODE.store(MODE_LOCAL_FAIL, Ordering::Release); - } - pub fn mock_agent() { - MODE.store(MODE_AGENT_FAIL, Ordering::Release); - } - pub fn reset() { - MODE.store(MODE_REAL, Ordering::Release); - } - pub fn is_local_mocked() -> bool { - MODE.load(Ordering::Acquire) == MODE_LOCAL_FAIL - } - pub fn is_agent_mocked() -> bool { - MODE.load(Ordering::Acquire) == MODE_AGENT_FAIL - } -} - -/// Outcome of executing a task — either completed or needs user approval. -#[derive(Debug)] -pub enum ExecutionOutcome { - /// Task completed (either read-only analysis or approved write). - Completed(ExecutionResult), - /// agentic-v1 recommends a write action on a read-only task. - /// Contains the recommended action description for the escalation. - UnapprovedWrite { - recommendation: String, - duration_ms: u64, - }, -} - -/// Execute a task. Routes to local model or agentic loop based on whether -/// the task needs external tools. -pub async fn execute_task( - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> Result { - let started = std::time::Instant::now(); - let task_has_write_intent = needs_tools(&task.title); - let mut config = crate::openhuman::config::Config::load_or_init() - .await - .map_err(|e| format!("config load: {e}"))?; - - let result = if task_has_write_intent { - // Task explicitly asks for a write action — run with full permissions. - info!( - "[subconscious:executor] write task: id={} — agentic loop, full permissions", - task.id - ); - execute_with_agent_full(&mut config, task, situation_report, identity_context) - .await - .map(|output| { - ExecutionOutcome::Completed(ExecutionResult { - output, - used_tools: true, - duration_ms: started.elapsed().as_millis() as u64, - }) - }) - } else if needs_agent(&task.title) { - // Read-only task but needs deeper reasoning — run analysis-only. - info!( - "[subconscious:executor] read-only task escalated: id={} — agentic loop, analysis only", - task.id - ); - let output = - execute_with_agent_analysis(&mut config, task, situation_report, identity_context) - .await?; - let duration_ms = started.elapsed().as_millis() as u64; - - if let Some(recommendation) = extract_recommended_action(&output) { - // agentic-v1 wants to take a write action the user didn't ask for. - Ok(ExecutionOutcome::UnapprovedWrite { - recommendation, - duration_ms, - }) - } else { - Ok(ExecutionOutcome::Completed(ExecutionResult { - output, - used_tools: false, - duration_ms, - })) - } - } else { - // Simple text-only task. Use local model if configured for subconscious - // tasks, otherwise fall back to the cloud agentic analysis path. - if config.workload_uses_local("subconscious") { - debug!( - "[subconscious:executor] text task: id={} — using local model", - task.id - ); - execute_with_local_model(&config, task, situation_report, identity_context) - .await - .map(|output| { - ExecutionOutcome::Completed(ExecutionResult { - output, - used_tools: false, - duration_ms: started.elapsed().as_millis() as u64, - }) - }) - } else { - info!( - "[subconscious:executor] text task: id={} — local AI disabled, using cloud fallback", - task.id - ); - let output = - execute_with_agent_analysis(&mut config, task, situation_report, identity_context) - .await - .map_err(|e| format!("cloud fallback agent execution: {e}"))?; - let duration_ms = started.elapsed().as_millis() as u64; - debug!( - "[subconscious:executor] text task cloud fallback complete: id={} — duration_ms={}", - task.id, duration_ms - ); - - // Suppress UnapprovedWrite: passive tasks that didn't trigger - // needs_agent should never escalate even if the cloud model's - // output contains RECOMMENDED ACTION. The write-intent gate is - // needs_tools for active tasks and needs_agent for read-only - // escalations; the cloud fallback is a passthrough for simple - // text tasks and must not silently change the contract. - Ok(ExecutionOutcome::Completed(ExecutionResult { - output, - used_tools: false, - duration_ms, - })) - } - }; - - if let Err(ref e) = result { - warn!("[subconscious:executor] task id={} failed: {e}", task.id); - } - - result -} - -/// Execute an approved write action — called after user approves an escalation -/// that originated from `UnapprovedWrite`. -/// -/// Independent `Config::load_or_init()`: the task was originally routed under -/// config_A in `execute_task`; now executes under config_B after user approval. -/// If `use_local_for_subconscious()` toggled between the two calls, the approval -/// was made under different assumptions. Risk is negligible in practice (config -/// changes require a restart to take effect on most fields), but callers should -/// be aware of this TOCTOU window. -pub async fn execute_approved_write( - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> Result { - let started = std::time::Instant::now(); - let mut config = crate::openhuman::config::Config::load_or_init() - .await - .map_err(|e| format!("config load: {e}"))?; - let output = - execute_with_agent_full(&mut config, task, situation_report, identity_context).await?; - Ok(ExecutionResult { - output, - used_tools: true, - duration_ms: started.elapsed().as_millis() as u64, - }) -} - -/// Execute a text-only task using the local Ollama model. -/// -/// The caller MUST have already checked `config.local_ai.use_local_for_subconscious()` -/// before calling this function. -async fn execute_with_local_model( - config: &crate::openhuman::config::Config, - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> Result { - #[cfg(test)] - if test_mocks::is_local_mocked() { - return Err("local model: mocked failure (test)".into()); - } - let prompt_text = prompt::build_text_execution_prompt(task, situation_report, identity_context); - - let messages = vec![ - crate::openhuman::inference::provider::traits::ChatMessage::system(prompt_text), - crate::openhuman::inference::provider::traits::ChatMessage::user("Execute the task now."), - ]; - let model_id = crate::openhuman::inference::model_ids::effective_chat_model_id(config); - let provider_string = - match crate::openhuman::inference::local::provider::provider_from_config(config) { - crate::openhuman::inference::local::provider::LocalAiProvider::Ollama => { - format!("ollama:{model_id}") - } - crate::openhuman::inference::local::provider::LocalAiProvider::LmStudio => { - format!("lmstudio:{model_id}") - } - }; - let (provider, model) = - crate::openhuman::inference::provider::factory::create_local_chat_provider_from_string( - &provider_string, - config, - ) - .map_err(|e| format!("local model: {e}"))?; - - provider - .chat_with_history(&messages, &model, config.default_temperature) - .await - .map_err(|e| format!("local model: {e}")) -} - -/// Execute with agentic-v1 at full permissions (write-intent tasks or approved writes). -/// -/// Retries up to 3 times with exponential backoff (2s, 4s, 8s) on 429 rate-limit -/// errors from the agentic-v1 cloud model. -async fn execute_with_agent_full( - config: &mut crate::openhuman::config::Config, - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> Result { - let prompt_text = prompt::build_tool_execution_prompt(task, situation_report, identity_context); - - agent_chat_with_retry(config, &prompt_text).await -} - -/// Execute with agentic-v1 in analysis-only mode (read-only tasks). -/// -/// The prompt instructs the model to analyze but not execute write actions. -async fn execute_with_agent_analysis( - config: &mut crate::openhuman::config::Config, - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> Result { - #[cfg(test)] - if test_mocks::is_agent_mocked() { - return Err("cloud fallback: mocked failure (test)".into()); - } - let prompt_text = prompt::build_analysis_only_prompt(task, situation_report, identity_context); - - agent_chat_with_retry(config, &prompt_text).await -} - -/// Call agent_chat with rate-limit retry (429 only, up to 3 attempts). -async fn agent_chat_with_retry( - config: &mut crate::openhuman::config::Config, - prompt: &str, -) -> Result { - const MAX_RETRIES: u32 = 3; - let mut attempt = 0; - - loop { - let result = - crate::openhuman::inference::local::ops::agent_chat(config, prompt, None, Some(0.3)) - .await; - - match result { - Ok(outcome) => return Ok(outcome.value), - Err(e) => { - let is_rate_limit = e.contains("429") || e.to_lowercase().contains("rate limit"); - attempt += 1; - - if is_rate_limit && attempt < MAX_RETRIES { - let backoff_secs = 2u64 << (attempt - 1); // 2, 4, 8 - warn!( - "[subconscious:executor] rate-limited (attempt {}/{}), retrying in {}s: {}", - attempt, MAX_RETRIES, backoff_secs, e - ); - tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await; - continue; - } - - return Err(format!("agent execution: {e}")); - } - } - } -} - -/// Check if the analysis output contains a recommended write action. -/// Returns the recommendation text if found. -fn extract_recommended_action(output: &str) -> Option { - // Look for "RECOMMENDED ACTION:" marker in the output - for line_idx in output.lines().enumerate().filter_map(|(i, l)| { - if l.trim().starts_with("RECOMMENDED ACTION:") { - Some(i) - } else { - None - } - }) { - let recommendation: String = output - .lines() - .skip(line_idx) - .collect::>() - .join("\n") - .trim() - .to_string(); - if !recommendation.is_empty() { - return Some(recommendation); - } - } - None -} - -/// Heuristic: does this task need the agentic loop (deeper reasoning, tools)? -/// -/// Tasks escalated by the local model that involve complex analysis -/// (multi-step reasoning, cross-referencing sources) benefit from agentic-v1 -/// even without write actions. -fn needs_agent(title: &str) -> bool { - let lower = title.to_lowercase(); - let agent_keywords = [ - "compare", - "cross-reference", - "correlate", - "investigate", - "deep dive", - "research", - "audit", - "trace", - "debug", - "diagnose", - ]; - agent_keywords.iter().any(|kw| lower.contains(kw)) -} - -/// Heuristic: does this task description imply needing external tools? -/// -/// Tasks with action verbs (send, create, post, delete, move, publish, schedule) -/// need the agentic loop. Tasks with passive verbs (summarize, check, monitor, -/// review, analyze, extract, classify) can be handled by local model. -pub fn needs_tools(title: &str) -> bool { - let lower = title.to_lowercase(); - let tool_keywords = [ - "send", - "post", - "create", - "delete", - "remove", - "move", - "publish", - "schedule", - "forward", - "reply", - "draft and send", - "upload", - "download", - "notify on", - "alert on", - "message", - "write to", - "update on", - "sync to", - ]; - tool_keywords.iter().any(|kw| lower.contains(kw)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - use tempfile::tempdir; - - /// Guard that sets an env var for the duration of the test and restores it on drop. - struct EnvVarGuard { - key: String, - old: Option, - } - - impl EnvVarGuard { - fn set_to_path(key: &str, value: &Path) -> Self { - let old = std::env::var(key).ok(); - std::env::set_var(key, value.to_str().expect("path is valid utf-8")); - Self { - key: key.to_string(), - old, - } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.old { - Some(value) => std::env::set_var(&self.key, value), - None => std::env::remove_var(&self.key), - } - } - } - - fn write_subconscious_test_config(workspace_root: &Path, local_ai_enabled: bool) { - let cfg = format!( - r#"default_temperature = 0.7 - -[local_ai] -runtime_enabled = {local_ai_enabled} -provider = "ollama" - -[local_ai.usage] -subconscious = {local_ai_enabled} - -[memory] -backend = "sqlite" -auto_save = true -embedding_provider = "none" -embedding_model = "none" -embedding_dimensions = 0 - -[secrets] -encrypt = false -"# - ); - std::fs::create_dir_all(workspace_root).expect("mkdir test workspace root"); - let config_path = workspace_root.join("config.toml"); - std::fs::write(&config_path, &cfg).expect("write test config"); - let _: crate::openhuman::config::Config = - toml::from_str(&cfg).expect("test config should deserialize"); - } - - fn make_text_task(title: &str) -> SubconsciousTask { - SubconsciousTask { - id: "test-id".into(), - title: title.into(), - source: super::super::types::TaskSource::User, - recurrence: super::super::types::TaskRecurrence::Once, - enabled: true, - last_run_at: None, - next_run_at: None, - completed: false, - created_at: 1700000000.0, - } - } - - #[tokio::test] - async fn execute_task_routes_to_cloud_when_local_disabled() { - let _env_lock = crate::openhuman::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - test_mocks::reset(); - let tmp = tempdir().expect("tempdir"); - let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); - write_subconscious_test_config(tmp.path(), false); - - test_mocks::mock_agent(); - let task = make_text_task("Summarize unread emails"); - let result = execute_task(&task, "", "").await; - - assert!(result.is_err(), "expected error (cloud path)"); - let err = result.unwrap_err(); - assert!( - err.contains("cloud fallback"), - "expected cloud fallback error, got: {err}" - ); - } - - #[tokio::test] - async fn execute_task_routes_to_local_when_local_enabled() { - let _env_lock = crate::openhuman::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - test_mocks::reset(); - let tmp = tempdir().expect("tempdir"); - let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); - write_subconscious_test_config(tmp.path(), true); - - test_mocks::mock_local(); - let task = make_text_task("Summarize unread emails"); - let result = execute_task(&task, "", "").await; - - assert!(result.is_err(), "expected error (local path)"); - let err = result.unwrap_err(); - assert!( - err.contains("local model"), - "expected local model error, got: {err}" - ); - } - - #[test] - fn needs_tools_detects_action_verbs() { - assert!(needs_tools("Send email digest to Telegram")); - assert!(needs_tools("Post weekly standup to Slack")); - assert!(needs_tools("Create a summary in Notion")); - assert!(needs_tools("Delete old calendar events")); - assert!(needs_tools("Forward urgent emails to team")); - assert!(needs_tools("Schedule a meeting for tomorrow")); - } - - #[test] - fn needs_tools_rejects_passive_verbs() { - assert!(!needs_tools("Summarize unread emails")); - assert!(!needs_tools("Check skills runtime health")); - assert!(!needs_tools("Monitor Ollama status")); - assert!(!needs_tools("Review upcoming deadlines")); - assert!(!needs_tools("Analyze email patterns")); - assert!(!needs_tools("Extract key points from Notion pages")); - assert!(!needs_tools("Classify email priority")); - } - - #[test] - fn needs_tools_case_insensitive() { - assert!(needs_tools("SEND a message to Slack")); - assert!(needs_tools("Send A Message To Slack")); - } - - #[test] - fn needs_agent_detects_complex_tasks() { - assert!(needs_agent("Compare Q1 and Q2 revenue data")); - assert!(needs_agent("Investigate why notifications stopped")); - assert!(needs_agent("Audit all active skill connections")); - assert!(!needs_agent("Check emails")); - assert!(!needs_agent("Summarize today's events")); - } - - #[test] - fn extract_recommended_action_finds_marker() { - let output = "Analysis complete. Found 3 urgent emails.\n\nRECOMMENDED ACTION: Forward the 3 urgent emails to #team-alerts on Slack."; - let action = extract_recommended_action(output); - assert!(action.is_some()); - assert!(action.unwrap().contains("Forward")); - } - - #[test] - fn extract_recommended_action_returns_none_when_absent() { - let output = "All skills are healthy. No issues found."; - assert!(extract_recommended_action(output).is_none()); - } -} +//! Legacy executor — retained as an empty module for backward +//! compatibility. Task execution was removed when the subconscious +//! switched to an agent-per-tick model. The agent now runs directly +//! in the engine tick via the chat provider. diff --git a/src/openhuman/subconscious/global.rs b/src/openhuman/subconscious/global.rs index 5767d4a065..14fbebbd11 100644 --- a/src/openhuman/subconscious/global.rs +++ b/src/openhuman/subconscious/global.rs @@ -1,13 +1,7 @@ //! Global singleton for the SubconsciousEngine. //! //! Shared between the heartbeat background loop and RPC handlers -//! so both see the same decision log, counters, and last_tick_at. -//! -//! Lifecycle note: the engine is bootstrapped **post-login** via -//! [`bootstrap_after_login`] so that `seed_default_tasks` runs against the -//! per-user workspace (`~/.openhuman/users//workspace/`) instead of the -//! pre-login global default. See `load.rs::resolve_runtime_config_dirs` for -//! how `active_user.toml` drives `config.workspace_dir`. +//! so both see the same state and counters. use super::engine::SubconsciousEngine; use std::sync::atomic::{AtomicBool, Ordering}; @@ -16,12 +10,7 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; static ENGINE: OnceLock>>> = OnceLock::new(); - -/// True once [`bootstrap_after_login`] has successfully seeded the engine and -/// spawned the heartbeat loop for the current active user. static BOOTSTRAPPED: AtomicBool = AtomicBool::new(false); - -/// Heartbeat loop handle so logout / user switch can abort it cleanly. static HEARTBEAT_HANDLE: OnceLock>>> = OnceLock::new(); fn engine_lock() -> &'static Arc>> { @@ -32,7 +21,6 @@ fn heartbeat_slot() -> &'static Mutex>> { HEARTBEAT_HANDLE.get_or_init(|| Mutex::new(None)) } -/// Get or initialize the global engine. Both heartbeat loop and RPC use this. pub async fn get_or_init_engine() -> Result>>, String> { let lock = engine_lock(); { @@ -42,7 +30,6 @@ pub async fn get_or_init_engine() -> Result } } - // Initialize let config = crate::openhuman::config::Config::load_or_init() .await .map_err(|e| format!("load config: {e}"))?; @@ -63,15 +50,6 @@ pub async fn get_or_init_engine() -> Result Ok(Arc::clone(lock)) } -/// Construct the engine (which seeds defaults into the per-user workspace) -/// and spawn the heartbeat loop. Idempotent per-process via [`BOOTSTRAPPED`]. -/// -/// Call this: -/// - after a successful login writes `active_user.toml`, OR -/// - at sidecar startup **iff** `active_user.toml` already exists. -/// -/// Calling before login would seed into the global pre-login workspace and -/// then silently diverge from the per-user workspace the UI reads from. pub async fn bootstrap_after_login() -> Result<(), String> { if BOOTSTRAPPED.swap(true, Ordering::SeqCst) { tracing::debug!("[subconscious] bootstrap already ran — skipping"); @@ -91,10 +69,6 @@ pub async fn bootstrap_after_login() -> Result<(), String> { return Ok(()); } - // Build the engine against the NOW-correct per-user workspace_dir. - // SubconsciousEngine::new calls seed_default_tasks() inside the - // constructor, so by the time this returns the 3 system defaults are - // present in `/subconscious/subconscious.db`. get_or_init_engine().await.inspect_err(|_e| { BOOTSTRAPPED.store(false, Ordering::SeqCst); })?; @@ -103,9 +77,6 @@ pub async fn bootstrap_after_login() -> Result<(), String> { "[subconscious] engine initialized against per-user workspace" ); - // Spawn the heartbeat loop and keep the JoinHandle so we can cancel it - // on logout. Without this the task would leak: tokio::spawn returns a - // detached task that drops on handle-drop but keeps running. let heartbeat = crate::openhuman::heartbeat::engine::HeartbeatEngine::new( config.heartbeat.clone(), config.workspace_dir.clone(), @@ -124,16 +95,9 @@ pub async fn bootstrap_after_login() -> Result<(), String> { Ok(()) } -/// Stop only the heartbeat loop. Keep the engine cache intact so manual -/// subconscious RPCs can still inspect state, while a future enable can spawn -/// a fresh loop. pub async fn stop_heartbeat_loop() { if let Some(handle) = heartbeat_slot().lock().await.take() { handle.abort(); - // Await the aborted task so it fully releases its engine reference - // before we let bootstrap_after_login spawn a fresh loop. Without this - // the old task can still be executing engine.run_tick() against a - // replaced engine state. match handle.await { Ok(()) => { tracing::debug!("[heartbeat] loop exited before abort completed"); @@ -150,12 +114,6 @@ pub async fn stop_heartbeat_loop() { BOOTSTRAPPED.store(false, Ordering::SeqCst); } -/// Tear down the engine + heartbeat loop so the next login rebuilds them -/// against the new user's workspace. Call on logout or account switch. -/// -/// Without this, the engine `OnceLock` would stay frozen on the previous -/// user's `workspace_dir` and subsequent ticks / RPC queries would leak -/// into the wrong DB. pub async fn reset_engine_for_user_switch() { stop_heartbeat_loop().await; diff --git a/src/openhuman/subconscious/integration_tests.rs b/src/openhuman/subconscious/integration_tests.rs index 7bdcd08291..7351ef04ba 100644 --- a/src/openhuman/subconscious/integration_tests.rs +++ b/src/openhuman/subconscious/integration_tests.rs @@ -1,285 +1,88 @@ #[cfg(test)] mod tests { - use crate::openhuman::subconscious::decision_log::DecisionLog; - use crate::openhuman::subconscious::store; - use crate::openhuman::subconscious::types::{ - EscalationPriority, EscalationStatus, TaskRecurrence, TaskSource, TickDecision, + use crate::openhuman::subconscious::reflection::{ + hydrate_draft, ReflectionDraft, ReflectionKind, }; + use crate::openhuman::subconscious::reflection_store; + use crate::openhuman::subconscious::store; #[test] - fn sqlite_task_lifecycle_one_off() { - let dir = tempfile::tempdir().unwrap(); - store::with_connection(dir.path(), |conn| { - // Add a one-off task - let task = store::add_task( - conn, - "Remind about meeting", - TaskSource::User, - TaskRecurrence::Once, - )?; - assert!(!task.completed); - assert_eq!(task.recurrence, TaskRecurrence::Once); - - // Should be due immediately - let due = store::due_tasks(conn, 9999999999.0)?; - assert_eq!(due.len(), 1); - - // Execute and complete - store::add_log_entry( - conn, - &task.id, - 1000.0, - "act", - Some("Reminded user"), - Some(50), - )?; - store::mark_task_completed(conn, &task.id)?; - - // Should no longer be due - let due = store::due_tasks(conn, 9999999999.0)?; - assert_eq!(due.len(), 0); - - // Task still exists but completed - let t = store::get_task(conn, &task.id)?; - assert!(t.completed); - - Ok(()) - }) - .unwrap(); - } - - #[test] - fn sqlite_task_lifecycle_recurrent() { + fn reflection_with_thread_id_persists() { let dir = tempfile::tempdir().unwrap(); store::with_connection(dir.path(), |conn| { - let task = store::add_task( - conn, - "Check email", - TaskSource::User, - TaskRecurrence::Cron("0 8 * * *".into()), - )?; - - // Execute and set next run - let now = 1000.0; - let next = 2000.0; - store::add_log_entry( - conn, - &task.id, - now, - "act", - Some("Checked 3 emails"), - Some(200), - )?; - store::update_task_run_times(conn, &task.id, now, Some(next))?; - - // Not due yet (before next_run_at) - let due = store::due_tasks(conn, 1500.0)?; - assert_eq!(due.len(), 0); - - // Due after next_run_at - let due = store::due_tasks(conn, 2500.0)?; - assert_eq!(due.len(), 1); - - // Task should NOT be completed - let t = store::get_task(conn, &task.id)?; - assert!(!t.completed); + let draft = ReflectionDraft { + kind: ReflectionKind::Opportunity, + body: "Test thought".into(), + proposed_action: Some("Do something".into()), + source_refs: vec!["entity:test".into()], + }; + let reflection = hydrate_draft( + draft, + "r-1".into(), + 1_700_000_000.0, + Vec::new(), + Some("thread-abc".into()), + ); + reflection_store::add_reflection(conn, &reflection)?; + let got = reflection_store::get_reflection(conn, "r-1")?.unwrap(); + assert_eq!(got.thread_id, Some("thread-abc".into())); + assert_eq!(got.body, "Test thought"); Ok(()) }) .unwrap(); } #[test] - fn escalation_approve_dismiss_flow() { + fn reflection_without_thread_id_persists() { let dir = tempfile::tempdir().unwrap(); store::with_connection(dir.path(), |conn| { - let task = store::add_task( - conn, - "Review deadline", - TaskSource::User, - TaskRecurrence::Once, - )?; - - // Create escalation - let esc = store::add_escalation( - conn, - &task.id, - None, - "Deadline conflict", - "Two deadlines on the same day", - &EscalationPriority::Important, - )?; - assert_eq!(esc.status, EscalationStatus::Pending); - assert_eq!(store::pending_escalation_count(conn)?, 1); - - // Approve - store::resolve_escalation(conn, &esc.id, &EscalationStatus::Approved)?; - let resolved = store::get_escalation(conn, &esc.id)?; - assert_eq!(resolved.status, EscalationStatus::Approved); - assert!(resolved.resolved_at.is_some()); - assert_eq!(store::pending_escalation_count(conn)?, 0); - - // Create another and dismiss - let esc2 = store::add_escalation( - conn, - &task.id, - None, - "Budget warning", - "Monthly spend at 90%", - &EscalationPriority::Normal, - )?; - store::resolve_escalation(conn, &esc2.id, &EscalationStatus::Dismissed)?; - let dismissed = store::get_escalation(conn, &esc2.id)?; - assert_eq!(dismissed.status, EscalationStatus::Dismissed); - + let draft = ReflectionDraft { + kind: ReflectionKind::DailyDigest, + body: "No thread".into(), + proposed_action: None, + source_refs: vec![], + }; + let reflection = hydrate_draft(draft, "r-2".into(), 1_700_000_000.0, Vec::new(), None); + reflection_store::add_reflection(conn, &reflection)?; + + let got = reflection_store::get_reflection(conn, "r-2")?.unwrap(); + assert!(got.thread_id.is_none()); Ok(()) }) .unwrap(); } #[test] - fn execution_log_tracks_history() { + fn list_recent_includes_thread_id() { let dir = tempfile::tempdir().unwrap(); store::with_connection(dir.path(), |conn| { - let task = store::add_task( - conn, - "Check health", - TaskSource::System, - TaskRecurrence::Pending, - )?; - - store::add_log_entry(conn, &task.id, 1000.0, "noop", Some("All healthy"), None)?; - store::add_log_entry( - conn, - &task.id, - 2000.0, - "act", - Some("Restarted skill"), - Some(500), - )?; - store::add_log_entry(conn, &task.id, 3000.0, "noop", Some("All healthy"), None)?; - - let entries = store::list_log_entries(conn, Some(&task.id), 10)?; - assert_eq!(entries.len(), 3); - // Most recent first - assert_eq!(entries[0].tick_at, 3000.0); - assert_eq!(entries[1].decision, "act"); - - // Global log - let all = store::list_log_entries(conn, None, 2)?; - assert_eq!(all.len(), 2); // limited to 2 - - Ok(()) - }) - .unwrap(); - } - - #[test] - fn decision_log_dedup_still_works() { - let mut log = DecisionLog::new(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs_f64(); - - log.record( - now, - TickDecision::Escalate, - "deadline email", - vec!["doc-1".into()], - ); - - // doc-1 should be filtered as already surfaced - let unsurfaced = log.filter_unsurfaced(&["doc-1".into(), "doc-2".into()]); - assert!(!unsurfaced.contains(&"doc-1".to_string())); - assert!(unsurfaced.contains(&"doc-2".to_string())); - - // Acknowledge doc-1 - log.mark_acknowledged(&["doc-1".into()]); - assert!(!log.was_already_surfaced(&["doc-1".into()])); - } - - #[test] - fn seed_then_query_tasks() { - let dir = tempfile::tempdir().unwrap(); - - store::with_connection(dir.path(), |conn| { - let count = store::seed_default_tasks(conn)?; - assert_eq!(count, 3); - - let tasks = store::list_tasks(conn, true)?; - assert_eq!(tasks.len(), 3); - assert!(tasks.iter().all(|t| t.source == TaskSource::System)); - assert!(tasks - .iter() - .all(|t| t.recurrence == TaskRecurrence::Pending)); - - // All should be due (no next_run_at set) - let due = store::due_tasks(conn, 9999999999.0)?; - assert_eq!(due.len(), 3); - - Ok(()) - }) - .unwrap(); - } - - /// Regression test for the "empty task list on fresh install" bug. - /// - /// The core server's startup path calls `get_or_init_engine()` to - /// eagerly construct a `SubconsciousEngine`, relying on the constructor - /// to seed the 3 default system tasks. This test locks in that - /// invariant: constructing the engine alone — with no tick, no - /// trigger RPC, and no explicit seed call — must leave the 3 defaults - /// in the SQLite store. - #[test] - fn engine_construction_seeds_default_tasks() { - use crate::openhuman::config::HeartbeatConfig; - use crate::openhuman::subconscious::SubconsciousEngine; - - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().to_path_buf(); - - // Construct the engine via the same path the core server uses at - // startup. Memory client is not required for seeding. - let _engine = SubconsciousEngine::from_heartbeat_config( - &HeartbeatConfig::default(), - workspace.clone(), - None, - ); - - // The 3 default system tasks must now exist in the store. - store::with_connection(&workspace, |conn| { - let tasks = store::list_tasks(conn, false)?; - assert_eq!( - tasks.len(), - 3, - "engine construction must seed the 3 default system tasks" - ); - assert!(tasks.iter().all(|t| t.source == TaskSource::System)); - assert!(tasks - .iter() - .all(|t| t.recurrence == TaskRecurrence::Pending)); - - Ok(()) - }) - .unwrap(); - - // Reconstructing the engine on the same workspace must not - // duplicate the defaults — seed_default_tasks is idempotent. - - let _engine2 = SubconsciousEngine::from_heartbeat_config( - &HeartbeatConfig::default(), - workspace.clone(), - None, - ); - - store::with_connection(&workspace, |conn| { - let tasks = store::list_tasks(conn, false)?; - assert_eq!( - tasks.len(), - 3, - "repeat engine construction must not duplicate default tasks" - ); + for i in 0..3 { + let draft = ReflectionDraft { + kind: ReflectionKind::HotnessSpike, + body: format!("thought {i}"), + proposed_action: None, + source_refs: vec![], + }; + let tid = if i == 1 { + Some("thread-xyz".into()) + } else { + None + }; + let reflection = hydrate_draft( + draft, + format!("r-{i}"), + 1_700_000_000.0 + f64::from(i), + Vec::new(), + tid, + ); + reflection_store::add_reflection(conn, &reflection)?; + } + + let list = reflection_store::list_recent(conn, 10, None)?; + assert_eq!(list.len(), 3); + assert_eq!(list[1].thread_id, Some("thread-xyz".into())); + assert!(list[0].thread_id.is_none()); Ok(()) }) .unwrap(); diff --git a/src/openhuman/subconscious/mod.rs b/src/openhuman/subconscious/mod.rs index 65ae988492..7966e25fb9 100644 --- a/src/openhuman/subconscious/mod.rs +++ b/src/openhuman/subconscious/mod.rs @@ -1,5 +1,4 @@ pub mod engine; -pub mod executor; pub mod global; pub mod prompt; pub mod reflection; @@ -10,9 +9,6 @@ pub mod source_chunk; pub mod store; pub mod types; -// Keep decision_log for potential future dedup queries against the log table. -pub mod decision_log; - #[cfg(test)] mod integration_tests; @@ -23,7 +19,4 @@ pub use schemas::{ all_registered_controllers as all_subconscious_registered_controllers, }; pub use source_chunk::SourceChunk; -pub use types::{ - Escalation, EscalationStatus, SubconsciousLogEntry, SubconsciousStatus, SubconsciousTask, - TaskRecurrence, TaskSource, TickDecision, TickResult, -}; +pub use types::{SubconsciousStatus, TickResult}; diff --git a/src/openhuman/subconscious/prompt.rs b/src/openhuman/subconscious/prompt.rs index a88cd05b6c..718f58d123 100644 --- a/src/openhuman/subconscious/prompt.rs +++ b/src/openhuman/subconscious/prompt.rs @@ -1,109 +1,64 @@ -//! Prompt builders for the subconscious evaluation and execution phases. +//! Prompt builder for the subconscious agent. //! -//! Injects OpenClaw identity context (SOUL.md, PROFILE.md) so the local model -//! reasons as the agent, not a generic evaluator. +//! The subconscious agent is a periodic summarizer that reads the situation +//! report (memory-tree signals, recent activity, hotness deltas) and +//! produces structured thoughts (reflections) about the user's state. -use super::types::SubconsciousTask; use std::path::Path; const IDENTITY_EXCERPT_CHARS: usize = 2000; -// ── Evaluation prompt ──────────────────────────────────────────────────────── - -/// Build the per-tick evaluation prompt. The local model evaluates each due -/// task against the situation report and returns a per-task decision. -pub fn build_evaluation_prompt( - tasks: &[SubconsciousTask], - situation_report: &str, - identity_context: &str, -) -> String { - let task_list = tasks - .iter() - .map(|t| format!("- [{}] {}", t.id, t.title)) - .collect::>() - .join("\n"); - +/// Build the system prompt for the subconscious agent tick. The agent +/// observes the user's world via the situation report and produces +/// structured reflections. +pub fn build_agent_prompt(situation_report: &str, identity_context: &str) -> String { format!( r#"{identity_context} -# Subconscious Loop — Task Evaluation - -You are the background awareness layer. You run periodically to evaluate -user-defined tasks against the current workspace state. +# Subconscious Agent — Periodic Summarizer -## Due tasks +You are the background awareness layer for the user. You run periodically +to observe the user's recent activity, memory signals, and context — then +surface interesting thoughts, patterns, and observations. -{task_list} - -## Current state +## Current State {situation_report} -## Your job - -For each task, check if the current state has anything relevant. Decide: -- **noop**: Nothing actionable for this task right now. -- **act**: The task should be executed now (state has relevant data). -- **escalate**: The task needs user approval before acting (ambiguous, risky, or irreversible). - -## Reflections (#623 — proactive layer) - -You also surface **reflections**: free-form observations grounded in the -memory-tree signal sections (Hotness deltas, Recent summaries, Latest -daily digest, Recap window). Reflections are *not* task evaluations — -they let you point out something the user should know, even if no task -covers it. +## Your Job -**Self vs. others (#1365)**: the situation report includes a *Your -Identifiers* section listing the user's connected-account handles, -emails, and user_ids. Use it as the source of truth for who the user is. +Observe the current state and produce **thoughts** — structured +observations about what's happening in the user's world. Each thought +should be grounded in the signals you see in the situation report. -- *Hotness deltas* tagged `(you)` are the user's own identifiers. Frame - reflections grounded in those in second person: *"Your phoenix mentions - surged 4× this hour."* Untagged hotness items are someone or something - else — reflect on them as *about that other entity*, not as the user. -- For *Recent summaries*, *Latest global digest*, and *Recap window* - body text, the prose mentions people by name, email, or handle inline. - Match each mention against the *Your Identifiers* list: if it appears - there, that sentence is about the user; otherwise it's about a - collaborator/contact. **Never attribute another person's activity to - the user.** A digest line "Cyrus deployed phoenix" is the user's - activity only when *Cyrus* (or the matching email/handle) appears in - *Your Identifiers* — otherwise Cyrus is someone the user is reading - about, and the reflection should reflect that. -- If a reflection mixes self and other signals, separate them - explicitly: *"You spent the morning on phoenix; meanwhile, Sam pushed - a refactor."* - -For each reflection: +For each thought: - `kind`: one of `hotness_spike` | `cross_source_pattern` | `daily_digest` | `due_item` | `risk` | `opportunity`. - `body`: short markdown-friendly observation. - `proposed_action` (optional): one-tap action text. When the user taps the action button, OpenHuman opens a *new* conversation thread seeded - with the body + this action — never auto-executed, never written into - any existing chat. -- `source_refs`: opaque ids from the situation report so we can trace - provenance. + with the body + this action — never auto-executed. +- `source_refs`: opaque ids from the situation report for provenance. + +**Self vs. others**: the situation report includes a *Your Identifiers* +section listing the user's connected-account handles, emails, and +user_ids. Use it as the source of truth for who the user is. Never +attribute another person's activity to the user. **Anti-double-emit**: the situation report's "Recent reflections" section shows what you already noticed. Re-emit only if the underlying signal -materially intensified — otherwise let it decay silently. +materially intensified. -**No side effects, no thread bloat**: reflections are observation-only. -They surface on the Intelligence tab; nothing is auto-posted into any -conversation. Only emit a `proposed_action` when there is a concrete -follow-up the user could plausibly want to start a chat about. +**Quality bar**: only surface thoughts that would genuinely help the user. +Skip trivial observations. If nothing interesting happened since the last +tick, return an empty array. -Cap: emit at most **5 reflections per tick**. Excess is dropped. +Cap: emit at most **5 thoughts per tick**. Excess is dropped. ## Output format (strict JSON, no other text) {{ - "evaluations": [ - {{"task_id": "", "decision": "noop|act|escalate", "reason": "one sentence"}} - ], - "reflections": [ + "thoughts": [ {{ "kind": "hotness_spike", "body": "Phoenix mentions surged 4× in last hour across Slack + email.", @@ -116,101 +71,16 @@ Cap: emit at most **5 reflections per tick**. Excess is dropped. ) } -/// Render a slice of recent reflections as a wire-format prompt block — -/// matches what the LLM was taught about in `build_evaluation_prompt`. -/// Used by the situation_report's "Recent reflections" section so the -/// representation is identical between teaching and reading. +/// Render a slice of recent reflections as a prompt block for the +/// situation report's "Recent reflections" section. pub fn format_recent_reflections_for_prompt( reflections: &[crate::openhuman::subconscious::reflection::Reflection], ) -> String { crate::openhuman::subconscious::situation_report::reflections::build_section(reflections) } -// ── Execution prompts ──────────────────────────────────────────────────────── - -/// Build the prompt for executing a text-only task via local Ollama model. -/// Used for tasks that don't need tools (summarize, extract, classify, etc.) -pub fn build_text_execution_prompt( - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> String { - format!( - r#"{identity_context} - -# Task Execution - -Execute the following task based on the current state. Respond with the result only. - -## Task -{task_title} - -## Current state -{situation_report} - -Do the task now. Return only the result — no explanations or meta-commentary."#, - task_title = task.title - ) -} - -/// Build the prompt for executing a tool-required task via the full agentic loop. -/// Used for tasks that need side effects (send message, create doc, etc.) -pub fn build_tool_execution_prompt( - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> String { - format!( - r#"{identity_context} - -# Background Task Execution - -You are executing a user-defined background task. Use your available tools to complete it. - -## Task -{task_title} - -## Current state -{situation_report} - -Execute this task using the appropriate tools. Complete the task fully — don't just describe what to do."#, - task_title = task.title - ) -} - -/// Build a read-only analysis prompt for agentic-v1. Used when a read-only task -/// is escalated — the agent should analyze and recommend but NOT execute writes. -pub fn build_analysis_only_prompt( - task: &SubconsciousTask, - situation_report: &str, - identity_context: &str, -) -> String { - format!( - r#"{identity_context} - -# Background Task — Analysis Only - -You are analyzing a background task. You may use read-only tools to gather information, -but you MUST NOT execute any write actions (send, post, create, delete, forward, reply, update, publish). - -If you determine that a write action is needed, describe exactly what you would do in your response -but do not execute it. Start your recommendation with "RECOMMENDED ACTION:" on its own line. - -## Task -{task_title} - -## Current state -{situation_report} - -Analyze the situation and report your findings. If action is needed, describe it clearly but do NOT execute."#, - task_title = task.title - ) -} - // ── Identity loading ───────────────────────────────────────────────────────── -/// Load identity context from SOUL.md and PROFILE.md in the workspace. -/// Returns a formatted string to prepend to prompts. pub fn load_identity_context(workspace_dir: &Path) -> String { let prompts_dir = resolve_prompts_dir(workspace_dir); let mut ctx = String::new(); @@ -222,8 +92,6 @@ pub fn load_identity_context(workspace_dir: &Path) -> String { } } - // PROFILE.md lives in the workspace root (not prompts dir) — it's - // generated by the onboarding enrichment pipeline, not bundled. if let Some(profile) = load_file_excerpt(workspace_dir, "PROFILE.md") { ctx.push_str("## User Profile\n\n"); ctx.push_str(&profile); @@ -238,13 +106,11 @@ pub fn load_identity_context(workspace_dir: &Path) -> String { } fn resolve_prompts_dir(workspace_dir: &Path) -> Option { - // Check workspace AI dir let workspace_ai = workspace_dir.join("ai"); if workspace_ai.is_dir() { return Some(workspace_ai); } - // Try CARGO_MANIFEST_DIR (dev builds) if let Some(dir) = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from) { let candidate = dir .join("src") @@ -256,7 +122,6 @@ fn resolve_prompts_dir(workspace_dir: &Path) -> Option { } } - // Walk up from cwd if let Ok(cwd) = std::env::current_dir() { return crate::openhuman::dev_paths::repo_ai_prompts_dir(&cwd); } @@ -281,60 +146,22 @@ fn load_file_excerpt(dir: &Path, filename: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::subconscious::types::{TaskRecurrence, TaskSource}; - - fn test_task(id: &str, title: &str) -> SubconsciousTask { - SubconsciousTask { - id: id.to_string(), - title: title.to_string(), - source: TaskSource::User, - recurrence: TaskRecurrence::Once, - enabled: true, - last_run_at: None, - next_run_at: None, - completed: false, - created_at: 0.0, - } - } #[test] - fn evaluation_prompt_includes_tasks_and_report() { - let tasks = vec![ - test_task("t1", "Check email"), - test_task("t2", "Review calendar"), - ]; - let prompt = build_evaluation_prompt(&tasks, "## State\nSome data.", "Identity here"); - assert!(prompt.contains("[t1] Check email")); - assert!(prompt.contains("[t2] Review calendar")); + fn agent_prompt_includes_report_and_identity() { + let prompt = build_agent_prompt("## State\nSome data.", "Identity here"); assert!(prompt.contains("Some data.")); assert!(prompt.contains("Identity here")); + assert!(prompt.contains("thoughts")); } #[test] - fn evaluation_prompt_includes_decision_schema() { - let tasks = vec![test_task("t1", "Task")]; - let prompt = build_evaluation_prompt(&tasks, "", ""); - assert!(prompt.contains("noop")); - assert!(prompt.contains("act")); - assert!(prompt.contains("escalate")); - assert!(prompt.contains("evaluations")); - assert!(prompt.contains("task_id")); - } - - #[test] - fn text_execution_prompt_includes_task_title() { - let task = test_task("t1", "Summarize urgent emails"); - let prompt = build_text_execution_prompt(&task, "3 new emails", "Identity"); - assert!(prompt.contains("Summarize urgent emails")); - assert!(prompt.contains("3 new emails")); - } - - #[test] - fn tool_execution_prompt_includes_tool_instructions() { - let task = test_task("t1", "Send digest to Telegram"); - let prompt = build_tool_execution_prompt(&task, "Email data here", "Identity"); - assert!(prompt.contains("Send digest to Telegram")); - assert!(prompt.contains("tools")); + fn agent_prompt_includes_output_schema() { + let prompt = build_agent_prompt("", ""); + assert!(prompt.contains("kind")); + assert!(prompt.contains("body")); + assert!(prompt.contains("proposed_action")); + assert!(prompt.contains("source_refs")); } #[test] diff --git a/src/openhuman/subconscious/reflection.rs b/src/openhuman/subconscious/reflection.rs index 7511a20ec6..edc795d046 100644 --- a/src/openhuman/subconscious/reflection.rs +++ b/src/openhuman/subconscious/reflection.rs @@ -54,6 +54,10 @@ pub struct Reflection { pub acted_on_at: Option, /// Epoch seconds when the user dismissed the card. pub dismissed_at: Option, + /// Thread ID of the agent conversation that produced this reflection. + /// Clicking the thought in the UI navigates to this thread. + #[serde(default)] + pub thread_id: Option, } /// Categorisation of the underlying signal. Start narrow; we can grow @@ -132,6 +136,7 @@ pub fn hydrate_draft( id: String, now: f64, source_chunks: Vec, + thread_id: Option, ) -> Reflection { Reflection { id, @@ -143,6 +148,7 @@ pub fn hydrate_draft( created_at: now, acted_on_at: None, dismissed_at: None, + thread_id, } } diff --git a/src/openhuman/subconscious/reflection_store.rs b/src/openhuman/subconscious/reflection_store.rs index 70be48caf1..bc2708f95a 100644 --- a/src/openhuman/subconscious/reflection_store.rs +++ b/src/openhuman/subconscious/reflection_store.rs @@ -36,7 +36,8 @@ pub const REFLECTION_SCHEMA_DDL: &str = " source_chunks TEXT NOT NULL DEFAULT '[]', created_at REAL NOT NULL, acted_on_at REAL, - dismissed_at REAL + dismissed_at REAL, + thread_id TEXT ); CREATE INDEX IF NOT EXISTS idx_reflections_created ON subconscious_reflections(created_at DESC); @@ -86,6 +87,13 @@ pub fn migrate_add_source_chunks_column(conn: &Connection) { ); } +pub fn migrate_add_thread_id_column(conn: &Connection) { + let _ = conn.execute( + "ALTER TABLE subconscious_reflections ADD COLUMN thread_id TEXT", + [], + ); +} + // ── Reflection CRUD ────────────────────────────────────────────────────────── /// Persist a fresh reflection. Idempotent on `id`: if a row with the same @@ -101,8 +109,8 @@ pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()> conn.execute( "INSERT OR IGNORE INTO subconscious_reflections ( id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + created_at, acted_on_at, dismissed_at, thread_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ reflection.id, reflection.kind.as_str(), @@ -113,14 +121,16 @@ pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()> reflection.created_at, reflection.acted_on_at, reflection.dismissed_at, + reflection.thread_id, ], ) .context("insert reflection")?; log::debug!( - "[subconscious::reflection_store] added id={} kind={} chunks={}", + "[subconscious::reflection_store] added id={} kind={} chunks={} thread={:?}", reflection.id, reflection.kind.as_str(), - reflection.source_chunks.len() + reflection.source_chunks.len(), + reflection.thread_id, ); Ok(()) } @@ -138,7 +148,7 @@ pub fn list_recent( let mapped: Vec = if let Some(ts) = since_ts { stmt = conn.prepare( "SELECT id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at + created_at, acted_on_at, dismissed_at, thread_id FROM subconscious_reflections WHERE created_at > ?1 ORDER BY created_at DESC LIMIT ?2", @@ -151,7 +161,7 @@ pub fn list_recent( } else { stmt = conn.prepare( "SELECT id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at + created_at, acted_on_at, dismissed_at, thread_id FROM subconscious_reflections ORDER BY created_at DESC LIMIT ?1", )?; @@ -168,7 +178,7 @@ pub fn list_recent( pub fn get_reflection(conn: &Connection, id: &str) -> Result> { let mut stmt = conn.prepare( "SELECT id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at + created_at, acted_on_at, dismissed_at, thread_id FROM subconscious_reflections WHERE id = ?1", )?; let r = stmt @@ -206,6 +216,7 @@ fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result { let created_at: f64 = row.get(6)?; let acted_on_at: Option = row.get(7)?; let dismissed_at: Option = row.get(8)?; + let thread_id: Option = row.get(9)?; let source_refs: Vec = serde_json::from_str(&source_refs_json).unwrap_or_else(|_| Vec::new()); @@ -222,6 +233,7 @@ fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result { created_at, acted_on_at, dismissed_at, + thread_id, }) } diff --git a/src/openhuman/subconscious/reflection_store_tests.rs b/src/openhuman/subconscious/reflection_store_tests.rs index 00e63a150a..72ae66c98c 100644 --- a/src/openhuman/subconscious/reflection_store_tests.rs +++ b/src/openhuman/subconscious/reflection_store_tests.rs @@ -24,7 +24,7 @@ fn sample_reflection(id: &str, created_at: f64) -> Reflection { proposed_action: Some("Take a look".into()), source_refs: vec!["entity:foo".into()], }; - hydrate_draft(draft, id.into(), created_at, Vec::new()) + hydrate_draft(draft, id.into(), created_at, Vec::new(), None) } #[test] diff --git a/src/openhuman/subconscious/reflection_tests.rs b/src/openhuman/subconscious/reflection_tests.rs index 2bd963b543..5af62a3800 100644 --- a/src/openhuman/subconscious/reflection_tests.rs +++ b/src/openhuman/subconscious/reflection_tests.rs @@ -61,7 +61,7 @@ fn hydrate_draft_fills_lifecycle_fields() { proposed_action: Some("Draft an invite list".into()), source_refs: vec!["entity:dinner".into()], }; - let r = hydrate_draft(draft, "abc-123".into(), 1_700_000_000.0, Vec::new()); + let r = hydrate_draft(draft, "abc-123".into(), 1_700_000_000.0, Vec::new(), None); assert_eq!(r.id, "abc-123"); assert_eq!(r.created_at, 1_700_000_000.0); assert!(r.acted_on_at.is_none()); diff --git a/src/openhuman/subconscious/schemas.rs b/src/openhuman/subconscious/schemas.rs index 7ae3deddf3..71308375af 100644 --- a/src/openhuman/subconscious/schemas.rs +++ b/src/openhuman/subconscious/schemas.rs @@ -1,11 +1,10 @@ -//! RPC endpoints for the subconscious task system. +//! RPC endpoints for the subconscious agent loop. use serde_json::{Map, Value}; use super::global::get_or_init_engine; use super::reflection_store; use super::store; -use super::types::{EscalationStatus, TaskPatch, TaskRecurrence, TaskSource}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::rpc::RpcOutcome; @@ -14,14 +13,6 @@ pub fn all_controller_schemas() -> Vec { vec![ schemas("status"), schemas("trigger"), - schemas("tasks_list"), - schemas("tasks_add"), - schemas("tasks_update"), - schemas("tasks_remove"), - schemas("log_list"), - schemas("escalations_list"), - schemas("escalations_approve"), - schemas("escalations_dismiss"), schemas("reflections_list"), schemas("reflections_act"), schemas("reflections_dismiss"), @@ -38,38 +29,6 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("trigger"), handler: handle_trigger, }, - RegisteredController { - schema: schemas("tasks_list"), - handler: handle_tasks_list, - }, - RegisteredController { - schema: schemas("tasks_add"), - handler: handle_tasks_add, - }, - RegisteredController { - schema: schemas("tasks_update"), - handler: handle_tasks_update, - }, - RegisteredController { - schema: schemas("tasks_remove"), - handler: handle_tasks_remove, - }, - RegisteredController { - schema: schemas("log_list"), - handler: handle_log_list, - }, - RegisteredController { - schema: schemas("escalations_list"), - handler: handle_escalations_list, - }, - RegisteredController { - schema: schemas("escalations_approve"), - handler: handle_escalations_approve, - }, - RegisteredController { - schema: schemas("escalations_dismiss"), - handler: handle_escalations_dismiss, - }, RegisteredController { schema: schemas("reflections_list"), handler: handle_reflections_list, @@ -101,143 +60,34 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![field("result", TypeSchema::Json, "Tick result.")], }, - "tasks_list" => ControllerSchema { - namespace: "subconscious", - function: "tasks_list", - description: "List all subconscious tasks.", - inputs: vec![field_opt( - "enabled_only", - TypeSchema::Bool, - "Filter to enabled tasks only.", - )], - outputs: vec![field("tasks", TypeSchema::Json, "Array of tasks.")], - }, - "tasks_add" => ControllerSchema { - namespace: "subconscious", - function: "tasks_add", - description: "Add a new task. The agent classifies it as one-off or recurrent.", - inputs: vec![ - field_req( - "title", - TypeSchema::String, - "Natural language task description.", - ), - field_opt( - "source", - TypeSchema::String, - "Task source: 'user' (default) or 'system'.", - ), - ], - outputs: vec![field("task", TypeSchema::Json, "The created task.")], - }, - "tasks_update" => ControllerSchema { - namespace: "subconscious", - function: "tasks_update", - description: "Update a task.", - inputs: vec![ - field_req("task_id", TypeSchema::String, "Task ID to update."), - field_opt("title", TypeSchema::String, "New title."), - field_opt( - "recurrence", - TypeSchema::String, - "New recurrence: 'once' | 'cron:' | 'pending'.", - ), - field_opt("enabled", TypeSchema::Bool, "Enable or disable."), - ], - outputs: vec![field("result", TypeSchema::Json, "Update confirmation.")], - }, - "tasks_remove" => ControllerSchema { - namespace: "subconscious", - function: "tasks_remove", - description: "Remove a task.", - inputs: vec![field_req( - "task_id", - TypeSchema::String, - "Task ID to remove.", - )], - outputs: vec![field("result", TypeSchema::Json, "Removal confirmation.")], - }, - "log_list" => ControllerSchema { - namespace: "subconscious", - function: "log_list", - description: "List execution log entries.", - inputs: vec![ - field_opt("task_id", TypeSchema::String, "Filter by task ID."), - field_opt("limit", TypeSchema::U64, "Max entries (default 50)."), - ], - outputs: vec![field("entries", TypeSchema::Json, "Log entries.")], - }, - "escalations_list" => ControllerSchema { - namespace: "subconscious", - function: "escalations_list", - description: "List escalations.", - inputs: vec![field_opt( - "status", - TypeSchema::String, - "Filter: 'pending' | 'approved' | 'dismissed'.", - )], - outputs: vec![field( - "escalations", - TypeSchema::Json, - "Escalation records.", - )], - }, - "escalations_approve" => ControllerSchema { - namespace: "subconscious", - function: "escalations_approve", - description: "Approve an escalation — execute the task.", - inputs: vec![field_req( - "escalation_id", - TypeSchema::String, - "Escalation ID.", - )], - outputs: vec![field("result", TypeSchema::Json, "Approval confirmation.")], - }, - "escalations_dismiss" => ControllerSchema { - namespace: "subconscious", - function: "escalations_dismiss", - description: "Dismiss an escalation — don't execute.", - inputs: vec![field_req( - "escalation_id", - TypeSchema::String, - "Escalation ID.", - )], - outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")], - }, - // ── #623: proactive reflection layer ───────────────────────────────── "reflections_list" => ControllerSchema { namespace: "subconscious", function: "reflections_list", - description: "List recent subconscious reflections (Observe + Notify). \ - Newest first.", + description: "List recent subconscious thoughts. Newest first.", inputs: vec![ field_opt("limit", TypeSchema::U64, "Max entries (default 50)."), field_opt( "since_ts", TypeSchema::F64, - "Epoch seconds — only return reflections newer than this.", + "Epoch seconds — only return thoughts newer than this.", ), ], outputs: vec![field( "reflections", TypeSchema::Json, - "Reflection records.", + "Thought records.", )], }, "reflections_act" => ControllerSchema { namespace: "subconscious", function: "reflections_act", - description: "Act on a reflection — creates a fresh conversation thread \ - and seeds it with the reflection body as the first ASSISTANT \ - message (with proposed_action appended if present). No LLM \ - turn fires — the user lands in a thread that opens with the \ - observation from OpenHuman, ready for them to reply. Marks \ - `acted_on_at`. Returns the new thread id so the frontend can \ - navigate to it.", + description: "Act on a thought — creates a fresh conversation thread \ + and seeds it with the thought body as the first ASSISTANT \ + message. Returns the new thread id.", inputs: vec![field_req( "reflection_id", TypeSchema::String, - "Reflection ID.", + "Thought ID.", )], outputs: vec![field( "result", @@ -248,11 +98,11 @@ pub fn schemas(function: &str) -> ControllerSchema { "reflections_dismiss" => ControllerSchema { namespace: "subconscious", function: "reflections_dismiss", - description: "Dismiss a reflection card. Sets `dismissed_at`.", + description: "Dismiss a thought card. Sets `dismissed_at`.", inputs: vec![field_req( "reflection_id", TypeSchema::String, - "Reflection ID.", + "Thought ID.", )], outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")], }, @@ -270,26 +120,13 @@ pub fn schemas(function: &str) -> ControllerSchema { fn handle_status(_params: Map) -> ControllerFuture { Box::pin(async move { - // Read status entirely from DB — never touch the engine mutex. - // The engine lock is held for the full tick duration, so any RPC - // that acquires it would block until the tick completes. let config = load_config().await?; let hb = &config.heartbeat; - let (task_count, pending_escalations, last_tick_at, total_ticks) = - store::with_connection(&config.workspace_dir, |conn| { - let tc = store::task_count(conn).unwrap_or(0); - let pe = store::pending_escalation_count(conn).unwrap_or(0); - let (lt, tt) = conn - .query_row( - "SELECT MAX(tick_at), COUNT(DISTINCT tick_at) FROM subconscious_log", - [], - |row| Ok((row.get::<_, Option>(0)?, row.get::<_, u64>(1)?)), - ) - .unwrap_or((None, 0)); - Ok((tc, pe, lt, tt)) - }) - .map_err(|e| format!("{e:#}"))?; + let last_tick_at = store::with_connection(&config.workspace_dir, |conn| { + store::get_last_tick_at(conn) + }) + .ok(); let provider_unavailable_reason = if hb.enabled && hb.inference_enabled { super::engine::subconscious_provider_unavailable_reason(&config) @@ -301,11 +138,9 @@ fn handle_status(_params: Map) -> ControllerFuture { provider_available: provider_unavailable_reason.is_none(), provider_unavailable_reason, interval_minutes: hb.interval_minutes.max(5), - last_tick_at, - total_ticks, - task_count, - pending_escalations, - consecutive_failures: 0, // Only available from in-memory state; 0 is fine for UI + last_tick_at: last_tick_at.filter(|v| *v > 0.0), + total_ticks: 0, + consecutive_failures: 0, }; to_json(RpcOutcome::single_log(status, "subconscious status")) @@ -316,8 +151,6 @@ fn handle_trigger(_params: Map) -> ControllerFuture { Box::pin(async move { let lock = get_or_init_engine().await?; - // Spawn the tick in the background so the RPC returns immediately. - // The frontend can poll status/log to see in_progress → final transitions. let lock_clone = std::sync::Arc::clone(&lock); tokio::spawn(async move { let guard = lock_clone.lock().await; @@ -325,9 +158,9 @@ fn handle_trigger(_params: Map) -> ControllerFuture { match engine.tick().await { Ok(result) => { tracing::info!( - "[subconscious] manual tick: executed={} escalated={} duration={}ms", - result.executed, - result.escalated, + "[subconscious] manual tick: thoughts={} thread={:?} duration={}ms", + result.thoughts_count, + result.thread_id, result.duration_ms ); } @@ -345,173 +178,6 @@ fn handle_trigger(_params: Map) -> ControllerFuture { }) } -fn handle_tasks_list(params: Map) -> ControllerFuture { - Box::pin(async move { - let enabled_only = params - .get("enabled_only") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let config = load_config().await?; - let tasks = store::with_connection(&config.workspace_dir, |conn| { - store::list_tasks(conn, enabled_only) - }) - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log(tasks, "tasks listed")) - }) -} - -fn handle_tasks_add(params: Map) -> ControllerFuture { - Box::pin(async move { - let title = params - .get("title") - .and_then(|v| v.as_str()) - .ok_or("title is required")? - .to_string(); - let source = match params.get("source").and_then(|v| v.as_str()) { - Some("system") => TaskSource::System, - _ => TaskSource::User, - }; - let lock = get_or_init_engine().await?; - let guard = lock.lock().await; - let engine = guard.as_ref().ok_or("engine not initialized")?; - let task = engine - .add_task(&title, source) - .await - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log(task, "task added")) - }) -} - -fn handle_tasks_update(params: Map) -> ControllerFuture { - Box::pin(async move { - let task_id = params - .get("task_id") - .and_then(|v| v.as_str()) - .ok_or("task_id is required")? - .to_string(); - let patch = TaskPatch { - title: params - .get("title") - .and_then(|v| v.as_str()) - .map(String::from), - recurrence: params.get("recurrence").and_then(|v| v.as_str()).map(|s| { - if s == "once" { - TaskRecurrence::Once - } else if let Some(expr) = s.strip_prefix("cron:") { - TaskRecurrence::Cron(expr.to_string()) - } else { - TaskRecurrence::Pending - } - }), - enabled: params.get("enabled").and_then(|v| v.as_bool()), - }; - let config = load_config().await?; - store::with_connection(&config.workspace_dir, |conn| { - store::update_task(conn, &task_id, &patch) - }) - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log( - serde_json::json!({"updated": task_id}), - "task updated", - )) - }) -} - -fn handle_tasks_remove(params: Map) -> ControllerFuture { - Box::pin(async move { - let task_id = params - .get("task_id") - .and_then(|v| v.as_str()) - .ok_or("task_id is required")? - .to_string(); - let config = load_config().await?; - store::with_connection(&config.workspace_dir, |conn| { - store::remove_task(conn, &task_id) - }) - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log( - serde_json::json!({"removed": task_id}), - "task removed", - )) - }) -} - -fn handle_log_list(params: Map) -> ControllerFuture { - Box::pin(async move { - let task_id = params.get("task_id").and_then(|v| v.as_str()); - let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; - let config = load_config().await?; - let entries = store::with_connection(&config.workspace_dir, |conn| { - store::list_log_entries(conn, task_id, limit) - }) - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log(entries, "log entries listed")) - }) -} - -fn handle_escalations_list(params: Map) -> ControllerFuture { - Box::pin(async move { - let status_filter = params - .get("status") - .and_then(|v| v.as_str()) - .map(|s| match s { - "approved" => EscalationStatus::Approved, - "dismissed" => EscalationStatus::Dismissed, - _ => EscalationStatus::Pending, - }); - let config = load_config().await?; - let escalations = store::with_connection(&config.workspace_dir, |conn| { - store::list_escalations(conn, status_filter.as_ref()) - }) - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log(escalations, "escalations listed")) - }) -} - -fn handle_escalations_approve(params: Map) -> ControllerFuture { - Box::pin(async move { - let escalation_id = params - .get("escalation_id") - .and_then(|v| v.as_str()) - .ok_or("escalation_id is required")? - .to_string(); - let lock = get_or_init_engine().await?; - let guard = lock.lock().await; - let engine = guard.as_ref().ok_or("engine not initialized")?; - engine - .approve_escalation(&escalation_id) - .await - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log( - serde_json::json!({"approved": escalation_id}), - "escalation approved and executed", - )) - }) -} - -fn handle_escalations_dismiss(params: Map) -> ControllerFuture { - Box::pin(async move { - let escalation_id = params - .get("escalation_id") - .and_then(|v| v.as_str()) - .ok_or("escalation_id is required")? - .to_string(); - let lock = get_or_init_engine().await?; - let guard = lock.lock().await; - let engine = guard.as_ref().ok_or("engine not initialized")?; - engine - .dismiss_escalation(&escalation_id) - .await - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log( - serde_json::json!({"dismissed": escalation_id}), - "escalation dismissed", - )) - }) -} - -// ── #623: proactive reflection handlers ────────────────────────────────────── - fn handle_reflections_list(params: Map) -> ControllerFuture { Box::pin(async move { let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; @@ -540,10 +206,6 @@ fn handle_reflections_act(params: Map) -> ControllerFuture { .map_err(|e| format!("{e:#}"))? .ok_or_else(|| format!("reflection not found: {reflection_id}"))?; - // Spawn a fresh conversation thread for this action. Reflections never - // write into the user's existing threads — each act gets its own - // chat so the active conversation stays uncluttered. Title is the - // first ~60 chars of the body so it's recognisable in the thread list. let thread_id = uuid::Uuid::new_v4().to_string(); let thread_title: String = { let mut s: String = reflection @@ -578,14 +240,6 @@ fn handle_reflections_act(params: Map) -> ControllerFuture { ) .map_err(|e| format!("ensure_thread (reflection-spawned) failed: {e}"))?; - // Seed the new thread with the reflection as the FIRST message, - // sent from `assistant` (i.e. OpenHuman speaking). The frontend - // renders this as a regular AI message, so the user lands in a - // thread that already starts with the observation. They can then - // type their own reply — no auto LLM turn fires here. This is - // distinct from `start_chat`, which would have appended the - // reflection as a USER message and immediately triggered an - // orchestrator response. let body_md = match reflection.proposed_action.as_deref() { Some(action) if !action.trim().is_empty() => format!( "{body}\n\n_Proposed action_: {action}", @@ -616,12 +270,6 @@ fn handle_reflections_act(params: Map) -> ControllerFuture { ) .map_err(|e| format!("append seed reflection message failed: {e}"))?; - // Stamp acted_on_at on success. If the stamp write fails, log a - // warning — the new thread already exists, so a silent failure - // here would leave the reflection unmarked and the user could - // re-Act on the same card and spawn a duplicate thread. The - // reflection itself is still actionable from the user's - // perspective, so we don't want to fail the whole call. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs_f64()) @@ -630,7 +278,7 @@ fn handle_reflections_act(params: Map) -> ControllerFuture { reflection_store::mark_acted(conn, &reflection_id, now) }) { log::warn!( - "[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e} — reflection card will reappear and a re-Act would spawn a duplicate thread", + "[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e}", reflection_id, thread_id ); @@ -672,14 +320,6 @@ fn handle_reflections_dismiss(params: Map) -> ControllerFuture { // ── Helpers ────────────────────────────────────────────────────────────────── async fn load_config() -> Result { - // Use the same 30s-bounded loader every other JSON-RPC domain uses - // (see cron/schemas.rs, webhooks/schemas.rs, etc.). Raw - // `Config::load_or_init()` can stall on `SecretStore::new` plus a chain - // of `decrypt_optional_secret` calls that may IPC to an OS keychain, - // so the subconscious handlers used to be the only unbounded outlier - // in the entire JSON-RPC surface. Under the Intelligence page's 3s - // poll that chokepoint let a slow keychain call pin the frontend's - // `Promise.all` and freeze the activity log on a stale snapshot. crate::openhuman::config::load_config_with_timeout().await } diff --git a/src/openhuman/subconscious/schemas_tests.rs b/src/openhuman/subconscious/schemas_tests.rs index 200fa43108..d4ac22a0de 100644 --- a/src/openhuman/subconscious/schemas_tests.rs +++ b/src/openhuman/subconscious/schemas_tests.rs @@ -1,14 +1,13 @@ use super::*; #[test] -fn all_schemas_returns_thirteen() { - // 10 task/escalation schemas + 3 reflection schemas (#623). - assert_eq!(all_controller_schemas().len(), 13); +fn all_schemas_returns_five() { + assert_eq!(all_controller_schemas().len(), 5); } #[test] -fn all_controllers_returns_thirteen() { - assert_eq!(all_registered_controllers().len(), 13); +fn all_controllers_returns_five() { + assert_eq!(all_registered_controllers().len(), 5); } #[test] @@ -23,233 +22,22 @@ fn reflection_rpcs_are_registered() { } #[test] -fn all_use_subconscious_namespace() { - for s in all_controller_schemas() { - assert_eq!(s.namespace, "subconscious"); - assert!(!s.description.is_empty()); - } -} - -#[test] -fn schemas_and_controllers_match() { - let s = all_controller_schemas(); - let c = all_registered_controllers(); - for (schema, ctrl) in s.iter().zip(c.iter()) { - assert_eq!(schema.function, ctrl.schema.function); - } -} - -#[test] -fn known_functions_resolve() { - for fn_name in [ - "status", - "trigger", - "tasks_list", - "tasks_add", - "tasks_update", - "tasks_remove", - "log_list", - "escalations_list", - "escalations_approve", - "escalations_dismiss", - ] { - let s = schemas(fn_name); - assert_ne!(s.function, "unknown", "{fn_name} fell through"); - } -} - -#[test] -fn unknown_function_returns_unknown() { - let s = schemas("nonexistent"); - assert_eq!(s.function, "unknown"); -} - -#[test] -fn status_schema_has_no_inputs() { - assert!(schemas("status").inputs.is_empty()); -} - -#[test] -fn trigger_schema_has_no_inputs() { - assert!(schemas("trigger").inputs.is_empty()); -} - -#[test] -fn tasks_add_requires_title() { - let s = schemas("tasks_add"); - let required: Vec<&str> = s - .inputs - .iter() - .filter(|f| f.required) - .map(|f| f.name) - .collect(); - assert!(required.contains(&"title")); -} - -#[test] -fn tasks_update_requires_task_id() { - let s = schemas("tasks_update"); - let required: Vec<&str> = s - .inputs +fn status_and_trigger_are_registered() { + let names: Vec<&str> = all_controller_schemas() .iter() - .filter(|f| f.required) - .map(|f| f.name) + .map(|s| s.function) .collect(); - assert!(required.contains(&"task_id")); + assert!(names.contains(&"status")); + assert!(names.contains(&"trigger")); } #[test] -fn tasks_remove_requires_task_id() { - let s = schemas("tasks_remove"); - let required: Vec<&str> = s - .inputs +fn task_endpoints_are_removed() { + let names: Vec<&str> = all_controller_schemas() .iter() - .filter(|f| f.required) - .map(|f| f.name) + .map(|s| s.function) .collect(); - assert!(required.contains(&"task_id")); -} - -#[test] -fn escalations_approve_requires_escalation_id() { - let s = schemas("escalations_approve"); - assert!(s - .inputs - .iter() - .any(|f| f.name == "escalation_id" && f.required)); -} - -#[test] -fn escalations_dismiss_requires_escalation_id() { - let s = schemas("escalations_dismiss"); - assert!(s - .inputs - .iter() - .any(|f| f.name == "escalation_id" && f.required)); -} - -#[test] -fn log_list_has_optional_inputs() { - let s = schemas("log_list"); - for input in &s.inputs { - assert!( - !input.required, - "log_list input '{}' should be optional", - input.name - ); - } -} - -#[test] -fn tasks_list_has_optional_enabled_only() { - let s = schemas("tasks_list"); - let enabled = s.inputs.iter().find(|f| f.name == "enabled_only"); - assert!(enabled.is_some_and(|f| !f.required)); -} - -// ── Field helpers ────────────────────────────────────────────── - -#[test] -fn field_helper_is_required() { - let f = field("name", TypeSchema::String, "desc"); - assert!(f.required); -} - -#[test] -fn field_req_helper_is_required() { - let f = field_req("name", TypeSchema::String, "desc"); - assert!(f.required); -} - -#[test] -fn field_opt_helper_is_not_required() { - let f = field_opt("name", TypeSchema::String, "desc"); - assert!(!f.required); -} - -// ── Error chain preservation ─────────────────────────────────── -// -// The RPC handlers in this module bridge `anyhow::Result` (from -// `store::with_connection` and the wrapped rusqlite errors) into the -// JSON-RPC `Result` boundary via `map_err(|e| ...)`. -// -// **Critical for observability**: plain `e.to_string()` on an -// `anyhow::Error` returns ONLY the outermost context. For a -// `with_connection` failure the outer wrap is -// `"failed to run subconscious schema DDL"` — the underlying rusqlite -// root (the actual SQLite error code + message) is dropped. That -// stringified message is what `jsonrpc::invoke_method_inner` later -// passes to `report_error_or_expected`, which in turn captures it in -// Sentry. Without the chain, Sentry events for TAURI-RUST-A only -// surface the generic wrapper text and the rusqlite root cause is -// permanently invisible. -// -// All `map_err` sites in `schemas.rs` use `format!("{e:#}")` (anyhow's -// alternate Display walks the cause chain inline joined by `": "`) so -// the rusqlite root reaches Sentry. These guard tests pin the format -// so future contributors don't silently regress to `e.to_string()`. -#[test] -fn anyhow_alternate_display_walks_chain() { - use anyhow::Context; - - let inner = anyhow::anyhow!("database is locked").context("execute_batch failed"); - let outer: anyhow::Result<()> = Err(inner).context("failed to run subconscious schema DDL"); - - let err = outer.unwrap_err(); - - // Plain to_string() — the broken (pre-fix) shape. Only outer - // wrapper reaches the caller, root cause lost. - let lossy = err.to_string(); - assert_eq!(lossy, "failed to run subconscious schema DDL"); - assert!( - !lossy.contains("database is locked"), - "plain Display must drop the root cause — if this changes the chain-formatter \ - is no longer load-bearing, revisit observability assumptions" - ); - - // Alternate Display — what schemas.rs map_err now produces. Every - // layer joined by ": " so the rusqlite root reaches Sentry. - let full = format!("{err:#}"); - assert!( - full.contains("failed to run subconscious schema DDL"), - "chain-formatted message must include outer wrapper, got: {full}" - ); - assert!( - full.contains("execute_batch failed"), - "chain-formatted message must include middle context, got: {full}" - ); - assert!( - full.contains("database is locked"), - "chain-formatted message must include the rusqlite root, got: {full}" - ); -} - -#[test] -fn anyhow_alternate_display_includes_rusqlite_error_chain() { - use anyhow::Context; - - // Simulate the exact shape produced by `with_connection`: - // a real rusqlite Error wrapped in `with_context(...)`. - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseBusy, - extended_code: 5, - }, - Some("database is locked".into()), - ); - let wrapped: anyhow::Result<()> = - Err(anyhow::Error::from(raw)).context("failed to run subconscious schema DDL"); - - let err = wrapped.unwrap_err(); - let chained = format!("{err:#}"); - - // Outer wrapper preserved. - assert!(chained.contains("failed to run subconscious schema DDL")); - // rusqlite-rendered root preserved — this is the signal Sentry - // needs to distinguish a DDL lock-race from a corruption / disk-full - // / permission failure. Without it, all four fingerprint identically. - assert!( - chained.contains("database is locked"), - "rusqlite root must appear in chain-formatted message, got: {chained}" - ); + assert!(!names.contains(&"tasks_list")); + assert!(!names.contains(&"tasks_add")); + assert!(!names.contains(&"escalations_list")); } diff --git a/src/openhuman/subconscious/situation_report/mod.rs b/src/openhuman/subconscious/situation_report/mod.rs index 3b5bf1bba8..c7947a0034 100644 --- a/src/openhuman/subconscious/situation_report/mod.rs +++ b/src/openhuman/subconscious/situation_report/mod.rs @@ -139,24 +139,9 @@ fn build_identifiers_section() -> String { out } -fn build_tasks_section(workspace_dir: &Path) -> String { - use std::fmt::Write; - let tasks = match super::store::with_connection(workspace_dir, |conn| { - super::store::list_tasks(conn, false) - }) { - Ok(tasks) => tasks, - Err(_) => return "## Pending Tasks\n\nFailed to read tasks.\n".to_string(), - }; - - if tasks.is_empty() { - return "## Pending Tasks\n\nNo tasks defined.\n".to_string(); - } +fn build_tasks_section(_workspace_dir: &Path) -> String { + String::new() - let mut section = String::from("## Pending Tasks\n\n"); - for task in &tasks { - let _ = writeln!(section, "- {}", task.title); - } - section } /// Append a section, truncating at a UTF-8 char boundary if it overflows diff --git a/src/openhuman/subconscious/store.rs b/src/openhuman/subconscious/store.rs index 0e2a850ae1..9eeb19b96d 100644 --- a/src/openhuman/subconscious/store.rs +++ b/src/openhuman/subconscious/store.rs @@ -1,4 +1,4 @@ -//! SQLite persistence for subconscious tasks, execution log, and escalations. +//! SQLite persistence for the subconscious engine. //! //! Follows the cron module's `with_connection` pattern: opens the database, //! runs DDL on every connection, and provides pure functions. @@ -6,65 +6,19 @@ //! ## Init-failure noise suppression (TAURI-RUST-A) //! //! `with_connection` runs the schema DDL on every call. Transient -//! `SQLITE_BUSY` / `SQLITE_LOCKED` errors (e.g. concurrent in-process RPC -//! calls, antivirus hold, network-drive WAL rejection) are handled by a -//! per-connection busy timeout (5 s) plus an application-level retry loop -//! (3 retries, 100 / 300 / 900 ms backoff). Only `DatabaseBusy` / -//! `DatabaseLocked` errors are retried — schema or corruption errors fail -//! through immediately so Sentry captures a real root cause rather than a -//! transient noise event. +//! `SQLITE_BUSY` / `SQLITE_LOCKED` errors are handled by a per-connection +//! busy timeout (5 s) plus an application-level retry loop (3 retries, +//! 100 / 300 / 900 ms backoff). use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use std::path::Path; use std::time::Duration; -use uuid::Uuid; -use super::types::{ - Escalation, EscalationPriority, EscalationStatus, SubconsciousLogEntry, SubconsciousTask, - TaskPatch, TaskRecurrence, TaskSource, -}; - -/// Per-connection busy handler window. Tracks the value used by the cron -/// module + other domain stores (`memory_store::unified::init`, 15s) and -/// the higher-throughput whatsapp/memory_queue path (5s). 5 s is enough -/// for the subconscious tick — RPC handlers are user-driven (status -/// polling at 3 s, manual triggers) and we'd rather fail fast than block -/// the UI thread for the full 15 s of contention. const BUSY_TIMEOUT: Duration = Duration::from_millis(5000); - -/// Maximum number of application-level retries after rusqlite's busy -/// handler is exhausted. The first attempt is "attempt 0" — total -/// attempts = `OPEN_RETRY_ATTEMPTS` + 1. const OPEN_RETRY_ATTEMPTS: u32 = 3; - -/// Base backoff for application-level retries; per-attempt sleep is -/// `BASE * 3^attempt` so the schedule is `100 ms / 300 ms / 900 ms` -/// totalling ≤ 1.3 s before the final attempt fails through. const OPEN_RETRY_BASE_MS: u64 = 100; -/// Open the subconscious database and run schema migrations. -/// -/// Three layers of defence against transient `SQLITE_BUSY` / `SQLITE_LOCKED` -/// at the open / DDL boundary, motivated by Sentry TAURI-RUST-A -/// (cross-platform, ~1.3k events / 24 h, RPC paths `subconscious_tasks_list` -/// and `subconscious_status`): -/// -/// 1. **Per-connection busy timeout** (`BUSY_TIMEOUT`, 5 s): SQLite's -/// default is `0` — first lock contention returns `SQLITE_BUSY` -/// immediately. The subconscious domain serialises several RPCs -/// (status poll every 3 s, tasks-list on Intelligence page, manual -/// trigger), each opening its own connection; without a timeout the -/// first concurrent open races and one returns `SQLITE_BUSY` mid-DDL. -/// 2. **Application-level retry** (3 attempts, exponential backoff -/// 100 / 300 / 900 ms): catches the residual case where the busy -/// handler is exhausted (long-running external write txn, AV scan -/// holding the file). Mirrors `whatsapp_data::sqlite_retry` / -/// `memory_queue::worker::is_sqlite_busy`. -/// 3. **Retry classifier** (`is_sqlite_busy`): only retries -/// `DatabaseBusy` / `DatabaseLocked`. Schema / syntax / corruption -/// errors are real bugs or unrecoverable file-state failures — -/// retrying just delays the report. pub fn with_connection( workspace_dir: &Path, f: impl FnOnce(&Connection) -> Result, @@ -78,11 +32,6 @@ pub fn with_connection( f(&conn) } -/// Open the SQLite file, set `busy_timeout`, run `SCHEMA_DDL`, and apply -/// the idempotent reflection-store migrations — retrying the whole -/// sequence on `SQLITE_BUSY` / `SQLITE_LOCKED`. Split out so the retry -/// loop has a single failure surface to classify and the happy path -/// stays linear. fn open_and_initialize_with_retry(db_path: &Path) -> Result { let mut last_err: Option = None; @@ -124,43 +73,23 @@ fn open_and_initialize_with_retry(db_path: &Path) -> Result { Err(last_err.expect("OPEN_RETRY_ATTEMPTS >= 0 ensures at least one attempt")) } -/// Single-shot open + DDL + migrations. Each invocation returns an -/// owned `Connection`; on failure the partially-initialised connection -/// is dropped before the caller retries. fn open_and_initialize(db_path: &Path) -> Result { let conn = Connection::open(db_path) .with_context(|| format!("failed to open subconscious DB: {}", db_path.display()))?; - // Set busy_timeout BEFORE running DDL — the very first PRAGMA / CREATE - // TABLE in SCHEMA_DDL can race with another in-process connection - // (subconscious RPCs each call `with_connection` independently), and - // SQLite's default busy_timeout is 0. conn.busy_timeout(BUSY_TIMEOUT) .context("configure subconscious busy_timeout")?; conn.execute_batch(SCHEMA_DDL) .context("failed to run subconscious schema DDL")?; - // Drop the legacy `disposition` / `surfaced_at` columns + their index - // from previously-migrated DBs. Idempotent — fresh installs and - // already-migrated DBs no-op via swallowed errors. super::reflection_store::migrate_drop_legacy_columns(&conn); - - // Add the `source_chunks` JSON column to previously-migrated DBs. - // Idempotent (duplicate-column errors swallowed). super::reflection_store::migrate_add_source_chunks_column(&conn); + super::reflection_store::migrate_add_thread_id_column(&conn); Ok(conn) } -/// Returns true when `err` is transient SQLite contention worth retrying -/// (`SQLITE_BUSY` / `SQLITE_LOCKED`). Schema / syntax / corruption errors -/// are NOT retried — the retry would just delay the same failure. -/// -/// Modelled on [`crate::openhuman::memory_queue::worker::is_sqlite_busy`] -/// and [`crate::openhuman::whatsapp_data::sqlite_retry::is_sqlite_busy`]; -/// kept private to the subconscious store so the retry policy can evolve -/// independently of those sibling domains. fn is_sqlite_busy(err: &anyhow::Error) -> bool { if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) = err.downcast_ref::() @@ -170,11 +99,6 @@ fn is_sqlite_busy(err: &anyhow::Error) -> bool { rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked ); } - // Fallback for errors wrapped under `.context(...)` layers — the - // rusqlite root may sit a few levels deep after `with_context` - // wraps the open / DDL failure. anyhow's alternate Display joins - // every cause with ": " so the SQLite-rendered phrase remains - // searchable. let msg = format!("{err:#}").to_ascii_lowercase(); msg.contains("database is locked") || msg.contains("database table is locked") } @@ -183,6 +107,8 @@ const SCHEMA_DDL: &str = " PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; + -- Legacy tables retained for backward compatibility with existing DBs. + -- No longer written to or read from. CREATE TABLE IF NOT EXISTS subconscious_tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, @@ -194,11 +120,6 @@ const SCHEMA_DDL: &str = " completed INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_tasks_next_run - ON subconscious_tasks(next_run_at); - CREATE INDEX IF NOT EXISTS idx_tasks_enabled - ON subconscious_tasks(enabled, completed); - CREATE TABLE IF NOT EXISTS subconscious_log ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, @@ -208,11 +129,6 @@ const SCHEMA_DDL: &str = " duration_ms INTEGER, created_at REAL NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_log_task - ON subconscious_log(task_id, tick_at DESC); - CREATE INDEX IF NOT EXISTS idx_log_tick - ON subconscious_log(tick_at DESC); - CREATE TABLE IF NOT EXISTS subconscious_escalations ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, @@ -224,18 +140,7 @@ const SCHEMA_DDL: &str = " created_at REAL NOT NULL, resolved_at REAL ); - CREATE INDEX IF NOT EXISTS idx_escalations_status - ON subconscious_escalations(status); - -- #623: reflection layer (proactive subconscious). Mirrored in - -- `super::reflection_store::REFLECTION_SCHEMA_DDL` for the unit - -- tests there. Legacy `disposition` / `surfaced_at` columns + - -- their index were removed when the auto-post-into-thread flow - -- was dropped — `migrate_drop_legacy_columns` cleans them off - -- previously-migrated DBs. The `source_chunks` JSON column was - -- added later for the memory-context snapshot feature — - -- `migrate_add_source_chunks_column` backfills previously-migrated - -- DBs that pre-date it. CREATE TABLE IF NOT EXISTS subconscious_reflections ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, @@ -245,7 +150,8 @@ const SCHEMA_DDL: &str = " source_chunks TEXT NOT NULL DEFAULT '[]', created_at REAL NOT NULL, acted_on_at REAL, - dismissed_at REAL + dismissed_at REAL, + thread_id TEXT ); CREATE INDEX IF NOT EXISTS idx_reflections_created ON subconscious_reflections(created_at DESC); @@ -256,495 +162,19 @@ const SCHEMA_DDL: &str = " captured_at REAL NOT NULL ); - -- Tiny KV table for engine-local state that needs to survive - -- process restarts. Currently holds: - -- * `last_tick_at` — unix-seconds float of the most recent - -- successful tick. Used by the situation- - -- report sections (`summaries`, `query_window`, - -- `digest`) as a `WHERE sealed_at_ms > ?` cutoff - -- so the LLM only sees memory-tree rows that - -- have appeared since it last looked. Without - -- persistence the cutoff resets to 0 on every - -- restart, the LLM keeps reading the same - -- summaries, and `persist_and_surface_reflections` - -- (which has no insert-time dedupe) accumulates - -- near-duplicate reflections about the same - -- chunks (#623). CREATE TABLE IF NOT EXISTS subconscious_state ( key TEXT PRIMARY KEY, value REAL NOT NULL ); "; -/// Test-only re-export of [`SCHEMA_DDL`] for unit tests in sibling -/// modules (e.g. `reflection_store_tests`) that need to spin up an -/// in-memory connection with the full schema. #[cfg(test)] pub(crate) const SCHEMA_DDL_FOR_TESTS: &str = SCHEMA_DDL; -// ── Task CRUD ──────────────────────────────────────────────────────────────── - -pub fn add_task( - conn: &Connection, - title: &str, - source: TaskSource, - recurrence: TaskRecurrence, -) -> Result { - let id = Uuid::new_v4().to_string(); - let now = now_secs(); - let source_str = serde_json::to_value(&source) - .unwrap_or_default() - .as_str() - .unwrap_or("user") - .to_string(); - let recurrence_str = recurrence_to_string(&recurrence); - - conn.execute( - "INSERT INTO subconscious_tasks (id, title, source, recurrence, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![id, title, source_str, recurrence_str, now], - )?; - - Ok(SubconsciousTask { - id, - title: title.to_string(), - source, - recurrence, - enabled: true, - last_run_at: None, - next_run_at: None, - completed: false, - created_at: now, - }) -} - -pub fn get_task(conn: &Connection, task_id: &str) -> Result { - conn.query_row( - "SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at - FROM subconscious_tasks WHERE id = ?1", - [task_id], - row_to_task, - ) - .with_context(|| format!("task not found: {task_id}")) -} - -pub fn list_tasks(conn: &Connection, enabled_only: bool) -> Result> { - let sql = if enabled_only { - "SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at - FROM subconscious_tasks WHERE enabled = 1 ORDER BY created_at" - } else { - "SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at - FROM subconscious_tasks ORDER BY created_at" - }; - let mut stmt = conn.prepare(sql)?; - let tasks = stmt - .query_map([], row_to_task)? - .collect::, _>>()?; - Ok(tasks) -} - -pub fn update_task(conn: &Connection, task_id: &str, patch: &TaskPatch) -> Result<()> { - if let Some(ref title) = patch.title { - conn.execute( - "UPDATE subconscious_tasks SET title = ?1 WHERE id = ?2", - rusqlite::params![title, task_id], - )?; - } - if let Some(ref recurrence) = patch.recurrence { - conn.execute( - "UPDATE subconscious_tasks SET recurrence = ?1 WHERE id = ?2", - rusqlite::params![recurrence_to_string(recurrence), task_id], - )?; - } - if let Some(enabled) = patch.enabled { - conn.execute( - "UPDATE subconscious_tasks SET enabled = ?1 WHERE id = ?2", - rusqlite::params![enabled, task_id], - )?; - } - Ok(()) -} - -/// Remove a task. System tasks cannot be deleted — only disabled. -pub fn remove_task(conn: &Connection, task_id: &str) -> Result<()> { - let source: String = conn - .query_row( - "SELECT source FROM subconscious_tasks WHERE id = ?1", - [task_id], - |row| row.get(0), - ) - .with_context(|| format!("task not found: {task_id}"))?; - - if source == "system" { - anyhow::bail!("System tasks cannot be deleted. Disable them instead."); - } - - conn.execute("DELETE FROM subconscious_tasks WHERE id = ?1", [task_id])?; - Ok(()) -} - -/// Get tasks that are due for evaluation (enabled, not completed, due now or never run). -pub fn due_tasks(conn: &Connection, now: f64) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at - FROM subconscious_tasks - WHERE enabled = 1 AND completed = 0 - AND (next_run_at IS NULL OR next_run_at <= ?1) - ORDER BY next_run_at NULLS FIRST", - )?; - let tasks = stmt - .query_map([now], row_to_task)? - .collect::, _>>()?; - Ok(tasks) -} - -pub fn mark_task_completed(conn: &Connection, task_id: &str) -> Result<()> { - conn.execute( - "UPDATE subconscious_tasks SET completed = 1 WHERE id = ?1", - [task_id], - )?; - Ok(()) -} - -pub fn update_task_run_times( - conn: &Connection, - task_id: &str, - last_run_at: f64, - next_run_at: Option, -) -> Result<()> { - conn.execute( - "UPDATE subconscious_tasks SET last_run_at = ?1, next_run_at = ?2 WHERE id = ?3", - rusqlite::params![last_run_at, next_run_at, task_id], - )?; - Ok(()) -} - -pub fn task_count(conn: &Connection) -> Result { - conn.query_row( - "SELECT COUNT(*) FROM subconscious_tasks WHERE enabled = 1 AND completed = 0", - [], - |row| row.get::<_, u64>(0), - ) - .map_err(Into::into) -} - -// ── Log CRUD ───────────────────────────────────────────────────────────────── - -pub fn add_log_entry( - conn: &Connection, - task_id: &str, - tick_at: f64, - decision: &str, - result: Option<&str>, - duration_ms: Option, -) -> Result { - let id = Uuid::new_v4().to_string(); - let now = now_secs(); - conn.execute( - "INSERT INTO subconscious_log (id, task_id, tick_at, decision, result, duration_ms, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - rusqlite::params![id, task_id, tick_at, decision, result, duration_ms, now], - )?; - Ok(SubconsciousLogEntry { - id, - task_id: task_id.to_string(), - tick_at, - decision: decision.to_string(), - result: result.map(String::from), - duration_ms, - created_at: now, - }) -} - -/// Update an existing log entry's decision, result, and duration in place. -pub fn update_log_entry( - conn: &Connection, - log_id: &str, - decision: &str, - result: Option<&str>, - duration_ms: Option, -) -> Result<()> { - conn.execute( - "UPDATE subconscious_log SET decision = ?1, result = ?2, duration_ms = ?3 WHERE id = ?4", - rusqlite::params![decision, result, duration_ms, log_id], - )?; - Ok(()) -} - -/// Bulk-update ALL in_progress log entries to cancelled. -/// Any entry still in_progress when a new tick starts is stale by definition. -pub fn cancel_stale_in_progress(conn: &Connection) -> Result { - let count = conn.execute( - "UPDATE subconscious_log SET decision = 'cancelled', result = 'Superseded by new tick' - WHERE decision = 'in_progress'", - [], - )?; - Ok(count) -} - -pub fn list_log_entries( - conn: &Connection, - task_id: Option<&str>, - limit: usize, -) -> Result> { - let (sql, params): (&str, Vec>) = if let Some(tid) = task_id { - ( - "SELECT id, task_id, tick_at, decision, result, duration_ms, created_at - FROM subconscious_log WHERE task_id = ?1 ORDER BY tick_at DESC LIMIT ?2", - vec![Box::new(tid.to_string()), Box::new(limit as i64)], - ) - } else { - ( - "SELECT id, task_id, tick_at, decision, result, duration_ms, created_at - FROM subconscious_log ORDER BY tick_at DESC LIMIT ?1", - vec![Box::new(limit as i64)], - ) - }; - let mut stmt = conn.prepare(sql)?; - let entries = stmt - .query_map(rusqlite::params_from_iter(params.iter()), |row| { - Ok(SubconsciousLogEntry { - id: row.get(0)?, - task_id: row.get(1)?, - tick_at: row.get(2)?, - decision: row.get(3)?, - result: row.get(4)?, - duration_ms: row.get(5)?, - created_at: row.get(6)?, - }) - })? - .collect::, _>>()?; - Ok(entries) -} - -// ── Escalation CRUD ────────────────────────────────────────────────────────── - -pub fn add_escalation( - conn: &Connection, - task_id: &str, - log_id: Option<&str>, - title: &str, - description: &str, - priority: &EscalationPriority, -) -> Result { - let id = Uuid::new_v4().to_string(); - let now = now_secs(); - let priority_str = serde_json::to_value(priority) - .unwrap_or_default() - .as_str() - .unwrap_or("normal") - .to_string(); - conn.execute( - "INSERT INTO subconscious_escalations (id, task_id, log_id, title, description, priority, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - rusqlite::params![id, task_id, log_id, title, description, priority_str, now], - )?; - Ok(Escalation { - id, - task_id: task_id.to_string(), - log_id: log_id.map(String::from), - title: title.to_string(), - description: description.to_string(), - priority: priority.clone(), - status: EscalationStatus::Pending, - created_at: now, - resolved_at: None, - }) -} - -pub fn list_escalations( - conn: &Connection, - status_filter: Option<&EscalationStatus>, -) -> Result> { - let (sql, params): (&str, Vec>) = if let Some(status) = - status_filter - { - let status_str = serde_json::to_value(status) - .unwrap_or_default() - .as_str() - .unwrap_or("pending") - .to_string(); - ( - "SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at - FROM subconscious_escalations WHERE status = ?1 ORDER BY created_at DESC", - vec![Box::new(status_str)], - ) - } else { - ( - "SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at - FROM subconscious_escalations ORDER BY created_at DESC", - vec![], - ) - }; - let mut stmt = conn.prepare(sql)?; - let rows = stmt - .query_map(rusqlite::params_from_iter(params.iter()), row_to_escalation)? - .collect::, _>>()?; - Ok(rows) -} - -pub fn resolve_escalation( - conn: &Connection, - escalation_id: &str, - status: &EscalationStatus, -) -> Result<()> { - let now = now_secs(); - let status_str = serde_json::to_value(status) - .unwrap_or_default() - .as_str() - .unwrap_or("dismissed") - .to_string(); - conn.execute( - "UPDATE subconscious_escalations SET status = ?1, resolved_at = ?2 WHERE id = ?3", - rusqlite::params![status_str, now, escalation_id], - )?; - Ok(()) -} - -pub fn pending_escalation_count(conn: &Connection) -> Result { - conn.query_row( - "SELECT COUNT(*) FROM subconscious_escalations WHERE status = 'pending'", - [], - |row| row.get::<_, u64>(0), - ) - .map_err(Into::into) -} - -pub fn get_escalation(conn: &Connection, escalation_id: &str) -> Result { - conn.query_row( - "SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at - FROM subconscious_escalations WHERE id = ?1", - [escalation_id], - row_to_escalation, - ) - .with_context(|| format!("escalation not found: {escalation_id}")) -} - -// ── Seed default system tasks ──────────────────────────────────────────────── - -/// Default system tasks that are always seeded and cannot be deleted. -const DEFAULT_SYSTEM_TASKS: &[&str] = &[ - "Check connected skills for errors or disconnections", - "Review new memory updates for actionable items", - "Monitor system health (Ollama, memory, connections)", -]; - -/// Seed default system tasks into SQLite. -/// Skips tasks whose title already exists. Returns the count of newly created tasks. -pub fn seed_default_tasks(conn: &Connection) -> Result { - let mut count = 0; - - for title in DEFAULT_SYSTEM_TASKS { - if !task_title_exists(conn, title)? { - add_task(conn, title, TaskSource::System, TaskRecurrence::Pending)?; - count += 1; - } - } - - Ok(count) -} - -fn task_title_exists(conn: &Connection, title: &str) -> Result { - Ok(conn.query_row( - "SELECT EXISTS(SELECT 1 FROM subconscious_tasks WHERE title = ?1)", - [title], - |row| row.get(0), - )?) -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result { - let source_str: String = row.get(2)?; - let recurrence_str: String = row.get(3)?; - Ok(SubconsciousTask { - id: row.get(0)?, - title: row.get(1)?, - source: string_to_source(&source_str), - recurrence: string_to_recurrence(&recurrence_str), - enabled: row.get::<_, bool>(4)?, - last_run_at: row.get(5)?, - next_run_at: row.get(6)?, - completed: row.get::<_, bool>(7)?, - created_at: row.get(8)?, - }) -} - -fn row_to_escalation(row: &rusqlite::Row) -> rusqlite::Result { - let priority_str: String = row.get(5)?; - let status_str: String = row.get(6)?; - Ok(Escalation { - id: row.get(0)?, - task_id: row.get(1)?, - log_id: row.get(2)?, - title: row.get(3)?, - description: row.get(4)?, - priority: string_to_priority(&priority_str), - status: string_to_status(&status_str), - created_at: row.get(7)?, - resolved_at: row.get(8)?, - }) -} - -fn recurrence_to_string(r: &TaskRecurrence) -> String { - match r { - TaskRecurrence::Once => "once".to_string(), - TaskRecurrence::Cron(expr) => format!("cron:{expr}"), - TaskRecurrence::Pending => "pending".to_string(), - } -} - -fn string_to_recurrence(s: &str) -> TaskRecurrence { - if s == "once" { - TaskRecurrence::Once - } else if let Some(expr) = s.strip_prefix("cron:") { - TaskRecurrence::Cron(expr.to_string()) - } else { - TaskRecurrence::Pending - } -} - -fn string_to_source(s: &str) -> TaskSource { - match s { - "system" => TaskSource::System, - _ => TaskSource::User, - } -} - -fn string_to_priority(s: &str) -> EscalationPriority { - match s { - "critical" => EscalationPriority::Critical, - "important" => EscalationPriority::Important, - _ => EscalationPriority::Normal, - } -} - -fn string_to_status(s: &str) -> EscalationStatus { - match s { - "approved" => EscalationStatus::Approved, - "dismissed" => EscalationStatus::Dismissed, - _ => EscalationStatus::Pending, - } -} - -fn now_secs() -> f64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs_f64()) - .unwrap_or(0.0) -} - // ── Engine state KV ────────────────────────────────────────────────────────── -/// SQLite key for the most recent successful tick, in unix seconds. -/// Loaded by [`SubconsciousEngine::from_heartbeat_config`] on init and -/// updated after every successful tick. See `subconscious_state` table -/// docstring in [`SCHEMA_DDL`] for the dedupe rationale. const STATE_KEY_LAST_TICK_AT: &str = "last_tick_at"; -/// Read the persisted `last_tick_at` from `subconscious_state`. Returns -/// `0.0` when the row is absent (cold start or fresh workspace) so the -/// caller can treat "never ticked" identically to "first run". pub fn get_last_tick_at(conn: &Connection) -> Result { let value: Option = conn .query_row( @@ -756,9 +186,6 @@ pub fn get_last_tick_at(conn: &Connection) -> Result { Ok(value.unwrap_or(0.0)) } -/// Persist `last_tick_at` so the next process restart picks up where -/// this run left off. Upsert via `INSERT OR REPLACE` — the table is one -/// row per key, so collisions are the expected case. pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> { conn.execute( "INSERT OR REPLACE INTO subconscious_state (key, value) VALUES (?1, ?2)", @@ -767,22 +194,11 @@ pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> { Ok(()) } -/// Compute the next run time for a cron expression. -/// Normalizes 5-field cron to 6-field (prepends seconds=0) for the `cron` crate. -pub fn compute_next_run(cron_expr: &str) -> Option { - let normalized = normalize_cron_expr(cron_expr); - let schedule = normalized.parse::().ok()?; - let next = schedule.upcoming(chrono::Utc).next()?; - Some(next.timestamp() as f64) -} - -fn normalize_cron_expr(expr: &str) -> String { - let fields: Vec<&str> = expr.split_whitespace().collect(); - if fields.len() == 5 { - format!("0 {expr}") - } else { - expr.to_string() - } +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) } #[cfg(test)] diff --git a/src/openhuman/subconscious/store_tests.rs b/src/openhuman/subconscious/store_tests.rs index 6948c8fa92..01a1a535f9 100644 --- a/src/openhuman/subconscious/store_tests.rs +++ b/src/openhuman/subconscious/store_tests.rs @@ -7,335 +7,30 @@ fn test_conn() -> Connection { } #[test] -fn crud_tasks() { +fn last_tick_at_round_trip() { let conn = test_conn(); - let task = add_task(&conn, "Check email", TaskSource::User, TaskRecurrence::Once).unwrap(); - assert_eq!(task.title, "Check email"); - assert!(!task.completed); - - let fetched = get_task(&conn, &task.id).unwrap(); - assert_eq!(fetched.title, "Check email"); - - let all = list_tasks(&conn, false).unwrap(); - assert_eq!(all.len(), 1); - - update_task( - &conn, - &task.id, - &TaskPatch { - title: Some("Check Gmail".into()), - ..Default::default() - }, - ) - .unwrap(); - let updated = get_task(&conn, &task.id).unwrap(); - assert_eq!(updated.title, "Check Gmail"); - - mark_task_completed(&conn, &task.id).unwrap(); - let done = get_task(&conn, &task.id).unwrap(); - assert!(done.completed); - - remove_task(&conn, &task.id).unwrap(); - assert!(get_task(&conn, &task.id).is_err()); -} - -#[test] -fn due_tasks_filters_correctly() { - let conn = test_conn(); - let now = now_secs(); - - // Task with no next_run_at — should be due - add_task( - &conn, - "No schedule", - TaskSource::User, - TaskRecurrence::Pending, - ) - .unwrap(); - - // Task with future next_run_at — should NOT be due - let future_task = - add_task(&conn, "Future task", TaskSource::User, TaskRecurrence::Once).unwrap(); - update_task_run_times(&conn, &future_task.id, now, Some(now + 3600.0)).unwrap(); - - // Task with past next_run_at — should be due - let past_task = add_task(&conn, "Past due", TaskSource::User, TaskRecurrence::Once).unwrap(); - update_task_run_times(&conn, &past_task.id, now - 7200.0, Some(now - 3600.0)).unwrap(); - - let due = due_tasks(&conn, now).unwrap(); - assert_eq!(due.len(), 2); // "No schedule" + "Past due" - assert!(due.iter().any(|t| t.title == "No schedule")); - assert!(due.iter().any(|t| t.title == "Past due")); - assert!(!due.iter().any(|t| t.title == "Future task")); + assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0); + set_last_tick_at(&conn, 12345.678).unwrap(); + assert_eq!(get_last_tick_at(&conn).unwrap(), 12345.678); } #[test] -fn crud_log_entries() { +fn last_tick_at_upsert() { let conn = test_conn(); - let task = add_task(&conn, "Test", TaskSource::User, TaskRecurrence::Once).unwrap(); - let now = now_secs(); - - let entry = add_log_entry( - &conn, - &task.id, - now, - "act", - Some("Did the thing"), - Some(150), - ) - .unwrap(); - assert_eq!(entry.decision, "act"); - - let entries = list_log_entries(&conn, Some(&task.id), 10).unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].result.as_deref(), Some("Did the thing")); - - let all_entries = list_log_entries(&conn, None, 10).unwrap(); - assert_eq!(all_entries.len(), 1); + set_last_tick_at(&conn, 1.0).unwrap(); + set_last_tick_at(&conn, 2.0).unwrap(); + assert_eq!(get_last_tick_at(&conn).unwrap(), 2.0); } #[test] -fn crud_escalations() { +fn schema_ddl_creates_tables() { let conn = test_conn(); - let task = add_task(&conn, "Test", TaskSource::User, TaskRecurrence::Once).unwrap(); - - let esc = add_escalation( - &conn, - &task.id, - None, - "Deadline moved", - "The API deadline was moved to tomorrow", - &EscalationPriority::Critical, - ) - .unwrap(); - assert_eq!(esc.status, EscalationStatus::Pending); - - let pending = list_escalations(&conn, Some(&EscalationStatus::Pending)).unwrap(); - assert_eq!(pending.len(), 1); - - assert_eq!(pending_escalation_count(&conn).unwrap(), 1); - - resolve_escalation(&conn, &esc.id, &EscalationStatus::Approved).unwrap(); - let resolved = get_escalation(&conn, &esc.id).unwrap(); - assert_eq!(resolved.status, EscalationStatus::Approved); - assert!(resolved.resolved_at.is_some()); - - assert_eq!(pending_escalation_count(&conn).unwrap(), 0); -} - -#[test] -fn seed_default_tasks_creates_system_tasks() { - let conn = test_conn(); - - let count = seed_default_tasks(&conn).unwrap(); - assert_eq!(count, DEFAULT_SYSTEM_TASKS.len()); - - // Second seed should not duplicate - let count2 = seed_default_tasks(&conn).unwrap(); - assert_eq!(count2, 0); - - let tasks = list_tasks(&conn, false).unwrap(); - assert_eq!(tasks.len(), DEFAULT_SYSTEM_TASKS.len()); - assert!(tasks.iter().all(|t| t.source == TaskSource::System)); -} - -#[test] -fn recurrence_roundtrip() { - assert_eq!( - string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Once)), - TaskRecurrence::Once - ); - assert_eq!( - string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Pending)), - TaskRecurrence::Pending - ); - assert_eq!( - string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Cron( - "0 8 * * *".into() - ))), - TaskRecurrence::Cron("0 8 * * *".into()) - ); -} - -// ── DDL resilience: classifier + retry happy path ────────────── -// -// These guards back Sentry TAURI-RUST-A: the production failure is -// `Connection::open` + `execute_batch(SCHEMA_DDL)` racing against -// another in-process connection that holds the write lock. With -// `BUSY_TIMEOUT` set and the application-level retry loop in place, -// the race resolves on its own; without them the first attempt -// returns `SQLITE_BUSY` and the user sees "failed to run subconscious -// schema DDL" in Sentry with no further context. - -#[test] -fn is_sqlite_busy_matches_database_busy_code() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseBusy, - extended_code: 5, // SQLITE_BUSY - }, - Some("database is locked".into()), - ); - let err = anyhow::Error::from(raw); - assert!(is_sqlite_busy(&err)); -} - -#[test] -fn is_sqlite_busy_matches_database_locked_code() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseLocked, - extended_code: 6, // SQLITE_LOCKED - }, - Some("database table is locked".into()), - ); - let err = anyhow::Error::from(raw); - assert!(is_sqlite_busy(&err)); -} - -#[test] -fn is_sqlite_busy_does_not_match_constraint_violation() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::ConstraintViolation, - extended_code: 19, - }, - Some("UNIQUE constraint failed".into()), - ); - let err = anyhow::Error::from(raw); - assert!(!is_sqlite_busy(&err)); -} - -#[test] -fn is_sqlite_busy_does_not_match_schema_syntax_error() { - // A genuine bug in `SCHEMA_DDL` (e.g. typo in CREATE TABLE) would - // surface as a `SqliteFailure(Unknown, ...)` with "syntax error" - // in the message — retrying just delays the same failure, so the - // classifier must reject it. Use Unknown + non-busy message. - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::Unknown, - extended_code: 1, - }, - Some("near \"FOO\": syntax error".into()), - ); - let err = anyhow::Error::from(raw); - assert!(!is_sqlite_busy(&err)); -} - -#[test] -fn is_sqlite_busy_matches_through_context_layers() { - // The production failure shape: a rusqlite error wrapped under - // `with_context("failed to run subconscious schema DDL")` — - // exactly what `open_and_initialize` produces. Downcast must - // still find the rusqlite root. - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseBusy, - extended_code: 5, - }, - Some("database is locked".into()), - ); - let wrapped: anyhow::Result<()> = Err(anyhow::Error::from(raw)) - .with_context(|| "failed to run subconscious schema DDL".to_string()); - let err = wrapped.unwrap_err(); - assert!(is_sqlite_busy(&err)); -} - -#[test] -fn is_sqlite_busy_text_fallback_when_downcast_misses() { - // If a future refactor stringifies the rusqlite error before - // wrapping (e.g. via anyhow!("{e}")), the downcast misses but - // the chain-formatter text still preserves "database is locked". - let err = anyhow::anyhow!("failed to run subconscious schema DDL: database is locked"); - assert!(is_sqlite_busy(&err)); -} - -#[test] -fn with_connection_resolves_external_write_contention() { - use std::sync::mpsc; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - - // First call: prime the DB so the file exists and the schema is - // initialized. Subsequent calls take the fast path. - with_connection(&workspace, |conn| { - add_task(conn, "primer", TaskSource::User, TaskRecurrence::Once)?; - Ok(()) - }) - .expect("prime DB"); - - // Hold an EXCLUSIVE write lock for ~250 ms in a side thread. - // The DDL loop in `open_and_initialize` re-runs PRAGMA journal_mode - // and CREATE TABLE IF NOT EXISTS — both are no-ops on an already - // initialized DB but still acquire the write lock briefly, which - // races against the held lock. The application-level retry - // (100 / 300 / 900 ms) plus the 5 s `busy_timeout` must absorb - // this and let the second `with_connection` succeed. - let db_path = workspace.join("subconscious").join("subconscious.db"); - let (lock_ready_tx, lock_ready_rx) = mpsc::channel::<()>(); - let (release_tx, release_rx) = mpsc::channel::<()>(); - let blocker = std::thread::spawn(move || { - let conn = rusqlite::Connection::open(&db_path).expect("open blocker conn"); - conn.busy_timeout(std::time::Duration::from_millis(100)) - .expect("blocker busy_timeout"); - let tx = conn - .unchecked_transaction() - .expect("begin blocker transaction"); - // Force write-lock acquisition immediately. - tx.execute( - "INSERT INTO subconscious_tasks \ - (id, title, source, recurrence, created_at) \ - VALUES ('blocker', 'blocker', 'user', 'pending', 0.0)", + let count: i32 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'subconscious_%'", [], + |row| row.get(0), ) - .expect("blocker insert"); - lock_ready_tx.send(()).expect("signal lock acquired"); - // Wait for the main thread to start contending, then a touch - // longer so the first one or two retries collide. - release_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("release signal"); - std::thread::sleep(std::time::Duration::from_millis(50)); - tx.rollback().expect("rollback blocker txn"); - }); - - // Wait until the blocker actually holds the write lock. - lock_ready_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("blocker never acquired lock"); - - // Contender: should retry through the busy window and succeed - // once the blocker rolls back. We release the blocker after - // ~250 ms so the second / third retry attempt lands in the - // unlocked window. - let release_tx_for_timer = release_tx.clone(); - let timer = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(250)); - let _ = release_tx_for_timer.send(()); - }); - - let result = with_connection(&workspace, |conn| { - // Confirm we can issue a real query through the contended - // connection — proves the open + DDL completed cleanly. - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM subconscious_tasks", [], |row| { - row.get(0) - }) - .unwrap_or(-1); - Ok(count) - }); - - timer.join().expect("timer thread panicked"); - blocker.join().expect("blocker thread panicked"); - - let count = result.expect("contended with_connection must succeed via retry"); - // Primer row is "primer"; blocker's INSERT was rolled back, so - // the count should be exactly 1. - assert_eq!( - count, 1, - "expected only the primer row after blocker rollback, got {count}" - ); + .unwrap(); + assert!(count >= 4); } diff --git a/src/openhuman/subconscious/types.rs b/src/openhuman/subconscious/types.rs index 9cac3c441c..6eebf79aa2 100644 --- a/src/openhuman/subconscious/types.rs +++ b/src/openhuman/subconscious/types.rs @@ -1,149 +1,7 @@ -//! Type definitions for the subconscious task execution system. +//! Type definitions for the subconscious agent loop. use serde::{Deserialize, Serialize}; -// ── Task types ─────────────────────────────────────────────────────────────── - -/// A task managed by the subconscious engine. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SubconsciousTask { - pub id: String, - pub title: String, - pub source: TaskSource, - pub recurrence: TaskRecurrence, - pub enabled: bool, - pub last_run_at: Option, - pub next_run_at: Option, - pub completed: bool, - pub created_at: f64, -} - -/// Where the task came from. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TaskSource { - /// Auto-populated by the system (skills health, Ollama status, etc.) - System, - /// Added by the user via UI or agent. - User, -} - -/// How often the task should run. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TaskRecurrence { - /// Execute once, then mark completed. - Once, - /// Recurrent on a cron schedule (5-field expression). - Cron(String), - /// Not yet classified — agent will decide on first tick. - Pending, -} - -/// Partial update for a task. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TaskPatch { - pub title: Option, - pub recurrence: Option, - pub enabled: Option, -} - -// ── Tick evaluation types ──────────────────────────────────────────────────── - -/// Per-tick decision for a single task. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TickDecision { - /// Nothing relevant in current state for this task. - #[default] - Noop, - /// State has something relevant — execute the task. - Act, - /// Ambiguous or risky — surface to user for approval. - Escalate, -} - -/// The local model's evaluation of a single task against the current state. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskEvaluation { - pub task_id: String, - pub decision: TickDecision, - pub reason: String, -} - -/// Full evaluation response from the per-tick LLM. -/// -/// `evaluations` covers the task-bound layer (act/escalate/noop per -/// existing task). `reflections` (#623) covers the proactive layer — -/// LLM-emitted observations grounded in memory-tree signals. The two -/// are independent: a tick may produce only one, the other, or both. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvaluationResponse { - pub evaluations: Vec, - /// Proactive-layer reflections (#623). Defaults to empty so older - /// LLM payloads remain forward-compatible. - #[serde(default)] - pub reflections: Vec, -} - -// ── Execution types ────────────────────────────────────────────────────────── - -/// Result of executing a single task. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExecutionResult { - pub output: String, - pub used_tools: bool, - pub duration_ms: u64, -} - -// ── Log types ──────────────────────────────────────────────────────────────── - -/// A single entry in the execution log. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SubconsciousLogEntry { - pub id: String, - pub task_id: String, - pub tick_at: f64, - pub decision: String, - pub result: Option, - pub duration_ms: Option, - pub created_at: f64, -} - -// ── Escalation types ───────────────────────────────────────────────────────── - -/// An escalation waiting for user input. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Escalation { - pub id: String, - pub task_id: String, - pub log_id: Option, - pub title: String, - pub description: String, - pub priority: EscalationPriority, - pub status: EscalationStatus, - pub created_at: f64, - pub resolved_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EscalationPriority { - Critical, - Important, - Normal, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EscalationStatus { - Pending, - Approved, - Dismissed, -} - -// ── Status types ───────────────────────────────────────────────────────────── - /// Summary of the subconscious engine status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubconsciousStatus { @@ -153,9 +11,6 @@ pub struct SubconsciousStatus { pub interval_minutes: u32, pub last_tick_at: Option, pub total_ticks: u64, - pub task_count: u64, - pub pending_escalations: u64, - /// Number of consecutive tick failures (resets on success). pub consecutive_failures: u64, } @@ -163,8 +18,7 @@ pub struct SubconsciousStatus { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TickResult { pub tick_at: f64, - pub evaluations: Vec, - pub executed: usize, - pub escalated: usize, + pub thoughts_count: usize, + pub thread_id: Option, pub duration_ms: u64, } From c1780f6d8d2de2e1720c9584117584a1cc0ee505 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 31 May 2026 01:09:27 -0700 Subject: [PATCH 02/20] refactor(ui): simplify subconscious tab for agent-per-tick model Strip task management, escalation, and activity log UI from the subconscious tab. The tab now shows: - Status bar with tick count, last tick time, interval selector - Run Now button to trigger a manual tick - Reflection/thought cards with a "View" button that navigates to the agent conversation thread Key changes: - tauriCommands/subconscious.ts: remove task/escalation types and functions, add thread_id to Reflection type - useSubconscious.ts: simplify to status + trigger only (reflections are self-contained in the cards component) - IntelligenceSubconsciousTab.tsx: remove task/escalation/log UI, simplify props to status + triggerTick + triggering - SubconsciousReflectionCards.tsx: add "View" button for reflections with thread_id - Intelligence.tsx: update to pass simplified props - Skills.tsx: remove subconsciousEscalationsDismiss reference - i18n: add reflections.viewConversation to all 14 locales --- .../IntelligenceSubconsciousTab.tsx | 416 +----------------- .../SubconsciousReflectionCards.tsx | 8 + .../IntelligenceSubconsciousTab.test.tsx | 39 +- .../SubconsciousReflectionCards.test.tsx | 1 + app/src/hooks/useSubconscious.ts | 155 +------ app/src/lib/i18n/ar.ts | 1 + app/src/lib/i18n/bn.ts | 1 + app/src/lib/i18n/de.ts | 1 + app/src/lib/i18n/en.ts | 1 + app/src/lib/i18n/es.ts | 1 + app/src/lib/i18n/fr.ts | 1 + app/src/lib/i18n/hi.ts | 1 + app/src/lib/i18n/id.ts | 1 + app/src/lib/i18n/it.ts | 1 + app/src/lib/i18n/ko.ts | 1 + app/src/lib/i18n/pl.ts | 1 + app/src/lib/i18n/pt.ts | 1 + app/src/lib/i18n/ru.ts | 1 + app/src/lib/i18n/zh-CN.ts | 1 + app/src/pages/Intelligence.tsx | 24 - app/src/pages/Skills.tsx | 5 +- .../Skills.composio-catalog.test.tsx | 1 - app/src/utils/tauriCommands/subconscious.ts | 172 +------- 23 files changed, 44 insertions(+), 791 deletions(-) diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx index 722087d812..e76846cd18 100644 --- a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -1,24 +1,11 @@ -import type { Dispatch, FormEvent, SetStateAction } from 'react'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; import { setSelectedThread } from '../../store/threadSlice'; -import type { - SubconsciousEscalation, - SubconsciousLogEntry, - SubconsciousStatus, - SubconsciousTask, -} from '../../utils/tauriCommands/subconscious'; +import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious'; import SubconsciousReflectionCards from './SubconsciousReflectionCards'; -const SKILL_KEYWORDS = - /\bskill\b|\boauth\b|\bnotion\b|\bgmail\b|\bintegration\b|\bdisconnect|\breconnect|\bre-?auth/i; - -function isSkillRelated(title: string, description: string): boolean { - return SKILL_KEYWORDS.test(title) || SKILL_KEYWORDS.test(description); -} - function formatInterval(minutes: number, t: (key: string) => string): string { switch (minutes) { case 5: @@ -42,58 +29,14 @@ function formatInterval(minutes: number, t: (key: string) => string): string { } } -function formatPriority(priority: string, t: (key: string) => string): string { - switch (priority) { - case 'critical': - return t('subconscious.priority.critical'); - case 'important': - return t('subconscious.priority.important'); - default: - return t('subconscious.priority.normal'); - } -} - -function formatDuration(durationMs: number, t: (key: string) => string): string { - if (durationMs > 1000) { - return t('subconscious.durationSeconds').replace('{seconds}', (durationMs / 1000).toFixed(1)); - } - return t('subconscious.durationMilliseconds').replace('{milliseconds}', String(durationMs)); -} - interface IntelligenceSubconsciousTabProps { - addSubconsciousTask: (title: string) => Promise; - approveEscalation: (escalationId: string) => Promise; - dismissEscalation: (escalationId: string) => Promise; - expandedLogIds: Set; - logEntries: SubconsciousLogEntry[]; - newTaskTitle: string; - removeSubconsciousTask: (taskId: string) => Promise; - setExpandedLogIds: Dispatch>>; - setNewTaskTitle: (value: string) => void; status: SubconsciousStatus | null; - tasks: SubconsciousTask[]; - toggleSubconsciousTask: (taskId: string, enabled: boolean) => Promise; triggerTick: () => Promise; triggering: boolean; - escalations: SubconsciousEscalation[]; - loading: boolean; } export default function IntelligenceSubconsciousTab({ - addSubconsciousTask, - approveEscalation, - dismissEscalation, - escalations, - expandedLogIds, - loading, - logEntries, - newTaskTitle, - removeSubconsciousTask, - setExpandedLogIds, - setNewTaskTitle, status, - tasks, - toggleSubconsciousTask, triggerTick, triggering, }: IntelligenceSubconsciousTabProps) { @@ -105,46 +48,12 @@ export default function IntelligenceSubconsciousTab({ ? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle')) : null; - // Reflection "Act" callback — sets the freshly-spawned thread as the - // selected one and navigates the user to the chat surface so they - // land in the new conversation. Reflections never write into existing - // threads (#623), so every act starts its own conversation. - // - // We dispatch `setSelectedThread` (NOT `setActiveThread`): the - // Conversations page reads `selectedThreadId` from the thread slice on - // mount and resumes that thread if present in the fetched list, - // falling back to the most recent thread otherwise. `activeThreadId` - // is a separate, runtime-only field used for in-flight chat-turn - // routing — setting it without `selectedThreadId` would not affect - // which thread the user lands on. - // - // Route is `/chat`, NOT `/conversations`. The repo's CLAUDE.md hash- - // route list is stale — `BottomTabBar` and `OpenhumanLinkModal` both - // navigate to `/chat`. Using `/conversations` falls through to a home - // redirect so the user ends up on `/home` instead of the new thread. - const handleNavigateToReflectionThread = (threadId: string) => { - console.debug('[subconscious-ui] reflection navigate:thread', { threadId }); + const handleNavigateToThread = (threadId: string) => { + console.debug('[subconscious-ui] navigate:thread', { threadId }); dispatch(setSelectedThread(threadId)); navigate('/chat'); }; - const handleAddTask = async (e: FormEvent) => { - e.preventDefault(); - const title = newTaskTitle.trim(); - if (!title) return; - console.debug('[subconscious-ui] add task:start', { title }); - try { - await addSubconsciousTask(title); - setNewTaskTitle(''); - console.debug('[subconscious-ui] add task:success', { title }); - } catch (error) { - console.debug('[subconscious-ui] add task:error', { - title, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - const handleRunTick = async () => { console.debug('[subconscious-ui] run tick:start', { triggering }); try { @@ -157,76 +66,12 @@ export default function IntelligenceSubconsciousTab({ } }; - const handleApproveEscalation = async (escalationId: string) => { - console.debug('[subconscious-ui] escalation approve:start', { escalationId }); - try { - await approveEscalation(escalationId); - console.debug('[subconscious-ui] escalation approve:success', { escalationId }); - } catch (error) { - console.debug('[subconscious-ui] escalation approve:error', { - escalationId, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - - const handleDismissEscalation = async (escalationId: string) => { - console.debug('[subconscious-ui] escalation dismiss:start', { escalationId }); - try { - await dismissEscalation(escalationId); - console.debug('[subconscious-ui] escalation dismiss:success', { escalationId }); - } catch (error) { - console.debug('[subconscious-ui] escalation dismiss:error', { - escalationId, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - - const handleFixInSkills = (escalationId: string) => { - console.debug('[subconscious-ui] escalation fix in skills:navigate', { escalationId }); - navigate('/skills', { state: { subconsciousEscalationId: escalationId } }); - }; - - const handleToggleTask = async (taskId: string, enabled: boolean, title: string) => { - console.debug('[subconscious-ui] task toggle:start', { taskId, enabled, title }); - try { - await toggleSubconsciousTask(taskId, enabled); - console.debug('[subconscious-ui] task toggle:success', { taskId, enabled, title }); - } catch (error) { - console.debug('[subconscious-ui] task toggle:error', { - taskId, - enabled, - title, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - - const handleRemoveTask = async (taskId: string, title: string) => { - console.debug('[subconscious-ui] task remove:start', { taskId, title }); - try { - await removeSubconsciousTask(taskId); - console.debug('[subconscious-ui] task remove:success', { taskId, title }); - } catch (error) { - console.debug('[subconscious-ui] task remove:error', { - taskId, - title, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - return (
{status && ( <> - - {status.task_count} {t('subconscious.tasks')} - - | {status.total_ticks} {t('subconscious.ticks')} @@ -266,9 +111,7 @@ export default function IntelligenceSubconsciousTab({
- ) : ( - - )} - -
-
- - ))} - - - )} - -
-

- {t('subconscious.activeTasks')} -

- {loading && tasks.length === 0 ? ( -
-
-
- ) : tasks.filter(t => !t.completed).length === 0 ? ( -

- {t('subconscious.noActiveTasks')} -

- ) : ( -
- {tasks - .filter(t => !t.completed && t.source === 'system') - .map(task => ( -
-
- - {task.title} - - - {t('subconscious.default')} - -
- ))} - {tasks - .filter(t => !t.completed && t.source !== 'system') - .map(task => ( -
-
- - - {task.title} - -
- -
- ))} -
- )} - -
void handleAddTask(e)} className="flex gap-2 mt-3"> - setNewTaskTitle(e.target.value)} - className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-900 dark:text-neutral-100 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors" - /> - -
-
- -
-

- {t('subconscious.activityLog')} -

- {logEntries.length === 0 ? ( -

- {t('subconscious.noActivity')} -

- ) : ( -
- {logEntries.map(entry => ( -
- - {new Date(entry.tick_at * 1000).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - - 120 ? 'cursor-pointer hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100' : ''}`} - onClick={() => { - if (entry.result && entry.result.length > 120) { - setExpandedLogIds(prev => { - const next = new Set(prev); - if (next.has(entry.id)) next.delete(entry.id); - else next.add(entry.id); - return next; - }); - } - }}> - {entry.result - ? expandedLogIds.has(entry.id) - ? entry.result - : entry.result.length > 120 - ? `${entry.result.substring(0, 120)}...` - : entry.result - : entry.decision === 'noop' - ? t('subconscious.decision.nothingNew') - : entry.decision === 'act' - ? t('subconscious.decision.completed') - : entry.decision === 'in_progress' - ? t('subconscious.decision.evaluating') - : entry.decision === 'escalate' - ? t('subconscious.decision.waitingApproval') - : entry.decision === 'failed' - ? t('subconscious.decision.failed') - : entry.decision === 'cancelled' - ? t('subconscious.decision.cancelled') - : entry.decision === 'dismissed' - ? t('subconscious.decision.skipped') - : entry.decision} - - {entry.duration_ms != null && ( - - {formatDuration(entry.duration_ms, t)} - - )} -
- ))} -
- )} -
); } diff --git a/app/src/components/intelligence/SubconsciousReflectionCards.tsx b/app/src/components/intelligence/SubconsciousReflectionCards.tsx index 67e4c7a1f2..ea02af58e7 100644 --- a/app/src/components/intelligence/SubconsciousReflectionCards.tsx +++ b/app/src/components/intelligence/SubconsciousReflectionCards.tsx @@ -259,6 +259,14 @@ export default function SubconsciousReflectionCards({ )}
+ {r.thread_id && ( + + )} {r.proposed_action && ( + ))}
-
-
- - - - + {mode === 'aggressive' && ( +

+ {t('subconscious.mode.aggressiveWarning')} +

+ )} +
+ + {/* Status bar + Run Now */} + {isEnabled && ( +
+
+ {status && ( + <> + + {status.total_ticks} {t('subconscious.ticks')} + + {status.last_tick_at && ( + <> + | + + {t('subconscious.last')}:{' '} + {new Date(status.last_tick_at * 1000).toLocaleTimeString()} + + + )} + {status.consecutive_failures > 0 && ( + <> + | + + {status.consecutive_failures} {t('subconscious.failed')} + + + )} + + )}
-
+ )} - {providerUnavailable && ( + {isEnabled && providerUnavailable && (
@@ -165,10 +184,12 @@ export default function IntelligenceSubconsciousTab({
)} - + {isEnabled && ( + + )}
); } diff --git a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx index 66ab511687..23d291acc8 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx @@ -1,7 +1,5 @@ /** * Vitest for the Intelligence Subconscious tab. - * - * Covers navigation from reflection cards and provider unavailable state. */ import { fireEvent, render, screen } from '@testing-library/react'; import type { ComponentProps } from 'react'; @@ -29,7 +27,14 @@ vi.mock('../SubconsciousReflectionCards', () => ({ })); function baseProps(): ComponentProps { - return { status: null, triggerTick: vi.fn(), triggering: false }; + return { + status: null, + mode: 'off', + triggerTick: vi.fn(), + triggering: false, + settingMode: false, + setMode: vi.fn(), + }; } describe('IntelligenceSubconsciousTab', () => { @@ -41,40 +46,43 @@ describe('IntelligenceSubconsciousTab', () => { vi.restoreAllMocks(); }); - it('on Act → dispatches setSelectedThread + navigates to /chat', () => { + it('renders three mode options', () => { render(); - fireEvent.click(screen.getByTestId('cards-stub-trigger')); - expect(mockDispatch).toHaveBeenCalledWith(setSelectedThread('spawned-thread-42')); - expect(mockNavigate).toHaveBeenCalledWith('/chat'); + expect(screen.getByText('Off')).toBeInTheDocument(); + expect(screen.getByText('Simple')).toBeInTheDocument(); + expect(screen.getByText('Aggressive')).toBeInTheDocument(); }); - it('shows provider unavailable state and blocks manual ticks', () => { - const triggerTick = vi.fn(); - render( - - ); + it('clicking a mode option calls setMode', () => { + const setMode = vi.fn(); + render(); + fireEvent.click(screen.getByText('Simple')); + expect(setMode).toHaveBeenCalledWith('simple'); + }); - expect(screen.getByText('Subconscious is paused')).toBeInTheDocument(); - expect(screen.getByText(/configure a local Subconscious provider/i)).toBeInTheDocument(); + it('hides Run Now and reflections when mode is off', () => { + render(); + expect(screen.queryByText('Run Now')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cards-stub-trigger')).not.toBeInTheDocument(); + }); - const runNow = screen.getByRole('button', { name: /Run Now/i }); - expect(runNow).toBeDisabled(); - fireEvent.click(runNow); - expect(triggerTick).not.toHaveBeenCalled(); + it('shows Run Now and reflections when mode is simple', () => { + render(); + expect(screen.getByText('Run Now')).toBeInTheDocument(); + expect(screen.getByTestId('cards-stub-trigger')).toBeInTheDocument(); + }); - fireEvent.click(screen.getByRole('button', { name: /AI settings/i })); - expect(mockNavigate).toHaveBeenCalledWith('/settings/llm'); + it('shows aggressive warning when mode is aggressive', () => { + render(); + expect( + screen.getByText(/full tool access including writes/) + ).toBeInTheDocument(); + }); + + it('on Act → dispatches setSelectedThread + navigates to /chat', () => { + render(); + fireEvent.click(screen.getByTestId('cards-stub-trigger')); + expect(mockDispatch).toHaveBeenCalledWith(setSelectedThread('spawned-thread-42')); + expect(mockNavigate).toHaveBeenCalledWith('/chat'); }); }); diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 9c37cc7419..59c5e12b44 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -138,6 +138,7 @@ const baseHeartbeatSettings = { meeting_lookahead_minutes: 60, max_calendar_connections_per_tick: 2, reminder_lookahead_minutes: 30, + subconscious_mode: 'off' as 'off' | 'simple' | 'aggressive', }; const baseUsage = { diff --git a/app/src/hooks/useSubconscious.ts b/app/src/hooks/useSubconscious.ts index 0920fabd3b..3e0929ce09 100644 --- a/app/src/hooks/useSubconscious.ts +++ b/app/src/hooks/useSubconscious.ts @@ -1,27 +1,39 @@ /** * useSubconscious — hook for the subconscious engine UI. * - * Provides status, thoughts (reflections), and engine control actions - * for the subconscious tab on the Intelligence page. + * Provides status, mode control, and engine actions for the + * subconscious tab on the Intelligence page. */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { isTauri, subconsciousStatus, subconsciousTrigger } from '../utils/tauriCommands'; +import { + isTauri, + openhumanHeartbeatSettingsGet, + openhumanHeartbeatSettingsSet, + subconsciousStatus, + subconsciousTrigger, +} from '../utils/tauriCommands'; +import type { SubconsciousMode } from '../utils/tauriCommands/heartbeat'; import type { SubconsciousStatus } from '../utils/tauriCommands/subconscious'; export interface UseSubconsciousResult { status: SubconsciousStatus | null; + mode: SubconsciousMode; loading: boolean; triggering: boolean; + settingMode: boolean; refresh: () => Promise; triggerTick: () => Promise; + setMode: (mode: SubconsciousMode) => Promise; error: string | null; } export function useSubconscious(): UseSubconsciousResult { const [status, setStatus] = useState(null); + const [mode, setModeState] = useState('off'); const [loading, setLoading] = useState(false); const [triggering, setTriggering] = useState(false); + const [settingMode, setSettingMode] = useState(false); const [error, setError] = useState(null); const fetchingRef = useRef(false); @@ -31,8 +43,15 @@ export function useSubconscious(): UseSubconsciousResult { setLoading(true); setError(null); try { - const statusRes = await withTimeout(subconsciousStatus()); + const [statusRes, settingsRes] = await Promise.all([ + withTimeout(subconsciousStatus()), + withTimeout(openhumanHeartbeatSettingsGet()), + ]); if (statusRes) setStatus(unwrap(statusRes) ?? null); + const settings = settingsRes ? unwrap<{ settings: { subconscious_mode: SubconsciousMode } }>(settingsRes) : null; + if (settings?.settings?.subconscious_mode) { + setModeState(settings.settings.subconscious_mode); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load subconscious data'); } finally { @@ -53,16 +72,44 @@ export function useSubconscious(): UseSubconsciousResult { } }, [triggering]); + const setMode = useCallback( + async (newMode: SubconsciousMode) => { + if (!isTauri()) return; + setSettingMode(true); + setModeState(newMode); + try { + await openhumanHeartbeatSettingsSet({ subconscious_mode: newMode }); + await refresh(); + } catch (err) { + console.warn('[subconscious] setMode failed:', err); + setError(err instanceof Error ? err.message : 'Failed to update mode'); + } finally { + setSettingMode(false); + } + }, + [refresh] + ); + useEffect(() => { refresh(); - const interval = setInterval(refresh, 3000); + const interval = setInterval(refresh, 5000); return () => { clearInterval(interval); fetchingRef.current = false; }; }, [refresh]); - return { status, loading, triggering, refresh, triggerTick, error }; + return { + status, + mode, + loading, + triggering, + settingMode, + refresh, + triggerTick, + setMode, + error, + }; } const RPC_TIMEOUT_MS = 2500; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index e06f1a760a..185e449d9e 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1882,6 +1882,14 @@ const messages: TranslationMap = { 'reflections.act': 'تنفيذ', 'reflections.dismiss': 'تجاهل', 'reflections.viewConversation': 'عرض', + 'subconscious.mode.label': 'وضع اللاوعي', + 'subconscious.mode.off.title': 'إيقاف', + 'subconscious.mode.off.desc': 'اللاوعي معطل.', + 'subconscious.mode.simple.title': 'بسيط', + 'subconscious.mode.simple.desc': 'مراقبة للقراءة فقط كل 30 دقيقة. الوصول للذاكرة والملفات فقط.', + 'subconscious.mode.aggressive.title': 'مكثف', + 'subconscious.mode.aggressive.desc': 'وصول كامل للأدوات كل 5 دقائق. يمكنه الكتابة وإنشاء وكلاء وتفويض المهام.', + 'subconscious.mode.aggressiveWarning': 'الوضع المكثف يمنح اللاوعي وصولاً كاملاً للأدوات بما في ذلك الكتابة وإنشاء الوكلاء الفرعيين.', 'whatsapp.chatsSynced': 'محادثات مزامنة', 'whatsapp.chatSynced': 'محادثة مزامنة', 'sync.active': 'نشط', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 2350e9a93d..11c59fd204 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1920,6 +1920,14 @@ const messages: TranslationMap = { 'reflections.act': 'কাজ করুন', 'reflections.dismiss': 'বাদ দিন', 'reflections.viewConversation': 'দেখুন', + 'subconscious.mode.label': 'অবচেতন মোড', + 'subconscious.mode.off.title': 'বন্ধ', + 'subconscious.mode.off.desc': 'অবচেতন নিষ্ক্রিয়।', + 'subconscious.mode.simple.title': 'সরল', + 'subconscious.mode.simple.desc': 'প্রতি ৩০ মিনিটে শুধুমাত্র পঠনযোগ্য পর্যবেক্ষণ। শুধু মেমরি ও ফাইল অ্যাক্সেস।', + 'subconscious.mode.aggressive.title': 'আক্রমণাত্মক', + 'subconscious.mode.aggressive.desc': 'প্রতি ৫ মিনিটে সম্পূর্ণ টুল অ্যাক্সেস। লিখতে, এজেন্ট তৈরি ও কাজ অর্পণ করতে পারে।', + 'subconscious.mode.aggressiveWarning': 'আক্রমণাত্মক মোড অবচেতনকে লেখা ও সাব-এজেন্ট তৈরিসহ সম্পূর্ণ টুল অ্যাক্সেস দেয়।', 'whatsapp.chatsSynced': 'চ্যাট সিঙ্ক হয়েছে', 'whatsapp.chatSynced': 'চ্যাট সিঙ্ক হয়েছে', 'sync.active': 'সক্রিয়', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 77b402d615..14a3d1806e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1968,6 +1968,14 @@ const messages: TranslationMap = { 'reflections.act': 'Handeln', 'reflections.dismiss': 'Entlassen', 'reflections.viewConversation': 'Ansehen', + 'subconscious.mode.label': 'Unterbewusstsein-Modus', + 'subconscious.mode.off.title': 'Aus', + 'subconscious.mode.off.desc': 'Unterbewusstsein ist deaktiviert.', + 'subconscious.mode.simple.title': 'Einfach', + 'subconscious.mode.simple.desc': 'Nur-Lese-Beobachtung alle 30 Minuten. Nur Speicher- und Dateizugriff.', + 'subconscious.mode.aggressive.title': 'Aggressiv', + 'subconscious.mode.aggressive.desc': 'Voller Werkzeugzugriff alle 5 Minuten. Kann schreiben, Agenten starten und Aufgaben delegieren.', + 'subconscious.mode.aggressiveWarning': 'Aggressiver Modus gewährt dem Unterbewusstsein vollen Werkzeugzugriff einschließlich Schreiben und Sub-Agenten-Erstellung.', 'whatsapp.chatsSynced': 'Chats synchronisiert', 'whatsapp.chatSynced': 'Chat synchronisiert', 'sync.active': 'Aktiv', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index e825eb0d1d..02fd5acdf6 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2121,6 +2121,16 @@ const en: TranslationMap = { 'reflections.dismiss': 'Dismiss', 'reflections.viewConversation': 'View', + // Subconscious mode selector + 'subconscious.mode.label': 'Subconscious Mode', + 'subconscious.mode.off.title': 'Off', + 'subconscious.mode.off.desc': 'Subconscious is disabled.', + 'subconscious.mode.simple.title': 'Simple', + 'subconscious.mode.simple.desc': 'Read-only observation every 30 minutes. Memory and file access only.', + 'subconscious.mode.aggressive.title': 'Aggressive', + 'subconscious.mode.aggressive.desc': 'Full tool access every 5 minutes. Can write, spawn agents, and delegate tasks.', + 'subconscious.mode.aggressiveWarning': 'Aggressive mode gives the subconscious full tool access including writes and sub-agent spawning.', + // WhatsApp 'whatsapp.chatsSynced': 'chats synced', 'whatsapp.chatSynced': 'chat synced', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index be16035298..7fadae23d0 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1960,6 +1960,14 @@ const messages: TranslationMap = { 'reflections.act': 'Actuar', 'reflections.dismiss': 'Descartar', 'reflections.viewConversation': 'Ver', + 'subconscious.mode.label': 'Modo subconsciente', + 'subconscious.mode.off.title': 'Apagado', + 'subconscious.mode.off.desc': 'El subconsciente está desactivado.', + 'subconscious.mode.simple.title': 'Simple', + 'subconscious.mode.simple.desc': 'Observación de solo lectura cada 30 minutos. Solo acceso a memoria y archivos.', + 'subconscious.mode.aggressive.title': 'Agresivo', + 'subconscious.mode.aggressive.desc': 'Acceso completo a herramientas cada 5 minutos. Puede escribir, crear agentes y delegar tareas.', + 'subconscious.mode.aggressiveWarning': 'El modo agresivo otorga al subconsciente acceso completo a herramientas, incluyendo escritura y creación de subagentes.', 'whatsapp.chatsSynced': 'chats sincronizados', 'whatsapp.chatSynced': 'chat sincronizado', 'sync.active': 'Activo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index ed3ecb40b8..c113a9579f 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1966,6 +1966,14 @@ const messages: TranslationMap = { 'reflections.act': 'Agir', 'reflections.dismiss': 'Ignorer', 'reflections.viewConversation': 'Voir', + 'subconscious.mode.label': 'Mode subconscient', + 'subconscious.mode.off.title': 'Désactivé', + 'subconscious.mode.off.desc': 'Le subconscient est désactivé.', + 'subconscious.mode.simple.title': 'Simple', + 'subconscious.mode.simple.desc': 'Observation en lecture seule toutes les 30 minutes. Accès mémoire et fichiers uniquement.', + 'subconscious.mode.aggressive.title': 'Agressif', + 'subconscious.mode.aggressive.desc': 'Accès complet aux outils toutes les 5 minutes. Peut écrire, créer des agents et déléguer des tâches.', + 'subconscious.mode.aggressiveWarning': "Le mode agressif donne au subconscient un accès complet aux outils, y compris l'écriture et la création de sous-agents.", 'whatsapp.chatsSynced': 'conversations synchronisées', 'whatsapp.chatSynced': 'conversation synchronisée', 'sync.active': 'Actif', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 8dde5793c1..cd1f6846dd 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1920,6 +1920,14 @@ const messages: TranslationMap = { 'reflections.act': 'करें', 'reflections.dismiss': 'हटाएं', 'reflections.viewConversation': 'देखें', + 'subconscious.mode.label': 'अवचेतन मोड', + 'subconscious.mode.off.title': 'बंद', + 'subconscious.mode.off.desc': 'अवचेतन अक्षम है।', + 'subconscious.mode.simple.title': 'सरल', + 'subconscious.mode.simple.desc': 'हर 30 मिनट में केवल-पठन अवलोकन। केवल मेमोरी और फ़ाइल एक्सेस।', + 'subconscious.mode.aggressive.title': 'आक्रामक', + 'subconscious.mode.aggressive.desc': 'हर 5 मिनट में पूर्ण टूल एक्सेस। लिख सकता है, एजेंट बना सकता है और कार्य सौंप सकता है।', + 'subconscious.mode.aggressiveWarning': 'आक्रामक मोड अवचेतन को लेखन और उप-एजेंट निर्माण सहित पूर्ण टूल एक्सेस देता है।', 'whatsapp.chatsSynced': 'चैट्स सिंक हुईं', 'whatsapp.chatSynced': 'चैट सिंक हुई', 'sync.active': 'एक्टिव', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c7c8aa4303..8ef602feec 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1923,6 +1923,14 @@ const messages: TranslationMap = { 'reflections.act': 'Tindakan', 'reflections.dismiss': 'Abaikan', 'reflections.viewConversation': 'Lihat', + 'subconscious.mode.label': 'Mode Alam Bawah Sadar', + 'subconscious.mode.off.title': 'Mati', + 'subconscious.mode.off.desc': 'Alam bawah sadar dinonaktifkan.', + 'subconscious.mode.simple.title': 'Sederhana', + 'subconscious.mode.simple.desc': 'Pengamatan hanya-baca setiap 30 menit. Hanya akses memori dan file.', + 'subconscious.mode.aggressive.title': 'Agresif', + 'subconscious.mode.aggressive.desc': 'Akses alat penuh setiap 5 menit. Dapat menulis, membuat agen, dan mendelegasikan tugas.', + 'subconscious.mode.aggressiveWarning': 'Mode agresif memberikan alam bawah sadar akses alat penuh termasuk menulis dan membuat sub-agen.', 'whatsapp.chatsSynced': 'obrolan disinkronkan', 'whatsapp.chatSynced': 'obrolan disinkronkan', 'sync.active': 'Aktif', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 2bfa22ab7a..7b53e51119 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1951,6 +1951,14 @@ const messages: TranslationMap = { 'reflections.act': 'Agisci', 'reflections.dismiss': 'Ignora', 'reflections.viewConversation': 'Visualizza', + 'subconscious.mode.label': 'Modalità subconscio', + 'subconscious.mode.off.title': 'Disattivato', + 'subconscious.mode.off.desc': 'Il subconscio è disattivato.', + 'subconscious.mode.simple.title': 'Semplice', + 'subconscious.mode.simple.desc': 'Osservazione in sola lettura ogni 30 minuti. Solo accesso a memoria e file.', + 'subconscious.mode.aggressive.title': 'Aggressivo', + 'subconscious.mode.aggressive.desc': 'Accesso completo agli strumenti ogni 5 minuti. Può scrivere, creare agenti e delegare compiti.', + 'subconscious.mode.aggressiveWarning': 'La modalità aggressiva concede al subconscio accesso completo agli strumenti, inclusa scrittura e creazione di sotto-agenti.', 'whatsapp.chatsSynced': 'chat sincronizzate', 'whatsapp.chatSynced': 'chat sincronizzata', 'sync.active': 'Attivo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index a72fc4028a..b717f311ac 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1900,6 +1900,14 @@ const messages: TranslationMap = { 'reflections.act': '실행', 'reflections.dismiss': '닫기', 'reflections.viewConversation': '보기', + 'subconscious.mode.label': '잠재의식 모드', + 'subconscious.mode.off.title': '끔', + 'subconscious.mode.off.desc': '잠재의식이 비활성화되었습니다.', + 'subconscious.mode.simple.title': '심플', + 'subconscious.mode.simple.desc': '30분마다 읽기 전용 관찰. 메모리 및 파일 접근만 가능.', + 'subconscious.mode.aggressive.title': '적극적', + 'subconscious.mode.aggressive.desc': '5분마다 전체 도구 접근. 쓰기, 에이전트 생성, 작업 위임 가능.', + 'subconscious.mode.aggressiveWarning': '적극적 모드는 잠재의식에 쓰기 및 하위 에이전트 생성을 포함한 전체 도구 접근 권한을 부여합니다.', 'whatsapp.chatsSynced': '채팅 동기화됨', 'whatsapp.chatSynced': '채팅 동기화됨', 'sync.active': '활성', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 30628e39b7..e710fd4297 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1941,6 +1941,14 @@ const messages: TranslationMap = { 'reflections.act': 'Wykonaj', 'reflections.dismiss': 'Odrzuć', 'reflections.viewConversation': 'Zobacz', + 'subconscious.mode.label': 'Tryb podświadomości', + 'subconscious.mode.off.title': 'Wyłączony', + 'subconscious.mode.off.desc': 'Podświadomość jest wyłączona.', + 'subconscious.mode.simple.title': 'Prosty', + 'subconscious.mode.simple.desc': 'Obserwacja tylko do odczytu co 30 minut. Tylko dostęp do pamięci i plików.', + 'subconscious.mode.aggressive.title': 'Agresywny', + 'subconscious.mode.aggressive.desc': 'Pełny dostęp do narzędzi co 5 minut. Może pisać, tworzyć agentów i delegować zadania.', + 'subconscious.mode.aggressiveWarning': 'Tryb agresywny daje podświadomości pełny dostęp do narzędzi, w tym pisanie i tworzenie podagentów.', 'whatsapp.chatsSynced': 'rozmów zsynchronizowano', 'whatsapp.chatSynced': 'rozmowa zsynchronizowana', 'sync.active': 'Aktywna', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index fb574674ae..3a0d34a6a7 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1957,6 +1957,14 @@ const messages: TranslationMap = { 'reflections.act': 'Agir', 'reflections.dismiss': 'Dispensar', 'reflections.viewConversation': 'Ver', + 'subconscious.mode.label': 'Modo subconsciente', + 'subconscious.mode.off.title': 'Desligado', + 'subconscious.mode.off.desc': 'O subconsciente está desativado.', + 'subconscious.mode.simple.title': 'Simples', + 'subconscious.mode.simple.desc': 'Observação somente leitura a cada 30 minutos. Apenas acesso a memória e arquivos.', + 'subconscious.mode.aggressive.title': 'Agressivo', + 'subconscious.mode.aggressive.desc': 'Acesso completo a ferramentas a cada 5 minutos. Pode escrever, criar agentes e delegar tarefas.', + 'subconscious.mode.aggressiveWarning': 'O modo agressivo concede ao subconsciente acesso completo a ferramentas, incluindo escrita e criação de subagentes.', 'whatsapp.chatsSynced': 'chats sincronizados', 'whatsapp.chatSynced': 'chat sincronizado', 'sync.active': 'Ativo', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index cff31aee61..79c980b664 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1933,6 +1933,14 @@ const messages: TranslationMap = { 'reflections.act': 'Выполнить', 'reflections.dismiss': 'Закрыть', 'reflections.viewConversation': 'Просмотр', + 'subconscious.mode.label': 'Режим подсознания', + 'subconscious.mode.off.title': 'Выкл', + 'subconscious.mode.off.desc': 'Подсознание отключено.', + 'subconscious.mode.simple.title': 'Простой', + 'subconscious.mode.simple.desc': 'Наблюдение в режиме чтения каждые 30 минут. Только доступ к памяти и файлам.', + 'subconscious.mode.aggressive.title': 'Агрессивный', + 'subconscious.mode.aggressive.desc': 'Полный доступ к инструментам каждые 5 минут. Может писать, создавать агентов и делегировать задачи.', + 'subconscious.mode.aggressiveWarning': 'Агрессивный режим даёт подсознанию полный доступ к инструментам, включая запись и создание подагентов.', 'whatsapp.chatsSynced': 'чатов синхронизировано', 'whatsapp.chatSynced': 'чат синхронизирован', 'sync.active': 'Активно', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d425793e8a..29fb11c9e5 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1823,6 +1823,14 @@ const messages: TranslationMap = { 'reflections.act': '执行', 'reflections.dismiss': '忽略', 'reflections.viewConversation': '查看', + 'subconscious.mode.label': '潜意识模式', + 'subconscious.mode.off.title': '关闭', + 'subconscious.mode.off.desc': '潜意识已禁用。', + 'subconscious.mode.simple.title': '简单', + 'subconscious.mode.simple.desc': '每30分钟只读观察。仅可访问记忆和文件。', + 'subconscious.mode.aggressive.title': '积极', + 'subconscious.mode.aggressive.desc': '每5分钟完整工具访问。可写入、创建代理和委派任务。', + 'subconscious.mode.aggressiveWarning': '积极模式赋予潜意识完整的工具访问权限,包括写入和创建子代理。', 'whatsapp.chatsSynced': '个对话已同步', 'whatsapp.chatSynced': '个对话已同步', 'sync.active': '活跃', diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index a320b1a5c1..5be9f1971d 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -43,8 +43,11 @@ export default function Intelligence() { // Subconscious engine data const { status: subconsciousEngineStatus, + mode: subconsciousMode, triggering: subconsciousTriggering, + settingMode: subconsciousSettingMode, triggerTick, + setMode: setSubconsciousMode, } = useSubconscious(); // Socket integration @@ -165,8 +168,11 @@ export default function Intelligence() { {activeTab === 'subconscious' && ( )} diff --git a/app/src/utils/tauriCommands/heartbeat.ts b/app/src/utils/tauriCommands/heartbeat.ts index 4fe21836e0..0454d369b9 100644 --- a/app/src/utils/tauriCommands/heartbeat.ts +++ b/app/src/utils/tauriCommands/heartbeat.ts @@ -4,6 +4,8 @@ import { callCoreRpc } from '../../services/coreRpcClient'; import { type CommandResponse, isTauri } from './common'; +export type SubconsciousMode = 'off' | 'simple' | 'aggressive'; + export interface HeartbeatSettings { enabled: boolean; interval_minutes: number; @@ -15,6 +17,7 @@ export interface HeartbeatSettings { meeting_lookahead_minutes: number; max_calendar_connections_per_tick: number; reminder_lookahead_minutes: number; + subconscious_mode: SubconsciousMode; } export type HeartbeatSettingsPatch = Partial; diff --git a/app/src/utils/tauriCommands/subconscious.ts b/app/src/utils/tauriCommands/subconscious.ts index 588e644fb5..83a443a9d9 100644 --- a/app/src/utils/tauriCommands/subconscious.ts +++ b/app/src/utils/tauriCommands/subconscious.ts @@ -8,6 +8,7 @@ import { type CommandResponse, isTauri } from './common'; export interface SubconsciousStatus { enabled: boolean; + mode: 'off' | 'simple' | 'aggressive'; provider_available: boolean; provider_unavailable_reason: string | null; interval_minutes: number; From a656b56a8fb61776f6238d2b1cdb163ed029e290 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 1 Jun 2026 17:16:29 -0400 Subject: [PATCH 08/20] fix(subconscious): add subconscious_mode to heartbeat RPC schema The heartbeat_settings_set schema was missing the subconscious_mode input field, so the JSON-RPC dispatcher stripped it before reaching the handler. Also removes time intervals from mode descriptions (will be a separate slider). --- app/src/lib/i18n/ar.ts | 4 ++-- app/src/lib/i18n/bn.ts | 4 ++-- app/src/lib/i18n/de.ts | 4 ++-- app/src/lib/i18n/en.ts | 4 ++-- app/src/lib/i18n/es.ts | 4 ++-- app/src/lib/i18n/fr.ts | 4 ++-- app/src/lib/i18n/hi.ts | 4 ++-- app/src/lib/i18n/id.ts | 4 ++-- app/src/lib/i18n/it.ts | 4 ++-- app/src/lib/i18n/ko.ts | 4 ++-- app/src/lib/i18n/pl.ts | 4 ++-- app/src/lib/i18n/pt.ts | 4 ++-- app/src/lib/i18n/ru.ts | 4 ++-- app/src/lib/i18n/zh-CN.ts | 4 ++-- src/openhuman/heartbeat/schemas.rs | 13 +++++++++++++ 15 files changed, 41 insertions(+), 28 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 185e449d9e..611f66f561 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1886,9 +1886,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'إيقاف', 'subconscious.mode.off.desc': 'اللاوعي معطل.', 'subconscious.mode.simple.title': 'بسيط', - 'subconscious.mode.simple.desc': 'مراقبة للقراءة فقط كل 30 دقيقة. الوصول للذاكرة والملفات فقط.', + 'subconscious.mode.simple.desc': 'مراقبة للقراءة فقط. الوصول للذاكرة والملفات فقط.', 'subconscious.mode.aggressive.title': 'مكثف', - 'subconscious.mode.aggressive.desc': 'وصول كامل للأدوات كل 5 دقائق. يمكنه الكتابة وإنشاء وكلاء وتفويض المهام.', + 'subconscious.mode.aggressive.desc': 'وصول كامل للأدوات. يمكنه الكتابة وإنشاء وكلاء وتفويض المهام.', 'subconscious.mode.aggressiveWarning': 'الوضع المكثف يمنح اللاوعي وصولاً كاملاً للأدوات بما في ذلك الكتابة وإنشاء الوكلاء الفرعيين.', 'whatsapp.chatsSynced': 'محادثات مزامنة', 'whatsapp.chatSynced': 'محادثة مزامنة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 11c59fd204..c09cb4bedb 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1924,9 +1924,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'বন্ধ', 'subconscious.mode.off.desc': 'অবচেতন নিষ্ক্রিয়।', 'subconscious.mode.simple.title': 'সরল', - 'subconscious.mode.simple.desc': 'প্রতি ৩০ মিনিটে শুধুমাত্র পঠনযোগ্য পর্যবেক্ষণ। শুধু মেমরি ও ফাইল অ্যাক্সেস।', + 'subconscious.mode.simple.desc': 'শুধুমাত্র পঠনযোগ্য পর্যবেক্ষণ। শুধু মেমরি ও ফাইল অ্যাক্সেস।', 'subconscious.mode.aggressive.title': 'আক্রমণাত্মক', - 'subconscious.mode.aggressive.desc': 'প্রতি ৫ মিনিটে সম্পূর্ণ টুল অ্যাক্সেস। লিখতে, এজেন্ট তৈরি ও কাজ অর্পণ করতে পারে।', + 'subconscious.mode.aggressive.desc': 'সম্পূর্ণ টুল অ্যাক্সেস। লিখতে, এজেন্ট তৈরি ও কাজ অর্পণ করতে পারে।', 'subconscious.mode.aggressiveWarning': 'আক্রমণাত্মক মোড অবচেতনকে লেখা ও সাব-এজেন্ট তৈরিসহ সম্পূর্ণ টুল অ্যাক্সেস দেয়।', 'whatsapp.chatsSynced': 'চ্যাট সিঙ্ক হয়েছে', 'whatsapp.chatSynced': 'চ্যাট সিঙ্ক হয়েছে', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 14a3d1806e..1fa05486e9 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1972,9 +1972,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Aus', 'subconscious.mode.off.desc': 'Unterbewusstsein ist deaktiviert.', 'subconscious.mode.simple.title': 'Einfach', - 'subconscious.mode.simple.desc': 'Nur-Lese-Beobachtung alle 30 Minuten. Nur Speicher- und Dateizugriff.', + 'subconscious.mode.simple.desc': 'Nur-Lese-Beobachtung. Nur Speicher- und Dateizugriff.', 'subconscious.mode.aggressive.title': 'Aggressiv', - 'subconscious.mode.aggressive.desc': 'Voller Werkzeugzugriff alle 5 Minuten. Kann schreiben, Agenten starten und Aufgaben delegieren.', + 'subconscious.mode.aggressive.desc': 'Voller Werkzeugzugriff. Kann schreiben, Agenten starten und Aufgaben delegieren.', 'subconscious.mode.aggressiveWarning': 'Aggressiver Modus gewährt dem Unterbewusstsein vollen Werkzeugzugriff einschließlich Schreiben und Sub-Agenten-Erstellung.', 'whatsapp.chatsSynced': 'Chats synchronisiert', 'whatsapp.chatSynced': 'Chat synchronisiert', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 02fd5acdf6..bfc3cc3fef 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2126,9 +2126,9 @@ const en: TranslationMap = { 'subconscious.mode.off.title': 'Off', 'subconscious.mode.off.desc': 'Subconscious is disabled.', 'subconscious.mode.simple.title': 'Simple', - 'subconscious.mode.simple.desc': 'Read-only observation every 30 minutes. Memory and file access only.', + 'subconscious.mode.simple.desc': 'Read-only observation. Memory and file access only.', 'subconscious.mode.aggressive.title': 'Aggressive', - 'subconscious.mode.aggressive.desc': 'Full tool access every 5 minutes. Can write, spawn agents, and delegate tasks.', + 'subconscious.mode.aggressive.desc': 'Full tool access. Can write, spawn agents, and delegate tasks.', 'subconscious.mode.aggressiveWarning': 'Aggressive mode gives the subconscious full tool access including writes and sub-agent spawning.', // WhatsApp diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 7fadae23d0..e39b673170 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1964,9 +1964,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Apagado', 'subconscious.mode.off.desc': 'El subconsciente está desactivado.', 'subconscious.mode.simple.title': 'Simple', - 'subconscious.mode.simple.desc': 'Observación de solo lectura cada 30 minutos. Solo acceso a memoria y archivos.', + 'subconscious.mode.simple.desc': 'Observación de solo lectura. Solo acceso a memoria y archivos.', 'subconscious.mode.aggressive.title': 'Agresivo', - 'subconscious.mode.aggressive.desc': 'Acceso completo a herramientas cada 5 minutos. Puede escribir, crear agentes y delegar tareas.', + 'subconscious.mode.aggressive.desc': 'Acceso completo a herramientas. Puede escribir, crear agentes y delegar tareas.', 'subconscious.mode.aggressiveWarning': 'El modo agresivo otorga al subconsciente acceso completo a herramientas, incluyendo escritura y creación de subagentes.', 'whatsapp.chatsSynced': 'chats sincronizados', 'whatsapp.chatSynced': 'chat sincronizado', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index c113a9579f..e8ec2eda62 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1970,9 +1970,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Désactivé', 'subconscious.mode.off.desc': 'Le subconscient est désactivé.', 'subconscious.mode.simple.title': 'Simple', - 'subconscious.mode.simple.desc': 'Observation en lecture seule toutes les 30 minutes. Accès mémoire et fichiers uniquement.', + 'subconscious.mode.simple.desc': 'Observation en lecture seule. Accès mémoire et fichiers uniquement.', 'subconscious.mode.aggressive.title': 'Agressif', - 'subconscious.mode.aggressive.desc': 'Accès complet aux outils toutes les 5 minutes. Peut écrire, créer des agents et déléguer des tâches.', + 'subconscious.mode.aggressive.desc': 'Accès complet aux outils. Peut écrire, créer des agents et déléguer des tâches.', 'subconscious.mode.aggressiveWarning': "Le mode agressif donne au subconscient un accès complet aux outils, y compris l'écriture et la création de sous-agents.", 'whatsapp.chatsSynced': 'conversations synchronisées', 'whatsapp.chatSynced': 'conversation synchronisée', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index cd1f6846dd..337fbaa532 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1924,9 +1924,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'बंद', 'subconscious.mode.off.desc': 'अवचेतन अक्षम है।', 'subconscious.mode.simple.title': 'सरल', - 'subconscious.mode.simple.desc': 'हर 30 मिनट में केवल-पठन अवलोकन। केवल मेमोरी और फ़ाइल एक्सेस।', + 'subconscious.mode.simple.desc': 'केवल-पठन अवलोकन। केवल मेमोरी और फ़ाइल एक्सेस।', 'subconscious.mode.aggressive.title': 'आक्रामक', - 'subconscious.mode.aggressive.desc': 'हर 5 मिनट में पूर्ण टूल एक्सेस। लिख सकता है, एजेंट बना सकता है और कार्य सौंप सकता है।', + 'subconscious.mode.aggressive.desc': 'पूर्ण टूल एक्सेस। लिख सकता है, एजेंट बना सकता है और कार्य सौंप सकता है।', 'subconscious.mode.aggressiveWarning': 'आक्रामक मोड अवचेतन को लेखन और उप-एजेंट निर्माण सहित पूर्ण टूल एक्सेस देता है।', 'whatsapp.chatsSynced': 'चैट्स सिंक हुईं', 'whatsapp.chatSynced': 'चैट सिंक हुई', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 8ef602feec..61524c2175 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1927,9 +1927,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Mati', 'subconscious.mode.off.desc': 'Alam bawah sadar dinonaktifkan.', 'subconscious.mode.simple.title': 'Sederhana', - 'subconscious.mode.simple.desc': 'Pengamatan hanya-baca setiap 30 menit. Hanya akses memori dan file.', + 'subconscious.mode.simple.desc': 'Pengamatan hanya-baca. Hanya akses memori dan file.', 'subconscious.mode.aggressive.title': 'Agresif', - 'subconscious.mode.aggressive.desc': 'Akses alat penuh setiap 5 menit. Dapat menulis, membuat agen, dan mendelegasikan tugas.', + 'subconscious.mode.aggressive.desc': 'Akses alat penuh. Dapat menulis, membuat agen, dan mendelegasikan tugas.', 'subconscious.mode.aggressiveWarning': 'Mode agresif memberikan alam bawah sadar akses alat penuh termasuk menulis dan membuat sub-agen.', 'whatsapp.chatsSynced': 'obrolan disinkronkan', 'whatsapp.chatSynced': 'obrolan disinkronkan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 7b53e51119..7f59bcfa39 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1955,9 +1955,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Disattivato', 'subconscious.mode.off.desc': 'Il subconscio è disattivato.', 'subconscious.mode.simple.title': 'Semplice', - 'subconscious.mode.simple.desc': 'Osservazione in sola lettura ogni 30 minuti. Solo accesso a memoria e file.', + 'subconscious.mode.simple.desc': 'Osservazione in sola lettura. Solo accesso a memoria e file.', 'subconscious.mode.aggressive.title': 'Aggressivo', - 'subconscious.mode.aggressive.desc': 'Accesso completo agli strumenti ogni 5 minuti. Può scrivere, creare agenti e delegare compiti.', + 'subconscious.mode.aggressive.desc': 'Accesso completo agli strumenti. Può scrivere, creare agenti e delegare compiti.', 'subconscious.mode.aggressiveWarning': 'La modalità aggressiva concede al subconscio accesso completo agli strumenti, inclusa scrittura e creazione di sotto-agenti.', 'whatsapp.chatsSynced': 'chat sincronizzate', 'whatsapp.chatSynced': 'chat sincronizzata', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index b717f311ac..0a5ef3a478 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1904,9 +1904,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': '끔', 'subconscious.mode.off.desc': '잠재의식이 비활성화되었습니다.', 'subconscious.mode.simple.title': '심플', - 'subconscious.mode.simple.desc': '30분마다 읽기 전용 관찰. 메모리 및 파일 접근만 가능.', + 'subconscious.mode.simple.desc': '읽기 전용 관찰. 메모리 및 파일 접근만 가능.', 'subconscious.mode.aggressive.title': '적극적', - 'subconscious.mode.aggressive.desc': '5분마다 전체 도구 접근. 쓰기, 에이전트 생성, 작업 위임 가능.', + 'subconscious.mode.aggressive.desc': '전체 도구 접근. 쓰기, 에이전트 생성, 작업 위임 가능.', 'subconscious.mode.aggressiveWarning': '적극적 모드는 잠재의식에 쓰기 및 하위 에이전트 생성을 포함한 전체 도구 접근 권한을 부여합니다.', 'whatsapp.chatsSynced': '채팅 동기화됨', 'whatsapp.chatSynced': '채팅 동기화됨', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index e710fd4297..cc82f52caa 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1945,9 +1945,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Wyłączony', 'subconscious.mode.off.desc': 'Podświadomość jest wyłączona.', 'subconscious.mode.simple.title': 'Prosty', - 'subconscious.mode.simple.desc': 'Obserwacja tylko do odczytu co 30 minut. Tylko dostęp do pamięci i plików.', + 'subconscious.mode.simple.desc': 'Obserwacja tylko do odczytu. Tylko dostęp do pamięci i plików.', 'subconscious.mode.aggressive.title': 'Agresywny', - 'subconscious.mode.aggressive.desc': 'Pełny dostęp do narzędzi co 5 minut. Może pisać, tworzyć agentów i delegować zadania.', + 'subconscious.mode.aggressive.desc': 'Pełny dostęp do narzędzi. Może pisać, tworzyć agentów i delegować zadania.', 'subconscious.mode.aggressiveWarning': 'Tryb agresywny daje podświadomości pełny dostęp do narzędzi, w tym pisanie i tworzenie podagentów.', 'whatsapp.chatsSynced': 'rozmów zsynchronizowano', 'whatsapp.chatSynced': 'rozmowa zsynchronizowana', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3a0d34a6a7..24f7f63e87 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1961,9 +1961,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Desligado', 'subconscious.mode.off.desc': 'O subconsciente está desativado.', 'subconscious.mode.simple.title': 'Simples', - 'subconscious.mode.simple.desc': 'Observação somente leitura a cada 30 minutos. Apenas acesso a memória e arquivos.', + 'subconscious.mode.simple.desc': 'Observação somente leitura. Apenas acesso a memória e arquivos.', 'subconscious.mode.aggressive.title': 'Agressivo', - 'subconscious.mode.aggressive.desc': 'Acesso completo a ferramentas a cada 5 minutos. Pode escrever, criar agentes e delegar tarefas.', + 'subconscious.mode.aggressive.desc': 'Acesso completo a ferramentas. Pode escrever, criar agentes e delegar tarefas.', 'subconscious.mode.aggressiveWarning': 'O modo agressivo concede ao subconsciente acesso completo a ferramentas, incluindo escrita e criação de subagentes.', 'whatsapp.chatsSynced': 'chats sincronizados', 'whatsapp.chatSynced': 'chat sincronizado', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 79c980b664..426659a5ee 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1937,9 +1937,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': 'Выкл', 'subconscious.mode.off.desc': 'Подсознание отключено.', 'subconscious.mode.simple.title': 'Простой', - 'subconscious.mode.simple.desc': 'Наблюдение в режиме чтения каждые 30 минут. Только доступ к памяти и файлам.', + 'subconscious.mode.simple.desc': 'Наблюдение в режиме чтения. Только доступ к памяти и файлам.', 'subconscious.mode.aggressive.title': 'Агрессивный', - 'subconscious.mode.aggressive.desc': 'Полный доступ к инструментам каждые 5 минут. Может писать, создавать агентов и делегировать задачи.', + 'subconscious.mode.aggressive.desc': 'Полный доступ к инструментам. Может писать, создавать агентов и делегировать задачи.', 'subconscious.mode.aggressiveWarning': 'Агрессивный режим даёт подсознанию полный доступ к инструментам, включая запись и создание подагентов.', 'whatsapp.chatsSynced': 'чатов синхронизировано', 'whatsapp.chatSynced': 'чат синхронизирован', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 29fb11c9e5..bba00c7ae9 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1827,9 +1827,9 @@ const messages: TranslationMap = { 'subconscious.mode.off.title': '关闭', 'subconscious.mode.off.desc': '潜意识已禁用。', 'subconscious.mode.simple.title': '简单', - 'subconscious.mode.simple.desc': '每30分钟只读观察。仅可访问记忆和文件。', + 'subconscious.mode.simple.desc': '只读观察。仅可访问记忆和文件。', 'subconscious.mode.aggressive.title': '积极', - 'subconscious.mode.aggressive.desc': '每5分钟完整工具访问。可写入、创建代理和委派任务。', + 'subconscious.mode.aggressive.desc': '完整工具访问。可写入、创建代理和委派任务。', 'subconscious.mode.aggressiveWarning': '积极模式赋予潜意识完整的工具访问权限,包括写入和创建子代理。', 'whatsapp.chatsSynced': '个对话已同步', 'whatsapp.chatSynced': '个对话已同步', diff --git a/src/openhuman/heartbeat/schemas.rs b/src/openhuman/heartbeat/schemas.rs index 16a8902db3..1a65525de8 100644 --- a/src/openhuman/heartbeat/schemas.rs +++ b/src/openhuman/heartbeat/schemas.rs @@ -81,6 +81,10 @@ pub fn schemas(function: &str) -> ControllerSchema { "reminder_lookahead_minutes", "Max lookahead window (minutes) for reminder notifications.", ), + optional_string( + "subconscious_mode", + "Subconscious operating mode: off, simple, or aggressive.", + ), ], outputs: vec![FieldSchema { name: "settings", @@ -161,3 +165,12 @@ fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema { required: false, } } + +fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment, + required: false, + } +} From 06c82c76cfd39f36557315ead93a6312aa2f1b8b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 1 Jun 2026 17:31:30 -0400 Subject: [PATCH 09/20] feat(ui): add frequency slider to subconscious tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a range slider (5m → 24h) below the mode selector that controls how often the subconscious agent runs. Saves via heartbeat_settings_set on mouse/touch release. Removes the old disabled select dropdown and its legacy i18n keys (fiveMinutes, tenMinutes, etc.). --- .../IntelligenceSubconsciousTab.tsx | 101 +++++++++++++++--- .../IntelligenceSubconsciousTab.test.tsx | 2 + app/src/hooks/useSubconscious.ts | 31 +++++- app/src/lib/i18n/ar.ts | 13 +-- app/src/lib/i18n/bn.ts | 13 +-- app/src/lib/i18n/de.ts | 13 +-- app/src/lib/i18n/en.ts | 13 +-- app/src/lib/i18n/es.ts | 13 +-- app/src/lib/i18n/fr.ts | 13 +-- app/src/lib/i18n/hi.ts | 13 +-- app/src/lib/i18n/id.ts | 13 +-- app/src/lib/i18n/it.ts | 13 +-- app/src/lib/i18n/ko.ts | 13 +-- app/src/lib/i18n/pl.ts | 13 +-- app/src/lib/i18n/pt.ts | 13 +-- app/src/lib/i18n/ru.ts | 13 +-- app/src/lib/i18n/zh-CN.ts | 13 +-- app/src/pages/Intelligence.tsx | 4 + 18 files changed, 189 insertions(+), 131 deletions(-) diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx index e2abdcd08c..9716ad3c19 100644 --- a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -1,3 +1,4 @@ +import { useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; @@ -27,22 +28,45 @@ const MODE_OPTIONS: ModeOption[] = [ }, ]; +const INTERVAL_STOPS = [5, 10, 15, 30, 60, 120, 360, 720, 1440]; + +function formatMinutes(minutes: number, t: (key: string) => string): string { + if (minutes < 60) return t('subconscious.interval.minutes').replace('{n}', String(minutes)); + const hours = minutes / 60; + if (hours === 1) return t('subconscious.interval.oneHour'); + if (hours === 24) return t('subconscious.interval.oneDay'); + return t('subconscious.interval.hours').replace('{n}', String(hours)); +} + +function minutesToSlider(minutes: number): number { + const idx = INTERVAL_STOPS.indexOf(minutes); + return idx >= 0 ? idx : 0; +} + +function sliderToMinutes(value: number): number { + return INTERVAL_STOPS[value] ?? 30; +} + interface IntelligenceSubconsciousTabProps { status: SubconsciousStatus | null; mode: SubconsciousMode; + intervalMinutes: number; triggerTick: () => Promise; triggering: boolean; settingMode: boolean; setMode: (mode: SubconsciousMode) => Promise; + setIntervalMinutes: (minutes: number) => Promise; } export default function IntelligenceSubconsciousTab({ status, mode, + intervalMinutes, triggerTick, triggering, settingMode, setMode, + setIntervalMinutes, }: IntelligenceSubconsciousTabProps) { const { t } = useT(); const navigate = useNavigate(); @@ -53,6 +77,23 @@ export default function IntelligenceSubconsciousTab({ : null; const isEnabled = mode !== 'off'; + const [localSlider, setLocalSlider] = useState(() => minutesToSlider(intervalMinutes)); + + const handleSliderChange = useCallback( + (e: React.ChangeEvent) => { + const val = Number(e.target.value); + setLocalSlider(val); + }, + [] + ); + + const handleSliderCommit = useCallback(() => { + const minutes = sliderToMinutes(localSlider); + if (minutes !== intervalMinutes) { + void setIntervalMinutes(minutes); + } + }, [localSlider, intervalMinutes, setIntervalMinutes]); + const handleNavigateToThread = (threadId: string) => { dispatch(setSelectedThread(threadId)); navigate('/chat'); @@ -69,37 +110,35 @@ export default function IntelligenceSubconsciousTab({ }; return ( -
+
{/* Mode selector */}

{t('subconscious.mode.label')}

-
+
{MODE_OPTIONS.map(opt => ( @@ -112,6 +151,36 @@ export default function IntelligenceSubconsciousTab({ )}
+ {/* Frequency slider */} + {isEnabled && ( +
+
+ + + {formatMinutes(sliderToMinutes(localSlider), t)} + +
+ +
+ 5m + 1h + 24h +
+
+ )} + {/* Status bar + Run Now */} {isEnabled && (
diff --git a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx index 23d291acc8..c8dede244c 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx @@ -30,10 +30,12 @@ function baseProps(): ComponentProps { return { status: null, mode: 'off', + intervalMinutes: 30, triggerTick: vi.fn(), triggering: false, settingMode: false, setMode: vi.fn(), + setIntervalMinutes: vi.fn(), }; } diff --git a/app/src/hooks/useSubconscious.ts b/app/src/hooks/useSubconscious.ts index 3e0929ce09..599229c9cf 100644 --- a/app/src/hooks/useSubconscious.ts +++ b/app/src/hooks/useSubconscious.ts @@ -19,18 +19,21 @@ import type { SubconsciousStatus } from '../utils/tauriCommands/subconscious'; export interface UseSubconsciousResult { status: SubconsciousStatus | null; mode: SubconsciousMode; + intervalMinutes: number; loading: boolean; triggering: boolean; settingMode: boolean; refresh: () => Promise; triggerTick: () => Promise; setMode: (mode: SubconsciousMode) => Promise; + setIntervalMinutes: (minutes: number) => Promise; error: string | null; } export function useSubconscious(): UseSubconsciousResult { const [status, setStatus] = useState(null); const [mode, setModeState] = useState('off'); + const [intervalMinutes, setIntervalState] = useState(30); const [loading, setLoading] = useState(false); const [triggering, setTriggering] = useState(false); const [settingMode, setSettingMode] = useState(false); @@ -48,9 +51,16 @@ export function useSubconscious(): UseSubconsciousResult { withTimeout(openhumanHeartbeatSettingsGet()), ]); if (statusRes) setStatus(unwrap(statusRes) ?? null); - const settings = settingsRes ? unwrap<{ settings: { subconscious_mode: SubconsciousMode } }>(settingsRes) : null; - if (settings?.settings?.subconscious_mode) { - setModeState(settings.settings.subconscious_mode); + const settings = settingsRes + ? unwrap<{ settings: { subconscious_mode: SubconsciousMode; interval_minutes: number } }>(settingsRes) + : null; + if (settings?.settings) { + if (settings.settings.subconscious_mode) { + setModeState(settings.settings.subconscious_mode); + } + if (settings.settings.interval_minutes) { + setIntervalState(settings.settings.interval_minutes); + } } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load subconscious data'); @@ -90,6 +100,19 @@ export function useSubconscious(): UseSubconsciousResult { [refresh] ); + const setIntervalMinutes = useCallback( + async (minutes: number) => { + if (!isTauri()) return; + setIntervalState(minutes); + try { + await openhumanHeartbeatSettingsSet({ interval_minutes: minutes }); + } catch (err) { + console.warn('[subconscious] setInterval failed:', err); + } + }, + [] + ); + useEffect(() => { refresh(); const interval = setInterval(refresh, 5000); @@ -102,12 +125,14 @@ export function useSubconscious(): UseSubconsciousResult { return { status, mode, + intervalMinutes, loading, triggering, settingMode, refresh, triggerTick, setMode, + setIntervalMinutes, error, }; } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 611f66f561..6fe82279f8 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1890,6 +1890,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'مكثف', 'subconscious.mode.aggressive.desc': 'وصول كامل للأدوات. يمكنه الكتابة وإنشاء وكلاء وتفويض المهام.', 'subconscious.mode.aggressiveWarning': 'الوضع المكثف يمنح اللاوعي وصولاً كاملاً للأدوات بما في ذلك الكتابة وإنشاء الوكلاء الفرعيين.', + 'subconscious.interval.label': 'التردد', + 'subconscious.interval.minutes': '{n} د', + 'subconscious.interval.hours': '{n} س', + 'subconscious.interval.oneHour': 'ساعة واحدة', + 'subconscious.interval.oneDay': '24 ساعة', 'whatsapp.chatsSynced': 'محادثات مزامنة', 'whatsapp.chatSynced': 'محادثة مزامنة', 'sync.active': 'نشط', @@ -4067,14 +4072,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'التصفية حسب المصدر', 'calls.comingSoonDescription': 'المكالمات بمساعدة الذكاء الاصطناعي قادمة قريباً. ابقَ على اطلاع.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 دقائق', - 'subconscious.interval.tenMinutes': '10 دقائق', - 'subconscious.interval.fifteenMinutes': '15 دقيقة', - 'subconscious.interval.thirtyMinutes': '30 دقيقة', - 'subconscious.interval.oneHour': 'ساعة واحدة', - 'subconscious.interval.sixHours': '6 ساعات', - 'subconscious.interval.twelveHours': '12 ساعة', - 'subconscious.interval.oneDay': 'يوم واحد', 'subconscious.priority.critical': 'حرجة', 'subconscious.priority.important': 'مهم', 'subconscious.priority.normal': 'عادي', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index c09cb4bedb..7a255848ab 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1928,6 +1928,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'আক্রমণাত্মক', 'subconscious.mode.aggressive.desc': 'সম্পূর্ণ টুল অ্যাক্সেস। লিখতে, এজেন্ট তৈরি ও কাজ অর্পণ করতে পারে।', 'subconscious.mode.aggressiveWarning': 'আক্রমণাত্মক মোড অবচেতনকে লেখা ও সাব-এজেন্ট তৈরিসহ সম্পূর্ণ টুল অ্যাক্সেস দেয়।', + 'subconscious.interval.label': 'ফ্রিকোয়েন্সি', + 'subconscious.interval.minutes': '{n} মি', + 'subconscious.interval.hours': '{n} ঘ', + 'subconscious.interval.oneHour': '১ ঘণ্টা', + 'subconscious.interval.oneDay': '২৪ ঘণ্টা', 'whatsapp.chatsSynced': 'চ্যাট সিঙ্ক হয়েছে', 'whatsapp.chatSynced': 'চ্যাট সিঙ্ক হয়েছে', 'sync.active': 'সক্রিয়', @@ -4135,14 +4140,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'উত্স দ্বারা ফিল্টার', 'calls.comingSoonDescription': 'AI-সহায়তা কলগুলি শীঘ্রই আসছে৷ সাথে থাকুন।', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 মিনিট', - 'subconscious.interval.tenMinutes': '10 মিনিট', - 'subconscious.interval.fifteenMinutes': '15 মিনিট', - 'subconscious.interval.thirtyMinutes': '30 মিনিট', - 'subconscious.interval.oneHour': '1 ঘন্টা', - 'subconscious.interval.sixHours': '1 ঘন্টা', - 'subconscious.interval.twelveHours': '[[I18N_SEP_92731]] 12 ঘন্টা', - 'subconscious.interval.oneDay': '1 দিন', 'subconscious.priority.critical': 'গুরুত্বপূর্ণ', 'subconscious.priority.important': 'গুরুত্বপূর্ণ', 'subconscious.priority.normal': 'স্বাভাবিক', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 1fa05486e9..6562efed4c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1976,6 +1976,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Aggressiv', 'subconscious.mode.aggressive.desc': 'Voller Werkzeugzugriff. Kann schreiben, Agenten starten und Aufgaben delegieren.', 'subconscious.mode.aggressiveWarning': 'Aggressiver Modus gewährt dem Unterbewusstsein vollen Werkzeugzugriff einschließlich Schreiben und Sub-Agenten-Erstellung.', + 'subconscious.interval.label': 'Häufigkeit', + 'subconscious.interval.minutes': '{n} Min', + 'subconscious.interval.hours': '{n} Std', + 'subconscious.interval.oneHour': '1 Stunde', + 'subconscious.interval.oneDay': '24 Stunden', 'whatsapp.chatsSynced': 'Chats synchronisiert', 'whatsapp.chatSynced': 'Chat synchronisiert', 'sync.active': 'Aktiv', @@ -4249,14 +4254,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Nach Quelle filtern', 'calls.comingSoonDescription': 'KI-unterstützte Anrufe folgen in Kürze. Bleiben Sie dran.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 Min.', - 'subconscious.interval.tenMinutes': '10 Min.', - 'subconscious.interval.fifteenMinutes': '15 Min.', - 'subconscious.interval.thirtyMinutes': '30 Min.', - 'subconscious.interval.oneHour': '1 Stunde', - 'subconscious.interval.sixHours': '6 Stunden', - 'subconscious.interval.twelveHours': '12 Stunden', - 'subconscious.interval.oneDay': '1 Tag', 'subconscious.priority.critical': 'kritisch', 'subconscious.priority.important': 'wichtig', 'subconscious.priority.normal': 'normal', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index bfc3cc3fef..9a8425fa49 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2130,6 +2130,11 @@ const en: TranslationMap = { 'subconscious.mode.aggressive.title': 'Aggressive', 'subconscious.mode.aggressive.desc': 'Full tool access. Can write, spawn agents, and delegate tasks.', 'subconscious.mode.aggressiveWarning': 'Aggressive mode gives the subconscious full tool access including writes and sub-agent spawning.', + 'subconscious.interval.label': 'Frequency', + 'subconscious.interval.minutes': '{n} min', + 'subconscious.interval.hours': '{n}h', + 'subconscious.interval.oneHour': '1 hour', + 'subconscious.interval.oneDay': '24 hours', // WhatsApp 'whatsapp.chatsSynced': 'chats synced', @@ -4407,14 +4412,6 @@ const en: TranslationMap = { 'memory.sourceFilterAria': 'Filter by source', 'calls.comingSoonDescription': 'AI-assisted calls are coming soon. Stay tuned.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 min', - 'subconscious.interval.tenMinutes': '10 min', - 'subconscious.interval.fifteenMinutes': '15 min', - 'subconscious.interval.thirtyMinutes': '30 min', - 'subconscious.interval.oneHour': '1 hour', - 'subconscious.interval.sixHours': '6 hours', - 'subconscious.interval.twelveHours': '12 hours', - 'subconscious.interval.oneDay': '1 day', 'subconscious.priority.critical': 'critical', 'subconscious.priority.important': 'important', 'subconscious.priority.normal': 'normal', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index e39b673170..434f0de1a0 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1968,6 +1968,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Agresivo', 'subconscious.mode.aggressive.desc': 'Acceso completo a herramientas. Puede escribir, crear agentes y delegar tareas.', 'subconscious.mode.aggressiveWarning': 'El modo agresivo otorga al subconsciente acceso completo a herramientas, incluyendo escritura y creación de subagentes.', + 'subconscious.interval.label': 'Frecuencia', + 'subconscious.interval.minutes': '{n} min', + 'subconscious.interval.hours': '{n}h', + 'subconscious.interval.oneHour': '1 hora', + 'subconscious.interval.oneDay': '24 horas', 'whatsapp.chatsSynced': 'chats sincronizados', 'whatsapp.chatSynced': 'chat sincronizado', 'sync.active': 'Activo', @@ -4215,14 +4220,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Filtrar por fuente', 'calls.comingSoonDescription': 'Las llamadas asistidas por IA llegarán pronto. Mantente atento.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 minutos', - 'subconscious.interval.tenMinutes': '10 minutos', - 'subconscious.interval.fifteenMinutes': '15 minutos', - 'subconscious.interval.thirtyMinutes': '30 minutos', - 'subconscious.interval.oneHour': '1 hora', - 'subconscious.interval.sixHours': '6 horas', - 'subconscious.interval.twelveHours': '12 horas', - 'subconscious.interval.oneDay': '1 dia', 'subconscious.priority.critical': 'crítico', 'subconscious.priority.important': 'importante', 'subconscious.priority.normal': 'normales', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e8ec2eda62..88c96de457 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1974,6 +1974,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Agressif', 'subconscious.mode.aggressive.desc': 'Accès complet aux outils. Peut écrire, créer des agents et déléguer des tâches.', 'subconscious.mode.aggressiveWarning': "Le mode agressif donne au subconscient un accès complet aux outils, y compris l'écriture et la création de sous-agents.", + 'subconscious.interval.label': 'Fréquence', + 'subconscious.interval.minutes': '{n} min', + 'subconscious.interval.hours': '{n}h', + 'subconscious.interval.oneHour': '1 heure', + 'subconscious.interval.oneDay': '24 heures', 'whatsapp.chatsSynced': 'conversations synchronisées', 'whatsapp.chatSynced': 'conversation synchronisée', 'sync.active': 'Actif', @@ -4230,14 +4235,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Filtrer par source', 'calls.comingSoonDescription': "Les appels assistés par IA arrivent bientôt. Restez à l'écoute.", 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 min', - 'subconscious.interval.tenMinutes': '10 min', - 'subconscious.interval.fifteenMinutes': '15 min', - 'subconscious.interval.thirtyMinutes': '30 min', - 'subconscious.interval.oneHour': '1 heure', - 'subconscious.interval.sixHours': '6 heures', - 'subconscious.interval.twelveHours': '12 heures', - 'subconscious.interval.oneDay': '1 jour', 'subconscious.priority.critical': 'critique', 'subconscious.priority.important': 'important', 'subconscious.priority.normal': 'normal', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 337fbaa532..11477e82cb 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1928,6 +1928,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'आक्रामक', 'subconscious.mode.aggressive.desc': 'पूर्ण टूल एक्सेस। लिख सकता है, एजेंट बना सकता है और कार्य सौंप सकता है।', 'subconscious.mode.aggressiveWarning': 'आक्रामक मोड अवचेतन को लेखन और उप-एजेंट निर्माण सहित पूर्ण टूल एक्सेस देता है।', + 'subconscious.interval.label': 'आवृत्ति', + 'subconscious.interval.minutes': '{n} मि', + 'subconscious.interval.hours': '{n} घं', + 'subconscious.interval.oneHour': '1 घंटा', + 'subconscious.interval.oneDay': '24 घंटे', 'whatsapp.chatsSynced': 'चैट्स सिंक हुईं', 'whatsapp.chatSynced': 'चैट सिंक हुई', 'sync.active': 'एक्टिव', @@ -4144,14 +4149,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'स्रोत के अनुसार फ़िल्टर करें', 'calls.comingSoonDescription': 'एआई-सहायक कॉल जल्द ही आ रही हैं। बने रहें।', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 मिनट', - 'subconscious.interval.tenMinutes': '10 मिनट', - 'subconscious.interval.fifteenMinutes': '15 मि', - 'subconscious.interval.thirtyMinutes': '30 मि', - 'subconscious.interval.oneHour': '1 घंटा', - 'subconscious.interval.sixHours': '6 घंटे', - 'subconscious.interval.twelveHours': '12 घंटे', - 'subconscious.interval.oneDay': '1 दिन', 'subconscious.priority.critical': 'आलोचनात्मक', 'subconscious.priority.important': 'महत्वपूर्ण', 'subconscious.priority.normal': 'सामान्य', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 61524c2175..e260d5635f 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1931,6 +1931,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Agresif', 'subconscious.mode.aggressive.desc': 'Akses alat penuh. Dapat menulis, membuat agen, dan mendelegasikan tugas.', 'subconscious.mode.aggressiveWarning': 'Mode agresif memberikan alam bawah sadar akses alat penuh termasuk menulis dan membuat sub-agen.', + 'subconscious.interval.label': 'Frekuensi', + 'subconscious.interval.minutes': '{n} mnt', + 'subconscious.interval.hours': '{n} jam', + 'subconscious.interval.oneHour': '1 jam', + 'subconscious.interval.oneDay': '24 jam', 'whatsapp.chatsSynced': 'obrolan disinkronkan', 'whatsapp.chatSynced': 'obrolan disinkronkan', 'sync.active': 'Aktif', @@ -4155,14 +4160,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Filter berdasarkan sumber', 'calls.comingSoonDescription': 'Panggilan dengan bantuan AI akan segera hadir. Pantau terus.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 menit', - 'subconscious.interval.tenMinutes': '10 menit', - 'subconscious.interval.fifteenMinutes': '15 menit', - 'subconscious.interval.thirtyMinutes': '30 menit', - 'subconscious.interval.oneHour': '1 jam', - 'subconscious.interval.sixHours': '6 jam', - 'subconscious.interval.twelveHours': '12 jam', - 'subconscious.interval.oneDay': '1 hari', 'subconscious.priority.critical': 'kritis', 'subconscious.priority.important': 'penting', 'subconscious.priority.normal': 'normal', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 7f59bcfa39..47cb10e7cc 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1959,6 +1959,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Aggressivo', 'subconscious.mode.aggressive.desc': 'Accesso completo agli strumenti. Può scrivere, creare agenti e delegare compiti.', 'subconscious.mode.aggressiveWarning': 'La modalità aggressiva concede al subconscio accesso completo agli strumenti, inclusa scrittura e creazione di sotto-agenti.', + 'subconscious.interval.label': 'Frequenza', + 'subconscious.interval.minutes': '{n} min', + 'subconscious.interval.hours': '{n}h', + 'subconscious.interval.oneHour': '1 ora', + 'subconscious.interval.oneDay': '24 ore', 'whatsapp.chatsSynced': 'chat sincronizzate', 'whatsapp.chatSynced': 'chat sincronizzata', 'sync.active': 'Attivo', @@ -4207,14 +4212,6 @@ const messages: TranslationMap = { 'calls.comingSoonDescription': "Le chiamate assistite dall'IA sono in arrivo. Resta sintonizzato.", 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 minuti', - 'subconscious.interval.tenMinutes': '10 minuti', - 'subconscious.interval.fifteenMinutes': '15 minuti', - 'subconscious.interval.thirtyMinutes': '30 minuti', - 'subconscious.interval.oneHour': '1 ora', - 'subconscious.interval.sixHours': '6 ore', - 'subconscious.interval.twelveHours': '12 ore', - 'subconscious.interval.oneDay': '1 giorno', 'subconscious.priority.critical': 'critico', 'subconscious.priority.important': 'importante', 'subconscious.priority.normal': 'normale', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 0a5ef3a478..af817698f7 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1908,6 +1908,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': '적극적', 'subconscious.mode.aggressive.desc': '전체 도구 접근. 쓰기, 에이전트 생성, 작업 위임 가능.', 'subconscious.mode.aggressiveWarning': '적극적 모드는 잠재의식에 쓰기 및 하위 에이전트 생성을 포함한 전체 도구 접근 권한을 부여합니다.', + 'subconscious.interval.label': '빈도', + 'subconscious.interval.minutes': '{n}분', + 'subconscious.interval.hours': '{n}시간', + 'subconscious.interval.oneHour': '1시간', + 'subconscious.interval.oneDay': '24시간', 'whatsapp.chatsSynced': '채팅 동기화됨', 'whatsapp.chatSynced': '채팅 동기화됨', 'sync.active': '활성', @@ -4103,14 +4108,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': '소스별 필터링', 'calls.comingSoonDescription': 'AI 지원 통화가 곧 제공됩니다. 기대해 주세요.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5분', - 'subconscious.interval.tenMinutes': '10분', - 'subconscious.interval.fifteenMinutes': '15분', - 'subconscious.interval.thirtyMinutes': '30분', - 'subconscious.interval.oneHour': '1시간', - 'subconscious.interval.sixHours': '6시간', - 'subconscious.interval.twelveHours': '12시간', - 'subconscious.interval.oneDay': '1일', 'subconscious.priority.critical': '심각', 'subconscious.priority.important': '중요', 'subconscious.priority.normal': '정상', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index cc82f52caa..7801b5306d 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1949,6 +1949,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Agresywny', 'subconscious.mode.aggressive.desc': 'Pełny dostęp do narzędzi. Może pisać, tworzyć agentów i delegować zadania.', 'subconscious.mode.aggressiveWarning': 'Tryb agresywny daje podświadomości pełny dostęp do narzędzi, w tym pisanie i tworzenie podagentów.', + 'subconscious.interval.label': 'Częstotliwość', + 'subconscious.interval.minutes': '{n} min', + 'subconscious.interval.hours': '{n} godz', + 'subconscious.interval.oneHour': '1 godzina', + 'subconscious.interval.oneDay': '24 godziny', 'whatsapp.chatsSynced': 'rozmów zsynchronizowano', 'whatsapp.chatSynced': 'rozmowa zsynchronizowana', 'sync.active': 'Aktywna', @@ -4208,14 +4213,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Filtruj po źródle', 'calls.comingSoonDescription': 'Połączenia wspierane AI pojawią się wkrótce. Bądź na bieżąco.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 min', - 'subconscious.interval.tenMinutes': '10 min', - 'subconscious.interval.fifteenMinutes': '15 min', - 'subconscious.interval.thirtyMinutes': '30 min', - 'subconscious.interval.oneHour': '1 godz.', - 'subconscious.interval.sixHours': '6 godz.', - 'subconscious.interval.twelveHours': '12 godz.', - 'subconscious.interval.oneDay': '1 dzień', 'subconscious.priority.critical': 'krytyczne', 'subconscious.priority.important': 'ważne', 'subconscious.priority.normal': 'normalne', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 24f7f63e87..5bea12a10e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1965,6 +1965,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Agressivo', 'subconscious.mode.aggressive.desc': 'Acesso completo a ferramentas. Pode escrever, criar agentes e delegar tarefas.', 'subconscious.mode.aggressiveWarning': 'O modo agressivo concede ao subconsciente acesso completo a ferramentas, incluindo escrita e criação de subagentes.', + 'subconscious.interval.label': 'Frequência', + 'subconscious.interval.minutes': '{n} min', + 'subconscious.interval.hours': '{n}h', + 'subconscious.interval.oneHour': '1 hora', + 'subconscious.interval.oneDay': '24 horas', 'whatsapp.chatsSynced': 'chats sincronizados', 'whatsapp.chatSynced': 'chat sincronizado', 'sync.active': 'Ativo', @@ -4207,14 +4212,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Filtrar por origem', 'calls.comingSoonDescription': 'Chamadas assistidas por IA chegam em breve. Fique ligado.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 minutos', - 'subconscious.interval.tenMinutes': '10 minutos', - 'subconscious.interval.fifteenMinutes': '15 minutos', - 'subconscious.interval.thirtyMinutes': '30 minutos', - 'subconscious.interval.oneHour': '1 hora', - 'subconscious.interval.sixHours': '6 horas', - 'subconscious.interval.twelveHours': '12 horas', - 'subconscious.interval.oneDay': '1 dia', 'subconscious.priority.critical': 'crítico', 'subconscious.priority.important': 'importante', 'subconscious.priority.normal': 'normal', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 426659a5ee..c0b0d3b180 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1941,6 +1941,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': 'Агрессивный', 'subconscious.mode.aggressive.desc': 'Полный доступ к инструментам. Может писать, создавать агентов и делегировать задачи.', 'subconscious.mode.aggressiveWarning': 'Агрессивный режим даёт подсознанию полный доступ к инструментам, включая запись и создание подагентов.', + 'subconscious.interval.label': 'Частота', + 'subconscious.interval.minutes': '{n} мин', + 'subconscious.interval.hours': '{n} ч', + 'subconscious.interval.oneHour': '1 час', + 'subconscious.interval.oneDay': '24 часа', 'whatsapp.chatsSynced': 'чатов синхронизировано', 'whatsapp.chatSynced': 'чат синхронизирован', 'sync.active': 'Активно', @@ -4175,14 +4180,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Фильтровать по источнику', 'calls.comingSoonDescription': 'Звонки с поддержкой ИИ скоро появятся. Следите за обновлениями.', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5 минут', - 'subconscious.interval.tenMinutes': '10 минут', - 'subconscious.interval.fifteenMinutes': '15 минут', - 'subconscious.interval.thirtyMinutes': '30 минут', - 'subconscious.interval.oneHour': '1 час', - 'subconscious.interval.sixHours': '6 часов', - 'subconscious.interval.twelveHours': '12 часов', - 'subconscious.interval.oneDay': '1 день', 'subconscious.priority.critical': 'критический', 'subconscious.priority.important': 'важный', 'subconscious.priority.normal': 'нормальный', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index bba00c7ae9..9c81d8bab6 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1831,6 +1831,11 @@ const messages: TranslationMap = { 'subconscious.mode.aggressive.title': '积极', 'subconscious.mode.aggressive.desc': '完整工具访问。可写入、创建代理和委派任务。', 'subconscious.mode.aggressiveWarning': '积极模式赋予潜意识完整的工具访问权限,包括写入和创建子代理。', + 'subconscious.interval.label': '频率', + 'subconscious.interval.minutes': '{n}分钟', + 'subconscious.interval.hours': '{n}小时', + 'subconscious.interval.oneHour': '1小时', + 'subconscious.interval.oneDay': '24小时', 'whatsapp.chatsSynced': '个对话已同步', 'whatsapp.chatSynced': '个对话已同步', 'sync.active': '活跃', @@ -3940,14 +3945,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': '按来源过滤', 'calls.comingSoonDescription': '人工智能辅助通话即将推出。敬请关注。', 'whatsapp.title': 'WhatsApp', - 'subconscious.interval.fiveMinutes': '5分钟', - 'subconscious.interval.tenMinutes': '10分钟', - 'subconscious.interval.fifteenMinutes': '15分钟', - 'subconscious.interval.thirtyMinutes': '30分钟', - 'subconscious.interval.oneHour': '1小时', - 'subconscious.interval.sixHours': '6小时', - 'subconscious.interval.twelveHours': '12小时', - 'subconscious.interval.oneDay': '1天', 'subconscious.priority.critical': '批评的', 'subconscious.priority.important': '重要的', 'subconscious.priority.normal': '正常', diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 5be9f1971d..e3ef7ee23a 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -44,10 +44,12 @@ export default function Intelligence() { const { status: subconsciousEngineStatus, mode: subconsciousMode, + intervalMinutes: subconsciousInterval, triggering: subconsciousTriggering, settingMode: subconsciousSettingMode, triggerTick, setMode: setSubconsciousMode, + setIntervalMinutes: setSubconsciousInterval, } = useSubconscious(); // Socket integration @@ -169,10 +171,12 @@ export default function Intelligence() { )} From b296a03ab2a4301b6a258d7635e20aafbf63a216 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 1 Jun 2026 14:03:42 -0700 Subject: [PATCH 10/20] fix(inference): surface actionable error when Managed route fails with no credits (#3121) Co-authored-by: Steven Enamakel --- .../channels/providers/web_errors.rs | 32 +++++++++++++-- src/openhuman/channels/providers/web_tests.rs | 40 +++++++++++++++++++ ...els_provider_leftovers_raw_coverage_e2e.rs | 7 +++- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/openhuman/channels/providers/web_errors.rs b/src/openhuman/channels/providers/web_errors.rs index dc5cdc2cae..58f89819be 100644 --- a/src/openhuman/channels/providers/web_errors.rs +++ b/src/openhuman/channels/providers/web_errors.rs @@ -17,13 +17,33 @@ pub(crate) fn is_inference_budget_exceeded_error(message: &str) -> bool { let normalized = BUDGET_ERROR_NORMALIZE_RE .replace_all(&message.trim().to_ascii_lowercase(), " ") .into_owned(); - BUDGET_ERROR_PATTERNS + if BUDGET_ERROR_PATTERNS .iter() .any(|pattern| pattern.is_match(&normalized)) + { + return true; + } + // Align with the canonical OpenHuman-backend budget detector + // (`billing_error::is_budget_exhausted_message`) so the managed + // no-credits response — a 400 carrying "Insufficient budget" / + // "Insufficient balance" — surfaces the actionable budget message + // below instead of the generic "Something went wrong" apology + // (issue #3088). Without this, an Ollama user with zero credits and + // routing still on Managed sees an opaque "provider error" and has no + // way to self-diagnose that they must top up or switch routing. + crate::openhuman::inference::provider::is_budget_exhausted_message(message) } pub(crate) fn inference_budget_exceeded_user_message() -> &'static str { - "I don't have any budget available right now. Please top up your credits or choose a plan to continue." + // Keep the literal "top up" / "credits" tokens (asserted by + // `budget_exceeded_copy_mentions_top_up`) and add the self-diagnosis + // path for issue #3088: a user who enabled a local model but left + // routing on Managed needs to know they can switch to their own model + // rather than being stuck. We guide, never auto-switch — the user's + // routing choice in Settings is respected. + "You're out of credits, so I can't run the managed (cloud) model right now. \ + You can top up your credits or pick a plan to continue — or, if you've enabled a \ + local model like Ollama, switch routing to \"Use Your Own Models\" in Settings → AI Configuration." } pub(crate) fn generic_inference_error_user_message() -> &'static str { @@ -379,6 +399,12 @@ pub(crate) fn classify_inference_error(err: &str) -> ClassifiedError { } else if lower.contains("402") || lower.contains("payment required") || lower.contains("insufficient balance") + // Issue #3088: the OpenHuman managed backend reports no-credits as a + // 400 with "Insufficient budget" (not a 402), which previously fell + // through to the generic "Something went wrong" branch. Catch the + // canonical budget phrases here so the user gets the actionable + // top-up / switch-to-your-own-model guidance instead. + || is_inference_budget_exceeded_error(err) { // `openhuman_billing` means OpenHuman's own credit/quota system — // a 402 carrying the "openhuman" envelope (or no envelope at all, @@ -392,7 +418,7 @@ pub(crate) fn classify_inference_error(err: &str) -> ClassifiedError { }; ClassifiedError { error_type: "budget_exhausted", - message: with_provider_detail("Insufficient credits. Please top up to continue.", err), + message: with_provider_detail(inference_budget_exceeded_user_message(), err), source, retryable: false, retry_after_ms: None, diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs index e2d2065284..2f2b1cfd9b 100644 --- a/src/openhuman/channels/providers/web_tests.rs +++ b/src/openhuman/channels/providers/web_tests.rs @@ -134,6 +134,16 @@ fn detects_backend_budget_exhaustion_error() { assert!(is_inference_budget_exceeded_error( "provider error: budget exceeded, please add credits" )); + // Issue #3088: the OpenHuman managed backend reports no-credits as a + // 400 carrying these canonical phrases (see `billing_error.rs`). They + // were previously NOT recognised here, so the error fell through to the + // generic "Something went wrong" branch. They must now match. + assert!(is_inference_budget_exceeded_error( + "openhuman API error (400 Bad Request): Insufficient budget" + )); + assert!(is_inference_budget_exceeded_error( + "openhuman API error (400 Bad Request): Insufficient balance" + )); assert!(!is_inference_budget_exceeded_error( "OpenHuman API error (500): Internal server error" )); @@ -144,6 +154,36 @@ fn budget_exceeded_copy_mentions_top_up() { let message = inference_budget_exceeded_user_message(); assert!(message.contains("top up")); assert!(message.contains("credits")); + // Issue #3088: the copy must guide the user to the self-service fix — + // switching routing to their own local model — so an Ollama user with + // no credits can self-diagnose. We guide, never auto-switch. + assert!(message.contains("Use Your Own Models")); + assert!(message.contains("Settings")); +} + +#[test] +fn classify_inference_error_managed_insufficient_budget_400_is_budget_exhausted() { + // Issue #3088: a managed (OpenHuman backend) no-credits failure arrives + // as a 400 with "Insufficient budget" — NOT a 402. It previously fell + // through to the generic `inference` branch ("Something went wrong"), + // leaving the user unable to self-diagnose. It must now classify as + // budget_exhausted with actionable, non-retryable copy. + let raw = "openhuman API error (400 Bad Request): Insufficient budget"; + let classified = classify_inference_error(raw); + assert_eq!(classified.error_type, "budget_exhausted"); + assert_eq!( + classified.source, "openhuman_billing", + "the OpenHuman backend's own credit system is the origin" + ); + assert!( + !classified.retryable, + "out of credits — retrying the same prompt won't help" + ); + assert!( + classified.message.contains("Use Your Own Models"), + "must guide the user to switch routing: {}", + classified.message + ); } #[test] diff --git a/tests/channels_provider_leftovers_raw_coverage_e2e.rs b/tests/channels_provider_leftovers_raw_coverage_e2e.rs index 84b3008323..496b8455aa 100644 --- a/tests/channels_provider_leftovers_raw_coverage_e2e.rs +++ b/tests/channels_provider_leftovers_raw_coverage_e2e.rs @@ -318,11 +318,14 @@ async fn web_round19_covers_classifier_variants_and_cancel_cleanup() { assert_eq!(auth.source, "config"); assert!(!auth.retryable); + // Issue #3088: budget-signal strings now classify as `budget_exhausted` + // instead of falling through to the generic `inference` branch — the + // user gets an actionable "top up or switch routing" message. let budget = web_test_support::classify_error_for_test( "inference budget exceeded: monthly limit reached", ); - assert_eq!(budget.error_type, "inference"); - assert_eq!(budget.source, "provider"); + assert_eq!(budget.error_type, "budget_exhausted"); + assert_eq!(budget.source, "openhuman_billing"); let network = web_test_support::classify_error_for_test( "request error: dns error while trying to connect", From aa91c66bc3c925ec58ca2a96dbd8705fc7ffd4d5 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:52:25 +0530 Subject: [PATCH 11/20] fix(memory/safety): exclude bare-phone patterns from strict PII rejection (#2848) (#3028) --- src/openhuman/memory_store/safety/pii.rs | 137 ++++++++++++++++++++--- 1 file changed, 120 insertions(+), 17 deletions(-) diff --git a/src/openhuman/memory_store/safety/pii.rs b/src/openhuman/memory_store/safety/pii.rs index bf7c057edd..87390d5091 100644 --- a/src/openhuman/memory_store/safety/pii.rs +++ b/src/openhuman/memory_store/safety/pii.rs @@ -190,13 +190,15 @@ pub fn redact_pii(text: &str) -> Sanitized { /// [`super::has_likely_secret`]). /// /// Uses the **strict** match set — only formatted / keyword-gated patterns. -/// Bare-numeric patterns whose only gate is a checksum (credit card via Luhn, -/// bare CPF, bare CNPJ) are excluded here because their false-positive rate -/// against random digit runs (millisecond timestamps, sequence IDs, padded -/// counters) is too high to use as a hard rejection signal on internal -/// identifiers. Content scrubbing via [`redact_pii`] still applies those -/// patterns — false positives are tolerable there because they only replace -/// bytes inside a string, not reject the whole write. +/// Bare-numeric patterns whose only signal is a digit run (credit card via +/// Luhn, bare CPF, bare CNPJ) or a phone-shaped digit run (NANP without +/// separators, E.164 leading `+`) are excluded here because their false- +/// positive rate against scanner-built namespace/key identifiers (WhatsApp +/// JIDs like `12025551234-1543890267@g.us`, telegram numeric peer IDs, +/// millisecond timestamps, padded counters) is too high to use as a hard +/// rejection signal. Content scrubbing via [`redact_pii`] still applies +/// those patterns — false positives are tolerable there because they only +/// replace bytes inside a string, not reject the whole write. pub fn has_likely_pii(value: &str) -> bool { let nview = NormalizedView::build(value); SCREEN.is_match(&nview.normalized) && !collect_strict_redactions(&nview.normalized).is_empty() @@ -215,16 +217,19 @@ fn collect_redactions(norm: &str) -> Vec { collect_redactions_inner(norm, true) } -/// Variant of [`collect_redactions`] that omits bare-numeric checksum -/// patterns (credit card via Luhn, bare CPF, bare CNPJ). Used for -/// boundary checks like [`has_likely_pii`] where rejection on a checksum -/// hit alone would have too many false positives on internal identifiers -/// (timestamps, padded counters). +/// Variant of [`collect_redactions`] that omits bare-numeric patterns +/// whose only signal is a digit-run shape: credit card via Luhn, bare +/// CPF, bare CNPJ, NANP phones (separators optional, so any 10-11 digit +/// run starting `[2-9]`/`1[2-9]` matches), and E.164 phones (literal `+` +/// the only signal). Used for boundary checks like [`has_likely_pii`] +/// where rejection on such a hit alone would have too many false +/// positives on scanner-built identifiers (WhatsApp group JIDs +/// `-@g.us`, timestamps, padded counters). fn collect_strict_redactions(norm: &str) -> Vec { collect_redactions_inner(norm, false) } -fn collect_redactions_inner(norm: &str, include_bare_checksum: bool) -> Vec { +fn collect_redactions_inner(norm: &str, include_bare_numeric: bool) -> Vec { let mut hits: Vec = Vec::new(); // Priority order: most specific / highest-confidence first. @@ -241,7 +246,7 @@ fn collect_redactions_inner(norm: &str, include_bare_checksum: bool) -> Vec // IBAN before credit card: CC can match an IBAN tail of all digits. push_checksum(&mut hits, norm, &IBAN_RE, PII_IBAN, |s| valid_iban(s)); - if include_bare_checksum { + if include_bare_numeric { // Credit card before bare CPF/CNPJ to avoid catching a 13-19 digit run as CPF/CNPJ. push_checksum(&mut hits, norm, &CC_RE, PII_CC, |s| valid_luhn(s)); @@ -269,9 +274,16 @@ fn collect_redactions_inner(norm: &str, include_bare_checksum: bool) -> Vec push_simple(&mut hits, norm, &RFC_RE, PII_RFC); push_simple(&mut hits, norm, &PAN_IN_RE, PII_PAN_IN); - // Phones: E.164 first (more specific), then NANP. - push_simple(&mut hits, norm, &PHONE_E164_RE, PII_PHONE); - push_simple(&mut hits, norm, &PHONE_NANP_RE, PII_PHONE); + if include_bare_numeric { + // Phones: E.164 first (more specific), then NANP. Both are bare-numeric + // shapes — NANP allows optional separators (`\b\d{10,11}\b` matches as + // `XXX-XXX-XXXX`), and E.164 keys on a literal `+` with no further gate. + // Strict callers (boundary checks like `has_likely_pii`) exclude these + // so scanner-built namespace/key values (WhatsApp JIDs + // `-@g.us`, telegram numeric peer IDs) don't get rejected. + push_simple(&mut hits, norm, &PHONE_E164_RE, PII_PHONE); + push_simple(&mut hits, norm, &PHONE_NANP_RE, PII_PHONE); + } // My Number — captured digit group only, keyword remains visible. push_captured(&mut hits, norm, &MYNUM_RE, PII_MYNUM, |_| true); @@ -981,6 +993,97 @@ Phone +15551234567."; assert!(has_likely_pii("cuit-20-11111111-2")); } + /// Regression for Sentry TAURI-RUST-54T / GH #2848: scanner-built + /// `namespace` and `key` values containing bare-numeric phone-shaped + /// digit runs (WhatsApp group JID `-@g.us`, WhatsApp + /// broadcast `@broadcast`, US-prefixed WhatsApp 1:1 JID, + /// telegram numeric peer ID) must NOT be rejected by the boundary + /// PII check. NANP matches `\d{10,11}` with optional separators — + /// strict mode must skip it. Content scrubbing via `redact_pii` + /// continues to redact these substrings (see + /// `redact_pii_still_blurs_bare_phone_in_content` below). + #[test] + fn has_likely_pii_ignores_scanner_bare_phone_keys() { + for key in [ + // WhatsApp group JID — chat_id = "-@g.us" + "12025551234-1543890267@g.us:2026-05-30", + // WhatsApp broadcast list + "12025551234@broadcast:2026-05-30", + // WhatsApp 1:1 JID, country-coded US number (`1` + 10 digits) + "12025551234@c.us:2026-05-30", + // Same shape carried in the namespace + "whatsapp-web:12025551234@c.us", + "whatsapp-web:12025551234-1543890267@g.us", + // Telegram numeric peer_id key + "4123456789:2026-05-30", + ] { + assert!( + !has_likely_pii(key), + "scanner-built key {key:?} must not be rejected as PII" + ); + } + } + + /// Same regression but for the E.164 (`+`-prefixed) shape — iMessage + /// posts `key = format!("{chat_id}:{day}")` where `chat_id` can be + /// `+12025551234`. Strict mode must skip; content redaction stays. + #[test] + fn has_likely_pii_ignores_bare_e164_phone_keys() { + for key in [ + "+12025551234:2026-05-30", + "imessage:+12025551234", + "imessage:+12025551234:2026-05-30", + ] { + assert!( + !has_likely_pii(key), + "E.164-shaped key {key:?} must not be rejected as PII" + ); + } + } + + /// `redact_pii` (content scrubbing path — NOT the boundary check) + /// must still redact formatted NANP and E.164 phone numbers found + /// inside document bodies. False positives in the content path only + /// blur substring bytes; they do not reject the write — which is the + /// asymmetry this PR preserves vs. the boundary check. + /// + /// Note: bare 10-digit NANP runs (`2025551234` with no separators) + /// are NOT reached by `redact_pii` at all — the SCREEN fast-path + /// requires either `\d{11,}`, a separator, or `+`, so a bare 10-digit + /// run short-circuits as "no candidate". That pre-existed this PR; a + /// pinning sentinel for it lives below. + #[test] + fn redact_pii_still_blurs_formatted_and_e164_phone_in_content() { + let out = redact_pii("call me at 202-555-1234 or +12025551234"); + let n_phone = out.value.matches(PII_PHONE).count(); + assert!( + n_phone >= 2, + "redact_pii must still blur both formatted NANP and E.164 phones in content, \ + got {n_phone} PII_PHONE token(s) in: {}", + out.value + ); + assert!(out.report.pii_redactions >= 2); + } + + /// Sentinel pinning a pre-existing SCREEN limitation: a bare 10-digit + /// NANP run (`2025551234` with no separators) is short-circuited by + /// the `SCREEN` fast-path because no `SCREEN` regex matches a 10-digit + /// bare run (`\d{11,}` is the closest, but it needs 11+). This is the + /// status quo on `main` — this PR does not change it. The test exists + /// so any future widening of `SCREEN` (e.g. to catch bare NANP) trips + /// here as a deliberate review checkpoint, NOT a regression. + #[test] + fn redact_pii_does_not_reach_bare_10_digit_nanp_today() { + let out = redact_pii("call me at 2025551234 thanks"); + assert!( + !out.value.contains(PII_PHONE), + "SCREEN fast-path historically skips bare 10-digit NANP — \ + if this test fails, SCREEN was widened; revisit the boundary-check \ + behavior in `has_likely_pii` before adjusting. Got: {}", + out.value + ); + } + #[test] fn empty_text_is_noop() { unchanged(""); From ea1a5b862faf37d18d0e67fd35ac8db55694c2f3 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 2 Jun 2026 03:33:14 +0530 Subject: [PATCH 12/20] test: green Rust Core Coverage (stale assertions + env-race serialization) (#3156) Co-authored-by: Claude Opus 4.8 (1M context) --- src/core/cli.rs | 9 +++++++++ tests/near90_closure_raw_coverage_e2e.rs | 11 +++++++++++ tests/tools_approval_channels_raw_coverage_e2e.rs | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/src/core/cli.rs b/src/core/cli.rs index 52bc5d5641..5c4d592d24 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -278,8 +278,17 @@ fn run_server_command(args: &[String]) -> Result<()> { crate::core::logging::init_for_cli_run(verbose, log_scope); // Initialize the Tokio multi-threaded runtime. + // + // A single agent turn is a very large async state machine (system prompt + + // hundreds of tool specs + the nested provider/tool loop), and delegating + // to a sub-agent runs another full turn one level down. Even with the inner + // sub-agent future boxed (`subagent_runner::ops`), that nesting overflows + // tokio's default 2 MiB worker-thread stack and aborts the whole process + // (SIGABRT: "thread 'tokio-rt-worker' has overflowed its stack"), taking + // the JSON-RPC server down mid-request. Give workers a roomier stack. let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() + .thread_stack_size(16 * 1024 * 1024) .build()?; rt.block_on(async { crate::core::jsonrpc::run_server(host.as_deref(), port, socketio_enabled).await diff --git a/tests/near90_closure_raw_coverage_e2e.rs b/tests/near90_closure_raw_coverage_e2e.rs index 1017d9e4d3..02e3748b1c 100644 --- a/tests/near90_closure_raw_coverage_e2e.rs +++ b/tests/near90_closure_raw_coverage_e2e.rs @@ -441,6 +441,17 @@ async fn round20_memory_sources_readers_and_sync_cover_error_edges_without_netwo std::fs::create_dir_all(&bin).expect("bin dir"); let script = bin.join("gh"); write_fake_gh_round20(&script); + let git_stub = bin.join("git"); + std::fs::write(&git_stub, "#!/usr/bin/env bash\nexit 1\n").expect("write fake git"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&git_stub) + .expect("metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&git_stub, perms).expect("chmod fake git"); + } let old_path = std::env::var("PATH").unwrap_or_default(); let _path = EnvGuard::set("PATH", format!("{}:{old_path}", bin.display())); diff --git a/tests/tools_approval_channels_raw_coverage_e2e.rs b/tests/tools_approval_channels_raw_coverage_e2e.rs index 501ffdda0f..255b381864 100644 --- a/tests/tools_approval_channels_raw_coverage_e2e.rs +++ b/tests/tools_approval_channels_raw_coverage_e2e.rs @@ -1606,6 +1606,14 @@ fn tools_and_tool_registry_public_surfaces_cover_schema_and_assembly_paths() { #[tokio::test] async fn orchestrator_tool_synthesis_covers_agent_and_integration_delegation_edges() { + // This test reads the process-global connection/toolkit registry (the + // integrations tool's available-toolkit list). Sibling tests mutate + // OPENHUMAN_WORKSPACE under env_lock; without holding it here, a concurrent + // workspace swap trampled our view and dropped gmail_pro/slack_bot from the + // unknown-toolkit suggestion (flaky only under llvm-cov's slower parallel + // run). Hold the same lock so this test is hermetic without serializing the + // whole suite. + let _lock = env_lock(); let mut registry = AgentDefinitionRegistry::default(); registry.insert(coverage_agent_definition( "researcher", From 8a6a253325e990785aacf0d61321295993f535f2 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 2 Jun 2026 04:11:57 +0530 Subject: [PATCH 13/20] fix(composio): complete connection-disconnect cleanup (config entry + memory tree) (#3140) Co-authored-by: Claude Opus 4.8 (1M context) --- src/openhuman/composio/ops.rs | 24 + src/openhuman/composio/ops_tests.rs | 240 ++++++++++ src/openhuman/memory_sources/mod.rs | 5 +- src/openhuman/memory_sources/registry.rs | 29 ++ src/openhuman/memory_store/chunks/store.rs | 30 ++ .../memory_store/chunks/store_tests.rs | 438 ++++++++++++++++++ src/openhuman/memory_store/trees/store.rs | 66 +++ tests/memory_sync_sources_raw_coverage_e2e.rs | 79 +++- 8 files changed, 907 insertions(+), 4 deletions(-) diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index 198a581753..4b56342021 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -497,6 +497,30 @@ pub async fn composio_delete_connection( ); } } + // Prune the local memory_sources registry entry for this connection. + // The registry keys composio sources by `connection_id` and the + // reconciler only ever upserts, so a deleted connection's + // `[[memory_sources]]` entry is otherwise orphaned forever (and on + // reconnect the backend mints a fresh `connection_id`, leaving the stale + // one stranded). Best-effort: the backend connection is already gone, so + // a config-save failure must not fail the whole delete — log and move on. + match crate::openhuman::memory_sources::registry::remove_composio_source_by_connection_id( + connection_id, + ) + .await + { + Ok(0) => {} + Ok(removed) => tracing::debug!( + connection_id = %connection_id, + removed, + "[composio] pruned memory_sources entry after connection deletion" + ), + Err(e) => tracing::warn!( + connection_id = %connection_id, + error = %e, + "[composio] failed to prune memory_sources entry after connection deletion (non-fatal)" + ), + } crate::core::event_bus::publish_global( crate::core::event_bus::DomainEvent::ComposioConnectionDeleted { toolkit: toolkit.unwrap_or_else(|| "unknown".to_string()), diff --git a/src/openhuman/composio/ops_tests.rs b/src/openhuman/composio/ops_tests.rs index 70a881597c..5e7d928137 100644 --- a/src/openhuman/composio/ops_tests.rs +++ b/src/openhuman/composio/ops_tests.rs @@ -522,6 +522,246 @@ async fn composio_delete_connection_clear_memory_deletes_slack_source() { assert_eq!(remaining[0].metadata.source_id, "slack:c2"); } +/// #4: full path through the REAL `composio_delete_connection` handler +/// (clear_memory=true, mock backend) — deleting a connection's last chunk must +/// cascade away its source summary tree AND the summary's on-disk content file, +/// not just the chunk rows. The tree is a real `get_or_create_source_tree`; the +/// content file sits at the production `content_path` location. +#[tokio::test] +async fn composio_delete_connection_clear_memory_cascades_source_tree_and_content_file() { + use crate::openhuman::memory::tree_source::registry::get_or_create_source_tree; + use crate::openhuman::memory_store::trees::store as tree_store; + use crate::openhuman::memory_store::trees::types::{SummaryNode, TreeKind}; + use rusqlite::params; + + let app = Router::new() + .route( + "/agent-integrations/composio/connections", + get(|| async { + Json(json!({ + "success": true, + "data": {"connections": [ + {"id":"c1","toolkit":"slack","status":"ACTIVE"} + ]} + })) + }), + ) + .route( + "/agent-integrations/composio/connections/{id}", + axum::routing::delete(|Path(_id): Path| async move { + Json(json!({"success": true, "data": {"deleted": true}})) + }), + ); + let base = start_mock_backend(app).await; + let tmp = tempfile::tempdir().unwrap(); + let config = config_with_backend(&tmp, base); + + // One slack chunk for connection c1 → source_id `slack:c1`. + let chunk = sample_memory_chunk(SourceKind::Chat, "slack:c1", 0); + memory_tree_store::upsert_chunks(&config, &[chunk.clone()]).expect("seed chunk"); + + // Real source tree for that source + a summary whose content file lives at + // the production content-root location. + let tree = get_or_create_source_tree(&config, "slack:c1").expect("source tree"); + let ts = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); + let rel = "summaries/slack_c1/L1/sum-1.md"; + let abs = config.memory_tree_content_root().join(rel); + std::fs::create_dir_all(abs.parent().unwrap()).unwrap(); + std::fs::write(&abs, "summarised slack body").unwrap(); + + memory_tree_store::with_connection(&config, |conn| { + let tx = conn.unchecked_transaction()?; + tree_store::insert_summary_tx( + &tx, + &SummaryNode { + id: "sum-1".into(), + tree_id: tree.id.clone(), + tree_kind: TreeKind::Source, + level: 1, + parent_id: None, + child_ids: vec![chunk.id.clone()], + content: "preview".into(), + token_count: 3, + entities: vec![], + topics: vec![], + time_range_start: ts, + time_range_end: ts, + score: 0.5, + sealed_at: ts, + deleted: false, + embedding: None, + }, + None, + "test/model@3", + )?; + tx.execute( + "UPDATE mem_tree_summaries SET content_path = ?1 WHERE id = 'sum-1'", + params![rel], + )?; + tx.commit()?; + Ok(()) + }) + .expect("seed summary + content file pointer"); + + // sanity: tree + on-disk file exist before the disconnect. + assert!( + tree_store::get_tree_by_scope(&config, TreeKind::Source, "slack:c1") + .unwrap() + .is_some() + ); + assert!(abs.exists()); + + // ---- act: the REAL handler, clear_memory=true ---- + let outcome = composio_delete_connection(&config, "c1", true) + .await + .unwrap(); + assert!(outcome.value.deleted); + assert_eq!(outcome.value.memory_chunks_deleted, 1); + + // chunk, source tree, summary row, AND on-disk content file are all gone. + assert!(memory_tree_store::get_chunk(&config, &chunk.id) + .unwrap() + .is_none()); + assert!( + tree_store::get_tree_by_scope(&config, TreeKind::Source, "slack:c1") + .unwrap() + .is_none() + ); + memory_tree_store::with_connection(&config, |conn| { + let n: i64 = conn.query_row("SELECT COUNT(*) FROM mem_tree_summaries", [], |r| r.get(0))?; + assert_eq!(n, 0); + Ok(()) + }) + .unwrap(); + assert!( + !abs.exists(), + "summary content file must be removed via the real handler cascade" + ); +} + +/// #4 (full live seal): like the above, but the summary + on-disk file are +/// produced by the REAL `seal_one_level` pipeline (staged chunk body → +/// summarise → `stage_summary`), not hand-written. Then the REAL +/// `composio_delete_connection(clear_memory=true)` handler must cascade the +/// tree, the summary row, AND the seal-produced content file away. +#[tokio::test] +async fn composio_delete_connection_clear_memory_cascades_live_sealed_tree_and_file() { + use crate::openhuman::memory::tree_source::registry::get_or_create_source_tree; + use crate::openhuman::memory_store::chunks::store::{ + get_summary_content_pointers, upsert_staged_chunks_tx, + }; + use crate::openhuman::memory_store::content::stage_chunks; + use crate::openhuman::memory_store::trees::store as tree_store; + use crate::openhuman::memory_store::trees::types::{Buffer, TreeKind}; + use crate::openhuman::memory_tree::tree::bucket_seal::{seal_one_level, LabelStrategy}; + + let app = Router::new() + .route( + "/agent-integrations/composio/connections", + get(|| async { + Json(json!({ + "success": true, + "data": {"connections": [ + {"id":"c1","toolkit":"slack","status":"ACTIVE"} + ]} + })) + }), + ) + .route( + "/agent-integrations/composio/connections/{id}", + axum::routing::delete(|Path(_id): Path| async move { + Json(json!({"success": true, "data": {"deleted": true}})) + }), + ); + let base = start_mock_backend(app).await; + let tmp = tempfile::tempdir().unwrap(); + let mut config = config_with_backend(&tmp, base); + // Force the inert embedder so the real seal's summary-embed step doesn't + // reach a live endpoint. `config_with_backend` stores a cloud session + + // api_url, so the factory would otherwise build a *cloud* embedder against + // the mock (no embeddings route). `embeddings_provider = "none"` is the + // actual switch that selects `InertEmbedder`. + config.embeddings_provider = Some("none".to_string()); + config.memory_tree.embedding_endpoint = None; + config.memory_tree.embedding_model = None; + config.memory_tree.embedding_strict = false; + + // Real chunk for slack:c1 WITH its body staged to disk, so the seal's + // `hydrate_leaf_inputs` → `read_chunk_body` can resolve it. + let chunk = sample_memory_chunk(SourceKind::Chat, "slack:c1", 0); + memory_tree_store::upsert_chunks(&config, &[chunk.clone()]).expect("seed chunk"); + let staged = stage_chunks( + &config.memory_tree_content_root(), + std::slice::from_ref(&chunk), + ) + .expect("stage chunk body"); + memory_tree_store::with_connection(&config, |conn| { + let tx = conn.unchecked_transaction()?; + upsert_staged_chunks_tx(&tx, &staged)?; + tx.commit()?; + Ok(()) + }) + .expect("record staged chunk pointer"); + + // Run the REAL seal — produces a genuine summary row + on-disk file. + let tree = get_or_create_source_tree(&config, "slack:c1").expect("source tree"); + let buf = Buffer { + tree_id: tree.id.clone(), + level: 0, + item_ids: vec![chunk.id.clone()], + token_sum: i64::from(chunk.token_count), + oldest_at: Some(chunk.metadata.time_range.0), + }; + let summary_id = seal_one_level(&config, &tree, &buf, &LabelStrategy::Empty, false) + .await + .expect("real seal produces a summary"); + + // The seal wrote a real on-disk content file for the summary. + let (rel, _sha) = get_summary_content_pointers(&config, &summary_id) + .unwrap() + .expect("seal staged a summary content file"); + let abs = { + let mut p = config.memory_tree_content_root(); + for c in rel.split('/') { + p.push(c); + } + p + }; + assert!( + abs.exists(), + "seal must have written a summary file on disk" + ); + assert!( + tree_store::get_tree_by_scope(&config, TreeKind::Source, "slack:c1") + .unwrap() + .is_some() + ); + + // ---- act: REAL handler, clear_memory=true ---- + let outcome = composio_delete_connection(&config, "c1", true) + .await + .unwrap(); + assert!(outcome.value.deleted); + assert_eq!(outcome.value.memory_chunks_deleted, 1); + + // chunk, tree, summary row, and the seal-produced file are all gone. + assert!(memory_tree_store::get_chunk(&config, &chunk.id) + .unwrap() + .is_none()); + assert!( + tree_store::get_tree_by_scope(&config, TreeKind::Source, "slack:c1") + .unwrap() + .is_none() + ); + assert!(tree_store::get_summary(&config, &summary_id) + .unwrap() + .is_none()); + assert!( + !abs.exists(), + "seal-produced summary file must be removed via the real handler cascade" + ); +} + #[tokio::test] async fn composio_delete_connection_clear_memory_keeps_other_gmail_connections() { let app = Router::new() diff --git a/src/openhuman/memory_sources/mod.rs b/src/openhuman/memory_sources/mod.rs index 2c680b1107..565d0917ce 100644 --- a/src/openhuman/memory_sources/mod.rs +++ b/src/openhuman/memory_sources/mod.rs @@ -25,8 +25,9 @@ pub mod sync; pub mod types; pub use registry::{ - add_source, get_source, list_enabled_by_kind, list_sources, remove_source, update_source, - upsert_composio_source, MemorySourcePatch, + add_source, get_source, list_enabled_by_kind, list_sources, + remove_composio_source_by_connection_id, remove_source, update_source, upsert_composio_source, + MemorySourcePatch, }; pub use schemas::{ all_controller_schemas as all_memory_sources_controller_schemas, diff --git a/src/openhuman/memory_sources/registry.rs b/src/openhuman/memory_sources/registry.rs index da21dc8fd5..98d0afd48f 100644 --- a/src/openhuman/memory_sources/registry.rs +++ b/src/openhuman/memory_sources/registry.rs @@ -137,6 +137,35 @@ pub async fn remove_source(id: &str) -> Result { Ok(removed) } +/// Remove every composio source bound to `connection_id` — the disconnect path. +/// +/// Mirrors [`upsert_composio_source`], which keys composio sources on +/// `connection_id`. [`remove_source`] keys on the `src_*` id, which the +/// connection-delete flow doesn't have, so this is the connection-keyed +/// counterpart. Returns the number of entries removed (0 if none matched). +pub async fn remove_composio_source_by_connection_id(connection_id: &str) -> Result { + let mut config = config_rpc::load_config_with_timeout().await?; + let before = config.memory_sources.len(); + config.memory_sources.retain(|s| { + !(s.kind == SourceKind::Composio && s.connection_id.as_deref() == Some(connection_id)) + }); + let removed = before - config.memory_sources.len(); + + if removed > 0 { + tracing::info!( + connection_id = %connection_id, + removed, + "[memory_sources] removed composio source(s) on connection disconnect" + ); + config + .save() + .await + .map_err(|e| format!("failed to save config: {e:#}"))?; + } + + Ok(removed) +} + /// Upsert a composio source — used by the auto-registration path. /// If a source with the same `connection_id` already exists, updates /// the label; otherwise inserts a new entry. diff --git a/src/openhuman/memory_store/chunks/store.rs b/src/openhuman/memory_store/chunks/store.rs index 122f8e47b8..ea470622cd 100644 --- a/src/openhuman/memory_store/chunks/store.rs +++ b/src/openhuman/memory_store/chunks/store.rs @@ -967,6 +967,36 @@ fn delete_chunks_by_source_filter( )?; } + // A fully-orphaned source has zero chunks left, so its summary tree + // now summarises deleted content — and its unsealed buffer holds + // dangling chunk ids. Cascade-delete the tree (summaries + sidecars + // + entity-index + buffer + tree row) so a `clear_memory` delete is + // complete and stale summaries can't resurface in retrieval. Source + // trees use the chunk `source_id` verbatim as their scope, so we + // match on that. Same tx as the chunk delete → atomic. + for source_id in &orphaned_deleted_sources { + if let Some(tree) = + crate::openhuman::memory_store::trees::store::get_tree_by_scope_conn( + &tx, + crate::openhuman::memory_store::trees::types::TreeKind::Source, + source_id, + )? + { + let cascade = crate::openhuman::memory_store::trees::store::delete_tree_cascade_tx( + &tx, &tree.id, + )?; + // Defer the summary content-file removal to the same + // post-commit sweep as the chunk files. + content_paths.extend(cascade.content_paths); + log::debug!( + "[memory::chunk_store] {op}: orphaned source_id_hash={} → deleted source tree tree_id={} summaries={}", + redact_value(source_id), + tree.id, + cascade.removed_summaries, + ); + } + } + let deleted = chunks.len(); tx.commit()?; Ok(deleted) diff --git a/src/openhuman/memory_store/chunks/store_tests.rs b/src/openhuman/memory_store/chunks/store_tests.rs index 06cf8c4658..d09cde8307 100644 --- a/src/openhuman/memory_store/chunks/store_tests.rs +++ b/src/openhuman/memory_store/chunks/store_tests.rs @@ -297,6 +297,444 @@ fn delete_chunks_by_source_removes_chunks_side_rows_and_ingest_gate() { .unwrap(); } +/// Forget-path (`clear_memory=true`) e2e: deleting the last chunk of a source +/// must cascade-delete its summary tree (tree row + summaries + sidecars + +/// entity-index + unsealed buffer), leave a sibling source untouched, and a +/// queued `Seal` job for the now-gone tree must settle to `Done` (not stick +/// in pending). Mocked connection (tempdir), chunks, tree/summary/buffer, job. +#[tokio::test] +async fn clear_memory_delete_cascades_orphaned_source_tree_and_settles_queued_job() { + use crate::openhuman::memory_queue::{store as queue_store, types as queue_types}; + use crate::openhuman::memory_store::trees::store as tree_store; + use crate::openhuman::memory_store::trees::types::{ + Buffer, SummaryNode, Tree, TreeKind, TreeStatus, + }; + + let (_tmp, cfg) = test_config(); + let ts = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); + + // ---- mocked chunks: gmail:acct (conn-1, disconnecting) + gmail:other (conn-2, survives) ---- + let mk_email = |source_id: &str, seq: u32, owner: &str, ts_ms: i64| { + let mut c = sample_chunk(source_id, seq, ts_ms); + c.metadata.source_kind = SourceKind::Email; + c.metadata.owner = owner.to_string(); + c + }; + let a0 = mk_email("gmail:acct", 0, "gmail-sync:conn-1", 1_700_000_000_000); + let a1 = mk_email("gmail:acct", 1, "gmail-sync:conn-1", 1_700_000_001_000); + let b0 = mk_email("gmail:other", 0, "gmail-sync:conn-2", 1_700_000_002_000); + upsert_chunks(&cfg, &[a0.clone(), a1.clone(), b0.clone()]).unwrap(); + + // ---- mocked source trees (scope == source_id), each with summary + sidecars + entity-index + buffer ---- + let mk_tree = |id: &str, scope: &str| Tree { + id: id.into(), + kind: TreeKind::Source, + scope: scope.into(), + root_id: None, + max_level: 1, + status: TreeStatus::Active, + created_at: ts, + last_sealed_at: Some(ts), + }; + tree_store::insert_tree(&cfg, &mk_tree("tree-acct", "gmail:acct")).unwrap(); + tree_store::insert_tree(&cfg, &mk_tree("tree-other", "gmail:other")).unwrap(); + + let mk_summary = |id: &str, tree_id: &str, children: Vec| SummaryNode { + id: id.into(), + tree_id: tree_id.into(), + tree_kind: TreeKind::Source, + level: 1, + parent_id: None, + child_ids: children, + content: format!("summary for {tree_id}"), + token_count: 3, + entities: vec![], + topics: vec![], + time_range_start: ts, + time_range_end: ts, + score: 0.5, + sealed_at: ts, + deleted: false, + embedding: None, + }; + + with_connection(&cfg, |conn| { + let tx = conn.unchecked_transaction()?; + + tree_store::insert_summary_tx( + &tx, + &mk_summary("sum-acct", "tree-acct", vec![a0.id.clone(), a1.id.clone()]), + None, + "test/model@3", + )?; + tree_store::insert_summary_tx( + &tx, + &mk_summary("sum-other", "tree-other", vec![b0.id.clone()]), + None, + "test/model@3", + )?; + + // summary sidecars: embeddings for both summaries, reembed-skip only for sum-acct. + for sid in ["sum-acct", "sum-other"] { + tx.execute( + "INSERT INTO mem_tree_summary_embeddings ( + summary_id, model_signature, vector, dim, created_at + ) VALUES (?1, 'test/model@3', ?2, 3, 1700000000.0)", + params![sid, vec![1_u8, 2, 3]], + )?; + } + tx.execute( + "INSERT INTO mem_tree_summary_reembed_skipped ( + summary_id, model_signature, reason, skipped_at_ms + ) VALUES ('sum-acct', 'test/model@3', 'terminal', 1700000000000)", + [], + )?; + + // tree-keyed entity-index rows (summary nodes) for each tree. + for (sid, tree_id) in [("sum-acct", "tree-acct"), ("sum-other", "tree-other")] { + tx.execute( + "INSERT INTO mem_tree_entity_index ( + entity_id, node_id, node_kind, entity_kind, surface, + score, timestamp_ms, tree_id, is_user + ) VALUES (?1, ?2, 'summary', 'person', 'email', 0.9, 1700000000000, ?3, 0)", + params![format!("entity:{sid}"), sid, tree_id], + )?; + } + + // unsealed buffers (the "queue" frontier) referencing the chunk ids. + tree_store::upsert_buffer_tx( + &tx, + &Buffer { + tree_id: "tree-acct".into(), + level: 0, + item_ids: vec![a0.id.clone(), a1.id.clone()], + token_sum: 24, + oldest_at: Some(ts), + }, + )?; + tree_store::upsert_buffer_tx( + &tx, + &Buffer { + tree_id: "tree-other".into(), + level: 0, + item_ids: vec![b0.id.clone()], + token_sum: 12, + oldest_at: Some(ts), + }, + )?; + + assert!(claim_source_ingest_tx( + &tx, + SourceKind::Email, + "gmail:acct", + 1_700_000_000_000 + )?); + assert!(claim_source_ingest_tx( + &tx, + SourceKind::Email, + "gmail:other", + 1_700_000_000_000 + )?); + tx.commit()?; + Ok(()) + }) + .unwrap(); + + // ---- mocked job: a Seal queued for the tree that's about to be deleted ---- + let seal_payload = queue_types::SealPayload { + tree_id: "tree-acct".into(), + level: 0, + force_now_ms: None, + }; + let job_id = queue_store::enqueue(&cfg, &queue_types::NewJob::seal(&seal_payload).unwrap()) + .unwrap() + .expect("seal job enqueued"); + + // ---- act: disconnect conn-1 with clear_memory=true → delete its chunks ---- + let deleted = delete_chunks_by_owner(&cfg, SourceKind::Email, "gmail-sync:conn-1").unwrap(); + assert_eq!(deleted, 2); + + // chunks: acct gone, other survives. + assert!(get_chunk(&cfg, &a0.id).unwrap().is_none()); + assert!(get_chunk(&cfg, &a1.id).unwrap().is_none()); + assert!(get_chunk(&cfg, &b0.id).unwrap().is_some()); + + // the orphaned source tree is gone; the sibling tree is untouched. + assert!( + tree_store::get_tree_by_scope(&cfg, TreeKind::Source, "gmail:acct") + .unwrap() + .is_none() + ); + assert!( + tree_store::get_tree_by_scope(&cfg, TreeKind::Source, "gmail:other") + .unwrap() + .is_some() + ); + + // exactly the tree-acct rows are cascaded away across every dependent table. + with_connection(&cfg, |conn| { + let count = |sql: &str| -> rusqlite::Result { conn.query_row(sql, [], |r| r.get(0)) }; + assert_eq!(count("SELECT COUNT(*) FROM mem_tree_trees")?, 1); + assert_eq!(count("SELECT COUNT(*) FROM mem_tree_summaries")?, 1); + assert_eq!( + count("SELECT COUNT(*) FROM mem_tree_summary_embeddings")?, + 1 + ); + assert_eq!( + count("SELECT COUNT(*) FROM mem_tree_summary_reembed_skipped")?, + 0 + ); + assert_eq!(count("SELECT COUNT(*) FROM mem_tree_buffers")?, 1); + assert_eq!(count("SELECT COUNT(*) FROM mem_tree_entity_index")?, 1); + // and what survives belongs to tree-other. + assert_eq!( + count("SELECT COUNT(*) FROM mem_tree_summaries WHERE tree_id = 'tree-other'")?, + 1 + ); + Ok(()) + }) + .unwrap(); + + // ---- the queued Seal job settles to Done (tree missing), not stuck pending ---- + let claimed = queue_store::claim_next(&cfg, queue_store::DEFAULT_LOCK_DURATION_MS) + .unwrap() + .expect("seal job claimable"); + assert_eq!(claimed.kind, queue_types::JobKind::Seal); + let outcome = crate::openhuman::memory_queue::handlers::handle_job(&cfg, &claimed) + .await + .expect("handle_job ok"); + assert!( + matches!(outcome, queue_types::JobOutcome::Done), + "seal over a deleted tree must no-op to Done, got {outcome:?}" + ); + queue_store::mark_done(&cfg, &claimed).unwrap(); + assert_eq!( + queue_store::get_job(&cfg, &job_id).unwrap().unwrap().status, + queue_types::JobStatus::Done + ); +} + +/// #1: the cascade must also delete the summary's **on-disk content file**, not +/// just the row — otherwise a `clear_memory` delete leaves the summarised text +/// orphaned on disk. +#[test] +fn clear_memory_delete_removes_orphaned_summary_content_file() { + use crate::openhuman::memory_store::trees::store as tree_store; + use crate::openhuman::memory_store::trees::types::{SummaryNode, Tree, TreeKind, TreeStatus}; + + let (_tmp, cfg) = test_config(); + let ts = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); + + let mut c = sample_chunk("gmail:acct", 0, 1_700_000_000_000); + c.metadata.source_kind = SourceKind::Email; + c.metadata.owner = "gmail-sync:conn-1".to_string(); + upsert_chunks(&cfg, &[c.clone()]).unwrap(); + + tree_store::insert_tree( + &cfg, + &Tree { + id: "tree-acct".into(), + kind: TreeKind::Source, + scope: "gmail:acct".into(), + root_id: None, + max_level: 1, + status: TreeStatus::Active, + created_at: ts, + last_sealed_at: Some(ts), + }, + ) + .unwrap(); + + // A real on-disk summary content file under the memory tree content root. + let rel = "summaries/gmail_acct/L1/sum-acct.md"; + let abs = cfg.memory_tree_content_root().join(rel); + std::fs::create_dir_all(abs.parent().unwrap()).unwrap(); + std::fs::write(&abs, "summarised email body").unwrap(); + assert!(abs.exists()); + + with_connection(&cfg, |conn| { + let tx = conn.unchecked_transaction()?; + tree_store::insert_summary_tx( + &tx, + &SummaryNode { + id: "sum-acct".into(), + tree_id: "tree-acct".into(), + tree_kind: TreeKind::Source, + level: 1, + parent_id: None, + child_ids: vec![c.id.clone()], + content: "preview".into(), + token_count: 3, + entities: vec![], + topics: vec![], + time_range_start: ts, + time_range_end: ts, + score: 0.5, + sealed_at: ts, + deleted: false, + embedding: None, + }, + None, + "test/model@3", + )?; + tx.execute( + "UPDATE mem_tree_summaries SET content_path = ?1 WHERE id = 'sum-acct'", + params![rel], + )?; + assert!(claim_source_ingest_tx( + &tx, + SourceKind::Email, + "gmail:acct", + 1_700_000_000_000 + )?); + tx.commit()?; + Ok(()) + }) + .unwrap(); + + delete_chunks_by_owner(&cfg, SourceKind::Email, "gmail-sync:conn-1").unwrap(); + + assert!( + tree_store::get_tree_by_scope(&cfg, TreeKind::Source, "gmail:acct") + .unwrap() + .is_none() + ); + assert!( + !abs.exists(), + "orphaned summary content file must be removed from disk" + ); +} + +/// #2: the safety property — deleting one connection's chunks must NOT delete +/// the source tree while ANOTHER connection still owns chunks for the same +/// account (source not yet orphaned). +#[test] +fn clear_memory_delete_keeps_tree_when_another_connection_still_owns_chunks() { + use crate::openhuman::memory_store::trees::store as tree_store; + use crate::openhuman::memory_store::trees::types::{Buffer, Tree, TreeKind, TreeStatus}; + + let (_tmp, cfg) = test_config(); + let ts = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); + + // Same account `gmail:acct`, two connections (owners). + let mut a = sample_chunk("gmail:acct", 0, 1_700_000_000_000); + a.metadata.source_kind = SourceKind::Email; + a.metadata.owner = "gmail-sync:conn-1".to_string(); + let mut b = sample_chunk("gmail:acct", 1, 1_700_000_001_000); + b.metadata.source_kind = SourceKind::Email; + b.metadata.owner = "gmail-sync:conn-2".to_string(); + upsert_chunks(&cfg, &[a.clone(), b.clone()]).unwrap(); + + tree_store::insert_tree( + &cfg, + &Tree { + id: "tree-acct".into(), + kind: TreeKind::Source, + scope: "gmail:acct".into(), + root_id: None, + max_level: 1, + status: TreeStatus::Active, + created_at: ts, + last_sealed_at: Some(ts), + }, + ) + .unwrap(); + with_connection(&cfg, |conn| { + let tx = conn.unchecked_transaction()?; + tree_store::upsert_buffer_tx( + &tx, + &Buffer { + tree_id: "tree-acct".into(), + level: 0, + item_ids: vec![a.id.clone(), b.id.clone()], + token_sum: 24, + oldest_at: Some(ts), + }, + )?; + assert!(claim_source_ingest_tx( + &tx, + SourceKind::Email, + "gmail:acct", + 1_700_000_000_000 + )?); + tx.commit()?; + Ok(()) + }) + .unwrap(); + + // Disconnect ONLY conn-1. + let deleted = delete_chunks_by_owner(&cfg, SourceKind::Email, "gmail-sync:conn-1").unwrap(); + assert_eq!(deleted, 1); + + // conn-1's chunk is gone, conn-2's remains → source still has chunks → + // the tree (and its buffer + ingest gate) MUST survive. + assert!(get_chunk(&cfg, &a.id).unwrap().is_none()); + assert!(get_chunk(&cfg, &b.id).unwrap().is_some()); + assert!( + tree_store::get_tree_by_scope(&cfg, TreeKind::Source, "gmail:acct") + .unwrap() + .is_some(), + "tree must survive while another connection still owns chunks" + ); + assert!(is_source_ingested(&cfg, SourceKind::Email, "gmail:acct").unwrap()); + with_connection(&cfg, |conn| { + let n: i64 = conn.query_row("SELECT COUNT(*) FROM mem_tree_buffers", [], |r| r.get(0))?; + assert_eq!(n, 1); + Ok(()) + }) + .unwrap(); +} + +/// #3: queued `Extract` / `AppendBuffer` jobs that reference a chunk deleted +/// out from under them settle to `Done` (warn-and-skip), not stuck pending. +#[tokio::test] +async fn queued_jobs_for_deleted_chunk_settle_to_done() { + use crate::openhuman::memory_queue::{store as queue_store, types as queue_types}; + + let (_tmp, cfg) = test_config(); + let c = sample_chunk("slack:#eng", 0, 1_700_000_000_000); + upsert_chunks(&cfg, &[c.clone()]).unwrap(); + delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:#eng").unwrap(); + assert!(get_chunk(&cfg, &c.id).unwrap().is_none()); + + queue_store::enqueue( + &cfg, + &queue_types::NewJob::extract_chunk(&queue_types::ExtractChunkPayload { + chunk_id: c.id.clone(), + }) + .unwrap(), + ) + .unwrap(); + queue_store::enqueue( + &cfg, + &queue_types::NewJob::append_buffer(&queue_types::AppendBufferPayload { + node: queue_types::NodeRef::Leaf { + chunk_id: c.id.clone(), + }, + target: queue_types::AppendTarget::Source { + source_id: "slack:#eng".into(), + }, + }) + .unwrap(), + ) + .unwrap(); + + for _ in 0..2 { + let job = queue_store::claim_next(&cfg, queue_store::DEFAULT_LOCK_DURATION_MS) + .unwrap() + .expect("job claimable"); + let outcome = crate::openhuman::memory_queue::handlers::handle_job(&cfg, &job) + .await + .expect("handle_job ok"); + assert!( + matches!(outcome, queue_types::JobOutcome::Done), + "{:?} over a deleted chunk must settle Done, got {outcome:?}", + job.kind + ); + queue_store::mark_done(&cfg, &job).unwrap(); + } +} + #[test] fn delete_chunks_by_owner_preserves_other_owners_for_same_source() { let (_tmp, cfg) = test_config(); diff --git a/src/openhuman/memory_store/trees/store.rs b/src/openhuman/memory_store/trees/store.rs index 1d4ad71a18..283ccac813 100644 --- a/src/openhuman/memory_store/trees/store.rs +++ b/src/openhuman/memory_store/trees/store.rs @@ -69,6 +69,72 @@ pub(crate) fn insert_tree_conn(conn: &Connection, tree: &Tree) -> Result<()> { Ok(()) } +/// Hard-delete one tree and every dependent row, within an existing tx. +/// +/// Cascade order mirrors [`crate::openhuman::memory_store::chunks::store`]'s +/// global/topic purge: summary sidecars (`summary_embeddings` / +/// `summary_reembed_skipped`, keyed by `summary_id`) first, then +/// `entity_index` + `buffers` (keyed by `tree_id`), then the `summaries`, +/// then the tree row. Used by the chunk-delete path when a source's last +/// chunk is removed, so the now-contentless summary tree (and its unsealed +/// buffer) doesn't outlive the data it summarised. Returns the number of +/// summary rows removed. +pub(crate) fn delete_tree_cascade_tx( + tx: &Transaction<'_>, + tree_id: &str, +) -> Result { + // Collect the on-disk content-file paths BEFORE deleting the summary rows + // — sealed summaries stage their body to `content_path` under the memory + // tree content root (see `bucket_seal::seal_one_level` → `stage_summary`). + // The caller removes these files after the tx commits (mirroring + // `remove_chunk_content_files`), so a `clear_memory` delete doesn't leave + // the summarised text orphaned on disk. + let content_paths: Vec = { + let mut stmt = tx.prepare( + "SELECT content_path FROM mem_tree_summaries \ + WHERE tree_id = ?1 AND content_path IS NOT NULL AND content_path <> ''", + )?; + let rows = stmt.query_map(params![tree_id], |row| row.get::<_, String>(0))?; + rows.collect::>>() + .context("collect summary content paths for tree cascade delete")? + }; + + tx.execute( + "DELETE FROM mem_tree_summary_embeddings WHERE summary_id IN \ + (SELECT id FROM mem_tree_summaries WHERE tree_id = ?1)", + params![tree_id], + )?; + tx.execute( + "DELETE FROM mem_tree_summary_reembed_skipped WHERE summary_id IN \ + (SELECT id FROM mem_tree_summaries WHERE tree_id = ?1)", + params![tree_id], + )?; + tx.execute( + "DELETE FROM mem_tree_entity_index WHERE tree_id = ?1", + params![tree_id], + )?; + let removed_summaries = tx.execute( + "DELETE FROM mem_tree_summaries WHERE tree_id = ?1", + params![tree_id], + )?; + tx.execute( + "DELETE FROM mem_tree_buffers WHERE tree_id = ?1", + params![tree_id], + )?; + tx.execute("DELETE FROM mem_tree_trees WHERE id = ?1", params![tree_id])?; + Ok(TreeCascadeDeletion { + removed_summaries, + content_paths, + }) +} + +/// Outcome of [`delete_tree_cascade_tx`]: how many summary rows were removed +/// and the on-disk content-file paths the caller must delete post-commit. +pub(crate) struct TreeCascadeDeletion { + pub removed_summaries: usize, + pub content_paths: Vec, +} + /// Fetch a tree by `(kind, scope)`. Returns `None` if no such tree exists. pub fn get_tree_by_scope(config: &Config, kind: TreeKind, scope: &str) -> Result> { with_connection(config, |conn| get_tree_by_scope_conn(conn, kind, scope)) diff --git a/tests/memory_sync_sources_raw_coverage_e2e.rs b/tests/memory_sync_sources_raw_coverage_e2e.rs index 976aaac0a0..c195bfde42 100644 --- a/tests/memory_sync_sources_raw_coverage_e2e.rs +++ b/tests/memory_sync_sources_raw_coverage_e2e.rs @@ -21,8 +21,9 @@ use openhuman_core::openhuman::credentials::{ }; use openhuman_core::openhuman::memory_sources::readers::SourceReader; use openhuman_core::openhuman::memory_sources::{ - add_source, get_source, list_enabled_by_kind, list_sources, remove_source, update_source, - upsert_composio_source, MemorySourceEntry, MemorySourcePatch, SourceKind, + add_source, get_source, list_enabled_by_kind, list_sources, + remove_composio_source_by_connection_id, remove_source, update_source, upsert_composio_source, + MemorySourceEntry, MemorySourcePatch, SourceKind, }; use openhuman_core::openhuman::memory_sync::composio::bus::{ ComposioConfigChangedSubscriber, ComposioConnectionCreatedSubscriber, ComposioTriggerSubscriber, @@ -202,6 +203,80 @@ async fn memory_sources_registry_persists_crud_and_composio_upserts() { assert_eq!(all[0].id, first.id); } +#[tokio::test] +async fn remove_composio_source_by_connection_id_prunes_on_disconnect_and_survives_reconnect() { + let _guard = env_lock(); + let tmp = TempDir::new().expect("tempdir"); + let config = config_in(&tmp); + let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); + let _home = EnvGuard::set_path("HOME", tmp.path()); + let _backend = EnvGuard::unset("BACKEND_URL"); + persist_config(&config).await; + + // Two live composio connections plus an unrelated folder source. + let gmail_old = upsert_composio_source("gmail", "conn-old", "Gmail · conn-old") + .await + .expect("insert gmail"); + upsert_composio_source("slack", "conn-slack", "Slack") + .await + .expect("insert slack"); + let mut folder = source(SourceKind::Folder, "src_folder_disc"); + folder.path = Some(tmp.path().join("notes").to_string_lossy().into_owned()); + folder.glob = Some("**/*.md".to_string()); + add_source(folder.clone()).await.expect("add folder"); + + // No-match is a no-op (returns 0, removes nothing). + assert_eq!( + remove_composio_source_by_connection_id("conn-does-not-exist") + .await + .expect("no-match remove"), + 0 + ); + assert_eq!(list_sources().await.expect("list").len(), 3); + + // Disconnect: prune ONLY the matching composio source, by connection_id. + assert_eq!( + remove_composio_source_by_connection_id("conn-old") + .await + .expect("prune on disconnect"), + 1 + ); + let after_disconnect = list_sources().await.expect("list after disconnect"); + assert_eq!(after_disconnect.len(), 2); + assert!( + after_disconnect.iter().all(|s| s.id != gmail_old.id), + "old gmail entry must be gone" + ); + assert!( + after_disconnect + .iter() + .any(|s| s.connection_id.as_deref() == Some("conn-slack")), + "the other composio connection must be untouched" + ); + assert!( + after_disconnect.iter().any(|s| s.id == folder.id), + "non-composio folder source must be untouched" + ); + + // Reconnect: backend mints a NEW connection_id for the same Gmail account. + // upsert inserts a fresh entry; no stale duplicate is left behind. + let gmail_new = upsert_composio_source("gmail", "conn-new", "Gmail · conn-new") + .await + .expect("reconnect gmail"); + assert_ne!(gmail_new.id, gmail_old.id); + let final_sources = list_sources().await.expect("final list"); + let gmail_entries: Vec<_> = final_sources + .iter() + .filter(|s| s.toolkit.as_deref() == Some("gmail")) + .collect(); + assert_eq!( + gmail_entries.len(), + 1, + "exactly one gmail source after reconnect — no orphan" + ); + assert_eq!(gmail_entries[0].connection_id.as_deref(), Some("conn-new")); +} + #[tokio::test] async fn rss_reader_lists_reads_and_reports_feed_errors_from_loopback() { let _guard = env_lock(); From de85d1f509339d95a4609df17a70ed3ae8433bd4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:06:04 -0700 Subject: [PATCH 14/20] feat(analytics): wire product event tracking (#3123) --- app/.env.example | 11 + app/scripts/e2e-web-session.sh | 1 + app/src-tauri/tauri.conf.json | 28 +- app/src/components/BottomTabBar.tsx | 17 +- .../__tests__/BottomTabBar.test.tsx | 26 +- .../__tests__/DefaultRedirect.test.tsx | 17 +- .../__tests__/ServiceBlockingGate.test.tsx | 13 + .../__tests__/DeveloperOptionsPanel.test.tsx | 12 + app/src/lib/coreState/__tests__/store.test.ts | 6 +- app/src/lib/i18n/ar.ts | 8 +- app/src/lib/i18n/bn.ts | 8 +- app/src/lib/i18n/de.ts | 8 +- app/src/lib/i18n/en.ts | 8 +- app/src/lib/i18n/es.ts | 8 +- app/src/lib/i18n/fr.ts | 8 +- app/src/lib/i18n/hi.ts | 8 +- app/src/lib/i18n/id.ts | 8 +- app/src/lib/i18n/it.ts | 8 +- app/src/lib/i18n/ko.ts | 8 +- app/src/lib/i18n/pl.ts | 8 +- app/src/lib/i18n/pt.ts | 8 +- app/src/lib/i18n/ru.ts | 8 +- app/src/lib/i18n/zh-CN.ts | 8 +- app/src/main.tsx | 3 +- .../__tests__/CoreStateProvider.test.tsx | 2 +- app/src/services/__tests__/analytics.test.ts | 289 ++++++++++-- app/src/services/__tests__/apiClient.test.ts | 11 + app/src/services/analytics.ts | 431 +++++++++++++++--- app/src/test/setup.ts | 10 + app/src/utils/config.ts | 25 + .../playwright/specs/agent-review.spec.ts | 4 +- .../settings-account-preferences.spec.ts | 2 +- .../settings-channels-permissions.spec.ts | 4 +- .../tools/skill_delegation.rs | 20 +- src/openhuman/keyring_consent/policy.rs | 10 + src/openhuman/meet_agent/store.rs | 3 + .../memory_sources/readers/github.rs | 14 +- tests/connectivity_raw_coverage_e2e.rs | 51 ++- tests/inference_agent_raw_coverage_e2e.rs | 2 +- ...nce_voice_http_round23_raw_coverage_e2e.rs | 32 +- ...ources_closure_round23_raw_coverage_e2e.rs | 14 +- ...ources_readers_round21_raw_coverage_e2e.rs | 42 +- 42 files changed, 960 insertions(+), 252 deletions(-) diff --git a/app/.env.example b/app/.env.example index 3bd910adc2..d0b490328f 100644 --- a/app/.env.example +++ b/app/.env.example @@ -36,6 +36,17 @@ VITE_SKILLS_GITHUB_REPO=tinyhumansai/openhuman-skills # Only anonymous page views and feature-engagement events are sent — no PII. VITE_GA_MEASUREMENT_ID= +# [optional] OpenPanel analytics project. Leave client id blank to disable. +# The API URL should include `/api` and must accept this browser/client id. +VITE_OPENPANEL_CLIENT_ID=e9c996d5-497f-4eec-9bde-630019ad525b +VITE_OPENPANEL_API_URL=https://panel.tinyhumans.ai/api + +# [optional] Analytics version metadata. Defaults to the frontend package +# version when unset; CI/release jobs may override if these ever diverge. +# VITE_OPENHUMAN_BINARY_VERSION=0.57.4 +# VITE_OPENHUMAN_CORE_CARGO_VERSION=0.57.4 +# VITE_OPENHUMAN_TAURI_CARGO_VERSION=0.57.4 + # [optional] Sentry DSN for error reporting (leave blank to disable) VITE_SENTRY_DSN= diff --git a/app/scripts/e2e-web-session.sh b/app/scripts/e2e-web-session.sh index ea925f59c7..44c12f4b7c 100755 --- a/app/scripts/e2e-web-session.sh +++ b/app/scripts/e2e-web-session.sh @@ -110,6 +110,7 @@ fi export OPENHUMAN_CORE_TOKEN="$PW_CORE_RPC_TOKEN" export OPENHUMAN_TELEGRAM_BOT_API_BASE="http://127.0.0.1:${E2E_MOCK_PORT}" +export RUST_MIN_STACK="${RUST_MIN_STACK:-16777216}" "$OPENHUMAN_CORE_BIN" run --host 127.0.0.1 --port "$OPENHUMAN_CORE_PORT" \ >"$OPENHUMAN_WORKSPACE/core.log" 2>&1 & diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index f38e140795..c4ce93f076 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -23,20 +23,13 @@ } ], "security": { - "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob:; frame-src 'self' https: data: blob:" + "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.googletagmanager.com; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob: https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; frame-src 'self' https: data: blob:" }, "macOSPrivateApi": true }, "bundle": { "active": true, - "targets": [ - "app", - "dmg", - "deb", - "nsis", - "msi", - "appimage" - ], + "targets": ["app", "dmg", "deb", "nsis", "msi", "appimage"], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -44,10 +37,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": [ - "../../src/openhuman/agent/prompts", - "recipes/**/*" - ], + "resources": ["../../src/openhuman/agent/prompts", "recipes/**/*"], "linux": { "deb": { "depends": [ @@ -66,9 +56,7 @@ "minimumSystemVersion": "10.15", "entitlements": "entitlements.sidecar.plist", "infoPlist": "Info.plist", - "dmg": { - "background": "./images/background-dmg.png" - } + "dmg": { "background": "./images/background-dmg.png" } } }, "plugins": { @@ -79,12 +67,6 @@ "https://github.com/tinyhumansai/openhuman/releases/latest/download/latest.json" ] }, - "deep-link": { - "desktop": { - "schemes": [ - "openhuman" - ] - } - } + "deep-link": { "desktop": { "schemes": ["openhuman"] } } } } diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx index c87ed4d70c..85226c0afe 100644 --- a/app/src/components/BottomTabBar.tsx +++ b/app/src/components/BottomTabBar.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useT } from '../lib/i18n/I18nContext'; import { useCoreState } from '../providers/CoreStateProvider'; +import { trackEvent } from '../services/analytics'; import { selectCompanionSessionActive } from '../store/companionSlice'; import { useAppSelector } from '../store/hooks'; import { selectUnreadCount } from '../store/notificationSlice'; @@ -174,6 +175,20 @@ const BottomTabBar = () => { return location.pathname === path; }; + const activeTab = tabs.find(tab => isActive(tab.path)); + + const handleTabClick = (tab: (typeof tabs)[number], active: boolean) => { + if (!active) { + trackEvent('tab_bar_change', { + from_tab: activeTab?.id ?? 'unknown', + to_tab: tab.id, + from_path: location.pathname, + to_path: tab.path, + }); + } + navigate(tab.path); + }; + return ( // pointer-events-none on the full-width shell so transparent areas (e.g. // beside the centered nav pill) do not steal clicks from sticky footers @@ -214,7 +229,7 @@ const BottomTabBar = () => {