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:
parent
f4f180ac41
commit
ec2d5cc93d
29 changed files with 3600 additions and 116 deletions
284
src/service/mod.rs
Normal file
284
src/service/mod.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
use crate::config::Config;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const SERVICE_LABEL: &str = "com.zeroclaw.daemon";
|
||||
|
||||
pub fn handle_command(command: super::ServiceCommands, config: &Config) -> Result<()> {
|
||||
match command {
|
||||
super::ServiceCommands::Install => install(config),
|
||||
super::ServiceCommands::Start => start(config),
|
||||
super::ServiceCommands::Stop => stop(config),
|
||||
super::ServiceCommands::Status => status(config),
|
||||
super::ServiceCommands::Uninstall => uninstall(config),
|
||||
}
|
||||
}
|
||||
|
||||
fn install(config: &Config) -> Result<()> {
|
||||
if cfg!(target_os = "macos") {
|
||||
install_macos(config)
|
||||
} else if cfg!(target_os = "linux") {
|
||||
install_linux(config)
|
||||
} else {
|
||||
anyhow::bail!("Service management is supported on macOS and Linux only");
|
||||
}
|
||||
}
|
||||
|
||||
fn start(config: &Config) -> Result<()> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let plist = macos_service_file()?;
|
||||
run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?;
|
||||
run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL))?;
|
||||
println!("✅ Service started");
|
||||
Ok(())
|
||||
} else if cfg!(target_os = "linux") {
|
||||
run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?;
|
||||
run_checked(Command::new("systemctl").args(["--user", "start", "zeroclaw.service"]))?;
|
||||
println!("✅ Service started");
|
||||
Ok(())
|
||||
} else {
|
||||
let _ = config;
|
||||
anyhow::bail!("Service management is supported on macOS and Linux only")
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(config: &Config) -> Result<()> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let plist = macos_service_file()?;
|
||||
let _ = run_checked(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL));
|
||||
let _ = run_checked(
|
||||
Command::new("launchctl")
|
||||
.arg("unload")
|
||||
.arg("-w")
|
||||
.arg(&plist),
|
||||
);
|
||||
println!("✅ Service stopped");
|
||||
Ok(())
|
||||
} else if cfg!(target_os = "linux") {
|
||||
let _ = run_checked(Command::new("systemctl").args(["--user", "stop", "zeroclaw.service"]));
|
||||
println!("✅ Service stopped");
|
||||
Ok(())
|
||||
} else {
|
||||
let _ = config;
|
||||
anyhow::bail!("Service management is supported on macOS and Linux only")
|
||||
}
|
||||
}
|
||||
|
||||
fn status(config: &Config) -> Result<()> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let out = run_capture(Command::new("launchctl").arg("list"))?;
|
||||
let running = out.lines().any(|line| line.contains(SERVICE_LABEL));
|
||||
println!(
|
||||
"Service: {}",
|
||||
if running {
|
||||
"✅ running/loaded"
|
||||
} else {
|
||||
"❌ not loaded"
|
||||
}
|
||||
);
|
||||
println!("Unit: {}", macos_service_file()?.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cfg!(target_os = "linux") {
|
||||
let out = run_capture(Command::new("systemctl").args([
|
||||
"--user",
|
||||
"is-active",
|
||||
"zeroclaw.service",
|
||||
]))
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
println!("Service state: {}", out.trim());
|
||||
println!("Unit: {}", linux_service_file(config)?.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
anyhow::bail!("Service management is supported on macOS and Linux only")
|
||||
}
|
||||
|
||||
fn uninstall(config: &Config) -> Result<()> {
|
||||
stop(config)?;
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
let file = macos_service_file()?;
|
||||
if file.exists() {
|
||||
fs::remove_file(&file)
|
||||
.with_context(|| format!("Failed to remove {}", file.display()))?;
|
||||
}
|
||||
println!("✅ Service uninstalled ({})", file.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cfg!(target_os = "linux") {
|
||||
let file = linux_service_file(config)?;
|
||||
if file.exists() {
|
||||
fs::remove_file(&file)
|
||||
.with_context(|| format!("Failed to remove {}", file.display()))?;
|
||||
}
|
||||
let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
|
||||
println!("✅ Service uninstalled ({})", file.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
anyhow::bail!("Service management is supported on macOS and Linux only")
|
||||
}
|
||||
|
||||
fn install_macos(config: &Config) -> Result<()> {
|
||||
let file = macos_service_file()?;
|
||||
if let Some(parent) = file.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().context("Failed to resolve current executable")?;
|
||||
let logs_dir = config
|
||||
.config_path
|
||||
.parent()
|
||||
.map_or_else(|| PathBuf::from("."), PathBuf::from)
|
||||
.join("logs");
|
||||
fs::create_dir_all(&logs_dir)?;
|
||||
|
||||
let stdout = logs_dir.join("daemon.stdout.log");
|
||||
let stderr = logs_dir.join("daemon.stderr.log");
|
||||
|
||||
let plist = format!(
|
||||
r#"<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
|
||||
<plist version=\"1.0\">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{label}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{exe}</string>
|
||||
<string>daemon</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{stdout}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{stderr}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#,
|
||||
label = SERVICE_LABEL,
|
||||
exe = xml_escape(&exe.display().to_string()),
|
||||
stdout = xml_escape(&stdout.display().to_string()),
|
||||
stderr = xml_escape(&stderr.display().to_string())
|
||||
);
|
||||
|
||||
fs::write(&file, plist)?;
|
||||
println!("✅ Installed launchd service: {}", file.display());
|
||||
println!(" Start with: zeroclaw service start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_linux(config: &Config) -> Result<()> {
|
||||
let file = linux_service_file(config)?;
|
||||
if let Some(parent) = file.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().context("Failed to resolve current executable")?;
|
||||
let unit = format!(
|
||||
"[Unit]\nDescription=ZeroClaw daemon\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={} daemon\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n",
|
||||
exe.display()
|
||||
);
|
||||
|
||||
fs::write(&file, unit)?;
|
||||
let _ = run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]));
|
||||
let _ = run_checked(Command::new("systemctl").args(["--user", "enable", "zeroclaw.service"]));
|
||||
println!("✅ Installed systemd user service: {}", file.display());
|
||||
println!(" Start with: zeroclaw service start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn macos_service_file() -> Result<PathBuf> {
|
||||
let home = directories::UserDirs::new()
|
||||
.map(|u| u.home_dir().to_path_buf())
|
||||
.context("Could not find home directory")?;
|
||||
Ok(home
|
||||
.join("Library")
|
||||
.join("LaunchAgents")
|
||||
.join(format!("{SERVICE_LABEL}.plist")))
|
||||
}
|
||||
|
||||
fn linux_service_file(config: &Config) -> Result<PathBuf> {
|
||||
let home = directories::UserDirs::new()
|
||||
.map(|u| u.home_dir().to_path_buf())
|
||||
.context("Could not find home directory")?;
|
||||
let _ = config;
|
||||
Ok(home
|
||||
.join(".config")
|
||||
.join("systemd")
|
||||
.join("user")
|
||||
.join("zeroclaw.service"))
|
||||
}
|
||||
|
||||
fn run_checked(command: &mut Command) -> Result<()> {
|
||||
let output = command.output().context("Failed to spawn command")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Command failed: {}", stderr.trim());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_capture(command: &mut Command) -> Result<String> {
|
||||
let output = command.output().context("Failed to spawn command")?;
|
||||
let mut text = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
if text.trim().is_empty() {
|
||||
text = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn xml_escape(raw: &str) -> String {
|
||||
raw.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xml_escape_escapes_reserved_chars() {
|
||||
let escaped = xml_escape("<&>\"' and text");
|
||||
assert_eq!(escaped, "<&>"' and text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_capture_reads_stdout() {
|
||||
let out = run_capture(Command::new("sh").args(["-lc", "echo hello"]))
|
||||
.expect("stdout capture should succeed");
|
||||
assert_eq!(out.trim(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_capture_falls_back_to_stderr() {
|
||||
let out = run_capture(Command::new("sh").args(["-lc", "echo warn 1>&2"]))
|
||||
.expect("stderr capture should succeed");
|
||||
assert_eq!(out.trim(), "warn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_checked_errors_on_non_zero_status() {
|
||||
let err = run_checked(Command::new("sh").args(["-lc", "exit 17"]))
|
||||
.expect_err("non-zero exit should error");
|
||||
assert!(err.to_string().contains("Command failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_service_file_has_expected_suffix() {
|
||||
let file = linux_service_file(&Config::default()).unwrap();
|
||||
let path = file.to_string_lossy();
|
||||
assert!(path.ends_with(".config/systemd/user/zeroclaw.service"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue