use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::memory::longterm::LongTermMemory;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HygieneConfig {
pub enabled: bool,
pub interval_hours: u64,
pub expired_threshold: f32,
pub max_entries: usize,
pub conversation_keep: usize,
}
impl Default for HygieneConfig {
fn default() -> Self {
Self {
enabled: true,
interval_hours: 12,
expired_threshold: 0.1,
max_entries: 1000,
conversation_keep: 50,
}
}
}
pub struct HygieneReport {
pub expired_removed: usize,
pub least_used_removed: usize,
pub conversations_pruned: usize,
}
impl HygieneReport {
pub fn total(&self) -> usize {
self.expired_removed + self.least_used_removed + self.conversations_pruned
}
}
const LAST_HYGIENE_KEY: &str = "system:last_hygiene_at";
pub fn should_run(last_run_ts: Option<i64>, interval_hours: u64) -> bool {
let Some(last) = last_run_ts else {
return true; };
let now = chrono::Utc::now().timestamp();
let elapsed_hours = ((now - last).max(0) as u64) / 3600;
elapsed_hours >= interval_hours
}
pub fn last_run_timestamp(memory: &LongTermMemory) -> Option<i64> {
memory
.get_readonly(LAST_HYGIENE_KEY)
.and_then(|entry| entry.value.parse::<i64>().ok())
}
pub async fn run_hygiene_cycle_memory_only(
memory: &mut LongTermMemory,
config: &HygieneConfig,
) -> HygieneReport {
let mut report = HygieneReport {
expired_removed: 0,
least_used_removed: 0,
conversations_pruned: 0,
};
match memory.cleanup_expired(config.expired_threshold) {
Ok(n) => report.expired_removed = n,
Err(e) => warn!("Hygiene: cleanup_expired failed: {}", e),
}
if memory.count() > config.max_entries {
match memory.cleanup_least_used(config.max_entries) {
Ok(n) => report.least_used_removed = n,
Err(e) => warn!("Hygiene: cleanup_least_used failed: {}", e),
}
}
let now_str = chrono::Utc::now().timestamp().to_string();
if let Err(e) = memory
.set(LAST_HYGIENE_KEY, &now_str, "system", vec![], 0.1)
.await
{
warn!("Hygiene: failed to record timestamp: {}", e);
}
report
}
pub fn start_hygiene_scheduler(
memory: Arc<Mutex<LongTermMemory>>,
config: HygieneConfig,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
if !config.enabled {
info!("Memory hygiene disabled");
return;
}
loop {
let should = {
let mem = memory.lock().await;
let last = last_run_timestamp(&mem);
should_run(last, config.interval_hours)
};
if should {
let report = {
let mut mem = memory.lock().await;
run_hygiene_cycle_memory_only(&mut mem, &config).await
};
if report.total() > 0 {
info!(
"Hygiene: removed {} expired, {} least-used",
report.expired_removed, report.least_used_removed
);
}
}
tokio::time::sleep(Duration::from_secs(3600)).await;
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_memory() -> (LongTermMemory, TempDir) {
let dir = TempDir::new().expect("temp dir");
let path = dir.path().join("longterm.json");
let mem = LongTermMemory::with_path(path).expect("memory");
(mem, dir)
}
#[test]
fn test_hygiene_config_defaults() {
let config = HygieneConfig::default();
assert!(config.enabled);
assert_eq!(config.interval_hours, 12);
assert!((config.expired_threshold - 0.1).abs() < f32::EPSILON);
assert_eq!(config.max_entries, 1000);
assert_eq!(config.conversation_keep, 50);
}
#[test]
fn test_hygiene_config_disabled() {
let config = HygieneConfig {
enabled: false,
..Default::default()
};
assert!(!config.enabled);
}
#[tokio::test]
async fn test_run_hygiene_cycle_empty_memory() {
let (mut mem, _dir) = temp_memory();
let config = HygieneConfig::default();
let report = run_hygiene_cycle_memory_only(&mut mem, &config).await;
assert_eq!(report.expired_removed, 0);
assert_eq!(report.least_used_removed, 0);
}
#[tokio::test]
async fn test_run_hygiene_cycle_respects_max_entries() {
let (mut mem, _dir) = temp_memory();
let config = HygieneConfig {
max_entries: 2,
..Default::default()
};
mem.set("k1", "v1", "general", vec![], 1.0).await.unwrap();
mem.set("k2", "v2", "general", vec![], 1.0).await.unwrap();
mem.set("k3", "v3", "general", vec![], 1.0).await.unwrap();
let report = run_hygiene_cycle_memory_only(&mut mem, &config).await;
assert!(
mem.count() <= config.max_entries + 1,
"count should be <= {}, got {}",
config.max_entries + 1,
mem.count()
);
assert!(report.least_used_removed > 0);
}
#[test]
fn test_should_run_no_timestamp() {
assert!(should_run(None, 12));
}
#[test]
fn test_should_run_recent() {
let one_hour_ago = chrono::Utc::now().timestamp() - 3600;
assert!(!should_run(Some(one_hour_ago), 12));
}
#[test]
fn test_should_run_overdue() {
let day_ago = chrono::Utc::now().timestamp() - 86400;
assert!(should_run(Some(day_ago), 12));
}
#[test]
fn test_report_total() {
let report = HygieneReport {
expired_removed: 3,
least_used_removed: 5,
conversations_pruned: 2,
};
assert_eq!(report.total(), 10);
}
#[tokio::test]
async fn test_hygiene_records_timestamp() {
let (mut mem, _dir) = temp_memory();
let config = HygieneConfig::default();
assert!(last_run_timestamp(&mem).is_none());
run_hygiene_cycle_memory_only(&mut mem, &config).await;
assert!(last_run_timestamp(&mem).is_some());
}
}