feat: subconscious loop — local-model background awareness via heartbeat (#268)

* feat: subconscious loop — local-model background awareness via heartbeat

Add a subconscious inference layer to the heartbeat engine. On each
tick, the engine reads HEARTBEAT.md tasks, builds a delta-based
situation report (memory docs, graph relations, skills health,
environment), and evaluates them with the local Ollama model.

Architecture:
- HeartbeatEngine (scheduler) delegates to SubconsciousEngine (brain)
- HeartbeatConfig extended with inference_enabled, context_budget_tokens
- No separate SubconsciousConfig — all config lives under [heartbeat]
- Default HEARTBEAT.md ships with 3 active tasks (email, deadlines, skills)

Subconscious module (src/openhuman/subconscious/):
- engine.rs: tick logic, local model inference, escalation to cloud model
- situation_report.rs: delta assembler (memory, graph, skills, env, tasks)
- prompt.rs: task-driven system prompt for local model
- decision_log.rs: dedup tracking with 24h TTL and acknowledgment
- types.rs: Decision (noop/act/escalate), TickOutput, RecommendedAction
- schemas.rs: RPC controllers (subconscious_status, subconscious_trigger)
- integration_test.rs: two-tick lifecycle test with fixtures

Decision flow:
- noop: no changes, skip — no LLM call wasted
- act: local model recommends actions → stored in memory KV
- escalate: calls cloud model to resolve → concrete actions stored

Verified with real Ollama inference (gemma3:4b):
- Tick 1: ingested gmail+notion → "act: deadline needs attention" (high)
- Tick 2: ingested state changes → "act: deadline moved" (high)
- Skills health section populated from live skill registry

Closes #145

* feat: add subconscious_actions RPC endpoint

New endpoint openhuman.subconscious_actions returns stored action
entries from the subconscious KV namespace, sorted by most recent
first, with configurable limit (default 20).

Response format:
{
  "entries": [
    { "tick_at": 1775117975.58, "actions": [...] }
  ],
  "count": 1
}

The upcoming subconscious page will call this to display
notifications and recommended actions to the user.

* fix: budget underflow and UTF-8 panic in situation report truncation

- Use saturating_add for newline byte to prevent underflow when
  section exactly fills the remaining budget
- Truncate at valid UTF-8 char boundary using char_indices instead
  of raw byte slicing, which panicked on multibyte characters
- Add tests for exact-fit and multibyte truncation

* fix: address CodeRabbit review — shared engine, dedup, consistent schema

Fixes from CodeRabbit review on PR #268:

- #8 Two engine instances: Add global.rs singleton shared between
  HeartbeatEngine::run() and RPC handlers. Both use get_or_init_engine()
  so decision log, counters, and last_tick_at are always in sync.

- #3 Dedup disabled: tick() now extracts actual document IDs from
  memory via build_situation_report_with_doc_ids() and passes them
  to decision_log.record(). filter_unsurfaced() actually filters now.

- #5 Decision log not loaded on trigger: tick() loads persisted log
  from KV on first execution (total_ticks == 0), not only from run().

- #4 Inconsistent action schema: handle_escalation() normalizes agent
  response into RecommendedAction[] via normalize_escalation_response().
  Both act and escalate paths store the same schema.

- #7 Key collision: store_actions() uses millisecond timestamp + random
  suffix instead of second-precision truncation.

- #10 No-changes unreachable: tick() checks has_new_data (unsurfaced
  doc IDs) OR has_memory_changes (report text) instead of naive string
  matching on environment section.

* fix: include document content in situation report, not just titles

The local model needs actual content to evaluate HEARTBEAT.md tasks
meaningfully. Previously it only saw titles like "Deadline reminder"
with no way to know if it's urgent.

Now recalls content per namespace (up to 500 chars each, max 10
namespaces) via client.recall_namespace(). The model sees actual
email text and page content alongside the task checklist.

* fix: timestamp parsing, byte-boundary slicing, and truncation overshoot

- schemas.rs: split on first ':' after 'actions:' prefix before parsing
  timestamp, so keys like 'actions:123456:xyz' parse correctly
- situation_report.rs: use truncate_at_char_boundary() for error strings
  instead of raw byte slice which panics on multibyte characters
- situation_report.rs: fix append_section and truncate_at_char_boundary
  to use char END offset (i + len_utf8) in take_while condition, so
  multibyte chars that start before but end after the budget are excluded
This commit is contained in:
sanil-23
2026-04-02 21:51:09 +05:30
committed by GitHub
parent 8acd34d2a9
commit 945a5c4a02
21 changed files with 2352 additions and 166 deletions
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# End-to-end subconscious loop test with real local AI (Ollama).
# Ingests data, runs ticks, verifies decisions.
set -euo pipefail
CORE_BIN="./app/src-tauri/binaries/openhuman-core-x86_64-pc-windows-msvc.exe"
RPC_PORT=7810
RPC_URL="http://127.0.0.1:${RPC_PORT}/rpc"
FIXTURES="./tests/fixtures/subconscious"
if [ ! -f "$CORE_BIN" ]; then echo "ERROR: Core binary not found"; exit 1; fi
# Check Ollama
if ! curl -s --max-time 3 http://127.0.0.1:11434/ >/dev/null 2>&1; then
echo "ERROR: Ollama not running. Start with: ollama serve"
exit 1
fi
echo "=== Subconscious Loop E2E Test ==="
echo ""
# Start core server
echo "[setup] Starting core on port $RPC_PORT..."
OPENHUMAN_CORE_PORT="$RPC_PORT" "$CORE_BIN" serve > /tmp/subconscious-test.log 2>&1 &
SERVER_PID=$!
cleanup() { kill "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true; }
trap cleanup EXIT
for i in $(seq 1 15); do
if curl -s "$RPC_URL" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":0,"method":"openhuman.health_snapshot","params":{}}' 2>/dev/null | grep -q "result"; then
echo "[setup] Server ready."
break
fi
[ "$i" -eq 15 ] && { echo "ERROR: Server timeout"; exit 1; }
sleep 1
done
rpc() {
curl -s --max-time 120 "$RPC_URL" -H "Content-Type: application/json" -d "$1" 2>&1
}
# Write HEARTBEAT.md to the workspace
echo "[setup] Writing HEARTBEAT.md to workspace..."
WORKSPACE="$HOME/.openhuman/workspace"
mkdir -p "$WORKSPACE"
cp "$FIXTURES/heartbeat.md" "$WORKSPACE/HEARTBEAT.md"
echo "[setup] HEARTBEAT.md written: $(cat "$WORKSPACE/HEARTBEAT.md" | grep "^- " | wc -l) tasks"
echo ""
echo "========================================="
echo " PHASE 1: Ingest tick 1 data"
echo "========================================="
# Ingest tick1 gmail
GMAIL1=$(cat "$FIXTURES/tick1_gmail.txt")
GMAIL1_ESC=$(echo "$GMAIL1" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
RESULT=$(rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"openhuman.memory_doc_ingest\",\"params\":{\"namespace\":\"skill-gmail\",\"key\":\"tick1-gmail\",\"title\":\"Deadline reminder and meeting invite\",\"content\":$GMAIL1_ESC,\"source_type\":\"gmail\",\"priority\":\"high\",\"category\":\"core\"}}")
echo "Gmail tick1 ingested: $(echo "$RESULT" | python3 -c "import sys,json;d=json.load(sys.stdin);r=d.get('result',{});print(f\"{r.get('entityCount',0)} entities, {r.get('relationCount',0)} relations\")" 2>/dev/null || echo "$RESULT" | head -c 200)"
# Ingest tick1 notion
NOTION1=$(cat "$FIXTURES/tick1_notion.txt")
NOTION1_ESC=$(echo "$NOTION1" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
RESULT=$(rpc "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"openhuman.memory_doc_ingest\",\"params\":{\"namespace\":\"skill-notion\",\"key\":\"tick1-notion\",\"title\":\"Q1 Delivery Tracker\",\"content\":$NOTION1_ESC,\"source_type\":\"notion\",\"priority\":\"high\",\"category\":\"core\"}}")
echo "Notion tick1 ingested: $(echo "$RESULT" | python3 -c "import sys,json;d=json.load(sys.stdin);r=d.get('result',{});print(f\"{r.get('entityCount',0)} entities, {r.get('relationCount',0)} relations\")" 2>/dev/null || echo "$RESULT" | head -c 200)"
# Check what's in memory
echo ""
echo "Namespaces after tick1 ingest:"
rpc '{"jsonrpc":"2.0","id":3,"method":"openhuman.memory_list_namespaces","params":{}}' | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('result',{}).get('data',{}).get('namespaces',[]))" 2>/dev/null
echo ""
echo "========================================="
echo " PHASE 2: Subconscious Tick 1"
echo "========================================="
echo "(Calling local AI via Ollama — may take 30-60s)"
TICK1=$(rpc '{"jsonrpc":"2.0","id":10,"method":"openhuman.subconscious_trigger","params":{}}')
echo "Tick 1 result:"
echo "$TICK1" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), indent=2))" 2>/dev/null || echo "$TICK1" | head -c 500
echo ""
echo "========================================="
echo " PHASE 3: Ingest tick 2 data (state change)"
echo "========================================="
# Ingest tick2 gmail (deadline moved)
GMAIL2=$(cat "$FIXTURES/tick2_gmail.txt")
GMAIL2_ESC=$(echo "$GMAIL2" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
RESULT=$(rpc "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"openhuman.memory_doc_ingest\",\"params\":{\"namespace\":\"skill-gmail\",\"key\":\"tick2-gmail\",\"title\":\"URGENT deadline moved to tomorrow\",\"content\":$GMAIL2_ESC,\"source_type\":\"gmail\",\"priority\":\"high\",\"category\":\"core\"}}")
echo "Gmail tick2 ingested: $(echo "$RESULT" | python3 -c "import sys,json;d=json.load(sys.stdin);r=d.get('result',{});print(f\"{r.get('entityCount',0)} entities, {r.get('relationCount',0)} relations\")" 2>/dev/null || echo "$RESULT" | head -c 200)"
# Ingest tick2 notion (tracker updated)
NOTION2=$(cat "$FIXTURES/tick2_notion.txt")
NOTION2_ESC=$(echo "$NOTION2" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
RESULT=$(rpc "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"openhuman.memory_doc_ingest\",\"params\":{\"namespace\":\"skill-notion\",\"key\":\"tick2-notion\",\"title\":\"Q1 Tracker updated - unblocked\",\"content\":$NOTION2_ESC,\"source_type\":\"notion\",\"priority\":\"high\",\"category\":\"core\"}}")
echo "Notion tick2 ingested: $(echo "$RESULT" | python3 -c "import sys,json;d=json.load(sys.stdin);r=d.get('result',{});print(f\"{r.get('entityCount',0)} entities, {r.get('relationCount',0)} relations\")" 2>/dev/null || echo "$RESULT" | head -c 200)"
echo ""
echo "========================================="
echo " PHASE 4: Subconscious Tick 2"
echo "========================================="
echo "(Calling local AI via Ollama — may take 30-60s)"
TICK2=$(rpc '{"jsonrpc":"2.0","id":11,"method":"openhuman.subconscious_trigger","params":{}}')
echo "Tick 2 result:"
echo "$TICK2" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), indent=2))" 2>/dev/null || echo "$TICK2" | head -c 500
echo ""
echo "========================================="
echo " PHASE 5: Status check"
echo "========================================="
STATUS=$(rpc '{"jsonrpc":"2.0","id":12,"method":"openhuman.subconscious_status","params":{}}')
echo "Subconscious status:"
echo "$STATUS" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), indent=2))" 2>/dev/null || echo "$STATUS" | head -c 500
echo ""
echo "========================================="
echo " DONE"
echo "========================================="
+3
View File
@@ -67,6 +67,7 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::billing::all_billing_registered_controllers());
controllers.extend(crate::openhuman::team::all_team_registered_controllers());
controllers.extend(crate::openhuman::voice::all_voice_registered_controllers());
controllers.extend(crate::openhuman::subconscious::all_subconscious_registered_controllers());
controllers
}
@@ -99,6 +100,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::billing::all_billing_controller_schemas());
schemas.extend(crate::openhuman::team::all_team_controller_schemas());
schemas.extend(crate::openhuman::voice::all_voice_controller_schemas());
schemas.extend(crate::openhuman::subconscious::all_subconscious_controller_schemas());
schemas
}
@@ -137,6 +139,7 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"billing" => Some("Subscription plan, payment links, and credit top-up via the backend."),
"team" => Some("Team member management, invites, and role changes via the backend."),
"voice" => Some("Speech-to-text and text-to-speech using local models."),
"subconscious" => Some("Periodic local-model background awareness loop."),
_ => None,
}
}
+22 -1
View File
@@ -3,17 +3,38 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Heartbeat configuration — periodic background loop that evaluates
/// HEARTBEAT.md tasks against workspace state using local model inference.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HeartbeatConfig {
/// Enable the heartbeat loop.
pub enabled: bool,
/// Tick interval in minutes (minimum 5).
pub interval_minutes: u32,
/// Enable subconscious inference (local model evaluation).
/// When false, the heartbeat only counts tasks without reasoning.
#[serde(default)]
pub inference_enabled: bool,
/// Maximum token budget for the situation report (default 40k).
#[serde(default = "default_context_budget")]
pub context_budget_tokens: u32,
/// Override model for escalation (default: use config.default_model).
#[serde(default)]
pub escalation_model: Option<String>,
}
fn default_context_budget() -> u32 {
40_000
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
interval_minutes: 15,
inference_enabled: false,
context_budget_tokens: default_context_budget(),
escalation_model: None,
}
}
}
+77 -138
View File
@@ -1,10 +1,13 @@
use crate::openhuman::config::HeartbeatConfig;
use crate::openhuman::subconscious::global::get_or_init_engine;
use crate::openhuman::subconscious::types::Decision;
use anyhow::Result;
use std::path::Path;
use tokio::time::{self, Duration};
use tracing::{info, warn};
/// Heartbeat engine — reads HEARTBEAT.md and executes tasks periodically
/// Heartbeat engine — periodic scheduler that delegates to the subconscious
/// loop for task-driven evaluation via local model inference.
pub struct HeartbeatEngine {
config: HeartbeatConfig,
workspace_dir: std::path::PathBuf,
@@ -18,39 +21,84 @@ impl HeartbeatEngine {
}
}
/// Start the heartbeat loop (runs until cancelled)
/// Start the heartbeat loop (runs until cancelled).
/// On each tick, delegates to the shared global subconscious engine.
pub async fn run(&self) -> Result<()> {
if !self.config.enabled {
info!("Heartbeat disabled");
info!("[heartbeat] disabled");
return Ok(());
}
let interval_mins = self.config.interval_minutes.max(5);
info!("💓 Heartbeat started: every {} minutes", interval_mins);
info!(
"[heartbeat] started: every {} minutes, subconscious inference {}",
interval_mins,
if self.config.inference_enabled {
"enabled"
} else {
"disabled (task counting only)"
}
);
let mut interval = time::interval(Duration::from_secs(u64::from(interval_mins) * 60));
loop {
interval.tick().await;
match self.tick().await {
Ok(tasks) => {
if tasks > 0 {
info!("💓 Heartbeat: processed {} tasks", tasks);
if self.config.inference_enabled {
// Get the shared global engine (same instance as RPC handlers)
let lock = match get_or_init_engine().await {
Ok(l) => l,
Err(e) => {
warn!("[heartbeat] failed to get engine: {e}");
continue;
}
};
let guard = lock.lock().await;
let engine = match guard.as_ref() {
Some(e) => e,
None => {
warn!("[heartbeat] engine not initialized");
continue;
}
};
match engine.tick().await {
Ok(result) => match result.output.decision {
Decision::Noop => {
info!("[heartbeat] tick: noop — {}", result.output.reason);
}
Decision::Act => {
info!(
"[heartbeat] tick: act — {} ({} actions)",
result.output.reason,
result.output.actions.len()
);
}
Decision::Escalate => {
info!("[heartbeat] tick: escalate — {}", result.output.reason);
}
},
Err(e) => {
warn!("[heartbeat] subconscious tick error: {e}");
}
}
Err(e) => {
warn!("💓 Heartbeat error: {}", e);
} else {
// Legacy mode: just count tasks
match self.collect_tasks().await {
Ok(tasks) => {
if !tasks.is_empty() {
info!("[heartbeat] {} tasks in HEARTBEAT.md", tasks.len());
}
}
Err(e) => {
warn!("[heartbeat] error reading tasks: {e}");
}
}
}
}
}
/// Single heartbeat tick — read HEARTBEAT.md and return task count
async fn tick(&self) -> Result<usize> {
Ok(self.collect_tasks().await?.len())
}
/// Read HEARTBEAT.md and return all parsed tasks.
pub async fn collect_tasks(&self) -> Result<Vec<String>> {
let heartbeat_path = self.workspace_dir.join("HEARTBEAT.md");
@@ -62,7 +110,7 @@ impl HeartbeatEngine {
}
/// Parse tasks from HEARTBEAT.md (lines starting with `- `)
fn parse_tasks(content: &str) -> Vec<String> {
pub(crate) fn parse_tasks(content: &str) -> Vec<String> {
content
.lines()
.filter_map(|line| {
@@ -76,14 +124,14 @@ impl HeartbeatEngine {
pub async fn ensure_heartbeat_file(workspace_dir: &Path) -> Result<()> {
let path = workspace_dir.join("HEARTBEAT.md");
if !path.exists() {
let default = "# Periodic Tasks\n\n\
# Add tasks below (one per line, starting with `- `)\n\
# The agent will check this file on each heartbeat tick.\n\
let default = "# Periodic Tasks\n\
#\n\
# Examples:\n\
# - Check my email for important messages\n\
# - Review my calendar for upcoming events\n\
# - Check the weather forecast\n";
# The subconscious loop checks these tasks periodically against\n\
# your workspace state (memory, skills, email, etc.)\n\
# Add or remove tasks — one per line starting with `- `\n\n\
- Check for new emails that need attention\n\
- Review upcoming deadlines and calendar events\n\
- Monitor connected skills for errors or disconnections\n";
tokio::fs::write(&path, default).await?;
}
Ok(())
@@ -120,38 +168,6 @@ mod tests {
let content = " - Indented task\n\t- Tab indented";
let tasks = HeartbeatEngine::parse_tasks(content);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0], "Indented task");
assert_eq!(tasks[1], "Tab indented");
}
#[test]
fn parse_tasks_dash_without_space_ignored() {
let content = "- Real task\n-\n- Another";
let tasks = HeartbeatEngine::parse_tasks(content);
// "-" trimmed = "-", does NOT start with "- " => skipped
// "- Real task" => "Real task"
// "- Another" => "Another"
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0], "Real task");
assert_eq!(tasks[1], "Another");
}
#[test]
fn parse_tasks_trailing_space_bullet_trimmed_to_dash() {
// "- " trimmed becomes "-" (trim removes trailing space)
// "-" does NOT start with "- " => skipped
let content = "- ";
let tasks = HeartbeatEngine::parse_tasks(content);
assert_eq!(tasks.len(), 0);
}
#[test]
fn parse_tasks_bullet_with_content_after_spaces() {
// "- hello " trimmed becomes "- hello" => starts_with "- " => "hello"
let content = "- hello ";
let tasks = HeartbeatEngine::parse_tasks(content);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0], "hello");
}
#[test]
@@ -159,41 +175,11 @@ mod tests {
let content = "- Check email 📧\n- Review calendar 📅\n- 日本語タスク";
let tasks = HeartbeatEngine::parse_tasks(content);
assert_eq!(tasks.len(), 3);
assert!(tasks[0].contains("📧"));
assert!(tasks[2].contains("日本語"));
}
#[test]
fn parse_tasks_mixed_markdown() {
let content = "# Periodic Tasks\n\n## Quick\n- Task A\n\n## Long\n- Task B\n\n* Not a dash bullet\n1. Not numbered";
let tasks = HeartbeatEngine::parse_tasks(content);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0], "Task A");
assert_eq!(tasks[1], "Task B");
}
#[test]
fn parse_tasks_single_task() {
let tasks = HeartbeatEngine::parse_tasks("- Only one");
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0], "Only one");
}
#[test]
fn parse_tasks_many_tasks() {
let content: String = (0..100).fold(String::new(), |mut s, i| {
use std::fmt::Write;
let _ = writeln!(s, "- Task {i}");
s
});
let tasks = HeartbeatEngine::parse_tasks(&content);
assert_eq!(tasks.len(), 100);
assert_eq!(tasks[99], "Task 99");
}
#[tokio::test]
async fn ensure_heartbeat_file_creates_file() {
let dir = std::env::temp_dir().join("openhuman_test_heartbeat");
async fn ensure_heartbeat_file_creates_file_with_defaults() {
let dir = std::env::temp_dir().join("openhuman_test_heartbeat_defaults");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
@@ -202,7 +188,9 @@ mod tests {
let path = dir.join("HEARTBEAT.md");
assert!(path.exists());
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(content.contains("Periodic Tasks"));
let tasks = HeartbeatEngine::parse_tasks(&content);
assert_eq!(tasks.len(), 3);
assert!(tasks.iter().any(|t| t.contains("email")));
let _ = tokio::fs::remove_dir_all(&dir).await;
}
@@ -224,58 +212,9 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn tick_returns_zero_when_no_file() {
let dir = std::env::temp_dir().join("openhuman_test_tick_no_file");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
let engine = HeartbeatEngine::new(
HeartbeatConfig {
enabled: true,
interval_minutes: 30,
},
dir.clone(),
);
let count = engine.tick().await.unwrap();
assert_eq!(count, 0);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn tick_counts_tasks_from_file() {
let dir = std::env::temp_dir().join("openhuman_test_tick_count");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("HEARTBEAT.md"), "- A\n- B\n- C")
.await
.unwrap();
let engine = HeartbeatEngine::new(
HeartbeatConfig {
enabled: true,
interval_minutes: 30,
},
dir.clone(),
);
let count = engine.tick().await.unwrap();
assert_eq!(count, 3);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn run_returns_immediately_when_disabled() {
let engine = HeartbeatEngine::new(
HeartbeatConfig {
enabled: false,
interval_minutes: 30,
},
std::env::temp_dir(),
);
// Should return Ok immediately, not loop forever
let engine = HeartbeatEngine::new(HeartbeatConfig::default(), std::env::temp_dir());
let result = engine.run().await;
assert!(result.is_ok());
}
+7 -27
View File
@@ -1,33 +1,13 @@
//! Heartbeat loop — periodic scheduler that delegates to the subconscious
//! engine for task-driven evaluation via local model inference.
//!
//! HEARTBEAT.md in the workspace defines the task checklist.
//! The subconscious engine evaluates tasks against workspace state
//! (memory, graph, skills) using the local Ollama model.
pub mod engine;
mod schemas;
pub use schemas::{
all_controller_schemas as all_heartbeat_controller_schemas,
all_registered_controllers as all_heartbeat_registered_controllers,
};
#[cfg(test)]
mod tests {
use crate::openhuman::config::HeartbeatConfig;
use crate::openhuman::heartbeat::engine::HeartbeatEngine;
#[test]
fn heartbeat_engine_is_constructible_via_module_export() {
let temp = tempfile::tempdir().unwrap();
let engine = HeartbeatEngine::new(HeartbeatConfig::default(), temp.path().to_path_buf());
let _ = engine;
}
#[tokio::test]
async fn ensure_heartbeat_file_creates_expected_file() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path();
HeartbeatEngine::ensure_heartbeat_file(workspace)
.await
.unwrap();
let heartbeat_path = workspace.join("HEARTBEAT.md");
assert!(heartbeat_path.exists());
}
}
+1
View File
@@ -36,6 +36,7 @@ pub mod screen_intelligence;
pub mod security;
pub mod service;
pub mod skills;
pub mod subconscious;
pub mod team;
pub mod tools;
pub mod util;
+235
View File
@@ -0,0 +1,235 @@
//! Decision log for tracking what the subconscious has already surfaced.
//! Prevents re-escalating the same state changes across ticks.
use super::types::{Decision, TickOutput};
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: Decision,
pub source_doc_ids: Vec<String>,
pub reason: String,
pub acknowledged: bool,
pub expires_at: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DecisionLog {
records: Vec<DecisionRecord>,
}
impl DecisionLog {
pub fn new() -> Self {
Self {
records: Vec::new(),
}
}
/// Check if any of the given doc IDs have already been surfaced
/// in a non-expired, unacknowledged record.
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 != Decision::Noop
&& r.source_doc_ids.iter().any(|id| doc_ids.contains(id))
})
}
/// Filter out doc IDs that have already been surfaced.
/// Returns only the new/unseen doc IDs.
pub fn filter_unsurfaced(&self, doc_ids: &[String]) -> Vec<String> {
let surfaced: HashSet<&str> = self
.records
.iter()
.filter(|r| {
!r.acknowledged && r.expires_at > now_secs() && r.decision != Decision::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()
}
/// Record a tick decision.
pub fn record(&mut self, tick_at: f64, output: &TickOutput, source_doc_ids: Vec<String>) {
self.records.push(DecisionRecord {
tick_at,
decision: output.decision.clone(),
source_doc_ids,
reason: output.reason.clone(),
acknowledged: false,
expires_at: tick_at + RECORD_TTL_SECS,
});
}
/// Mark all records matching any of the given doc IDs as acknowledged.
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;
}
}
}
/// Remove expired records.
pub fn prune_expired(&mut self) {
let now = now_secs();
self.records.retain(|r| r.expires_at > now);
}
/// Number of active (non-expired) records.
pub fn active_count(&self) -> usize {
let now = now_secs();
self.records.iter().filter(|r| r.expires_at > now).count()
}
/// All records (including expired, for debugging).
pub fn records(&self) -> &[DecisionRecord] {
&self.records
}
/// Serialize to JSON for storage in memory.
pub fn to_json(&self) -> Result<String, String> {
serde_json::to_string(self).map_err(|e| format!("serialize decision log: {e}"))
}
/// Deserialize from JSON.
pub fn from_json(json: &str) -> Result<Self, String> {
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::*;
use crate::openhuman::subconscious::types::{Decision, TickOutput};
fn tick_output(decision: Decision, reason: &str) -> TickOutput {
TickOutput {
decision,
reason: reason.to_string(),
actions: vec![],
}
}
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(),
&tick_output(Decision::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(),
&tick_output(Decision::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(),
&tick_output(Decision::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,
&tick_output(Decision::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,
&tick_output(Decision::Escalate, "old"),
vec!["doc-1".into()],
);
log.record(
now(),
&tick_output(Decision::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(),
&tick_output(Decision::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(),
&tick_output(Decision::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");
}
}
+590
View File
@@ -0,0 +1,590 @@
//! Subconscious loop engine — periodic background awareness.
//!
//! Replaces the old heartbeat engine with context-aware reasoning:
//! assembles a delta-based situation report, evaluates with the local
//! model, and decides whether to act, escalate, or do nothing.
use super::decision_log::DecisionLog;
use super::prompt::build_subconscious_prompt;
use super::situation_report::build_situation_report;
use super::types::{Decision, SubconsciousStatus, TickOutput, TickResult};
use crate::openhuman::config::Config;
use crate::openhuman::memory::{MemoryClient, MemoryClientRef};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{self, Duration};
use tracing::{debug, info, warn};
/// Memory namespace for storing subconscious state (decision log, etc.).
const SUBCONSCIOUS_NAMESPACE: &str = "subconscious";
/// Memory key for the persisted decision log.
const DECISION_LOG_KEY: &str = "__decision_log";
pub struct SubconsciousEngine {
workspace_dir: PathBuf,
interval_minutes: u32,
context_budget_tokens: u32,
enabled: bool,
memory: Option<MemoryClientRef>,
state: Arc<Mutex<EngineState>>,
}
struct EngineState {
last_tick_at: f64,
decision_log: DecisionLog,
total_ticks: u64,
total_escalations: u64,
}
impl SubconsciousEngine {
/// Create from the top-level Config (reads config.heartbeat).
pub fn new(config: &Config, memory: Option<MemoryClientRef>) -> Self {
Self::from_heartbeat_config(&config.heartbeat, config.workspace_dir.clone(), memory)
}
/// Create directly from HeartbeatConfig (used by HeartbeatEngine).
pub fn from_heartbeat_config(
heartbeat: &crate::openhuman::config::HeartbeatConfig,
workspace_dir: std::path::PathBuf,
memory: Option<MemoryClientRef>,
) -> Self {
Self {
workspace_dir,
interval_minutes: heartbeat.interval_minutes.max(5),
context_budget_tokens: heartbeat.context_budget_tokens,
enabled: heartbeat.enabled && heartbeat.inference_enabled,
memory,
state: Arc::new(Mutex::new(EngineState {
last_tick_at: 0.0,
decision_log: DecisionLog::new(),
total_ticks: 0,
total_escalations: 0,
})),
}
}
/// Start the subconscious loop (runs until cancelled).
pub async fn run(&self) -> Result<()> {
if !self.enabled {
info!("[subconscious] disabled, exiting");
return Ok(());
}
info!(
"[subconscious] started: every {} minutes, budget {} tokens",
self.interval_minutes, self.context_budget_tokens
);
// Load persisted decision log from memory
self.load_decision_log().await;
let mut interval =
time::interval(Duration::from_secs(u64::from(self.interval_minutes) * 60));
loop {
interval.tick().await;
match self.tick().await {
Ok(result) => {
info!(
"[subconscious] tick complete: decision={:?} reason=\"{}\" duration={}ms",
result.output.decision, result.output.reason, result.duration_ms
);
}
Err(e) => {
warn!("[subconscious] tick error: {e}");
}
}
}
}
/// Execute a single subconscious tick. Public for manual triggering via RPC.
///
/// The entire tick holds the state lock to prevent concurrent ticks
/// from duplicating work (fixes CodeRabbit #1: serialize executions).
pub async fn tick(&self) -> Result<TickResult> {
let started = std::time::Instant::now();
let tick_at = now_secs();
// Hold the lock for the entire tick to prevent concurrent execution
let mut state = self.state.lock().await;
// Load persisted decision log if this is the first tick (fixes #5)
if state.total_ticks == 0 {
if let Some(ref memory) = self.memory {
if let Ok(Some(value)) = memory
.kv_get(Some(SUBCONSCIOUS_NAMESPACE), DECISION_LOG_KEY)
.await
{
if let Some(json) = value.as_str() {
if let Ok(log) = DecisionLog::from_json(json) {
state.decision_log = log;
debug!("[subconscious] loaded persisted decision log");
}
}
}
}
}
state.decision_log.prune_expired();
let last_tick_at = state.last_tick_at;
// 1. Read HEARTBEAT.md tasks
let tasks = read_heartbeat_tasks(&self.workspace_dir).await;
if tasks.is_empty() {
debug!("[subconscious] HEARTBEAT.md empty or missing, skipping tick");
state.last_tick_at = tick_at;
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
output: TickOutput {
decision: Decision::Noop,
reason: "No tasks in HEARTBEAT.md".to_string(),
actions: vec![],
},
source_doc_ids: vec![],
duration_ms: started.elapsed().as_millis() as u64,
tokens_used: 0,
});
}
debug!(
"[subconscious] {} heartbeat tasks, assembling state (last_tick={:.0})",
tasks.len(),
last_tick_at
);
// 2. Assemble current state (delta since last tick)
let memory_ref = self.memory.as_ref().map(|m| m.as_ref());
let (report, new_doc_ids) = build_situation_report_with_doc_ids(
memory_ref,
&self.workspace_dir,
last_tick_at,
self.context_budget_tokens,
)
.await;
// 3. Filter out already-surfaced doc IDs (fixes #3: dedup)
let unsurfaced_doc_ids = state.decision_log.filter_unsurfaced(&new_doc_ids);
let has_new_data = !unsurfaced_doc_ids.is_empty();
// 4. Check if there's actually new state to evaluate
let has_memory_changes = report.contains("new/updated");
let has_changes = has_new_data || has_memory_changes;
let output = if !has_changes {
debug!("[subconscious] no actionable changes, skipping inference");
TickOutput {
decision: Decision::Noop,
reason: "No new state changes since last tick.".to_string(),
actions: vec![],
}
} else {
// 5. Build task-driven prompt and call local model
let prompt = build_subconscious_prompt(&tasks, &report);
debug!(
"[subconscious] calling local model ({} tasks, {} new docs)",
tasks.len(),
unsurfaced_doc_ids.len()
);
// Release lock during LLM call (it's slow)
drop(state);
let result = self.evaluate_with_local_model(&prompt).await?;
state = self.state.lock().await;
result
};
// 6. Update state
state.last_tick_at = tick_at;
state.total_ticks += 1;
// 7. Record decision with actual doc IDs (fixes #3: dedup)
if output.decision != Decision::Noop {
state
.decision_log
.record(tick_at, &output, unsurfaced_doc_ids.clone());
if output.decision == Decision::Escalate {
state.total_escalations += 1;
}
}
let duration_ms = started.elapsed().as_millis() as u64;
drop(state);
// 8. Persist decision log
self.save_decision_log().await;
// 9. Handle actions — always store as RecommendedAction JSON (fixes #4)
if !output.actions.is_empty() {
if let Ok(json) = serde_json::to_string(&output.actions) {
self.store_actions(&json).await;
}
}
if output.decision == Decision::Escalate {
self.handle_escalation(&output, &report).await;
}
Ok(TickResult {
tick_at,
output,
source_doc_ids: unsurfaced_doc_ids,
duration_ms,
tokens_used: 0,
})
}
/// Get current status.
pub async fn status(&self) -> SubconsciousStatus {
let state = self.state.lock().await;
SubconsciousStatus {
enabled: self.enabled,
interval_minutes: self.interval_minutes,
last_tick_at: if state.last_tick_at > 0.0 {
Some(state.last_tick_at)
} else {
None
},
last_decision: state
.decision_log
.records()
.last()
.map(|r| r.decision.clone()),
total_ticks: state.total_ticks,
total_escalations: state.total_escalations,
}
}
/// Evaluate the situation report using the local AI model (Ollama).
async fn evaluate_with_local_model(&self, prompt: &str) -> Result<TickOutput> {
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("load config: {e}"))?;
let messages = vec![
crate::openhuman::local_ai::ops::LocalAiChatMessage {
role: "system".to_string(),
content: prompt.to_string(),
},
crate::openhuman::local_ai::ops::LocalAiChatMessage {
role: "user".to_string(),
content:
"Evaluate the situation report and respond with ONLY the JSON decision object."
.to_string(),
},
];
match crate::openhuman::local_ai::ops::local_ai_chat(&config, messages, None).await {
Ok(outcome) => {
let text = outcome.value;
debug!("[subconscious] local model response: {text}");
parse_tick_output(&text)
}
Err(e) => {
warn!("[subconscious] local model inference failed: {e}, falling back to noop");
Ok(TickOutput {
decision: Decision::Noop,
reason: format!("Local model inference failed: {e}"),
actions: vec![],
})
}
}
}
/// Handle escalation — call the stronger model to resolve into concrete actions.
async fn handle_escalation(&self, output: &TickOutput, situation_report: &str) {
info!(
"[subconscious] ESCALATION: {} — calling agent for resolution",
output.reason
);
let escalation_prompt = format!(
"The subconscious background loop detected something important:\n\n\
Reason: {}\n\n\
Situation report:\n{}\n\n\
Based on this, what concrete actions should be taken? \
Respond with a JSON object:\n\
{{\"actions\": [{{\"type\": \"notify|store_memory|run_tool\", \"description\": \"what to do\", \"priority\": \"low|medium|high\"}}]}}",
output.reason, situation_report
);
let config = match crate::openhuman::config::Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
warn!("[subconscious] escalation failed — could not load config: {e}");
return;
}
};
match crate::openhuman::local_ai::ops::agent_chat_simple(
&config,
&escalation_prompt,
config.heartbeat.escalation_model.clone(),
Some(0.3),
)
.await
{
Ok(outcome) => {
info!("[subconscious] escalation resolved");
// Normalize agent response to RecommendedAction format (fixes #4)
let actions = normalize_escalation_response(&outcome.value, &output.reason);
if let Ok(json) = serde_json::to_string(&actions) {
self.store_actions(&json).await;
}
}
Err(e) => {
warn!("[subconscious] escalation agent call failed: {e}");
// Fall back: store the original actions from local model
if let Ok(json) = serde_json::to_string(&output.actions) {
self.store_actions(&json).await;
}
}
}
}
/// Store action results in the subconscious memory namespace for the UI to consume.
async fn store_actions(&self, content: &str) {
if let Some(ref memory) = self.memory {
// Use millisecond precision + random suffix to avoid key collisions (fixes #7)
let timestamp_ms = (now_secs() * 1000.0) as u64;
let suffix = rand_suffix();
let key = format!("actions:{timestamp_ms}:{suffix}");
let value = serde_json::Value::String(content.to_string());
if let Err(e) = memory
.kv_set(Some(SUBCONSCIOUS_NAMESPACE), &key, &value)
.await
{
warn!("[subconscious] failed to store actions: {e}");
} else {
debug!("[subconscious] actions stored as {key}");
}
}
}
/// Load decision log from memory.
async fn load_decision_log(&self) {
if let Some(ref memory) = self.memory {
match memory
.kv_get(Some(SUBCONSCIOUS_NAMESPACE), DECISION_LOG_KEY)
.await
{
Ok(Some(value)) => {
if let Some(json) = value.as_str() {
match DecisionLog::from_json(json) {
Ok(log) => {
let mut state = self.state.lock().await;
state.decision_log = log;
debug!("[subconscious] loaded decision log from memory");
}
Err(e) => {
warn!("[subconscious] failed to parse decision log: {e}");
}
}
}
}
Ok(None) => {
debug!("[subconscious] no persisted decision log found");
}
Err(e) => {
warn!("[subconscious] failed to load decision log: {e}");
}
}
}
}
/// Save decision log to memory.
async fn save_decision_log(&self) {
if let Some(ref memory) = self.memory {
let state = self.state.lock().await;
match state.decision_log.to_json() {
Ok(json) => {
let value = serde_json::Value::String(json);
if let Err(e) = memory
.kv_set(Some(SUBCONSCIOUS_NAMESPACE), DECISION_LOG_KEY, &value)
.await
{
warn!("[subconscious] failed to save decision log: {e}");
}
}
Err(e) => {
warn!("[subconscious] failed to serialize decision log: {e}");
}
}
}
}
}
/// Parse the local model's JSON response into a TickOutput.
fn parse_tick_output(text: &str) -> Result<TickOutput> {
// Try direct JSON parse first
if let Ok(output) = serde_json::from_str::<TickOutput>(text) {
return Ok(output);
}
// Try extracting JSON from markdown code blocks
let trimmed = text.trim();
if let Some(json_start) = trimmed.find('{') {
if let Some(json_end) = trimmed.rfind('}') {
let json_slice = &trimmed[json_start..=json_end];
if let Ok(output) = serde_json::from_str::<TickOutput>(json_slice) {
return Ok(output);
}
}
}
warn!("[subconscious] could not parse model output as JSON, defaulting to noop");
Ok(TickOutput {
decision: Decision::Noop,
reason: format!("Unparseable model output: {}", &text[..text.len().min(100)]),
actions: vec![],
})
}
/// Normalize the agent's escalation response into RecommendedAction format.
/// Ensures consistent schema regardless of what the agent returns.
fn normalize_escalation_response(
agent_response: &str,
reason: &str,
) -> Vec<super::types::RecommendedAction> {
// Try parsing as JSON with an actions array
if let Ok(value) = serde_json::from_str::<serde_json::Value>(agent_response) {
if let Some(actions) = value.get("actions").and_then(|a| a.as_array()) {
let mut result = Vec::new();
for action in actions {
if let Ok(ra) =
serde_json::from_value::<super::types::RecommendedAction>(action.clone())
{
result.push(ra);
}
}
if !result.is_empty() {
return result;
}
}
}
// Fallback: wrap the raw response as a single notify action
vec![super::types::RecommendedAction {
action_type: super::types::ActionType::EscalateToAgent,
description: format!(
"Escalation resolved: {}",
&agent_response[..agent_response.len().min(300)]
),
priority: super::types::Priority::High,
task: Some(reason.to_string()),
}]
}
/// Generate a short random suffix for KV key uniqueness.
fn rand_suffix() -> u32 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::thread::current().id().hash(&mut hasher);
(hasher.finish() % 10000) as u32
}
/// Wrapper around build_situation_report that also returns new doc IDs.
async fn build_situation_report_with_doc_ids(
memory: Option<&super::super::memory::MemoryClient>,
workspace_dir: &std::path::Path,
last_tick_at: f64,
token_budget: u32,
) -> (String, Vec<String>) {
let report = build_situation_report(memory, workspace_dir, last_tick_at, token_budget).await;
// Extract doc IDs from memory if available
let mut doc_ids = Vec::new();
if let Some(client) = memory {
if let Ok(docs) = client.list_documents(None).await {
let is_cold_start = last_tick_at <= 0.0;
if let Some(arr) = docs
.as_array()
.or_else(|| docs.get("documents").and_then(|v| v.as_array()))
{
for doc in arr {
let updated_at = doc
.get("updated_at")
.or_else(|| doc.get("updatedAt"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
if is_cold_start || updated_at > last_tick_at {
if let Some(id) = doc
.get("document_id")
.or_else(|| doc.get("documentId"))
.and_then(|v| v.as_str())
{
doc_ids.push(id.to_string());
}
}
}
}
}
}
(report, doc_ids)
}
/// Read tasks from HEARTBEAT.md in the workspace.
async fn read_heartbeat_tasks(workspace_dir: &std::path::Path) -> Vec<String> {
let path = workspace_dir.join("HEARTBEAT.md");
let content = match tokio::fs::read_to_string(&path).await {
Ok(c) => c,
Err(_) => return Vec::new(),
};
content
.lines()
.filter_map(|line| line.trim().strip_prefix("- ").map(ToString::to_string))
.filter(|s| !s.is_empty())
.collect()
}
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::*;
#[test]
fn parse_valid_noop() {
let output = parse_tick_output(
r#"{"decision": "noop", "reason": "Nothing changed.", "actions": []}"#,
)
.unwrap();
assert_eq!(output.decision, Decision::Noop);
}
#[test]
fn parse_valid_escalate() {
let output = parse_tick_output(
r#"{"decision": "escalate", "reason": "Deadline moved to tomorrow", "actions": [{"type": "escalate_to_agent", "description": "Notify about deadline change", "priority": "high"}]}"#,
)
.unwrap();
assert_eq!(output.decision, Decision::Escalate);
assert_eq!(output.actions.len(), 1);
}
#[test]
fn parse_json_in_markdown_block() {
let output = parse_tick_output(
"```json\n{\"decision\": \"act\", \"reason\": \"Store to memory\", \"actions\": []}\n```",
)
.unwrap();
assert_eq!(output.decision, Decision::Act);
}
#[test]
fn parse_garbage_falls_back_to_noop() {
let output = parse_tick_output("This is not JSON at all").unwrap();
assert_eq!(output.decision, Decision::Noop);
assert!(output.reason.contains("Unparseable"));
}
}
+44
View File
@@ -0,0 +1,44 @@
//! 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.
use super::engine::SubconsciousEngine;
use std::sync::{Arc, OnceLock};
use tokio::sync::Mutex;
static ENGINE: OnceLock<Arc<Mutex<Option<SubconsciousEngine>>>> = OnceLock::new();
fn engine_lock() -> &'static Arc<Mutex<Option<SubconsciousEngine>>> {
ENGINE.get_or_init(|| Arc::new(Mutex::new(None)))
}
/// Get or initialize the global engine. Both heartbeat loop and RPC use this.
pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>>>, String> {
let lock = engine_lock();
{
let guard = lock.lock().await;
if guard.is_some() {
return Ok(Arc::clone(lock));
}
}
// Initialize
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("load config: {e}"))?;
let memory =
crate::openhuman::memory::MemoryClient::from_workspace_dir(config.workspace_dir.clone())
.ok()
.map(Arc::new);
let engine = SubconsciousEngine::new(&config, memory);
let mut guard = lock.lock().await;
if guard.is_none() {
*guard = Some(engine);
}
Ok(Arc::clone(lock))
}
@@ -0,0 +1,270 @@
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::json;
use tempfile::TempDir;
use crate::openhuman::memory::embeddings::NoopEmbedding;
use crate::openhuman::memory::{
MemoryIngestionConfig, MemoryIngestionRequest, NamespaceDocumentInput, UnifiedMemory,
};
use crate::openhuman::subconscious::decision_log::DecisionLog;
use crate::openhuman::subconscious::situation_report::build_situation_report;
use crate::openhuman::subconscious::types::Decision;
fn fixture(path: &str) -> String {
let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(
base.join("tests")
.join("fixtures")
.join("subconscious")
.join(path),
)
.expect("fixture should load")
}
fn ci_safe_config() -> MemoryIngestionConfig {
MemoryIngestionConfig {
model_name: "__test_no_model__".to_string(),
..MemoryIngestionConfig::default()
}
}
async fn ingest(
memory: &UnifiedMemory,
namespace: &str,
key: &str,
title: &str,
content: &str,
) -> String {
let result = memory
.ingest_document(MemoryIngestionRequest {
document: NamespaceDocumentInput {
namespace: namespace.to_string(),
key: key.to_string(),
title: title.to_string(),
content: content.to_string(),
source_type: "test".to_string(),
priority: "high".to_string(),
tags: Vec::new(),
metadata: json!({}),
category: "core".to_string(),
session_id: None,
document_id: None,
},
config: ci_safe_config(),
})
.await
.unwrap();
result.document_id
}
/// Full two-tick integration test:
/// 1. Ingest tick1 data → build report → verify it contains the data
/// 2. Record a decision in the log
/// 3. Ingest tick2 data → build report → verify delta-only (not old data)
/// 4. Verify decision log deduplication
#[tokio::test]
async fn two_tick_lifecycle() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory = UnifiedMemory::new(workspace, Arc::new(NoopEmbedding), None).unwrap();
let client =
crate::openhuman::memory::MemoryClient::from_workspace_dir(workspace.to_path_buf())
.unwrap();
// Write HEARTBEAT.md
std::fs::write(workspace.join("HEARTBEAT.md"), fixture("heartbeat.md")).unwrap();
// ============================================================
// TICK 1: Ingest initial data
// ============================================================
let tick1_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let gmail_doc_id = ingest(
&memory,
"skill-gmail",
"tick1-deadline-email",
"API contract deadline reminder",
&fixture("tick1_gmail.txt"),
)
.await;
let notion_doc_id = ingest(
&memory,
"skill-notion",
"tick1-tracker",
"Q1 Delivery Tracker",
&fixture("tick1_notion.txt"),
)
.await;
// Build situation report for tick 1 (cold start: last_tick_at = 0)
let report1 = build_situation_report(
Some(&client),
workspace,
0.0, // cold start
40_000,
)
.await;
println!("=== TICK 1 REPORT ===");
println!("{}", &report1[..report1.len().min(2000)]);
println!("=====================\n");
// Verify tick 1 report contains ingested data
assert!(
report1.contains("Memory Documents"),
"Report should have memory docs section"
);
assert!(
report1.contains("skill-gmail") || report1.contains("deadline"),
"Report should mention gmail data"
);
assert!(
report1.contains("Pending Tasks"),
"Report should have tasks section"
);
assert!(
report1.contains("Check for deadline changes"),
"Report should include HEARTBEAT.md tasks"
);
// Simulate tick 1 decision: escalate the deadline
let mut decision_log = DecisionLog::new();
let tick1_output = crate::openhuman::subconscious::types::TickOutput {
decision: Decision::Escalate,
reason: "Deadline reminder for API contract (April 3)".to_string(),
actions: vec![],
};
decision_log.record(tick1_time, &tick1_output, vec![gmail_doc_id.clone()]);
println!(
"Decision log after tick 1: {} active records",
decision_log.active_count()
);
assert_eq!(decision_log.active_count(), 1);
assert!(decision_log.was_already_surfaced(&[gmail_doc_id.clone()]));
assert!(!decision_log.was_already_surfaced(&["nonexistent".to_string()]));
// ============================================================
// TICK 2: Ingest new data (state change)
// ============================================================
let tick2_time = tick1_time + 1.0; // 1 second later (simulated)
let gmail2_doc_id = ingest(
&memory,
"skill-gmail",
"tick2-deadline-moved",
"URGENT: API contract deadline moved to tomorrow",
&fixture("tick2_gmail.txt"),
)
.await;
let notion2_doc_id = ingest(
&memory,
"skill-notion",
"tick2-tracker-updated",
"Q1 Delivery Tracker (updated)",
&fixture("tick2_notion.txt"),
)
.await;
// Build situation report for tick 2 (delta since tick 1)
let report2 = build_situation_report(
Some(&client),
workspace,
tick1_time, // delta since tick 1
40_000,
)
.await;
println!("=== TICK 2 REPORT ===");
println!("{}", &report2[..report2.len().min(2000)]);
println!("=====================\n");
// Verify tick 2 report contains NEW data
assert!(
report2.contains("new/updated"),
"Report should show new/updated docs"
);
// Verify deduplication: old gmail doc should be filtered
let all_new_doc_ids = vec![
gmail_doc_id.clone(),
gmail2_doc_id.clone(),
notion2_doc_id.clone(),
];
let unsurfaced = decision_log.filter_unsurfaced(&all_new_doc_ids);
println!("Unsurfaced doc IDs: {:?}", unsurfaced);
assert!(
!unsurfaced.contains(&gmail_doc_id),
"Old deadline email should be filtered out (already surfaced)"
);
assert!(
unsurfaced.contains(&gmail2_doc_id),
"New deadline-moved email should NOT be filtered"
);
assert!(
unsurfaced.contains(&notion2_doc_id),
"Updated notion tracker should NOT be filtered"
);
// Record tick 2 decision
let tick2_output = crate::openhuman::subconscious::types::TickOutput {
decision: Decision::Escalate,
reason: "Deadline moved to tomorrow — urgent".to_string(),
actions: vec![],
};
decision_log.record(tick2_time, &tick2_output, vec![gmail2_doc_id.clone()]);
println!(
"Decision log after tick 2: {} active records",
decision_log.active_count()
);
assert_eq!(decision_log.active_count(), 2);
// ============================================================
// TICK 3: No new data
// ============================================================
let report3 = build_situation_report(
Some(&client),
workspace,
tick2_time, // delta since tick 2
40_000,
)
.await;
println!("=== TICK 3 REPORT ===");
println!("{}", &report3[..report3.len().min(2000)]);
println!("=====================\n");
// Tick 3 should show no changes
let has_changes = !report3.contains("No changes since last tick");
// Note: on cold data with fixed timestamps, all docs may appear
// "old" relative to tick2_time. The key test is that the decision
// log correctly filters previously surfaced items.
println!("Tick 3 has changes: {}", has_changes);
// Verify JSON roundtrip of decision log
let json = decision_log.to_json().unwrap();
let restored = DecisionLog::from_json(&json).unwrap();
assert_eq!(restored.active_count(), 2);
assert!(restored.was_already_surfaced(&[gmail_doc_id.clone()]));
assert!(restored.was_already_surfaced(&[gmail2_doc_id.clone()]));
// Verify acknowledgment
decision_log.mark_acknowledged(&[gmail_doc_id.clone()]);
assert!(
!decision_log.was_already_surfaced(&[gmail_doc_id]),
"Acknowledged docs should no longer be surfaced"
);
println!("=== ALL TESTS PASSED ===");
}
}
+17
View File
@@ -0,0 +1,17 @@
pub mod decision_log;
pub mod engine;
pub mod global;
pub mod prompt;
mod schemas;
pub mod situation_report;
pub mod types;
#[cfg(test)]
mod integration_test;
pub use engine::SubconsciousEngine;
pub use schemas::{
all_controller_schemas as all_subconscious_controller_schemas,
all_registered_controllers as all_subconscious_registered_controllers,
};
pub use types::{Decision, SubconsciousStatus, TickOutput, TickResult};
+100
View File
@@ -0,0 +1,100 @@
//! System prompt for the subconscious local-model evaluation.
/// Build the task-driven system prompt for the subconscious tick.
///
/// The local model evaluates each HEARTBEAT.md task against the current
/// state and decides which tasks have actionable items right now.
pub fn build_subconscious_prompt(tasks: &[String], situation_report: &str) -> String {
let task_list = tasks
.iter()
.enumerate()
.map(|(i, t)| format!("{}. {}", i + 1, t))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"# Subconscious Loop — Task Evaluation
You are the background awareness layer of OpenHuman. You run periodically
to check a list of user-defined tasks against the current workspace state.
## Your tasks to check
{task_list}
## Current state
{situation_report}
## Your job
For each task above, check if the current state contains anything relevant.
If a task has something actionable, include it in the actions list.
If no tasks have anything actionable, return noop.
## Output format (strict JSON, no other text)
{{
"decision": "noop" | "act" | "escalate",
"reason": "one sentence summary of what you found",
"actions": [
{{
"type": "notify" | "store_memory" | "escalate_to_agent",
"description": "what was found and what to do about it",
"priority": "low" | "medium" | "high",
"task": "which task this relates to"
}}
]
}}
## Decision rules
- **noop**: None of the tasks have anything actionable in the current state.
- **act**: One or more tasks have findings that can be summarized as a notification or stored as a memory note.
- **escalate**: A task finding requires complex reasoning or multi-step action that the full agent should handle (e.g. drafting a response, reprioritizing work, multi-tool operations).
## Examples
Task: "Check email for urgent items"
State shows: new email about deadline moved to tomorrow
→ act, notify with high priority
Task: "Monitor skills runtime health"
State shows: no skill data available
→ noop for this task
Task: "Check for deadline changes"
State shows: project tracker updated, 3 deadlines shifted, ownership changed
→ escalate (too complex for simple notification)
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_includes_tasks_and_report() {
let tasks = vec!["Check email".to_string(), "Review calendar".to_string()];
let prompt = build_subconscious_prompt(&tasks, "## Test\n\nSome data.");
assert!(prompt.contains("1. Check email"));
assert!(prompt.contains("2. Review calendar"));
assert!(prompt.contains("## Test"));
assert!(prompt.contains("Some data."));
}
#[test]
fn prompt_includes_json_schema() {
let prompt = build_subconscious_prompt(&["Task".into()], "");
assert!(prompt.contains("noop"));
assert!(prompt.contains("escalate"));
assert!(prompt.contains("escalate_to_agent"));
}
#[test]
fn prompt_includes_task_field_in_actions() {
let prompt = build_subconscious_prompt(&["Task".into()], "");
assert!(prompt.contains("\"task\""));
}
}
+175
View File
@@ -0,0 +1,175 @@
use serde_json::{Map, Value};
use super::global::get_or_init_engine;
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::rpc::RpcOutcome;
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![schemas("status"), schemas("trigger"), schemas("actions")]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("status"),
handler: handle_status,
},
RegisteredController {
schema: schemas("trigger"),
handler: handle_trigger,
},
RegisteredController {
schema: schemas("actions"),
handler: handle_actions,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"status" => ControllerSchema {
namespace: "subconscious",
function: "status",
description: "Get the current subconscious loop status.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Loop status including last tick, decision counts.",
required: true,
}],
},
"trigger" => ControllerSchema {
namespace: "subconscious",
function: "trigger",
description: "Manually trigger a subconscious tick.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Tick result with decision, reason, and actions.",
required: true,
}],
},
"actions" => ControllerSchema {
namespace: "subconscious",
function: "actions",
description: "List stored subconscious actions/notifications.",
inputs: vec![FieldSchema {
name: "limit",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Maximum number of actions to return (default: 20).",
required: false,
}],
outputs: vec![FieldSchema {
name: "actions",
ty: TypeSchema::Json,
comment: "Array of stored action entries with timestamps.",
required: true,
}],
},
_other => ControllerSchema {
namespace: "subconscious",
function: "unknown",
description: "Unknown subconscious controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Error details.",
required: true,
}],
},
}
}
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let lock = get_or_init_engine().await?;
let guard = lock.lock().await;
let engine = guard
.as_ref()
.ok_or_else(|| "engine not initialized".to_string())?;
let status = engine.status().await;
to_json(RpcOutcome::single_log(status, "subconscious status"))
})
}
fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let lock = get_or_init_engine().await?;
let guard = lock.lock().await;
let engine = guard
.as_ref()
.ok_or_else(|| "engine not initialized".to_string())?;
let result = engine.tick().await.map_err(|e| e.to_string())?;
to_json(RpcOutcome::single_log(
result,
"subconscious tick completed",
))
})
}
fn handle_actions(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("load config: {e}"))?;
let memory =
crate::openhuman::memory::MemoryClient::from_workspace_dir(config.workspace_dir)
.map_err(|e| format!("memory client: {e}"))?;
let entries = memory
.kv_list_namespace("subconscious")
.await
.map_err(|e| format!("list actions: {e}"))?;
let mut actions: Vec<Value> = Vec::new();
for entry in entries {
let key = entry.get("key").and_then(|v| v.as_str()).unwrap_or("");
if !key.starts_with("actions:") {
continue;
}
// Key format: "actions:{ms}:{suffix}" — extract timestamp before suffix
let timestamp = key
.strip_prefix("actions:")
.and_then(|s| s.split(':').next())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("[]");
let parsed_actions: Value =
serde_json::from_str(value).unwrap_or(Value::String(value.to_string()));
actions.push(serde_json::json!({
"tick_at": timestamp,
"actions": parsed_actions,
}));
}
actions.sort_by(|a, b| {
let ta = a.get("tick_at").and_then(|v| v.as_f64()).unwrap_or(0.0);
let tb = b.get("tick_at").and_then(|v| v.as_f64()).unwrap_or(0.0);
tb.partial_cmp(&ta).unwrap_or(std::cmp::Ordering::Equal)
});
actions.truncate(limit);
to_json(RpcOutcome::single_log(
serde_json::json!({ "entries": actions, "count": actions.len() }),
"subconscious actions listed",
))
})
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
@@ -0,0 +1,403 @@
//! Assembles a bounded "situation report" for the subconscious tick.
//! Gathers deltas since the last tick from memory, tools, environment,
//! and HEARTBEAT.md — capped at the configured token budget.
use crate::openhuman::memory::MemoryClient;
use std::fmt::Write;
use std::path::Path;
/// Rough chars-per-token estimate for budget enforcement.
const CHARS_PER_TOKEN: usize = 4;
/// Assemble the situation report for a subconscious tick.
///
/// `last_tick_at` is 0.0 on the first tick (cold start → include everything).
/// `token_budget` caps the output size; sections are truncated if exceeded.
pub async fn build_situation_report(
memory: Option<&MemoryClient>,
workspace_dir: &Path,
last_tick_at: f64,
token_budget: u32,
) -> String {
let char_budget = (token_budget as usize) * CHARS_PER_TOKEN;
let mut report = String::with_capacity(char_budget.min(64_000));
let mut remaining = char_budget;
// Section 1: Environment
let env_section = build_environment_section(workspace_dir);
append_section(&mut report, &mut remaining, &env_section);
// Section 2: HEARTBEAT.md tasks
let tasks_section = build_tasks_section(workspace_dir).await;
append_section(&mut report, &mut remaining, &tasks_section);
// Section 3: Memory documents (delta since last tick)
if let Some(client) = memory {
let docs_section = build_memory_docs_section(client, last_tick_at).await;
append_section(&mut report, &mut remaining, &docs_section);
// Section 4: Graph relations (delta since last tick)
let graph_section = build_graph_section(client, last_tick_at).await;
append_section(&mut report, &mut remaining, &graph_section);
} else {
append_section(
&mut report,
&mut remaining,
"## Memory\n\nMemory client not available.\n",
);
}
// Section 5: Skills runtime health
let skills_section = build_skills_section().await;
append_section(&mut report, &mut remaining, &skills_section);
if report.trim().is_empty() {
report.push_str("No state changes detected since last tick.\n");
}
report
}
fn build_environment_section(workspace_dir: &Path) -> String {
let host =
hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
let now = chrono::Local::now();
format!(
"## Environment\n\n\
Workspace: {}\n\
Host: {} | OS: {}\n\
Time: {}\n",
workspace_dir.display(),
host,
std::env::consts::OS,
now.format("%Y-%m-%d %H:%M:%S %Z"),
)
}
async fn build_tasks_section(workspace_dir: &Path) -> String {
let heartbeat_path = workspace_dir.join("HEARTBEAT.md");
let content = match tokio::fs::read_to_string(&heartbeat_path).await {
Ok(c) => c,
Err(_) => return "## Pending Tasks\n\nNo HEARTBEAT.md found.\n".to_string(),
};
let tasks: Vec<&str> = content
.lines()
.filter_map(|line| line.trim().strip_prefix("- "))
.collect();
if tasks.is_empty() {
return "## Pending Tasks\n\nNo tasks defined.\n".to_string();
}
let mut section = String::from("## Pending Tasks\n\n");
for task in &tasks {
let _ = writeln!(section, "- {task}");
}
section
}
/// Max chars of content to include per document in the report.
const DOC_CONTENT_SNIPPET_CHARS: usize = 500;
/// Max number of new docs to include full content for.
const MAX_DOCS_WITH_CONTENT: usize = 10;
async fn build_memory_docs_section(client: &MemoryClient, last_tick_at: f64) -> String {
let docs = match client.list_documents(None).await {
Ok(raw) => raw,
Err(e) => {
return format!("## Memory Documents\n\nFailed to list documents: {e}\n");
}
};
let doc_array = docs
.as_array()
.or_else(|| docs.get("documents").and_then(|v| v.as_array()));
let Some(doc_array) = doc_array else {
return "## Memory Documents\n\nNo documents found.\n".to_string();
};
let is_cold_start = last_tick_at <= 0.0;
// Collect new/updated doc metadata
let mut new_doc_keys: Vec<(String, String, String)> = Vec::new(); // (namespace, key, title)
for doc in doc_array {
let updated_at = doc
.get("updated_at")
.or_else(|| doc.get("updatedAt"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
if !is_cold_start && updated_at <= last_tick_at {
continue;
}
let title = doc
.get("title")
.or_else(|| doc.get("key"))
.and_then(|v| v.as_str())
.unwrap_or("untitled")
.to_string();
let namespace = doc
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let key = doc
.get("key")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
new_doc_keys.push((namespace, key, title));
}
if new_doc_keys.is_empty() {
return format!(
"## Memory Documents\n\n{} total documents. No changes since last tick.\n",
doc_array.len()
);
}
let mut section = format!(
"## Memory Documents\n\n{} total, {} new/updated since last tick:\n\n",
doc_array.len(),
new_doc_keys.len()
);
// Recall content per namespace for new docs
let mut recalled_namespaces = std::collections::HashSet::new();
let mut docs_with_content = 0;
for (namespace, _key, title) in &new_doc_keys {
let _ = writeln!(section, "### [{namespace}] {title}\n");
// Only recall each namespace once (may have multiple docs per namespace)
if docs_with_content < MAX_DOCS_WITH_CONTENT
&& recalled_namespaces.insert(namespace.clone())
{
if let Ok(Some(context)) = client.recall_namespace(namespace, 3).await {
let snippet = truncate_at_char_boundary(&context, DOC_CONTENT_SNIPPET_CHARS);
if !snippet.trim().is_empty() {
let _ = writeln!(section, "{snippet}\n");
}
}
docs_with_content += 1;
}
}
section
}
fn truncate_at_char_boundary(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
// Find the last char whose END offset fits within the budget
let truncate_at = text
.char_indices()
.map(|(i, ch)| i + ch.len_utf8())
.take_while(|end| *end <= max_bytes)
.last()
.unwrap_or(0);
format!("{}...", &text[..truncate_at])
}
async fn build_graph_section(client: &MemoryClient, last_tick_at: f64) -> String {
let relations = match client.graph_query(None, None, None).await {
Ok(rows) => rows,
Err(e) => {
return format!("## Knowledge Graph\n\nFailed to query graph: {e}\n");
}
};
if relations.is_empty() {
return "## Knowledge Graph\n\nNo relations.\n".to_string();
}
// Filter to relations updated since last tick
let is_cold_start = last_tick_at <= 0.0;
let mut new_relations: Vec<(&str, &str, &str)> = Vec::new();
for rel in &relations {
let updated_at = rel
.get("updatedAt")
.or_else(|| rel.get("updated_at"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
if !is_cold_start && updated_at <= last_tick_at {
continue;
}
let subject = rel.get("subject").and_then(|v| v.as_str()).unwrap_or("?");
let predicate = rel.get("predicate").and_then(|v| v.as_str()).unwrap_or("?");
let object = rel.get("object").and_then(|v| v.as_str()).unwrap_or("?");
new_relations.push((subject, predicate, object));
}
if new_relations.is_empty() {
return format!(
"## Knowledge Graph\n\n{} total relations. No changes since last tick.\n",
relations.len()
);
}
let mut section = format!(
"## Knowledge Graph\n\n{} total, {} new/updated:\n\n",
relations.len(),
new_relations.len()
);
for (s, p, o) in new_relations.iter().take(20) {
let _ = writeln!(section, "- {s} → {p} → {o}");
}
if new_relations.len() > 20 {
let _ = writeln!(section, "- ... and {} more", new_relations.len() - 20);
}
section
}
async fn build_skills_section() -> String {
// Call the skills_list RPC internally via the controller registry
let params = serde_json::Map::new();
let result = crate::core::all::try_invoke_registered_rpc("openhuman.skills_list", params).await;
let skills_value = match result {
Some(Ok(value)) => value,
Some(Err(_)) | None => {
return "## Skills Runtime\n\nSkill registry unavailable.\n".to_string();
}
};
let skills = match skills_value.as_array() {
Some(arr) => arr,
None => {
return "## Skills Runtime\n\nNo skills data.\n".to_string();
}
};
if skills.is_empty() {
return "## Skills Runtime\n\nNo skills installed.\n".to_string();
}
let mut section = String::from("## Skills Runtime\n\n");
for skill in skills {
let name = skill
.get("skill_id")
.or_else(|| skill.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let status = skill
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let setup = skill
.get("setup_complete")
.and_then(|v| v.as_bool())
.map(|b| if b { "ready" } else { "not setup" })
.unwrap_or("?");
let connection = skill
.get("connection_status")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut line = format!("- {name}: {status}");
if !connection.is_empty() && connection != status {
line.push_str(&format!(" ({connection})"));
}
if setup == "not setup" {
line.push_str(" [not setup]");
}
if let Some(err) = skill
.get("connection_error")
.or_else(|| skill.get("lastError"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
let truncated_err = truncate_at_char_boundary(err, 100);
line.push_str(&format!(" ERROR: {truncated_err}"));
}
let _ = writeln!(section, "{line}");
}
section
}
fn append_section(report: &mut String, remaining: &mut usize, section: &str) {
if *remaining == 0 {
return;
}
// +1 for the trailing newline we append
let needed = section.len().saturating_add(1);
if needed <= *remaining {
report.push_str(section);
report.push('\n');
*remaining -= needed;
} else {
// Truncate at a valid UTF-8 char boundary within budget
let budget = *remaining;
let truncate_at = section
.char_indices()
.map(|(i, ch)| i + ch.len_utf8())
.take_while(|end| *end <= budget)
.last()
.unwrap_or(0);
report.push_str(&section[..truncate_at]);
report.push_str("\n[... truncated — token budget exceeded]\n");
*remaining = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_section_contains_os_and_host() {
let section = build_environment_section(Path::new("/tmp/workspace"));
assert!(section.contains("## Environment"));
assert!(section.contains("Workspace: /tmp/workspace"));
assert!(section.contains("OS:"));
}
#[test]
fn append_section_truncates_on_budget() {
let mut report = String::new();
let mut remaining = 10;
append_section(&mut report, &mut remaining, "Hello, this is a long section");
assert!(report.starts_with("Hello, thi"));
assert!(report.contains("truncated"));
assert_eq!(remaining, 0);
}
#[test]
fn append_section_exact_fit_does_not_underflow() {
let mut report = String::new();
// "Hello" (5 bytes) + newline (1 byte) = 6 bytes needed
let mut remaining = 6;
append_section(&mut report, &mut remaining, "Hello");
assert_eq!(report, "Hello\n");
assert_eq!(remaining, 0);
}
#[test]
fn append_section_truncates_at_char_boundary() {
let mut report = String::new();
// "日本語" is 9 bytes (3 chars × 3 bytes each)
// Budget of 5 should truncate to "日" (3 bytes), not panic
let mut remaining = 5;
append_section(&mut report, &mut remaining, "日本語タスク");
assert!(report.starts_with(""));
assert!(report.contains("truncated"));
assert_eq!(remaining, 0);
}
#[test]
fn append_section_fits_within_budget() {
let mut report = String::new();
let mut remaining = 1000;
append_section(&mut report, &mut remaining, "Short");
assert!(report.contains("Short"));
assert!(remaining < 1000);
}
}
+71
View File
@@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
/// Decision produced by the subconscious tick.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decision {
/// Nothing meaningful changed — skip.
Noop,
/// Something changed that can be handled locally (store memory, update state).
Act,
/// Something important changed that requires the full agent.
Escalate,
}
/// A single action recommended by the subconscious.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendedAction {
#[serde(rename = "type")]
pub action_type: ActionType,
pub description: String,
pub priority: Priority,
/// Which HEARTBEAT.md task this action relates to.
#[serde(default)]
pub task: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionType {
Notify,
StoreMemory,
EscalateToAgent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Low,
Medium,
High,
}
/// Output from the local model after evaluating the situation report.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TickOutput {
pub decision: Decision,
pub reason: String,
#[serde(default)]
pub actions: Vec<RecommendedAction>,
}
/// Result of a single subconscious tick, including metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TickResult {
pub tick_at: f64,
pub output: TickOutput,
pub source_doc_ids: Vec<String>,
pub duration_ms: u64,
pub tokens_used: u64,
}
/// Summary of the subconscious loop status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubconsciousStatus {
pub enabled: bool,
pub interval_minutes: u32,
pub last_tick_at: Option<f64>,
pub last_decision: Option<Decision>,
pub total_ticks: u64,
pub total_escalations: u64,
}
+35
View File
@@ -0,0 +1,35 @@
# Subconscious Loop Test Fixtures
Two temporal sets simulating state changes between ticks.
## Tick 1 (initial state)
- `tick1_gmail.txt` — 3 emails: deadline reminder (April 3), CI notification (routine), meeting invite
- `tick1_notion.txt` — Project tracker: 3 threads (memory=in progress, skills=blocked, ingestion=complete)
- `heartbeat.md` — 3 periodic tasks
### Expected tick 1 behavior
- **Escalate**: Deadline reminder (April 3) — actionable, time-sensitive
- **Noop**: CI notification — routine, no action needed
- **Noop or act**: Meeting invite — informational, could store to memory
- **Noop**: Notion tracker — no urgent changes
- Decision log should record the deadline escalation with source doc ID
## Tick 2 (state change — 6 hours later)
- `tick2_gmail.txt` — 2 new emails: deadline MOVED UP to April 2 (urgent), skills unblocked
- `tick2_notion.txt` — Tracker updated: Thread 2 unblocked, deadline decision changed
### Expected tick 2 behavior
- **Skip**: Original deadline email (tick1) — already surfaced in tick 1
- **Escalate**: New deadline-moved email — different doc, more urgent (tomorrow!)
- **Act**: Skills unblocked email — store to memory, update known state
- **Act or escalate**: Notion tracker change — Thread 2 status changed, deadline decision changed
- Decision log should NOT re-surface the original deadline
## Tick 3 (no new data)
- No new fixtures ingested
- **Expected**: Noop — delta is empty, skip inference entirely
+5
View File
@@ -0,0 +1,5 @@
# Periodic Tasks
- Check for deadline changes in project tracker
- Review new emails for urgent items
- Monitor skills runtime health
+47
View File
@@ -0,0 +1,47 @@
From: ravi.kumar@vezures.xyz
To: sanil@vezures.xyz
Cc: asha.mehta@vezures.xyz
Subject: Re: API contract deadline reminder
Date: 2026-04-01 09:30:00
Hi Sanil,
Quick reminder — the API contract review is due by April 3. Please make sure the
final RPC method names and response envelope strategy are documented before then.
Ravi has already finished the core memory domain work. Asha is wrapping up the
ingestion evaluation fixtures.
Let me know if you need more time, but Steven wants this locked by Thursday.
Best,
Ravi
---
From: alerts@github.com
To: sanil@vezures.xyz
Subject: [tinyhumansai/openhuman] CI passed on main
Date: 2026-04-01 10:15:00
All checks passed on commit abc1234 — build, typecheck, lint.
No action required.
---
From: asha.mehta@vezures.xyz
To: sanil@vezures.xyz
Subject: Team sync — Wednesday 2pm
Date: 2026-04-01 11:00:00
Hey Sanil,
Scheduling a team sync for Wednesday April 2 at 2pm IST.
Agenda:
- Memory layer status update
- Ingestion quality review
- Skills runtime isolation discussion
Please confirm your availability.
Asha
+48
View File
@@ -0,0 +1,48 @@
# Q1 Delivery Tracker
Workspace: tinyhumans / engineering
Owner: Sanil Jain
Last edited: 2026-04-01
Status: In Progress
## Active Threads
### Thread 1: Memory Layer
Owner: Sanil Jain
Status: In Progress
Due date: 2026-04-15
Deliverables:
- Stable document storage
- Graph query and recall
- Controller registry migration
### Thread 2: Skills Runtime
Owner: Ravi Kumar
Status: Blocked
Due date: 2026-04-10
Deliverables:
- Per-skill QuickJS isolation
- OAuth credential refresh
- Webhook routing
Blocker: Waiting on credential exchange API from backend team.
### Thread 3: Ingestion Quality
Owner: Asha Mehta
Status: Complete
Due date: 2026-03-26
Deliverables:
- Gmail fixture evaluation
- Notion fixture evaluation
- GLiNER entity extraction baseline
## Decisions
- JSON-RPC retained as transport for desktop core
- Namespace is the primary storage scope key
- Controller registry pattern adopted for all RPC domains
## Team Preferences
- Sanil prefers core-first delivery over UI-first delivery
- Ravi prefers strict ownership boundaries for parallel agents
+32
View File
@@ -0,0 +1,32 @@
From: steven@vezures.xyz
To: sanil@vezures.xyz
Cc: ravi.kumar@vezures.xyz
Subject: URGENT: API contract deadline moved to tomorrow
Date: 2026-04-01 16:00:00
Sanil,
Change of plans — the API contract review has been moved up to April 2 (tomorrow).
The investor demo is now scheduled for April 4 instead of April 7, so we need
everything locked a day earlier.
Please prioritize this over other work today. Let me know if there are blockers.
Steven
---
From: ravi.kumar@vezures.xyz
To: sanil@vezures.xyz
Subject: Skills runtime — credential fix landed
Date: 2026-04-01 17:30:00
Sanil,
Good news — the backend team deployed the credential exchange fix.
OAuth token refresh should now work for Gmail and Notion skills.
I've unblocked Thread 2 and updated the tracker.
The skills runtime isolation work can resume now.
Ravi
+49
View File
@@ -0,0 +1,49 @@
# Q1 Delivery Tracker
Workspace: tinyhumans / engineering
Owner: Sanil Jain
Last edited: 2026-04-01
Status: In Progress
## Active Threads
### Thread 1: Memory Layer
Owner: Sanil Jain
Status: In Progress
Due date: 2026-04-15
Deliverables:
- Stable document storage
- Graph query and recall
- Controller registry migration
### Thread 2: Skills Runtime
Owner: Ravi Kumar
Status: In Progress
Due date: 2026-04-10
Deliverables:
- Per-skill QuickJS isolation
- OAuth credential refresh
- Webhook routing
Note: Unblocked — credential exchange API deployed by backend team.
### Thread 3: Ingestion Quality
Owner: Asha Mehta
Status: Complete
Due date: 2026-03-26
Deliverables:
- Gmail fixture evaluation
- Notion fixture evaluation
- GLiNER entity extraction baseline
## Decisions
- JSON-RPC retained as transport for desktop core
- Namespace is the primary storage scope key
- Controller registry pattern adopted for all RPC domains
- API contract deadline moved to April 2 (was April 3)
## Team Preferences
- Sanil prefers core-first delivery over UI-first delivery
- Ravi prefers strict ownership boundaries for parallel agents