Add proactivity features: memory CLI, session pruning, self-repair notifications, slash commands, status diagnostics, context warnings

Closes the proactivity gap with six features:

- Memory CLI (`ironclaw memory search/read/write/tree/status`) for direct workspace access without starting the full agent
- Session pruning background task that evicts idle sessions (configurable TTL, default 7 days)
- Self-repair notifications broadcast recovery results through channel manager instead of silent logging
- `/heartbeat`, `/summarize`, `/suggest` slash commands for manual heartbeat trigger, thread summarization, and next-step suggestions
- `ironclaw status` diagnostics command checking DB, session, secrets, embeddings, WASM tools, channels, heartbeat, and MCP servers
- Context pressure warning that notifies users before auto-compaction fires

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-05 19:28:13 -08:00
co-authored by Claude Opus 4.6
parent 1c9f9db420
commit f3c85f57fc
10 changed files with 960 additions and 17 deletions
+193
View File
@@ -0,0 +1,193 @@
//! System health and diagnostics CLI command.
//!
//! Checks database connectivity, session validity, embeddings,
//! WASM runtime, tool count, and channel availability.
use std::path::PathBuf;
use crate::settings::Settings;
/// Run the status command, printing system health info.
pub async fn run_status_command() -> anyhow::Result<()> {
let settings = Settings::load();
println!("IronClaw Status");
println!("===============\n");
// Version
println!(
" Version: {} v{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
);
// Database
let db_url_set = settings.database_url.is_some() || std::env::var("DATABASE_URL").is_ok();
print!(" Database: ");
if db_url_set {
// Try to connect
match check_database().await {
Ok(()) => println!("connected"),
Err(e) => println!("error ({})", e),
}
} else {
println!("not configured");
}
// Session / Auth
print!(" Session: ");
let session_path = crate::llm::session::default_session_path();
if session_path.exists() {
println!("found ({})", session_path.display());
} else {
println!("not found (run `ironclaw setup`)");
}
// Secrets
print!(" Secrets: ");
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|| crate::secrets::keychain::has_master_key();
if secrets_configured {
println!("configured ({:?})", settings.secrets_master_key_source);
} else {
println!("not configured");
}
// Embeddings
print!(" Embeddings: ");
let emb_enabled = settings.embeddings.enabled
|| std::env::var("OPENAI_API_KEY").is_ok()
|| std::env::var("EMBEDDING_ENABLED")
.map(|v| v == "true")
.unwrap_or(false);
if emb_enabled {
println!(
"enabled (provider: {}, model: {})",
settings.embeddings.provider, settings.embeddings.model
);
} else {
println!("disabled");
}
// WASM tools
print!(" WASM Tools: ");
let tools_dir = settings
.wasm
.tools_dir
.clone()
.unwrap_or_else(default_tools_dir);
if tools_dir.exists() {
let count = count_wasm_files(&tools_dir);
println!("{} installed ({})", count, tools_dir.display());
} else {
println!("directory not found ({})", tools_dir.display());
}
// WASM channels
print!(" Channels: ");
let channels_dir = settings
.channels
.wasm_channels_dir
.clone()
.unwrap_or_else(default_channels_dir);
let mut channel_info = vec!["cli".to_string()];
if settings.channels.http_enabled {
channel_info.push(format!(
"http:{}",
settings.channels.http_port.unwrap_or(3000)
));
}
if channels_dir.exists() {
let wasm_count = count_wasm_files(&channels_dir);
if wasm_count > 0 {
channel_info.push(format!("{} wasm", wasm_count));
}
}
println!("{}", channel_info.join(", "));
// Heartbeat
print!(" Heartbeat: ");
let hb_enabled = settings.heartbeat.enabled
|| std::env::var("HEARTBEAT_ENABLED")
.map(|v| v == "true")
.unwrap_or(false);
if hb_enabled {
println!("enabled (interval: {}s)", settings.heartbeat.interval_secs);
} else {
println!("disabled");
}
// MCP servers
print!(" MCP Servers: ");
match crate::tools::mcp::config::load_mcp_servers().await {
Ok(servers) => {
let enabled = servers.servers.iter().filter(|s| s.enabled).count();
let total = servers.servers.len();
println!("{} enabled / {} configured", enabled, total);
}
Err(_) => println!("none configured"),
}
// Settings path
println!("\n Settings: {}", Settings::default_path().display());
Ok(())
}
async fn check_database() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
let settings = Settings::load();
let url = std::env::var("DATABASE_URL")
.ok()
.or(settings.database_url)
.ok_or_else(|| anyhow::anyhow!("no URL"))?;
let config: deadpool_postgres::Config = deadpool_postgres::Config {
url: Some(url),
..Default::default()
};
let pool = config
.create_pool(
Some(deadpool_postgres::Runtime::Tokio1),
tokio_postgres::NoTls,
)
.map_err(|e| anyhow::anyhow!("pool error: {}", e))?;
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
.await
.map_err(|_| anyhow::anyhow!("timeout"))?
.map_err(|e| anyhow::anyhow!("{}", e))?;
client
.execute("SELECT 1", &[])
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(())
}
fn count_wasm_files(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "wasm"))
.count()
})
.unwrap_or(0)
}
fn default_tools_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("tools")
}
fn default_channels_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("channels")
}