use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use rusqlite::{params, TransactionBehavior};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::agent::messages::{Content, Message};
use crate::memory::config::MemoryConfig;
use crate::memory::error::MemoryError;
use crate::memory::salience::assign_salience;
use crate::memory::MemoryKind;
use cryptovault::CryptoVault;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub struct Memory {
pub id: String,
pub session_id: String,
pub kind: MemoryKind,
pub text: String,
pub embedding: Vec<f32>,
pub model_id: String,
pub dim: usize,
pub created_at: i64,
pub salience: f64,
pub access_count: u64,
pub last_accessed_at: i64,
pub superseded_by: Option<String>,
pub evicted_at: Option<i64>,
pub scope: String,
pub distilled_at: Option<i64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryDiagnostics {
pub active_count: usize,
pub archived_count: usize,
pub pending_reembed_count: usize,
pub ram_estimate_bytes: usize,
}
#[allow(dead_code)]
#[async_trait]
pub trait VectorStore: Send + Sync {
async fn insert(&self, m: &Memory) -> Result<(), MemoryError>;
async fn get(&self, id: &str) -> Result<Option<Memory>, MemoryError>;
async fn active(&self, scope: &str) -> Result<Vec<Memory>, MemoryError>;
async fn mark_accessed(&self, ids: &[String], now: i64) -> Result<(), MemoryError>;
async fn set_superseded(&self, id: &str, by: &str) -> Result<(), MemoryError>;
async fn set_evicted(&self, id: &str, at: Option<i64>) -> Result<(), MemoryError>;
async fn hard_delete(&self, ids: &[String]) -> Result<(), MemoryError>;
async fn set_distilled(&self, ids: &[String], at: i64) -> Result<(), MemoryError>;
async fn archived(&self) -> Result<Vec<Memory>, MemoryError>;
async fn update_embedding(
&self,
id: &str,
embedding: &[f32],
model_id: &str,
dim: usize,
) -> Result<(), MemoryError>;
async fn set_salience(&self, id: &str, salience: f64) -> Result<(), MemoryError>;
async fn upsert(&self, m: &Memory) -> Result<(), MemoryError>;
}
fn extract_message_text(msg: &Message) -> String {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text { text } => Some(text.as_str()),
Content::ToolResult { content, .. } => Some(content.as_str()),
Content::ToolUse { .. } => None,
})
.collect::<Vec<_>>()
.join(" ")
}
#[allow(dead_code)]
struct RawRow {
id: String,
session_id: String,
kind_str: String,
text_blob: String,
embedding_blob: String,
model_id: String,
dim: i64,
created_at: i64,
salience: f64,
access_count: i64,
last_accessed_at: i64,
superseded_by: Option<String>,
evicted_at: Option<i64>,
scope: String,
distilled_at: Option<i64>,
}
struct EncodedMemoryRow {
text_blob: String,
embedding_blob: String,
access_count_i64: i64,
}
#[allow(dead_code)]
const SELECT_COLS: &str = "SELECT id, session_id, kind, text_blob, embedding_blob, \
model_id, dim, created_at, salience, access_count, last_accessed_at, \
superseded_by, evicted_at, scope, distilled_at FROM memories";
#[allow(dead_code)]
fn row_from_query(row: &rusqlite::Row<'_>) -> rusqlite::Result<RawRow> {
Ok(RawRow {
id: row.get(0)?,
session_id: row.get(1)?,
kind_str: row.get(2)?,
text_blob: row.get(3)?,
embedding_blob: row.get(4)?,
model_id: row.get(5)?,
dim: row.get(6)?,
created_at: row.get(7)?,
salience: row.get(8)?,
access_count: row.get(9)?,
last_accessed_at: row.get(10)?,
superseded_by: row.get(11)?,
evicted_at: row.get(12)?,
scope: row.get(13)?,
distilled_at: row.get(14)?,
})
}
#[allow(dead_code)]
fn decode_row(raw: RawRow, vault: &CryptoVault, key: &[u8]) -> Result<Memory, MemoryError> {
let kind = parse_kind(&raw.kind_str)?;
let text = vault
.decrypt_with_key(key, &raw.text_blob)
.map_err(|e| MemoryError::Crypto(e.to_string()))?
.as_str()
.to_owned();
let emb_json = vault
.decrypt_with_key(key, &raw.embedding_blob)
.map_err(|e| MemoryError::Crypto(e.to_string()))?;
let embedding: Vec<f32> = serde_json::from_str(emb_json.as_str())
.map_err(|e| MemoryError::Storage(format!("embedding deserialization: {e}")))?;
let access_count = u64::try_from(raw.access_count).unwrap_or(u64::MAX);
Ok(Memory {
id: raw.id,
session_id: raw.session_id,
kind,
text,
embedding,
model_id: raw.model_id,
dim: raw.dim as usize,
created_at: raw.created_at,
salience: raw.salience,
access_count,
last_accessed_at: raw.last_accessed_at,
superseded_by: raw.superseded_by,
evicted_at: raw.evicted_at,
scope: raw.scope,
distilled_at: raw.distilled_at,
})
}
#[allow(dead_code)]
fn parse_kind(s: &str) -> Result<MemoryKind, MemoryError> {
match s {
"episodic" => Ok(MemoryKind::Episodic),
"preference" => Ok(MemoryKind::Preference),
other => Err(MemoryError::Storage(format!(
"unknown memory kind in DB: {:?} (data integrity error)",
other
))),
}
}
#[allow(dead_code)]
pub struct SqliteVectorStore {
conn: Arc<Mutex<rusqlite::Connection>>,
vault: CryptoVault,
derived_key: Zeroizing<Vec<u8>>,
}
const I64_MAX_SQL: i64 = i64::MAX;
impl SqliteVectorStore {
#[allow(dead_code)]
pub fn new(
conn: Arc<Mutex<rusqlite::Connection>>,
derived_key: Zeroizing<Vec<u8>>,
) -> Result<Self, MemoryError> {
{
let c = conn.lock().unwrap_or_else(|p| p.into_inner());
c.execute_batch(
"CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
kind TEXT NOT NULL,
text_blob TEXT NOT NULL,
embedding_blob TEXT NOT NULL,
model_id TEXT NOT NULL,
dim INTEGER NOT NULL,
created_at INTEGER NOT NULL,
salience REAL NOT NULL,
access_count INTEGER NOT NULL DEFAULT 0,
last_accessed_at INTEGER NOT NULL,
superseded_by TEXT,
evicted_at INTEGER,
scope TEXT NOT NULL DEFAULT 'root',
distilled_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope);",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
Ok(Self {
conn,
vault: CryptoVault::default(),
derived_key,
})
}
fn locked_conn(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
self.conn.lock().unwrap_or_else(|p| p.into_inner())
}
fn encode_memory(&self, m: &Memory) -> Result<EncodedMemoryRow, MemoryError> {
let text_blob = self
.vault
.encrypt_with_key(&self.derived_key, &m.text)
.map_err(|e| MemoryError::Crypto(e.to_string()))?;
let emb_json = serde_json::to_string(&m.embedding)
.map_err(|e| MemoryError::Storage(format!("embedding serialization: {e}")))?;
let embedding_blob = self
.vault
.encrypt_with_key(&self.derived_key, &emb_json)
.map_err(|e| MemoryError::Crypto(e.to_string()))?;
let access_count_i64 = i64::try_from(m.access_count).unwrap_or(i64::MAX);
Ok(EncodedMemoryRow {
text_blob,
embedding_blob,
access_count_i64,
})
}
pub async fn diagnostics(&self, scope: &str) -> Result<MemoryDiagnostics, MemoryError> {
let c = self.locked_conn();
let active_count: i64 = c
.query_row(
"SELECT COUNT(*) FROM memories \
WHERE evicted_at IS NULL AND superseded_by IS NULL AND scope = ?1",
params![scope],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let archived_count: i64 = c
.query_row(
"SELECT COUNT(*) FROM memories WHERE evicted_at IS NOT NULL",
[],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let pending_reembed_count: i64 = c
.query_row(
"SELECT COUNT(*) FROM memories \
WHERE evicted_at IS NULL AND superseded_by IS NULL AND scope = ?1 \
AND (model_id = '' OR dim = 0)",
params![scope],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let ram_estimate_bytes: i64 = c
.query_row(
"SELECT COALESCE(SUM(CAST(dim AS INTEGER) * 4), 0) FROM memories \
WHERE evicted_at IS NULL AND superseded_by IS NULL AND scope = ?1 \
AND dim > 0",
params![scope],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(MemoryDiagnostics {
active_count: usize::try_from(active_count).unwrap_or(usize::MAX),
archived_count: usize::try_from(archived_count).unwrap_or(usize::MAX),
pending_reembed_count: usize::try_from(pending_reembed_count).unwrap_or(usize::MAX),
ram_estimate_bytes: usize::try_from(ram_estimate_bytes).unwrap_or(usize::MAX),
})
}
#[allow(dead_code)]
pub async fn migrate_from_messages(
&self,
msgs: &[(String, Message)],
now: i64,
cfg: &MemoryConfig,
) -> Result<usize, MemoryError> {
struct Prepared {
id: String,
session_id: String,
text_blob: String,
embedding_blob: String,
salience: f64,
}
let empty_emb_json = "[]";
let mut prepared: Vec<Prepared> = Vec::with_capacity(msgs.len());
for (session_id, msg) in msgs {
let text = extract_message_text(msg);
if text.trim().is_empty() {
continue;
}
let role_str = format!("{:?}", msg.role);
let id = {
let mut h = Sha256::new();
h.update(session_id.as_bytes());
h.update([0x1F_u8]);
h.update(role_str.as_bytes());
h.update([0x1F_u8]);
h.update(text.as_bytes());
format!("mig:{:x}", h.finalize())
};
let salience = assign_salience(MemoryKind::Episodic, &text, msg.role.clone(), cfg);
let text_blob = self
.vault
.encrypt_with_key(&self.derived_key, &text)
.map_err(|e| MemoryError::Crypto(e.to_string()))?;
let embedding_blob = self
.vault
.encrypt_with_key(&self.derived_key, empty_emb_json)
.map_err(|e| MemoryError::Crypto(e.to_string()))?;
prepared.push(Prepared {
id,
session_id: session_id.clone(),
text_blob,
embedding_blob,
salience,
});
}
if prepared.is_empty() {
return Ok(0);
}
let mut imported = 0;
{
let mut conn = self.locked_conn();
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
for p in &prepared {
let count: i64 = tx
.query_row(
"SELECT COUNT(*) FROM memories WHERE id = ?1",
params![p.id],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if count > 0 {
continue;
}
tx.execute(
"INSERT INTO memories \
(id, session_id, kind, text_blob, embedding_blob, model_id, dim, \
created_at, salience, access_count, last_accessed_at, \
superseded_by, evicted_at, scope, distilled_at) \
VALUES (?1, ?2, 'episodic', ?3, ?4, '', 0, ?5, ?6, 0, ?5, \
NULL, NULL, 'root', NULL)",
params![
p.id,
p.session_id,
p.text_blob,
p.embedding_blob,
now,
p.salience
],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
imported += 1;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
Ok(imported)
}
}
#[async_trait]
impl VectorStore for SqliteVectorStore {
async fn insert(&self, m: &Memory) -> Result<(), MemoryError> {
let enc = self.encode_memory(m)?;
let c = self.locked_conn();
c.execute(
"INSERT INTO memories \
(id, session_id, kind, text_blob, embedding_blob, model_id, dim, \
created_at, salience, access_count, last_accessed_at, \
superseded_by, evicted_at, scope, distilled_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
m.id,
m.session_id,
m.kind.as_str(),
enc.text_blob,
enc.embedding_blob,
m.model_id,
m.dim as i64,
m.created_at,
m.salience,
enc.access_count_i64,
m.last_accessed_at,
m.superseded_by,
m.evicted_at,
m.scope,
m.distilled_at,
],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
async fn upsert(&self, m: &Memory) -> Result<(), MemoryError> {
let enc = self.encode_memory(m)?;
let c = self.locked_conn();
c.execute(
"INSERT OR REPLACE INTO memories \
(id, session_id, kind, text_blob, embedding_blob, model_id, dim, \
created_at, salience, access_count, last_accessed_at, \
superseded_by, evicted_at, scope, distilled_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
m.id,
m.session_id,
m.kind.as_str(),
enc.text_blob,
enc.embedding_blob,
m.model_id,
m.dim as i64,
m.created_at,
m.salience,
enc.access_count_i64,
m.last_accessed_at,
m.superseded_by,
m.evicted_at,
m.scope,
m.distilled_at,
],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
async fn get(&self, id: &str) -> Result<Option<Memory>, MemoryError> {
let raw: Option<RawRow> = {
let c = self.locked_conn();
let mut stmt = c
.prepare(&format!("{SELECT_COLS} WHERE id = ?1"))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut rows = stmt
.query_map(params![id], row_from_query)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
match rows.next() {
None => None,
Some(r) => Some(r.map_err(|e| MemoryError::Storage(e.to_string()))?),
}
};
match raw {
None => Ok(None),
Some(row) => Ok(Some(decode_row(row, &self.vault, &self.derived_key)?)),
}
}
async fn active(&self, scope: &str) -> Result<Vec<Memory>, MemoryError> {
let raw_rows: Vec<RawRow> = {
let c = self.locked_conn();
let mut stmt = c
.prepare(&format!(
"{SELECT_COLS} WHERE evicted_at IS NULL \
AND superseded_by IS NULL AND scope = ?1"
))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let iter = stmt
.query_map(params![scope], row_from_query)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut collected = Vec::new();
for r in iter {
collected.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
collected
};
let mut out = Vec::with_capacity(raw_rows.len());
for row in raw_rows {
out.push(decode_row(row, &self.vault, &self.derived_key)?);
}
Ok(out)
}
async fn mark_accessed(&self, ids: &[String], now: i64) -> Result<(), MemoryError> {
if ids.is_empty() {
return Ok(());
}
let mut c = self.locked_conn();
let tx = c
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let sql = format!(
"UPDATE memories \
SET access_count = CASE WHEN access_count < {I64_MAX_SQL} \
THEN access_count + 1 ELSE access_count END, \
last_accessed_at = ?2 \
WHERE id = ?1"
);
for id in ids {
tx.execute(&sql, params![id, now])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
async fn set_superseded(&self, id: &str, by: &str) -> Result<(), MemoryError> {
let c = self.locked_conn();
c.execute(
"UPDATE memories SET superseded_by = ?2 WHERE id = ?1",
params![id, by],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
async fn set_evicted(&self, id: &str, at: Option<i64>) -> Result<(), MemoryError> {
let c = self.locked_conn();
c.execute(
"UPDATE memories SET evicted_at = ?2 WHERE id = ?1",
params![id, at],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
async fn hard_delete(&self, ids: &[String]) -> Result<(), MemoryError> {
if ids.is_empty() {
return Ok(());
}
let mut c = self.locked_conn();
let tx = c
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
for id in ids {
tx.execute("DELETE FROM memories WHERE id = ?1", params![id])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
async fn set_distilled(&self, ids: &[String], at: i64) -> Result<(), MemoryError> {
if ids.is_empty() {
return Ok(());
}
let mut c = self.locked_conn();
let tx = c
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
for id in ids {
tx.execute(
"UPDATE memories SET distilled_at = ?2 WHERE id = ?1",
params![id, at],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
async fn update_embedding(
&self,
id: &str,
embedding: &[f32],
model_id: &str,
dim: usize,
) -> Result<(), MemoryError> {
let emb_json = serde_json::to_string(embedding)
.map_err(|e| MemoryError::Storage(format!("embedding serialization: {e}")))?;
let embedding_blob = self
.vault
.encrypt_with_key(&self.derived_key, &emb_json)
.map_err(|e| MemoryError::Crypto(e.to_string()))?;
let c = self.locked_conn();
let affected = c
.execute(
"UPDATE memories \
SET embedding_blob = ?2, model_id = ?3, dim = ?4 \
WHERE id = ?1",
params![id, embedding_blob, model_id, dim as i64],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if affected == 0 {
return Err(MemoryError::Storage(format!(
"update_embedding: id not found: {id}"
)));
}
Ok(())
}
async fn archived(&self) -> Result<Vec<Memory>, MemoryError> {
let raw_rows: Vec<RawRow> = {
let c = self.locked_conn();
let mut stmt = c
.prepare(&format!("{SELECT_COLS} WHERE evicted_at IS NOT NULL"))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let iter = stmt
.query_map([], row_from_query)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut collected = Vec::new();
for r in iter {
collected.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
collected
};
let mut out = Vec::with_capacity(raw_rows.len());
for row in raw_rows {
out.push(decode_row(row, &self.vault, &self.derived_key)?);
}
Ok(out)
}
async fn set_salience(&self, id: &str, salience: f64) -> Result<(), MemoryError> {
if !salience.is_finite() {
return Err(MemoryError::Config(format!(
"set_salience: non-finite value is not permitted (got {salience}); \
pass a finite value in [0.0, 1.0] or outside that range to clamp"
)));
}
let s = salience.clamp(0.0, 1.0);
let c = self.locked_conn();
c.execute(
"UPDATE memories SET salience = ?2 WHERE id = ?1",
params![id, s],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::messages::Message;
use crate::system::database::EncryptedSqliteMemory;
fn test_store() -> (tempfile::NamedTempFile, SqliteVectorStore) {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mem = EncryptedSqliteMemory::new(tmp.path().to_path_buf(), "pw".into()).unwrap();
let store = SqliteVectorStore::new(mem.shared_conn(), mem.data_key()).unwrap();
(tmp, store)
}
fn sample(id: &str, text: &str, emb: Vec<f32>) -> Memory {
Memory {
id: id.into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: text.into(),
embedding: emb.clone(),
model_id: "nomic-embed-text".into(),
dim: emb.len(),
created_at: 1000,
salience: 0.3,
access_count: 0,
last_accessed_at: 1000,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
}
}
fn raw_blob(tmp: &tempfile::NamedTempFile, col: &str) -> String {
let conn = rusqlite::Connection::open(tmp.path()).unwrap();
conn.query_row(&format!("SELECT {col} FROM memories LIMIT 1"), [], |r| {
r.get::<_, String>(0)
})
.unwrap()
}
#[tokio::test]
async fn test_persist_and_reload_roundtrips_vector_and_metadata() {
let (tmp, store) = test_store();
let m = sample("m1", "remember the api budget is 8000", vec![0.1, 0.2, 0.3]);
store.insert(&m).await.unwrap();
let got = store.get("m1").await.unwrap().unwrap();
assert_eq!(got, m);
assert_eq!(got.scope, "root"); assert!(!raw_blob(&tmp, "text_blob").contains("budget")); assert!(!raw_blob(&tmp, "embedding_blob").contains("0.1")); }
#[tokio::test]
async fn test_default_scope_is_root_and_active_filters_by_scope() {
let (_t, store) = test_store();
store.insert(&sample("a", "x", vec![0.0; 3])).await.unwrap();
assert_eq!(store.active("root").await.unwrap().len(), 1);
assert!(store.active("other").await.unwrap().is_empty());
}
#[tokio::test]
async fn test_mark_accessed_updates_only_named_ids() {
let (_t, store) = test_store();
store.insert(&sample("a", "a", vec![0.0; 3])).await.unwrap();
store.insert(&sample("b", "b", vec![0.0; 3])).await.unwrap();
store.mark_accessed(&["a".into()], 2000).await.unwrap();
assert_eq!(store.get("a").await.unwrap().unwrap().access_count, 1);
assert_eq!(
store.get("a").await.unwrap().unwrap().last_accessed_at,
2000
);
assert_eq!(store.get("b").await.unwrap().unwrap().access_count, 0);
}
#[tokio::test]
async fn test_access_count_storage_saturates_at_i64_max() {
let (_t, store) = test_store();
let mut m = sample("a", "x", vec![0.0; 3]);
m.access_count = i64::MAX as u64;
store.insert(&m).await.unwrap();
assert_eq!(
store.get("a").await.unwrap().unwrap().access_count,
i64::MAX as u64
);
}
#[tokio::test]
async fn test_migration_imports_prior_turns_without_loss_and_marks_for_lazy_embed() {
let (_t, store) = test_store();
let prior = vec![
("s1".to_string(), Message::user("old fact one")),
("s1".to_string(), Message::assistant("ack")),
];
let n = store
.migrate_from_messages(
&prior,
1000,
&crate::memory::config::MemoryConfig::default(),
)
.await
.unwrap();
assert_eq!(n, 2);
let active = store.active("root").await.unwrap();
assert_eq!(active.len(), 2);
assert!(active
.iter()
.all(|m| m.embedding.is_empty() && m.model_id.is_empty()));
assert!(active
.iter()
.all(|m| matches!(m.kind, crate::memory::MemoryKind::Episodic)));
assert!(active.iter().all(|m| m.scope == "root"));
}
#[tokio::test]
async fn test_migration_is_idempotent_across_reruns_and_batches() {
let (_t, store) = test_store();
let prior = vec![("s1".to_string(), Message::user("fact"))];
let cfg = crate::memory::config::MemoryConfig::default();
assert_eq!(
store
.migrate_from_messages(&prior, 1000, &cfg)
.await
.unwrap(),
1
);
assert_eq!(
store
.migrate_from_messages(&prior, 1000, &cfg)
.await
.unwrap(),
0
); assert_eq!(store.active("root").await.unwrap().len(), 1);
}
#[tokio::test]
async fn test_migration_is_atomic_on_failure() {
let (_t, store) = test_store();
let huge = "x".repeat(50 * 1024 * 1024 + 1);
let batch = vec![
("s1".to_string(), Message::user("ok")),
("s1".to_string(), Message::user(&huge)),
];
assert!(store
.migrate_from_messages(
&batch,
1000,
&crate::memory::config::MemoryConfig::default()
)
.await
.is_err());
assert!(
store.active("root").await.unwrap().is_empty(),
"rollback ⇒ nothing imported"
);
}
#[tokio::test]
async fn test_diagnostics_reports_active_archived_and_pending() {
let (_t, store) = test_store();
let m_active = sample(
"diag-active",
"active memory with vector",
vec![0.1, 0.2, 0.3],
);
store.insert(&m_active).await.unwrap();
let mut m_pending = sample("diag-pending", "pending re-embed memory", vec![]);
m_pending.model_id = String::new();
m_pending.dim = 0;
store.insert(&m_pending).await.unwrap();
let m_evict = sample("diag-evicted", "evicted memory", vec![0.4, 0.5]);
store.insert(&m_evict).await.unwrap();
store
.set_evicted("diag-evicted", Some(1_000_000))
.await
.unwrap();
let diag = store.diagnostics("root").await.unwrap();
assert_eq!(
diag.active_count, 2,
"two non-evicted records in root scope"
);
assert_eq!(diag.archived_count, 1, "one evicted record (any scope)");
assert_eq!(
diag.pending_reembed_count, 1,
"one active record without embedding"
);
assert!(
diag.ram_estimate_bytes > 0,
"active vector ⇒ positive RAM estimate (got 0)"
);
}
#[tokio::test]
async fn test_mark_accessed_saturates_at_i64_max() {
let (_t, store) = test_store();
let mut m = sample("sat", "overflow test", vec![0.0; 3]);
m.access_count = i64::MAX as u64;
store.insert(&m).await.unwrap();
store.mark_accessed(&["sat".into()], 9999).await.unwrap();
let got = store.get("sat").await.unwrap().unwrap();
assert_eq!(
got.access_count,
i64::MAX as u64,
"A1: access_count must stay at i64::MAX after mark_accessed (no overflow)"
);
}
#[tokio::test]
async fn test_update_embedding_on_missing_id_is_err() {
let (_t, store) = test_store();
let result = store
.update_embedding("no-such-id", &[0.1f32, 0.2], "model", 2)
.await;
assert!(
matches!(result, Err(MemoryError::Storage(_))),
"A2: update_embedding on missing id must be Err(Storage), got: {result:?}"
);
}
#[tokio::test]
async fn test_diagnostics_counts_are_usize_safe() {
let (_t, store) = test_store();
let d = store.diagnostics("root").await.unwrap();
assert_eq!(d.active_count, 0);
assert_eq!(d.archived_count, 0);
assert_eq!(d.pending_reembed_count, 0);
assert_eq!(d.ram_estimate_bytes, 0);
}
#[tokio::test]
async fn test_migration_imports_large_batch() {
let (_t, store) = test_store();
let prior: Vec<_> = (0..1000)
.map(|i| ("s1".to_string(), Message::user(&format!("fact {i}"))))
.collect();
assert_eq!(
store
.migrate_from_messages(
&prior,
1000,
&crate::memory::config::MemoryConfig::default()
)
.await
.unwrap(),
1000
);
assert_eq!(store.active("root").await.unwrap().len(), 1000);
}
#[tokio::test]
async fn test_hard_delete_multiple_ids_removes_all() {
let (_t, store) = test_store();
for i in 0..3u8 {
store
.insert(&sample(
&format!("del{i}"),
&format!("txt{i}"),
vec![0.0; 3],
))
.await
.unwrap();
}
store
.hard_delete(&["del0".into(), "del1".into(), "del2".into()])
.await
.unwrap();
assert!(
store.get("del0").await.unwrap().is_none(),
"del0 must be gone"
);
assert!(
store.get("del1").await.unwrap().is_none(),
"del1 must be gone"
);
assert!(
store.get("del2").await.unwrap().is_none(),
"del2 must be gone"
);
}
#[tokio::test]
async fn test_mark_accessed_batch_updates_all_ids() {
let (_t, store) = test_store();
for i in 0..3u8 {
store
.insert(&sample(&format!("acc{i}"), &format!("t{i}"), vec![0.0; 3]))
.await
.unwrap();
}
store
.mark_accessed(&["acc0".into(), "acc1".into(), "acc2".into()], 5000)
.await
.unwrap();
for i in 0..3u8 {
let m = store.get(&format!("acc{i}")).await.unwrap().unwrap();
assert_eq!(m.access_count, 1, "F2: acc{i} access_count must be 1");
assert_eq!(
m.last_accessed_at, 5000,
"F2: acc{i} last_accessed_at must be 5000"
);
}
}
#[tokio::test]
async fn test_set_distilled_batch_stamps_all_ids() {
let (_t, store) = test_store();
for i in 0..3u8 {
store
.insert(&sample(&format!("dst{i}"), &format!("t{i}"), vec![0.0; 3]))
.await
.unwrap();
}
store
.set_distilled(&["dst0".into(), "dst1".into(), "dst2".into()], 9999)
.await
.unwrap();
for i in 0..3u8 {
let m = store.get(&format!("dst{i}")).await.unwrap().unwrap();
assert_eq!(
m.distilled_at,
Some(9999),
"F2: dst{i} distilled_at must be 9999"
);
}
}
#[tokio::test]
async fn test_set_salience_nan_is_config_err() {
let (_t, store) = test_store();
store
.insert(&sample("sal-nan", "text", vec![0.0; 3]))
.await
.unwrap();
let result = store.set_salience("sal-nan", f64::NAN).await;
assert!(
matches!(result, Err(MemoryError::Config(_))),
"G3: set_salience(NaN) must return Err(Config), got: {result:?}"
);
}
#[tokio::test]
async fn test_set_salience_infinity_is_config_err() {
let (_t, store) = test_store();
store
.insert(&sample("sal-inf", "text", vec![0.0; 3]))
.await
.unwrap();
let result = store.set_salience("sal-inf", f64::INFINITY).await;
assert!(
matches!(result, Err(MemoryError::Config(_))),
"G3: set_salience(INFINITY) must return Err(Config), got: {result:?}"
);
}
#[tokio::test]
async fn test_set_salience_out_of_range_finite_is_clamped() {
let (_t, store) = test_store();
store
.insert(&sample("sal-clamp", "text", vec![0.0; 3]))
.await
.unwrap();
store.set_salience("sal-clamp", 1.5).await.unwrap();
let got = store.get("sal-clamp").await.unwrap().unwrap();
assert_eq!(
got.salience, 1.0,
"G3: out-of-range finite salience must be clamped to 1.0"
);
}
#[tokio::test]
async fn test_migration_uses_config_salience_for_marker_text() {
let (_t, store) = test_store();
let cfg = crate::memory::config::MemoryConfig {
salience_markers: vec!["important_marker".into()],
protect_salience_threshold: 0.9,
default_salience: 0.3,
..crate::memory::config::MemoryConfig::default()
};
let prior = vec![(
"s1".to_string(),
crate::agent::messages::Message::user("remember: important_marker fact"),
)];
let n = store
.migrate_from_messages(&prior, 1000, &cfg)
.await
.unwrap();
assert_eq!(n, 1, "one turn must be imported");
let active = store.active("root").await.unwrap();
assert_eq!(active.len(), 1);
assert!(
active[0].salience >= cfg.protect_salience_threshold,
"H1: migrated turn with marker must have salience >= {}, got {}",
cfg.protect_salience_threshold,
active[0].salience
);
}
#[tokio::test]
async fn test_migration_uses_config_default_salience_for_plain_text() {
let (_t, store) = test_store();
let cfg = crate::memory::config::MemoryConfig {
default_salience: 0.5,
salience_markers: vec!["important_marker".into()],
..crate::memory::config::MemoryConfig::default()
};
let prior = vec![(
"s1".to_string(),
crate::agent::messages::Message::user("just a plain turn"),
)];
store
.migrate_from_messages(&prior, 1000, &cfg)
.await
.unwrap();
let active = store.active("root").await.unwrap();
let expected = (cfg.default_salience + 0.05_f64).clamp(0.0, 1.0);
assert_eq!(
active[0].salience, expected,
"H1: plain turn salience must come from assign_salience, not hard-coded 0.3"
);
}
#[tokio::test]
async fn test_hard_delete_empty_slice_is_noop() {
let (_t, store) = test_store();
store
.insert(&sample("keep", "important", vec![0.0; 3]))
.await
.unwrap();
store.hard_delete(&[]).await.unwrap();
assert!(
store.get("keep").await.unwrap().is_some(),
"F2: empty hard_delete must not remove any rows"
);
}
#[tokio::test]
async fn test_insert_and_upsert_produce_equivalent_encrypted_rows() {
let (_t, store) = test_store();
let mut via_insert = sample("eq-insert", "encryption equivalence check", vec![0.1, 0.9]);
via_insert.access_count = 7;
store.insert(&via_insert).await.unwrap();
let mut via_upsert = via_insert.clone();
via_upsert.id = "eq-upsert".into();
store.upsert(&via_upsert).await.unwrap();
let got_insert = store.get("eq-insert").await.unwrap().unwrap();
let got_upsert = store.get("eq-upsert").await.unwrap().unwrap();
assert_eq!(got_insert.text, got_upsert.text, "text must be identical");
assert_eq!(
got_insert.embedding, got_upsert.embedding,
"embedding must be identical"
);
assert_eq!(
got_insert.salience, got_upsert.salience,
"salience must be identical"
);
assert_eq!(got_insert.kind, got_upsert.kind, "kind must be identical");
assert_eq!(
got_insert.access_count, got_upsert.access_count,
"access_count must be identical"
);
assert_eq!(
got_insert.scope, got_upsert.scope,
"scope must be identical"
);
}
}