rsclaw 2026.4.20

AI Agent Engine Compatible with OpenClaw
Documentation
//! Meditation engine -- periodic memory maintenance triggered by heartbeat.
//!
//! Three phases:
//! 1. Dedup: merge near-duplicate Core/Working memories (cosine sim > 0.92)
//! 2. Conflict: detect contradictory high-importance memories, LLM resolves
//! 3. Cleanup: demote "crystallized"-tagged memories to Peripheral after 7 days

use anyhow::Result;
use std::collections::HashSet;

use crate::agent::memory::{MemDocTier, MemoryStore};

// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------

/// Configuration for a meditation cycle.
pub struct MeditationConfig {
    /// Cosine similarity threshold for dedup (default: 0.92).
    pub dedup_threshold: f32,
    /// Max docs to process per cycle (default: 50).
    pub batch_size: usize,
    /// Days after crystallization before demoting to Peripheral.
    pub crystallized_ttl_days: u32,
}

impl Default for MeditationConfig {
    fn default() -> Self {
        Self {
            dedup_threshold: 0.92,
            batch_size: 50,
            crystallized_ttl_days: 7,
        }
    }
}

// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------

/// Summary of actions taken during a meditation cycle.
#[derive(Debug, Default)]
pub struct MeditationReport {
    /// Number of duplicate documents merged (deleted).
    pub duplicates_merged: usize,
    /// Number of crystallized documents cleaned (demoted).
    pub crystallized_cleaned: usize,
    /// Total documents inspected across all phases.
    pub total_processed: usize,
}

// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------

/// Run a full meditation cycle: dedup then cleanup.
///
/// Returns a report summarising what was changed.
pub async fn meditate(
    store: &mut MemoryStore,
    scope: &str,
    config: &MeditationConfig,
) -> Result<MeditationReport> {
    let mut report = MeditationReport::default();

    let merged = dedup_phase(store, scope, config).await?;
    report.duplicates_merged = merged;

    let cleaned = cleanup_phase(store, scope, config).await?;
    report.crystallized_cleaned = cleaned;

    report.total_processed = merged + cleaned;
    Ok(report)
}

// ---------------------------------------------------------------------------
// Dedup phase
// ---------------------------------------------------------------------------

/// Merge near-duplicate Core-tier documents.
///
/// For each pair above the similarity threshold, the document with the lower
/// importance score is deleted, keeping the stronger memory.
async fn dedup_phase(
    store: &mut MemoryStore,
    scope: &str,
    config: &MeditationConfig,
) -> Result<usize> {
    // Collect IDs of Core docs up-front (borrow released before mutation).
    let candidate_ids: Vec<String> = store
        .find_by_tier(&MemDocTier::Core, Some(scope))
        .into_iter()
        .take(config.batch_size)
        .map(|d| d.id.clone())
        .collect();

    let mut merged: usize = 0;
    let mut seen: HashSet<String> = HashSet::new();

    for doc_id in &candidate_ids {
        if seen.contains(doc_id) {
            continue;
        }

        let duplicates = store.find_near_duplicates(doc_id, Some(scope), config.dedup_threshold)?;

        for (dup_doc, _sim) in &duplicates {
            if seen.contains(&dup_doc.id) {
                continue;
            }

            // Decide which doc to keep: higher importance wins.
            let src = store.get_sync(doc_id);
            let src_importance = src.map(|d| d.importance).unwrap_or(0.0);

            let (keep_id, remove_id) = if src_importance >= dup_doc.importance {
                (doc_id.as_str(), dup_doc.id.as_str())
            } else {
                (dup_doc.id.as_str(), doc_id.as_str())
            };

            // Mark both as seen so neither is re-processed.
            seen.insert(keep_id.to_owned());
            seen.insert(remove_id.to_owned());

            store.delete(remove_id).await?;
            merged += 1;
        }

        seen.insert(doc_id.clone());
    }

    Ok(merged)
}

// ---------------------------------------------------------------------------
// Cleanup phase
// ---------------------------------------------------------------------------

/// Demote crystallized memories that have exceeded their TTL.
///
/// Documents tagged "crystallized" whose age exceeds `crystallized_ttl_days`
/// have their importance set to 0.01, which triggers demotion to Peripheral
/// via `evaluate_tier_transition`.
async fn cleanup_phase(
    store: &mut MemoryStore,
    scope: &str,
    config: &MeditationConfig,
) -> Result<usize> {
    let now_secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64;
    let ttl_secs = i64::from(config.crystallized_ttl_days) * 86_400;

    // Collect IDs of crystallized Core/Working docs whose age exceeds TTL.
    let mut to_demote: Vec<String> = Vec::new();

    for tier in &[MemDocTier::Core, MemDocTier::Working] {
        let docs = store.find_by_tier(tier, Some(scope));
        for doc in docs {
            if doc.tags.iter().any(|t| t == "crystallized") {
                let age_secs = now_secs - doc.created_at;
                if age_secs > ttl_secs {
                    to_demote.push(doc.id.clone());
                }
            }
        }
    }

    let mut cleaned: usize = 0;

    for id in &to_demote {
        // Read current importance so we can compute the delta to reach 0.01.
        let current = store.get_sync(id).map(|d| d.importance).unwrap_or(0.5);
        let delta = 0.01 - current;
        store.adjust_importance(id, delta).await?;
        cleaned += 1;
    }

    Ok(cleaned)
}