pub mod markdown;
pub mod schema;
use crate::{
memories_are_store_duplicates, memory_is_prune_protected, merge_duplicate_memory_item,
normalize_item_for_store, MemoryItem, MemoryStore, MemoryType, PrunePolicy, RelevanceConfig,
};
use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::{DateTime, TimeZone, Utc};
use rusqlite::{params, Connection};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
pub struct SqliteMemoryStore {
conn: Arc<Mutex<Connection>>,
base_dir: PathBuf,
relevance: RelevanceConfig,
}
impl std::fmt::Debug for SqliteMemoryStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SqliteMemoryStore")
.field("base_dir", &self.base_dir)
.finish()
}
}
impl SqliteMemoryStore {
pub async fn new(base_dir: impl AsRef<Path>) -> Result<Self> {
let base_dir = base_dir.as_ref().to_path_buf();
tokio::fs::create_dir_all(&base_dir)
.await
.with_context(|| format!("Cannot create memory dir {}", base_dir.display()))?;
let db_path = base_dir.join("memory.db");
let conn = tokio::task::spawn_blocking(move || -> Result<Connection> {
let conn = Connection::open(&db_path)
.with_context(|| format!("Cannot open SQLite DB at {}", db_path.display()))?;
schema::apply(&conn)?;
#[cfg(feature = "sqlite-vec")]
#[allow(clippy::missing_transmute_annotations)]
unsafe {
rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute(
sqlite_vec::sqlite3_vec_init as *const (),
)));
}
#[cfg(feature = "sqlite-vec")]
conn.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
memory_id TEXT PRIMARY KEY,
embedding FLOAT[1536]
);",
)?;
Ok(conn)
})
.await
.context("spawn_blocking panicked")??;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
base_dir,
relevance: RelevanceConfig::default(),
})
}
pub fn with_relevance(mut self, cfg: RelevanceConfig) -> Self {
self.relevance = cfg;
self
}
pub async fn log_session_event(
&self,
session_id: &str,
event_type: &str,
data: &serde_json::Value,
) -> Result<()> {
let now_ms = Utc::now().timestamp_millis();
let data_json = serde_json::to_string(data)?;
let session_id = session_id.to_string();
let event_type = event_type.to_string();
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let c = conn.lock().expect("sqlite lock poisoned");
c.execute(
"INSERT INTO session_log (session_id, event_type, data_json, timestamp_ms)
VALUES (?1, ?2, ?3, ?4)",
params![session_id, event_type, data_json, now_ms],
)?;
Ok(())
})
.await
.context("spawn_blocking panicked")?
}
pub async fn export_session_log(&self, session_id: &str) -> Result<Vec<serde_json::Value>> {
let session_id = session_id.to_string();
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<serde_json::Value>> {
let c = conn.lock().expect("sqlite lock poisoned");
let mut stmt = c.prepare(
"SELECT event_type, data_json, timestamp_ms
FROM session_log
WHERE session_id = ?1
ORDER BY timestamp_ms ASC",
)?;
let rows = stmt.query_map(params![session_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (event_type, data_json, ts) = row?;
let data: serde_json::Value =
serde_json::from_str(&data_json).unwrap_or(serde_json::Value::Null);
out.push(serde_json::json!({
"event_type": event_type,
"data": data,
"timestamp_ms": ts,
}));
}
Ok(out)
})
.await
.context("spawn_blocking panicked")?
}
#[cfg(feature = "sqlite-vec")]
pub async fn store_with_embedding(&self, item: MemoryItem, embedding: Vec<f32>) -> Result<()> {
let item = self.store_and_return(item).await?;
let id = item.id.clone();
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let c = conn.lock().expect("sqlite lock poisoned");
let blob: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
c.execute(
"INSERT OR REPLACE INTO memories_vec (memory_id, embedding) VALUES (?1, ?2)",
params![id, blob],
)?;
Ok(())
})
.await
.context("spawn_blocking panicked")?
}
#[cfg(feature = "sqlite-vec")]
pub async fn search_semantic(
&self,
query_embedding: Vec<f32>,
limit: usize,
) -> Result<Vec<MemoryItem>> {
let conn = self.conn.clone();
let blob: Vec<u8> = query_embedding
.iter()
.flat_map(|f| f.to_le_bytes())
.collect();
let ids: Vec<String> = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let c = conn.lock().expect("sqlite lock poisoned");
let mut stmt = c.prepare(
"SELECT memory_id
FROM memories_vec
WHERE embedding MATCH ?1
ORDER BY distance
LIMIT ?2",
)?;
let ids = stmt
.query_map(params![blob, limit as i64], |row| row.get::<_, String>(0))?
.filter_map(|r| r.ok())
.collect();
Ok(ids)
})
.await
.context("spawn_blocking panicked")??;
let mut items = Vec::with_capacity(ids.len());
for id in &ids {
if let Some(item) = self.retrieve(id).await? {
items.push(item);
}
}
Ok(items)
}
async fn with_conn<F, R>(&self, f: F) -> Result<R>
where
F: FnOnce(&Connection) -> Result<R> + Send + 'static,
R: Send + 'static,
{
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let c = conn.lock().expect("sqlite lock poisoned");
f(&c)
})
.await
.context("spawn_blocking panicked")?
}
}
#[async_trait]
impl MemoryStore for SqliteMemoryStore {
async fn store(&self, item: MemoryItem) -> Result<()> {
self.store_and_return(item).await.map(|_| ())
}
async fn store_and_return(&self, item: MemoryItem) -> Result<MemoryItem> {
let item = normalize_item_for_store(item);
let (stored, append_markdown) = self
.with_conn(move |c| {
let mut stmt = c.prepare(
"SELECT id, content, timestamp_ms, importance, tags, memory_type,
metadata, access_count, last_accessed_ms
FROM memories",
)?;
let existing_items: Vec<MemoryItem> = stmt
.query_map([], row_to_item)?
.filter_map(|row| row.ok())
.collect();
if let Some(existing) = existing_items
.into_iter()
.find(|existing| memories_are_store_duplicates(existing, &item))
{
if existing.id != item.id {
let merged = merge_duplicate_memory_item(existing, item.clone());
upsert_memory_item(c, &merged)?;
if item.id != merged.id {
c.execute("DELETE FROM memories WHERE id = ?1", params![item.id])?;
}
return Ok((merged, false));
}
}
upsert_memory_item(c, &item)?;
Ok((item, true))
})
.await?;
if append_markdown {
if let Err(e) = markdown::append(&self.base_dir, &stored).await {
eprintln!("[a3s-memory] markdown write failed for {}: {e}", stored.id);
}
}
Ok(stored)
}
async fn retrieve(&self, id: &str) -> Result<Option<MemoryItem>> {
let id = id.to_string();
let now_ms = Utc::now().timestamp_millis();
self.with_conn(move |c| {
let result = c.query_row(
"SELECT id, content, timestamp_ms, importance, tags, memory_type,
metadata, access_count, last_accessed_ms
FROM memories WHERE id = ?1",
params![id],
row_to_item,
);
match result {
Ok(mut item) => {
let _ = c.execute(
"UPDATE memories
SET access_count = access_count + 1, last_accessed_ms = ?1
WHERE id = ?2",
params![now_ms, item.id],
);
item.access_count += 1;
item.last_accessed = Some(ms_to_dt(now_ms));
Ok(Some(item))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(anyhow::anyhow!("retrieve({id}): {e}")),
}
})
.await
}
async fn search(&self, query: &str, limit: usize) -> Result<Vec<MemoryItem>> {
let query = query.to_string();
self.with_conn(move |c| {
let mut stmt = c.prepare(
"SELECT m.id, m.content, m.timestamp_ms, m.importance, m.tags,
m.memory_type, m.metadata, m.access_count, m.last_accessed_ms
FROM memories_fts
JOIN memories m ON memories_fts.rowid = m.rowid
WHERE memories_fts MATCH ?1
ORDER BY bm25(memories_fts)
LIMIT ?2",
)?;
let items = stmt
.query_map(params![query, limit as i64], |row| Ok(row_to_item(row)))?
.filter_map(|r| r.ok())
.filter_map(|r| r.ok())
.collect();
Ok(items)
})
.await
}
async fn search_by_tags(&self, tags: &[String], limit: usize) -> Result<Vec<MemoryItem>> {
if tags.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.clone();
let tags_owned: Vec<String> = tags.to_vec();
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryItem>> {
let c = conn.lock().expect("sqlite lock poisoned");
let mut stmt = c.prepare(
"SELECT id, content, timestamp_ms, importance, tags, memory_type,
metadata, access_count, last_accessed_ms
FROM memories
ORDER BY timestamp_ms DESC",
)?;
let items: Vec<MemoryItem> = stmt
.query_map([], |row| Ok(row_to_item(row)))?
.filter_map(|r| r.ok())
.filter_map(|r| r.ok())
.filter(|item| tags_owned.iter().any(|t| item.tags.contains(t)))
.take(limit)
.collect();
Ok(items)
})
.await
.context("spawn_blocking panicked")?
}
async fn get_recent(&self, limit: usize) -> Result<Vec<MemoryItem>> {
self.with_conn(move |c| {
let mut stmt = c.prepare(
"SELECT id, content, timestamp_ms, importance, tags, memory_type,
metadata, access_count, last_accessed_ms
FROM memories
ORDER BY timestamp_ms DESC
LIMIT ?1",
)?;
let items = stmt
.query_map(params![limit as i64], |row| Ok(row_to_item(row)))?
.filter_map(|r| r.ok())
.filter_map(|r| r.ok())
.collect();
Ok(items)
})
.await
}
async fn get_important(&self, threshold: f32, limit: usize) -> Result<Vec<MemoryItem>> {
self.with_conn(move |c| {
let mut stmt = c.prepare(
"SELECT id, content, timestamp_ms, importance, tags, memory_type,
metadata, access_count, last_accessed_ms
FROM memories
WHERE importance >= ?1
ORDER BY importance DESC
LIMIT ?2",
)?;
let items = stmt
.query_map(params![threshold, limit as i64], |row| Ok(row_to_item(row)))?
.filter_map(|r| r.ok())
.filter_map(|r| r.ok())
.collect();
Ok(items)
})
.await
}
async fn delete(&self, id: &str) -> Result<()> {
let id = id.to_string();
self.with_conn(move |c| {
c.execute("DELETE FROM memories WHERE id = ?1", params![id])?;
Ok(())
})
.await
}
async fn clear(&self) -> Result<()> {
self.with_conn(move |c| {
c.execute_batch(
"DELETE FROM memories;
INSERT INTO memories_fts(memories_fts) VALUES ('rebuild');",
)?;
Ok(())
})
.await
}
async fn count(&self) -> Result<usize> {
self.with_conn(move |c| {
let n: i64 = c.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
Ok(n as usize)
})
.await
}
async fn prune(&self, policy: &PrunePolicy) -> Result<usize> {
let cutoff_ms = (chrono::Utc::now() - chrono::Duration::days(policy.max_age_days as i64))
.timestamp_millis();
let min_importance = policy.min_importance_to_keep;
let max_items = policy.max_items;
let now = chrono::Utc::now();
self.with_conn(move |c| {
let mut stmt = c.prepare(
"SELECT id, content, timestamp_ms, importance, tags, memory_type,
metadata, access_count, last_accessed_ms
FROM memories",
)?;
let mut items: Vec<MemoryItem> = stmt
.query_map([], row_to_item)?
.filter_map(|row| row.ok())
.collect();
let mut delete_ids: Vec<String> = items
.iter()
.filter(|item| {
!memory_is_prune_protected(item)
&& item.timestamp.timestamp_millis() < cutoff_ms
&& item.importance < min_importance
})
.map(|item| item.id.clone())
.collect();
items.retain(|item| !delete_ids.contains(&item.id));
if max_items > 0 && items.len() > max_items {
let protected_count = items
.iter()
.filter(|item| memory_is_prune_protected(item))
.count();
let unprotected_to_keep = max_items.saturating_sub(protected_count);
let mut unprotected: Vec<MemoryItem> = items
.into_iter()
.filter(|item| !memory_is_prune_protected(item))
.collect();
unprotected.sort_by(|a, b| {
b.relevance_score_at(now, &RelevanceConfig::default())
.partial_cmp(&a.relevance_score_at(now, &RelevanceConfig::default()))
.unwrap_or(std::cmp::Ordering::Equal)
});
delete_ids.extend(
unprotected
.into_iter()
.skip(unprotected_to_keep)
.map(|item| item.id),
);
}
let deleted = delete_ids.len();
for id in delete_ids {
c.execute("DELETE FROM memories WHERE id = ?1", params![id])?;
}
Ok(deleted)
})
.await
}
}
fn upsert_memory_item(c: &Connection, item: &MemoryItem) -> Result<()> {
let tags_json = serde_json::to_string(&item.tags)?;
let meta_json = serde_json::to_string(&item.metadata)?;
let ts_ms = item.timestamp.timestamp_millis();
let last_acc_ms = item.last_accessed.map(|t| t.timestamp_millis());
let mtype = memory_type_to_str(&item.memory_type).to_string();
c.execute(
"INSERT OR REPLACE INTO memories
(id, content, timestamp_ms, importance, tags, memory_type, metadata,
access_count, last_accessed_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
item.id,
item.content,
ts_ms,
item.importance,
tags_json,
mtype,
meta_json,
item.access_count,
last_acc_ms,
],
)?;
Ok(())
}
fn row_to_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryItem> {
let id: String = row.get(0)?;
let content: String = row.get(1)?;
let ts_ms: i64 = row.get(2)?;
let importance: f32 = row.get(3)?;
let tags_json: String = row.get(4)?;
let mtype_str: String = row.get(5)?;
let meta_json: String = row.get(6)?;
let access_count: u32 = row.get(7)?;
let last_acc_ms: Option<i64> = row.get(8)?;
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
let metadata: std::collections::HashMap<String, String> =
serde_json::from_str(&meta_json).unwrap_or_default();
let memory_type = str_to_memory_type(&mtype_str);
let timestamp = ms_to_dt(ts_ms);
let last_accessed = last_acc_ms.map(ms_to_dt);
let content_lower = content.to_lowercase();
Ok(MemoryItem {
id,
content,
timestamp,
importance,
tags,
memory_type,
metadata,
access_count,
last_accessed,
content_lower,
})
}
fn ms_to_dt(ms: i64) -> DateTime<Utc> {
Utc.timestamp_millis_opt(ms)
.single()
.unwrap_or_else(Utc::now)
}
fn memory_type_to_str(t: &MemoryType) -> &'static str {
match t {
MemoryType::Episodic => "episodic",
MemoryType::Semantic => "semantic",
MemoryType::Procedural => "procedural",
MemoryType::Working => "working",
}
}
fn str_to_memory_type(s: &str) -> MemoryType {
match s {
"episodic" => MemoryType::Episodic,
"semantic" => MemoryType::Semantic,
"procedural" => MemoryType::Procedural,
"working" => MemoryType::Working,
_ => MemoryType::Episodic,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MemoryItem;
use tempfile::TempDir;
fn make_item(id: &str, content: &str, importance: f32) -> MemoryItem {
MemoryItem {
id: id.to_string(),
content: content.to_string(),
importance,
memory_type: MemoryType::Semantic,
..MemoryItem::new(content.to_string())
}
}
async fn store(dir: &TempDir) -> SqliteMemoryStore {
SqliteMemoryStore::new(dir.path()).await.unwrap()
}
#[tokio::test]
async fn test_store_and_retrieve() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let item = make_item("id1", "hello world", 0.5);
s.store(item.clone()).await.unwrap();
let got = s.retrieve("id1").await.unwrap().expect("should exist");
assert_eq!(got.content, "hello world");
assert_eq!(got.access_count, 1);
}
#[tokio::test]
async fn test_retrieve_missing_returns_none() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
assert!(s.retrieve("no-such-id").await.unwrap().is_none());
}
#[tokio::test]
async fn test_count_and_delete() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.store(make_item("a", "foo", 0.5)).await.unwrap();
s.store(make_item("b", "bar", 0.5)).await.unwrap();
assert_eq!(s.count().await.unwrap(), 2);
s.delete("a").await.unwrap();
assert_eq!(s.count().await.unwrap(), 1);
}
#[tokio::test]
async fn test_clear() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.store(make_item("x", "content", 0.8)).await.unwrap();
s.clear().await.unwrap();
assert_eq!(s.count().await.unwrap(), 0);
}
#[tokio::test]
async fn test_fts_search() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.store(make_item("1", "The quick brown fox", 0.5))
.await
.unwrap();
s.store(make_item("2", "lazy dog barks", 0.5))
.await
.unwrap();
let results = s.search("fox", 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "1");
}
#[tokio::test]
async fn test_search_by_tags() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let mut item = make_item("1", "tagged", 0.5);
item.tags = vec!["rust".to_string(), "ai".to_string()];
s.store(item).await.unwrap();
let mut item2 = make_item("2", "other", 0.5);
item2.tags = vec!["python".to_string()];
s.store(item2).await.unwrap();
let results = s.search_by_tags(&["rust".to_string()], 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "1");
}
#[tokio::test]
async fn test_store_deduplicates_durable_content() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let mut first = make_item(
"first",
"Run focused memory extraction tests after parser changes.",
0.3,
);
first.tags = vec!["memory".to_string()];
s.store(first).await.unwrap();
let mut duplicate = make_item(
"duplicate",
"run focused MEMORY extraction tests after parser changes!",
0.9,
);
duplicate.tags = vec!["tests".to_string()];
duplicate
.metadata
.insert("supersedes".to_string(), "old-memory".to_string());
let stored = s.store_and_return(duplicate).await.unwrap();
assert_eq!(stored.id, "first");
assert_eq!(s.count().await.unwrap(), 1);
let recalled = s.retrieve("first").await.unwrap().unwrap();
assert_eq!(recalled.importance, 0.9);
assert!(recalled.tags.contains(&"memory".to_string()));
assert!(recalled.tags.contains(&"tests".to_string()));
assert_eq!(
recalled.metadata.get("duplicate_count").map(String::as_str),
Some("1")
);
assert!(s.retrieve("duplicate").await.unwrap().is_none());
}
#[tokio::test]
async fn test_store_merges_near_duplicate_content() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let mut first = make_item(
"first",
"Run focused memory store tests after parser changes.",
0.35,
);
first.memory_type = MemoryType::Procedural;
first.tags = vec!["memory".to_string()];
s.store(first).await.unwrap();
let mut duplicate = make_item(
"duplicate",
"Run focused memory store regression tests after parser changes.",
0.9,
);
duplicate.memory_type = MemoryType::Procedural;
duplicate.tags = vec!["tests".to_string()];
let stored = s.store_and_return(duplicate).await.unwrap();
assert_eq!(stored.id, "first");
assert_eq!(s.count().await.unwrap(), 1);
let recalled = s.retrieve("first").await.unwrap().unwrap();
assert!(recalled.content.contains("regression tests"));
assert!(recalled.tags.contains(&"memory".to_string()));
assert!(recalled.tags.contains(&"tests".to_string()));
assert_eq!(
recalled.metadata.get("duplicate_count").map(String::as_str),
Some("1")
);
}
#[tokio::test]
async fn test_store_keeps_conflicting_near_duplicate_content() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.store(make_item(
"use",
"Use file memory store for local sessions.",
0.5,
))
.await
.unwrap();
s.store(make_item(
"avoid",
"Do not use file memory store for local sessions.",
0.5,
))
.await
.unwrap();
assert_eq!(s.count().await.unwrap(), 2);
assert!(s.retrieve("use").await.unwrap().is_some());
assert!(s.retrieve("avoid").await.unwrap().is_some());
}
#[tokio::test]
async fn test_get_important() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.store(make_item("lo", "low", 0.3)).await.unwrap();
s.store(make_item("hi", "high", 0.9)).await.unwrap();
let results = s.get_important(0.7, 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "hi");
}
#[tokio::test]
async fn test_get_recent_order() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.store(make_item("old", "old content", 0.5)).await.unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
s.store(make_item("new", "new content", 0.5)).await.unwrap();
let results = s.get_recent(2).await.unwrap();
assert_eq!(results[0].id, "new");
}
#[tokio::test]
async fn test_persistence_across_reopen() {
let dir = TempDir::new().unwrap();
{
let s = store(&dir).await;
s.store(make_item("persist", "durable data", 0.6))
.await
.unwrap();
}
let s2 = SqliteMemoryStore::new(dir.path()).await.unwrap();
let got = s2.retrieve("persist").await.unwrap();
assert!(got.is_some());
assert_eq!(got.unwrap().content, "durable data");
}
#[tokio::test]
async fn test_session_log_roundtrip() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.log_session_event("sess1", "tool_use", &serde_json::json!({"tool": "Read"}))
.await
.unwrap();
s.log_session_event("sess1", "response", &serde_json::json!({"len": 42}))
.await
.unwrap();
let log = s.export_session_log("sess1").await.unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0]["event_type"], "tool_use");
assert_eq!(log[1]["event_type"], "response");
}
#[tokio::test]
async fn test_markdown_important_written() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let mut item = make_item("imp", "very important note", 0.9);
item.memory_type = MemoryType::Semantic;
s.store(item).await.unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
let md_path = dir.path().join("MEMORY.md");
assert!(md_path.exists(), "MEMORY.md should be created");
let content = tokio::fs::read_to_string(&md_path).await.unwrap();
assert!(content.contains("very important note"));
}
#[tokio::test]
async fn test_prune_removes_old_low_importance() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let mut old_item = make_item("old_low", "stale memory", 0.2);
let cutoff_ms = (chrono::Utc::now() - chrono::Duration::days(100)).timestamp_millis();
{
let conn = s.conn.clone();
tokio::task::spawn_blocking(move || {
let c = conn.lock().unwrap();
c.execute(
"UPDATE memories SET timestamp_ms = ?1 WHERE id = ?2",
params![cutoff_ms, "old_low"],
)
.unwrap();
})
.await
.unwrap();
}
old_item.timestamp = chrono::Utc::now() - chrono::Duration::days(100);
s.store(old_item).await.unwrap();
{
let conn = s.conn.clone();
let ts = cutoff_ms - 1;
tokio::task::spawn_blocking(move || {
let c = conn.lock().unwrap();
c.execute(
"UPDATE memories SET timestamp_ms = ?1 WHERE id = 'old_low'",
params![ts],
)
.unwrap();
})
.await
.unwrap();
}
use crate::PrunePolicy;
let policy = PrunePolicy {
max_age_days: 90,
min_importance_to_keep: 0.5,
max_items: 0,
};
let deleted = s.prune(&policy).await.unwrap();
assert_eq!(deleted, 1);
assert_eq!(s.count().await.unwrap(), 0);
}
#[tokio::test]
async fn test_prune_protects_curated_memories() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let mut pinned = make_item("pinned", "Pinned low-importance memory.", 0.1);
pinned.tags = vec!["pinned".to_string()];
pinned.timestamp = chrono::Utc::now() - chrono::Duration::days(120);
s.store(pinned).await.unwrap();
let mut accessed = make_item(
"accessed",
"Frequently recalled low-importance memory.",
0.1,
);
accessed.timestamp = chrono::Utc::now() - chrono::Duration::days(120);
accessed.record_access();
accessed.record_access();
accessed.record_access();
s.store(accessed).await.unwrap();
let mut related = make_item("related", "Conflict memory should remain auditable.", 0.1);
related.timestamp = chrono::Utc::now() - chrono::Duration::days(120);
related
.metadata
.insert("conflicts_with".to_string(), "legacy-memory".to_string());
s.store(related).await.unwrap();
let mut stale = make_item("stale", "Unprotected stale memory.", 0.1);
stale.timestamp = chrono::Utc::now() - chrono::Duration::days(120);
s.store(stale).await.unwrap();
let policy = PrunePolicy {
max_age_days: 90,
min_importance_to_keep: 0.5,
max_items: 2,
};
let deleted = s.prune(&policy).await.unwrap();
assert_eq!(deleted, 1);
assert_eq!(s.count().await.unwrap(), 3);
assert!(s.retrieve("pinned").await.unwrap().is_some());
assert!(s.retrieve("accessed").await.unwrap().is_some());
assert!(s.retrieve("related").await.unwrap().is_some());
assert!(s.retrieve("stale").await.unwrap().is_none());
}
#[tokio::test]
async fn test_prune_keeps_high_importance() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
s.store(make_item("hi", "important", 0.9)).await.unwrap();
{
let conn = s.conn.clone();
let ts = (chrono::Utc::now() - chrono::Duration::days(100)).timestamp_millis();
tokio::task::spawn_blocking(move || {
let c = conn.lock().unwrap();
c.execute(
"UPDATE memories SET timestamp_ms = ?1 WHERE id = 'hi'",
params![ts],
)
.unwrap();
})
.await
.unwrap();
}
use crate::PrunePolicy;
let policy = PrunePolicy {
max_age_days: 90,
min_importance_to_keep: 0.5,
max_items: 0,
};
let deleted = s.prune(&policy).await.unwrap();
assert_eq!(deleted, 0);
assert_eq!(s.count().await.unwrap(), 1);
}
#[tokio::test]
async fn test_prune_max_items() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
for i in 0..10u32 {
s.store(make_item(
&format!("id{i}"),
&format!("content {i}"),
i as f32 * 0.1,
))
.await
.unwrap();
}
use crate::PrunePolicy;
let policy = PrunePolicy {
max_age_days: 9999,
min_importance_to_keep: 0.0,
max_items: 5,
};
let deleted = s.prune(&policy).await.unwrap();
assert_eq!(deleted, 5);
assert_eq!(s.count().await.unwrap(), 5);
}
#[tokio::test]
async fn test_markdown_episodic_daily_log() {
let dir = TempDir::new().unwrap();
let s = store(&dir).await;
let mut item = make_item("ep", "today's activity log", 0.3);
item.memory_type = MemoryType::Episodic;
s.store(item).await.unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
let daily_dir = dir.path().join("memory");
assert!(daily_dir.exists(), "memory/ directory should exist");
let mut has_today = false;
if let Ok(mut rd) = tokio::fs::read_dir(&daily_dir).await {
while let Ok(Some(entry)) = rd.next_entry().await {
let content = tokio::fs::read_to_string(entry.path())
.await
.unwrap_or_default();
if content.contains("today's activity log") {
has_today = true;
}
}
}
assert!(has_today, "Episodic item should appear in daily log");
}
}