feat(intelligence): complete Phase 2-3 migration to Rust

Phase 2 - Core Engines:
- Heartbeat Engine: Periodic proactive checks with quiet hours support
- Context Compactor: Token estimation and message summarization
  - CJK character handling (1.5 tokens per char)
  - Rule-based summary generation

Phase 3 - Advanced Features:
- Reflection Engine: Pattern analysis and improvement suggestions
- Agent Identity: SOUL.md/AGENTS.md/USER.md management
  - Proposal-based changes (requires user approval)
  - Snapshot history for rollback

All modules include:
- Tauri commands for frontend integration
- Unit tests
- Re-exported types via mod.rs

Reference: docs/plans/INTELLIGENCE-LAYER-MIGRATION.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-21 00:52:44 +08:00
parent 0db8a2822f
commit ef8f5cdb43
6 changed files with 2235 additions and 1 deletions

View File

@@ -0,0 +1,463 @@
//! Heartbeat Engine - Periodic proactive checks for ZCLAW agents
//!
//! Runs on a configurable interval, executing a checklist of items.
//! Each check can produce alerts that surface via desktop notification or UI.
//! Supports quiet hours (no notifications during sleep time).
//!
//! Phase 2 of Intelligence Layer Migration.
//! Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.1
use chrono::{DateTime, Local, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, Mutex};
use tokio::time::interval;
// === Types ===
/// Heartbeat configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatConfig {
pub enabled: bool,
#[serde(default = "default_interval")]
pub interval_minutes: u64,
pub quiet_hours_start: Option<String>, // "22:00" format
pub quiet_hours_end: Option<String>, // "08:00" format
#[serde(default)]
pub notify_channel: NotifyChannel,
#[serde(default)]
pub proactivity_level: ProactivityLevel,
#[serde(default = "default_max_alerts")]
pub max_alerts_per_tick: usize,
}
fn default_interval() -> u64 { 30 }
fn default_max_alerts() -> usize { 5 }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NotifyChannel {
#[default]
Ui,
Desktop,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ProactivityLevel {
Silent,
Light,
#[default]
Standard,
Autonomous,
}
/// Alert generated by heartbeat checks
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatAlert {
pub title: String,
pub content: String,
pub urgency: Urgency,
pub source: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Urgency {
Low,
Medium,
High,
}
/// Result of a single heartbeat tick
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatResult {
pub status: HeartbeatStatus,
pub alerts: Vec<HeartbeatAlert>,
pub checked_items: usize,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HeartbeatStatus {
Ok,
Alert,
}
/// Type alias for heartbeat check function
pub type HeartbeatCheckFn = Box<dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<HeartbeatAlert>> + Send>> + Send + Sync>;
// === Default Config ===
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
quiet_hours_start: Some("22:00".to_string()),
quiet_hours_end: Some("08:00".to_string()),
notify_channel: NotifyChannel::Ui,
proactivity_level: ProactivityLevel::Light,
max_alerts_per_tick: 5,
}
}
}
// === Heartbeat Engine ===
pub struct HeartbeatEngine {
agent_id: String,
config: Arc<Mutex<HeartbeatConfig>>,
running: Arc<Mutex<bool>>,
alert_sender: broadcast::Sender<HeartbeatAlert>,
history: Arc<Mutex<Vec<HeartbeatResult>>>,
}
impl HeartbeatEngine {
pub fn new(agent_id: String, config: Option<HeartbeatConfig>) -> Self {
let (alert_sender, _) = broadcast::channel(100);
Self {
agent_id,
config: Arc::new(Mutex::new(config.unwrap_or_default())),
running: Arc::new(Mutex::new(false)),
alert_sender,
history: Arc::new(Mutex::new(Vec::new())),
}
}
/// Start the heartbeat engine with periodic ticks
pub async fn start(&self) {
let mut running = self.running.lock().await;
if *running {
return;
}
*running = true;
drop(running);
let agent_id = self.agent_id.clone();
let config = Arc::clone(&self.config);
let running_clone = Arc::clone(&self.running);
let alert_sender = self.alert_sender.clone();
let history = Arc::clone(&self.history);
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(
config.lock().await.interval_minutes * 60,
));
loop {
ticker.tick().await;
if !*running_clone.lock().await {
break;
}
// Check quiet hours
if is_quiet_hours(&config.lock().await) {
continue;
}
// Execute heartbeat tick
let result = execute_tick(&agent_id, &config, &alert_sender).await;
// Store history
let mut hist = history.lock().await;
hist.push(result);
if hist.len() > 100 {
*hist = hist.split_off(50);
}
}
});
}
/// Stop the heartbeat engine
pub async fn stop(&self) {
let mut running = self.running.lock().await;
*running = false;
}
/// Check if the engine is running
pub async fn is_running(&self) -> bool {
*self.running.lock().await
}
/// Execute a single tick manually
pub async fn tick(&self) -> HeartbeatResult {
execute_tick(&self.agent_id, &self.config, &self.alert_sender).await
}
/// Subscribe to alerts
pub fn subscribe(&self) -> broadcast::Receiver<HeartbeatAlert> {
self.alert_sender.subscribe()
}
/// Get heartbeat history
pub async fn get_history(&self, limit: usize) -> Vec<HeartbeatResult> {
let hist = self.history.lock().await;
hist.iter().rev().take(limit).cloned().collect()
}
/// Update configuration
pub async fn update_config(&self, updates: HeartbeatConfig) {
let mut config = self.config.lock().await;
*config = updates;
}
/// Get current configuration
pub async fn get_config(&self) -> HeartbeatConfig {
self.config.lock().await.clone()
}
}
// === Helper Functions ===
/// Check if current time is within quiet hours
fn is_quiet_hours(config: &HeartbeatConfig) -> bool {
let start = match &config.quiet_hours_start {
Some(s) => s,
None => return false,
};
let end = match &config.quiet_hours_end {
Some(e) => e,
None => return false,
};
let now = Local::now();
let current_minutes = now.hour() * 60 + now.minute();
let start_minutes = parse_time_to_minutes(start);
let end_minutes = parse_time_to_minutes(end);
if start_minutes <= end_minutes {
// Same-day range (e.g., 13:00-17:00)
current_minutes >= start_minutes && current_minutes < end_minutes
} else {
// Cross-midnight range (e.g., 22:00-08:00)
current_minutes >= start_minutes || current_minutes < end_minutes
}
}
/// Parse "HH:MM" format to minutes since midnight
fn parse_time_to_minutes(time: &str) -> u32 {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() != 2 {
return 0;
}
let hours: u32 = parts[0].parse().unwrap_or(0);
let minutes: u32 = parts[1].parse().unwrap_or(0);
hours * 60 + minutes
}
/// Execute a single heartbeat tick
async fn execute_tick(
agent_id: &str,
config: &Arc<Mutex<HeartbeatConfig>>,
alert_sender: &broadcast::Sender<HeartbeatAlert>,
) -> HeartbeatResult {
let cfg = config.lock().await;
let mut alerts = Vec::new();
// Run built-in checks
let checks: Vec<(&str, fn(&str) -> Option<HeartbeatAlert>)> = vec![
("pending-tasks", check_pending_tasks),
("memory-health", check_memory_health),
("idle-greeting", check_idle_greeting),
];
for (source, check_fn) in checks {
if alerts.len() >= cfg.max_alerts_per_tick {
break;
}
if let Some(alert) = check_fn(agent_id) {
alerts.push(alert);
}
}
// Filter by proactivity level
let filtered_alerts = filter_by_proactivity(&alerts, &cfg.proactivity_level);
// Send alerts
for alert in &filtered_alerts {
let _ = alert_sender.send(alert.clone());
}
let status = if filtered_alerts.is_empty() {
HeartbeatStatus::Ok
} else {
HeartbeatStatus::Alert
};
HeartbeatResult {
status,
alerts: filtered_alerts,
checked_items: checks.len(),
timestamp: chrono::Utc::now().to_rfc3339(),
}
}
/// Filter alerts based on proactivity level
fn filter_by_proactivity(alerts: &[HeartbeatAlert], level: &ProactivityLevel) -> Vec<HeartbeatAlert> {
match level {
ProactivityLevel::Silent => vec![],
ProactivityLevel::Light => alerts
.iter()
.filter(|a| matches!(a.urgency, Urgency::High))
.cloned()
.collect(),
ProactivityLevel::Standard => alerts
.iter()
.filter(|a| matches!(a.urgency, Urgency::High | Urgency::Medium))
.cloned()
.collect(),
ProactivityLevel::Autonomous => alerts.to_vec(),
}
}
// === Built-in Checks ===
/// Check for pending task memories (placeholder - would connect to memory store)
fn check_pending_tasks(_agent_id: &str) -> Option<HeartbeatAlert> {
// In full implementation, this would query the memory store
// For now, return None (no tasks)
None
}
/// Check memory storage health (placeholder)
fn check_memory_health(_agent_id: &str) -> Option<HeartbeatAlert> {
// In full implementation, this would check memory stats
None
}
/// Check if user has been idle (placeholder)
fn check_idle_greeting(_agent_id: &str) -> Option<HeartbeatAlert> {
// In full implementation, this would check last interaction time
None
}
// === Tauri Commands ===
/// Heartbeat engine state for Tauri
pub type HeartbeatEngineState = Arc<Mutex<HashMap<String, HeartbeatEngine>>>;
/// Initialize heartbeat engine for an agent
#[tauri::command]
pub async fn heartbeat_init(
agent_id: String,
config: Option<HeartbeatConfig>,
state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<(), String> {
let engine = HeartbeatEngine::new(agent_id.clone(), config);
let mut engines = state.lock().await;
engines.insert(agent_id, engine);
Ok(())
}
/// Start heartbeat engine for an agent
#[tauri::command]
pub async fn heartbeat_start(
agent_id: String,
state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<(), String> {
let engines = state.lock().await;
let engine = engines
.get(&agent_id)
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
engine.start().await;
Ok(())
}
/// Stop heartbeat engine for an agent
#[tauri::command]
pub async fn heartbeat_stop(
agent_id: String,
state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<(), String> {
let engines = state.lock().await;
let engine = engines
.get(&agent_id)
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
engine.stop().await;
Ok(())
}
/// Execute a single heartbeat tick
#[tauri::command]
pub async fn heartbeat_tick(
agent_id: String,
state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<HeartbeatResult, String> {
let engines = state.lock().await;
let engine = engines
.get(&agent_id)
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
Ok(engine.tick().await)
}
/// Get heartbeat configuration
#[tauri::command]
pub async fn heartbeat_get_config(
agent_id: String,
state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<HeartbeatConfig, String> {
let engines = state.lock().await;
let engine = engines
.get(&agent_id)
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
Ok(engine.get_config().await)
}
/// Update heartbeat configuration
#[tauri::command]
pub async fn heartbeat_update_config(
agent_id: String,
config: HeartbeatConfig,
state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<(), String> {
let engines = state.lock().await;
let engine = engines
.get(&agent_id)
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
engine.update_config(config).await;
Ok(())
}
/// Get heartbeat history
#[tauri::command]
pub async fn heartbeat_get_history(
agent_id: String,
limit: Option<usize>,
state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<Vec<HeartbeatResult>, String> {
let engines = state.lock().await;
let engine = engines
.get(&agent_id)
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
Ok(engine.get_history(limit.unwrap_or(20)).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_time() {
assert_eq!(parse_time_to_minutes("00:00"), 0);
assert_eq!(parse_time_to_minutes("08:00"), 480);
assert_eq!(parse_time_to_minutes("22:00"), 1320);
assert_eq!(parse_time_to_minutes("23:59"), 1439);
}
#[test]
fn test_default_config() {
let config = HeartbeatConfig::default();
assert!(!config.enabled);
assert_eq!(config.interval_minutes, 30);
}
}