use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{Result, ZeptoError};
fn now_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn default_importance() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryEntry {
pub key: String,
pub value: String,
pub category: String,
pub created_at: u64,
pub last_accessed: u64,
pub access_count: u64,
pub tags: Vec<String>,
#[serde(default = "default_importance")]
pub importance: f32,
}
impl MemoryEntry {
pub fn decay_score(&self) -> f32 {
if self.category.eq_ignore_ascii_case("pinned") {
return 1.0;
}
let now = now_timestamp();
let age_secs = now.saturating_sub(self.last_accessed);
let age_days = age_secs as f64 / 86400.0;
self.importance * 0.5_f64.powf(age_days / 30.0) as f32
}
}
#[derive(Debug)]
pub struct LongTermMemory {
entries: HashMap<String, MemoryEntry>,
storage_path: PathBuf,
}
impl LongTermMemory {
pub fn new() -> Result<Self> {
let path = Config::dir().join("memory").join("longterm.json");
Self::with_path(path)
}
pub fn with_path(path: PathBuf) -> Result<Self> {
let entries = Self::load(&path)?;
Ok(Self {
entries,
storage_path: path,
})
}
pub fn set(
&mut self,
key: &str,
value: &str,
category: &str,
tags: Vec<String>,
importance: f32,
) -> Result<()> {
let now = now_timestamp();
if let Some(existing) = self.entries.get_mut(key) {
existing.value = value.to_string();
existing.category = category.to_string();
existing.tags = tags;
existing.importance = importance;
existing.last_accessed = now;
} else {
let entry = MemoryEntry {
key: key.to_string(),
value: value.to_string(),
category: category.to_string(),
created_at: now,
last_accessed: now,
access_count: 0,
tags,
importance,
};
self.entries.insert(key.to_string(), entry);
}
self.save()
}
pub fn get(&mut self, key: &str) -> Option<&MemoryEntry> {
let now = now_timestamp();
if let Some(entry) = self.entries.get_mut(key) {
entry.last_accessed = now;
entry.access_count += 1;
}
self.entries.get(key)
}
pub fn get_readonly(&self, key: &str) -> Option<&MemoryEntry> {
self.entries.get(key)
}
pub fn delete(&mut self, key: &str) -> Result<bool> {
let existed = self.entries.remove(key).is_some();
if existed {
self.save()?;
}
Ok(existed)
}
pub fn search(&self, query: &str) -> Vec<&MemoryEntry> {
let query_lower = query.to_lowercase();
let mut results: Vec<&MemoryEntry> = self
.entries
.values()
.filter(|entry| {
entry.key.to_lowercase().contains(&query_lower)
|| entry.value.to_lowercase().contains(&query_lower)
|| entry.category.to_lowercase().contains(&query_lower)
|| entry
.tags
.iter()
.any(|tag| tag.to_lowercase().contains(&query_lower))
})
.collect();
results.sort_by(|a, b| {
let a_exact = a.key.to_lowercase() == query_lower;
let b_exact = b.key.to_lowercase() == query_lower;
match (a_exact, b_exact) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b
.decay_score()
.partial_cmp(&a.decay_score())
.unwrap_or(std::cmp::Ordering::Equal),
}
});
results
}
pub fn list_by_category(&self, category: &str) -> Vec<&MemoryEntry> {
let cat_lower = category.to_lowercase();
let mut results: Vec<&MemoryEntry> = self
.entries
.values()
.filter(|entry| entry.category.to_lowercase() == cat_lower)
.collect();
results.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
results
}
pub fn list_all(&self) -> Vec<&MemoryEntry> {
let mut results: Vec<&MemoryEntry> = self.entries.values().collect();
results.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
results
}
pub fn count(&self) -> usize {
self.entries.len()
}
pub fn categories(&self) -> Vec<String> {
let mut cats: Vec<String> = self
.entries
.values()
.map(|e| e.category.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
cats.sort();
cats
}
pub fn cleanup_least_used(&mut self, keep_count: usize) -> Result<usize> {
if self.entries.len() <= keep_count {
return Ok(0);
}
let mut entries_vec: Vec<(String, f32)> = self
.entries
.iter()
.map(|(k, v)| (k.clone(), v.decay_score()))
.collect();
entries_vec.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let to_remove = entries_vec.len() - keep_count;
let keys_to_remove: Vec<String> = entries_vec
.into_iter()
.take(to_remove)
.map(|(k, _)| k)
.collect();
for key in &keys_to_remove {
self.entries.remove(key);
}
self.save()?;
Ok(to_remove)
}
pub fn summary(&self) -> String {
let count = self.count();
let cat_count = self.categories().len();
format!(
"Long-term memory: {} entries ({} categories)",
count, cat_count
)
}
pub fn save(&self) -> Result<()> {
if let Some(parent) = self.storage_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ZeptoError::Config(format!(
"Failed to create memory directory {}: {}",
parent.display(),
e
))
})?;
}
let json = serde_json::to_string_pretty(&self.entries).map_err(|e| {
ZeptoError::Config(format!("Failed to serialize long-term memory: {}", e))
})?;
std::fs::write(&self.storage_path, json).map_err(|e| {
ZeptoError::Config(format!(
"Failed to write long-term memory to {}: {}",
self.storage_path.display(),
e
))
})?;
Ok(())
}
fn load(path: &PathBuf) -> Result<HashMap<String, MemoryEntry>> {
if !path.exists() {
return Ok(HashMap::new());
}
let content = std::fs::read_to_string(path).map_err(|e| {
ZeptoError::Config(format!(
"Failed to read long-term memory from {}: {}",
path.display(),
e
))
})?;
if content.trim().is_empty() {
return Ok(HashMap::new());
}
let entries: HashMap<String, MemoryEntry> =
serde_json::from_str(&content).map_err(|e| {
ZeptoError::Config(format!("Failed to parse long-term memory JSON: {}", e))
})?;
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_memory() -> (LongTermMemory, TempDir) {
let dir = TempDir::new().expect("failed to create temp dir");
let path = dir.path().join("longterm.json");
let mem = LongTermMemory::with_path(path).expect("failed to create memory");
(mem, dir)
}
#[test]
fn test_memory_entry_creation() {
let entry = MemoryEntry {
key: "user:name".to_string(),
value: "Alice".to_string(),
category: "user".to_string(),
created_at: 1000,
last_accessed: 2000,
access_count: 5,
tags: vec!["identity".to_string()],
importance: 1.0,
};
assert_eq!(entry.key, "user:name");
assert_eq!(entry.value, "Alice");
assert_eq!(entry.category, "user");
assert_eq!(entry.created_at, 1000);
assert_eq!(entry.last_accessed, 2000);
assert_eq!(entry.access_count, 5);
assert_eq!(entry.tags, vec!["identity"]);
assert_eq!(entry.importance, 1.0);
}
#[test]
fn test_longterm_memory_new_empty() {
let (mem, _dir) = temp_memory();
assert_eq!(mem.count(), 0);
}
#[test]
fn test_set_and_get() {
let (mut mem, _dir) = temp_memory();
mem.set(
"user:name",
"Alice",
"user",
vec!["identity".to_string()],
1.0,
)
.unwrap();
let entry = mem.get("user:name").unwrap();
assert_eq!(entry.value, "Alice");
assert_eq!(entry.category, "user");
}
#[test]
fn test_set_upsert() {
let (mut mem, _dir) = temp_memory();
mem.set("user:name", "Alice", "user", vec![], 1.0).unwrap();
mem.set("user:name", "Bob", "user", vec!["updated".to_string()], 1.0)
.unwrap();
let entry = mem.get("user:name").unwrap();
assert_eq!(entry.value, "Bob");
assert_eq!(entry.tags, vec!["updated"]);
assert_eq!(mem.count(), 1);
}
#[test]
fn test_get_updates_access_stats() {
let (mut mem, _dir) = temp_memory();
mem.set("key1", "value1", "test", vec![], 1.0).unwrap();
let before_access = mem.get_readonly("key1").unwrap().last_accessed;
let before_count = mem.get_readonly("key1").unwrap().access_count;
let _ = mem.get("key1");
let _ = mem.get("key1");
let entry = mem.get_readonly("key1").unwrap();
assert_eq!(entry.access_count, before_count + 2);
assert!(entry.last_accessed >= before_access);
}
#[test]
fn test_get_readonly_no_update() {
let (mut mem, _dir) = temp_memory();
mem.set("key1", "value1", "test", vec![], 1.0).unwrap();
let before = mem.get_readonly("key1").unwrap().access_count;
let _ = mem.get_readonly("key1");
let _ = mem.get_readonly("key1");
let after = mem.get_readonly("key1").unwrap().access_count;
assert_eq!(before, after);
}
#[test]
fn test_get_nonexistent() {
let (mut mem, _dir) = temp_memory();
assert!(mem.get("nonexistent").is_none());
}
#[test]
fn test_delete_existing() {
let (mut mem, _dir) = temp_memory();
mem.set("key1", "value1", "test", vec![], 1.0).unwrap();
assert_eq!(mem.count(), 1);
let existed = mem.delete("key1").unwrap();
assert!(existed);
assert_eq!(mem.count(), 0);
assert!(mem.get("key1").is_none());
}
#[test]
fn test_delete_nonexistent() {
let (mut mem, _dir) = temp_memory();
let existed = mem.delete("nonexistent").unwrap();
assert!(!existed);
}
#[test]
fn test_search_by_key() {
let (mut mem, _dir) = temp_memory();
mem.set("user:name", "Alice", "user", vec![], 1.0).unwrap();
mem.set("project:name", "ZeptoClaw", "project", vec![], 1.0)
.unwrap();
let results = mem.search("user");
assert!(!results.is_empty());
assert!(results.iter().any(|e| e.key == "user:name"));
}
#[test]
fn test_search_by_value() {
let (mut mem, _dir) = temp_memory();
mem.set("key1", "Rust programming language", "fact", vec![], 1.0)
.unwrap();
mem.set("key2", "Python scripting", "fact", vec![], 1.0)
.unwrap();
let results = mem.search("Rust");
assert_eq!(results.len(), 1);
assert_eq!(results[0].key, "key1");
}
#[test]
fn test_search_by_tag() {
let (mut mem, _dir) = temp_memory();
mem.set(
"key1",
"some value",
"test",
vec!["important".to_string(), "work".to_string()],
1.0,
)
.unwrap();
mem.set(
"key2",
"other value",
"test",
vec!["personal".to_string()],
1.0,
)
.unwrap();
let results = mem.search("important");
assert_eq!(results.len(), 1);
assert_eq!(results[0].key, "key1");
}
#[test]
fn test_search_case_insensitive() {
let (mut mem, _dir) = temp_memory();
mem.set(
"Key1",
"Hello World",
"Test",
vec!["MyTag".to_string()],
1.0,
)
.unwrap();
assert!(!mem.search("hello").is_empty());
assert!(!mem.search("HELLO").is_empty());
assert!(!mem.search("key1").is_empty());
assert!(!mem.search("KEY1").is_empty());
assert!(!mem.search("mytag").is_empty());
assert!(!mem.search("test").is_empty());
}
#[test]
fn test_list_by_category() {
let (mut mem, _dir) = temp_memory();
mem.set("k1", "v1", "user", vec![], 1.0).unwrap();
mem.set("k2", "v2", "user", vec![], 1.0).unwrap();
mem.set("k3", "v3", "project", vec![], 1.0).unwrap();
let user_entries = mem.list_by_category("user");
assert_eq!(user_entries.len(), 2);
assert!(user_entries.iter().all(|e| e.category == "user"));
let project_entries = mem.list_by_category("project");
assert_eq!(project_entries.len(), 1);
}
#[test]
fn test_list_all() {
let (mut mem, _dir) = temp_memory();
mem.set("k1", "v1", "a", vec![], 1.0).unwrap();
mem.set("k2", "v2", "b", vec![], 1.0).unwrap();
mem.set("k3", "v3", "c", vec![], 1.0).unwrap();
let all = mem.list_all();
assert_eq!(all.len(), 3);
}
#[test]
fn test_count() {
let (mut mem, _dir) = temp_memory();
assert_eq!(mem.count(), 0);
mem.set("k1", "v1", "test", vec![], 1.0).unwrap();
assert_eq!(mem.count(), 1);
mem.set("k2", "v2", "test", vec![], 1.0).unwrap();
assert_eq!(mem.count(), 2);
mem.delete("k1").unwrap();
assert_eq!(mem.count(), 1);
}
#[test]
fn test_categories() {
let (mut mem, _dir) = temp_memory();
mem.set("k1", "v1", "user", vec![], 1.0).unwrap();
mem.set("k2", "v2", "fact", vec![], 1.0).unwrap();
mem.set("k3", "v3", "user", vec![], 1.0).unwrap();
mem.set("k4", "v4", "preference", vec![], 1.0).unwrap();
let cats = mem.categories();
assert_eq!(cats, vec!["fact", "preference", "user"]);
}
#[test]
fn test_cleanup_least_used() {
let (mut mem, _dir) = temp_memory();
mem.set("k1", "v1", "test", vec![], 0.5).unwrap();
mem.set("k2", "v2", "test", vec![], 0.3).unwrap();
mem.set("k3", "v3", "test", vec![], 1.0).unwrap();
let removed = mem.cleanup_least_used(2).unwrap();
assert_eq!(removed, 1);
assert_eq!(mem.count(), 2);
assert!(mem.get_readonly("k3").is_some());
assert!(mem.get_readonly("k1").is_some());
assert!(mem.get_readonly("k2").is_none());
}
#[test]
fn test_persistence_roundtrip() {
let dir = TempDir::new().expect("failed to create temp dir");
let path = dir.path().join("longterm.json");
{
let mut mem = LongTermMemory::with_path(path.clone()).unwrap();
mem.set(
"user:name",
"Alice",
"user",
vec!["identity".to_string()],
1.0,
)
.unwrap();
mem.set("fact:lang", "Rust", "fact", vec!["tech".to_string()], 1.0)
.unwrap();
}
{
let mem = LongTermMemory::with_path(path).unwrap();
assert_eq!(mem.count(), 2);
let entry = mem.get_readonly("user:name").unwrap();
assert_eq!(entry.value, "Alice");
assert_eq!(entry.tags, vec!["identity"]);
let entry2 = mem.get_readonly("fact:lang").unwrap();
assert_eq!(entry2.value, "Rust");
}
}
#[test]
fn test_summary() {
let (mut mem, _dir) = temp_memory();
assert_eq!(mem.summary(), "Long-term memory: 0 entries (0 categories)");
mem.set("k1", "v1", "user", vec![], 1.0).unwrap();
mem.set("k2", "v2", "fact", vec![], 1.0).unwrap();
mem.set("k3", "v3", "fact", vec![], 1.0).unwrap();
assert_eq!(mem.summary(), "Long-term memory: 3 entries (2 categories)");
}
#[test]
fn test_decay_score_fresh_entry() {
let (mut mem, _dir) = temp_memory();
mem.set("fresh", "value", "test", vec![], 1.0).unwrap();
let entry = mem.get_readonly("fresh").unwrap();
let score = entry.decay_score();
assert!(
(score - 1.0).abs() < 0.01,
"Fresh entry score was {}, expected ~1.0",
score
);
}
#[test]
fn test_decay_score_pinned_exempt() {
let (mut mem, _dir) = temp_memory();
mem.set("pinned_key", "value", "pinned", vec![], 1.0)
.unwrap();
if let Some(entry) = mem.entries.get_mut("pinned_key") {
entry.last_accessed = now_timestamp() - (365 * 86400); }
let entry = mem.get_readonly("pinned_key").unwrap();
let score = entry.decay_score();
assert_eq!(score, 1.0, "Pinned entry should score 1.0, got {}", score);
}
#[test]
fn test_decay_score_pinned_case_insensitive() {
let (mut mem, _dir) = temp_memory();
mem.set("pinned_key", "value", "Pinned", vec![], 1.0)
.unwrap();
if let Some(entry) = mem.entries.get_mut("pinned_key") {
entry.last_accessed = now_timestamp() - (365 * 86400);
}
let entry = mem.get_readonly("pinned_key").unwrap();
let score = entry.decay_score();
assert_eq!(
score, 1.0,
"Pinned (capital) entry should score 1.0, got {}",
score
);
}
#[test]
fn test_decay_score_old_entry_decays() {
let (mut mem, _dir) = temp_memory();
mem.set("old", "value", "test", vec![], 1.0).unwrap();
if let Some(entry) = mem.entries.get_mut("old") {
entry.last_accessed = now_timestamp() - (30 * 86400);
}
let entry = mem.get_readonly("old").unwrap();
let score = entry.decay_score();
assert!(
(score - 0.5).abs() < 0.05,
"30-day-old entry score was {}, expected ~0.5",
score
);
}
#[test]
fn test_decay_score_importance_scales() {
let (mut mem, _dir) = temp_memory();
mem.set("low_importance", "value", "test", vec![], 0.5)
.unwrap();
let entry = mem.get_readonly("low_importance").unwrap();
let score = entry.decay_score();
assert!(
(score - 0.5).abs() < 0.01,
"Low importance entry score was {}, expected ~0.5",
score
);
}
#[test]
fn test_search_sorted_by_decay_score() {
let (mut mem, _dir) = temp_memory();
mem.set("fresh", "test value", "test", vec![], 1.0).unwrap();
mem.set("old", "test value", "test", vec![], 1.0).unwrap();
if let Some(entry) = mem.entries.get_mut("old") {
entry.last_accessed = now_timestamp() - (60 * 86400); }
let results = mem.search("test");
assert_eq!(results.len(), 2);
assert_eq!(results[0].key, "fresh", "Fresh entry should rank first");
assert_eq!(results[1].key, "old", "Old entry should rank second");
}
#[test]
fn test_cleanup_evicts_by_decay_score() {
let (mut mem, _dir) = temp_memory();
mem.set("high", "value", "test", vec![], 2.0).unwrap();
mem.set("medium", "value", "test", vec![], 1.0).unwrap();
mem.set("low", "value", "test", vec![], 0.5).unwrap();
let removed = mem.cleanup_least_used(1).unwrap();
assert_eq!(removed, 2);
assert_eq!(mem.count(), 1);
assert!(
mem.get_readonly("high").is_some(),
"High importance entry should survive"
);
assert!(
mem.get_readonly("medium").is_none(),
"Medium importance entry should be removed"
);
assert!(
mem.get_readonly("low").is_none(),
"Low importance entry should be removed"
);
}
#[test]
fn test_importance_persists_roundtrip() {
let dir = TempDir::new().expect("failed to create temp dir");
let path = dir.path().join("longterm.json");
{
let mut mem = LongTermMemory::with_path(path.clone()).unwrap();
mem.set("high", "value", "test", vec![], 2.5).unwrap();
mem.set("low", "value", "test", vec![], 0.3).unwrap();
}
{
let mem = LongTermMemory::with_path(path).unwrap();
assert_eq!(mem.count(), 2);
let high = mem.get_readonly("high").unwrap();
assert_eq!(high.importance, 2.5, "High importance should persist");
let low = mem.get_readonly("low").unwrap();
assert_eq!(low.importance, 0.3, "Low importance should persist");
}
}
}