feat: enhance agent personality, tool guidance, and memory hygiene

- Expand communication style presets (professional, expressive, custom)
- Enrich SOUL.md with human-like tone and emoji-awareness guidance
- Add crash recovery and sub-task scoping guidance to AGENTS.md scaffold
- Add 'Use when / Don't use when' guidance to TOOLS.md and runtime prompts
- Implement memory hygiene system with configurable archiving and retention
- Add MemoryConfig options: hygiene_enabled, archive_after_days, purge_after_days, conversation_retention_days
- Archive old daily memory and session files to archive subdirectories
- Purge old archives and prune stale SQLite conversation rows
- Add comprehensive tests for new features
This commit is contained in:
argenis de la rosa 2026-02-14 11:28:39 -05:00
parent f4f180ac41
commit ec2d5cc93d
29 changed files with 3600 additions and 116 deletions

123
src/doctor/mod.rs Normal file
View file

@ -0,0 +1,123 @@
use crate::config::Config;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
const DAEMON_STALE_SECONDS: i64 = 30;
const SCHEDULER_STALE_SECONDS: i64 = 120;
const CHANNEL_STALE_SECONDS: i64 = 300;
pub fn run(config: &Config) -> Result<()> {
let state_file = crate::daemon::state_file_path(config);
if !state_file.exists() {
println!("🩺 ZeroClaw Doctor");
println!(" ❌ daemon state file not found: {}", state_file.display());
println!(" 💡 Start daemon with: zeroclaw daemon");
return Ok(());
}
let raw = std::fs::read_to_string(&state_file)
.with_context(|| format!("Failed to read {}", state_file.display()))?;
let snapshot: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse {}", state_file.display()))?;
println!("🩺 ZeroClaw Doctor");
println!(" State file: {}", state_file.display());
let updated_at = snapshot
.get("updated_at")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if let Ok(ts) = DateTime::parse_from_rfc3339(updated_at) {
let age = Utc::now()
.signed_duration_since(ts.with_timezone(&Utc))
.num_seconds();
if age <= DAEMON_STALE_SECONDS {
println!(" ✅ daemon heartbeat fresh ({age}s ago)");
} else {
println!(" ❌ daemon heartbeat stale ({age}s ago)");
}
} else {
println!(" ❌ invalid daemon timestamp: {updated_at}");
}
let mut channel_count = 0_u32;
let mut stale_channels = 0_u32;
if let Some(components) = snapshot
.get("components")
.and_then(serde_json::Value::as_object)
{
if let Some(scheduler) = components.get("scheduler") {
let scheduler_ok = scheduler
.get("status")
.and_then(serde_json::Value::as_str)
.map(|s| s == "ok")
.unwrap_or(false);
let scheduler_last_ok = scheduler
.get("last_ok")
.and_then(serde_json::Value::as_str)
.and_then(parse_rfc3339)
.map(|dt| Utc::now().signed_duration_since(dt).num_seconds())
.unwrap_or(i64::MAX);
if scheduler_ok && scheduler_last_ok <= SCHEDULER_STALE_SECONDS {
println!(
" ✅ scheduler healthy (last ok {}s ago)",
scheduler_last_ok
);
} else {
println!(
" ❌ scheduler unhealthy/stale (status_ok={}, age={}s)",
scheduler_ok, scheduler_last_ok
);
}
} else {
println!(" ❌ scheduler component missing");
}
for (name, component) in components {
if !name.starts_with("channel:") {
continue;
}
channel_count += 1;
let status_ok = component
.get("status")
.and_then(serde_json::Value::as_str)
.map(|s| s == "ok")
.unwrap_or(false);
let age = component
.get("last_ok")
.and_then(serde_json::Value::as_str)
.and_then(parse_rfc3339)
.map(|dt| Utc::now().signed_duration_since(dt).num_seconds())
.unwrap_or(i64::MAX);
if status_ok && age <= CHANNEL_STALE_SECONDS {
println!("{name} fresh (last ok {age}s ago)");
} else {
stale_channels += 1;
println!("{name} stale/unhealthy (status_ok={status_ok}, age={age}s)");
}
}
}
if channel_count == 0 {
println!(" no channel components tracked in state yet");
} else {
println!(
" Channel summary: {} total, {} stale",
channel_count, stale_channels
);
}
Ok(())
}
fn parse_rfc3339(raw: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(raw)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}