diff --git a/crates/zclaw-growth/src/lib.rs b/crates/zclaw-growth/src/lib.rs index 873292a..e3a5a95 100644 --- a/crates/zclaw-growth/src/lib.rs +++ b/crates/zclaw-growth/src/lib.rs @@ -67,6 +67,7 @@ pub mod summarizer; // Re-export main types for convenience pub use types::{ + DecayResult, ExtractedMemory, ExtractionConfig, GrowthStats, @@ -75,6 +76,7 @@ pub use types::{ RetrievalConfig, RetrievalResult, UriBuilder, + effective_importance, }; pub use extractor::{LlmDriverForExtraction, MemoryExtractor}; diff --git a/crates/zclaw-growth/src/storage/sqlite.rs b/crates/zclaw-growth/src/storage/sqlite.rs index e1e1b75..de92514 100644 --- a/crates/zclaw-growth/src/storage/sqlite.rs +++ b/crates/zclaw-growth/src/storage/sqlite.rs @@ -270,6 +270,74 @@ impl SqliteStorage { Ok(()) } + + /// Decay stale memories: reduce importance for long-unaccessed entries + /// and archive those below the minimum threshold. + /// + /// - For every `decay_interval_days` since last access, importance drops by 1. + /// - Memories with importance ≤ `archive_threshold` are deleted. + pub async fn decay_memories( + &self, + decay_interval_days: u32, + archive_threshold: u8, + ) -> crate::types::DecayResult { + // Step 1: Reduce importance of stale memories + let decay_result = sqlx::query( + r#" + UPDATE memories + SET importance = MAX(1, importance - CAST( + (julianday('now') - julianday(last_accessed)) / ? AS INTEGER + )) + WHERE last_accessed < datetime('now', '-' || ? || ' days') + AND importance > 1 + "#, + ) + .bind(decay_interval_days) + .bind(decay_interval_days) + .execute(&self.pool) + .await; + + let decayed = decay_result + .map(|r| r.rows_affected()) + .unwrap_or(0); + + // Step 2: Remove memories that fell below archive threshold + // and haven't been accessed in 90+ days + let archive_result = sqlx::query( + r#" + DELETE FROM memories + WHERE importance <= ? + AND last_accessed < datetime('now', '-90 days') + "#, + ) + .bind(archive_threshold as i32) + .execute(&self.pool) + .await; + + // Also clean up FTS entries for archived memories + let _ = sqlx::query( + r#" + DELETE FROM memories_fts + WHERE uri NOT IN (SELECT uri FROM memories) + "#, + ) + .execute(&self.pool) + .await; + + let archived = archive_result + .map(|r| r.rows_affected()) + .unwrap_or(0); + + if decayed > 0 || archived > 0 { + tracing::info!( + "[SqliteStorage] Memory decay: {} decayed, {} archived", + decayed, + archived + ); + } + + crate::types::DecayResult { decayed, archived } + } } impl sqlx::FromRow<'_, SqliteRow> for MemoryRow { @@ -567,7 +635,7 @@ impl VikingStorage for SqliteStorage { scorer.is_embedding_available() }; - let mut scored_entries: Vec<(f32, MemoryEntry)> = Vec::new(); + let mut scored_entries: Vec<(f32, f32, MemoryEntry)> = Vec::new(); for row in rows { let entry = self.row_to_entry(&row); @@ -613,15 +681,18 @@ impl VikingStorage for SqliteStorage { } } - scored_entries.push((semantic_score, entry)); + // Apply time decay to importance before final scoring + let time_decayed_importance = crate::types::effective_importance(&entry); + + scored_entries.push((semantic_score, time_decayed_importance, entry)); } - // Sort by score (descending), then by importance and access count + // Sort by: semantic score → time-decayed importance → access count (all descending) scored_entries.sort_by(|a, b| { b.0.partial_cmp(&a.0) .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| b.1.importance.cmp(&a.1.importance)) - .then_with(|| b.1.access_count.cmp(&a.1.access_count)) + .then_with(|| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)) + .then_with(|| b.2.access_count.cmp(&a.2.access_count)) }); // Apply limit @@ -629,7 +700,7 @@ impl VikingStorage for SqliteStorage { scored_entries.truncate(limit); } - Ok(scored_entries.into_iter().map(|(_, entry)| entry).collect()) + Ok(scored_entries.into_iter().map(|(_, _, entry)| entry).collect()) } async fn find_by_prefix(&self, prefix: &str) -> Result> { diff --git a/crates/zclaw-growth/src/types.rs b/crates/zclaw-growth/src/types.rs index e37427f..6c540c9 100644 --- a/crates/zclaw-growth/src/types.rs +++ b/crates/zclaw-growth/src/types.rs @@ -385,6 +385,29 @@ impl UriBuilder { } } +/// Result of a memory decay operation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecayResult { + /// Number of memories whose importance was reduced + pub decayed: u64, + /// Number of memories archived (importance fell below threshold) + pub archived: u64, +} + +/// Compute effective importance with time decay. +/// +/// Uses exponential decay: each 30-day period of non-access reduces +/// effective importance by ~50%. Frequently accessed memories decay slower +/// thanks to the access_count boost. +pub fn effective_importance(entry: &MemoryEntry) -> f32 { + let days_since = (Utc::now() - entry.last_accessed).num_days().max(0) as f32; + // Half-life: 30 days → decay factor per day ≈ 0.977 + let time_decay = 0.977_f32.powf(days_since); + // Access boost: every 10 accesses add 1 to base importance (capped at 10) + let boosted = (entry.importance as f32 + entry.access_count as f32 / 10.0).min(10.0); + boosted * time_decay +} + #[cfg(test)] mod tests { use super::*;