use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use cel_memory::{
AccessEntry, AgingReport, CallerScope, ChunkKind, ChunkSource, EvictionEntry, EvictionReason,
ExportBundle, ExportFilter, MemoryChunk, MemoryError, MemoryPredicate, MemoryProvider,
MemoryQuery, MemorySession, MemoryStats, MemoryTier, NewMemoryChunk, NewMemorySession,
PurgeReport, ReEmbedReport, Result as MemoryResult, SessionFilter, SessionOutcome,
};
use chrono::{DateTime, NaiveDate, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::cache::RetrieveCache;
use crate::error::SqliteMemoryError;
use crate::migrations;
use cel_memory::Embedder;
const RETRIEVE_CACHE_CAPACITY: usize = 256;
const RETRIEVE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
pub struct SqliteMemoryProvider {
conn: Arc<Mutex<Connection>>,
embedder: Arc<dyn Embedder>,
write_hook: Option<Arc<dyn cel_memory::MemoryWriteHook>>,
summarizer: Option<Arc<dyn cel_memory::Summarizer>>,
retrieve_cache: Arc<RetrieveCache<Vec<MemoryChunk>>>,
}
impl SqliteMemoryProvider {
pub async fn open(
path: impl AsRef<Path>,
embedder: Arc<dyn Embedder>,
) -> Result<Self, SqliteMemoryError> {
let path = path.as_ref().to_path_buf();
crate::vec_extension::register();
let conn = tokio::task::spawn_blocking(move || -> Result<Connection, SqliteMemoryError> {
let mut c = Connection::open(&path)?;
c.pragma_update(None, "journal_mode", "WAL")?;
c.pragma_update(None, "synchronous", "NORMAL")?;
migrations::run(&mut c)?;
Ok(c)
})
.await
.map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
embedder,
write_hook: None,
summarizer: None,
retrieve_cache: Arc::new(RetrieveCache::new(
RETRIEVE_CACHE_CAPACITY,
RETRIEVE_CACHE_TTL,
)),
})
}
pub fn with_write_hook(mut self, hook: Arc<dyn cel_memory::MemoryWriteHook>) -> Self {
self.write_hook = Some(hook);
self
}
pub fn with_summarizer(mut self, summarizer: Arc<dyn cel_memory::Summarizer>) -> Self {
self.summarizer = Some(summarizer);
self
}
#[doc(hidden)]
pub fn conn_for_test(&self) -> Arc<tokio::sync::Mutex<rusqlite::Connection>> {
Arc::clone(&self.conn)
}
fn invalidate_retrieve_cache(&self) {
self.retrieve_cache.clear();
}
pub async fn open_in_memory(embedder: Arc<dyn Embedder>) -> Result<Self, SqliteMemoryError> {
crate::vec_extension::register();
let conn = tokio::task::spawn_blocking(|| -> Result<Connection, SqliteMemoryError> {
let mut c = Connection::open_in_memory()?;
migrations::run(&mut c)?;
Ok(c)
})
.await
.map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
embedder,
write_hook: None,
summarizer: None,
retrieve_cache: Arc::new(RetrieveCache::new(
RETRIEVE_CACHE_CAPACITY,
RETRIEVE_CACHE_TTL,
)),
})
}
async fn fetch_session_chunks(&self, session_id: &str) -> MemoryResult<Vec<MemoryChunk>> {
let conn = Arc::clone(&self.conn);
let sid = session_id.to_string();
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, created_at, kind, tier, source, session_id,
project_root, caller_id, content, metadata,
importance, pinned, shareable, superseded_by,
embedding_model, embedding_dim
FROM memory_chunks
WHERE session_id = ?
AND kind != 'job_summary'
ORDER BY created_at ASC",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![sid], row_to_chunk)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
Ok(out)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn fetch_day_chunks(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
let start = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
.and_utc()
.timestamp_millis();
let end = date
.succ_opt()
.ok_or_else(|| MemoryError::InvalidArgument(format!("date overflow: {date}")))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
.and_utc()
.timestamp_millis();
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, created_at, kind, tier, source, session_id,
project_root, caller_id, content, metadata,
importance, pinned, superseded_by,
embedding_model, embedding_dim
FROM memory_chunks
WHERE created_at >= ?
AND created_at < ?
AND kind != 'rollup'
ORDER BY created_at ASC",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![start, end], row_to_chunk)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
Ok(out)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn fetch_rule_week_chunks(
&self,
rule_id: &str,
week_start: NaiveDate,
) -> MemoryResult<Vec<MemoryChunk>> {
let start = week_start
.and_hms_opt(0, 0, 0)
.ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {week_start}")))?
.and_utc()
.timestamp_millis();
let end_date = week_start
.checked_add_days(chrono::Days::new(7))
.ok_or_else(|| MemoryError::InvalidArgument(format!("week overflow: {week_start}")))?;
let end = end_date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {end_date}")))?
.and_utc()
.timestamp_millis();
let conn = Arc::clone(&self.conn);
let rid = rule_id.to_string();
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, created_at, kind, tier, source, session_id,
project_root, caller_id, content, metadata,
importance, pinned, superseded_by,
embedding_model, embedding_dim
FROM memory_chunks
WHERE kind = 'fire'
AND created_at >= ?
AND created_at < ?
AND json_extract(metadata, '$.rule_id') = ?
ORDER BY created_at ASC",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![start, end, rid], row_to_chunk)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
Ok(out)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn day_rollup_exists(&self, date: NaiveDate) -> MemoryResult<bool> {
let date_s = date.to_string();
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
let guard = conn.blocking_lock();
let count: i64 = guard
.query_row(
"SELECT COUNT(*) FROM memory_chunks
WHERE kind = 'rollup'
AND json_extract(metadata, '$.rollup_date') = ?",
params![date_s],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(count > 0)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn rule_week_rollup_exists(
&self,
rule_id: &str,
week_start: NaiveDate,
) -> MemoryResult<bool> {
let week_s = week_start.to_string();
let rid = rule_id.to_string();
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
let guard = conn.blocking_lock();
let count: i64 = guard
.query_row(
"SELECT COUNT(*) FROM memory_chunks
WHERE kind = 'rollup'
AND json_extract(metadata, '$.rollup_rule_id') = ?
AND json_extract(metadata, '$.rollup_week_start') = ?",
params![rid, week_s],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(count > 0)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn link_rollup_members(
&self,
rollup_id: &str,
member_ids: &[String],
) -> MemoryResult<()> {
let rid = rollup_id.to_string();
let members: Vec<String> = member_ids.to_vec();
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
for mid in &members {
tx.execute(
"INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
VALUES (?, ?)",
params![rid, mid],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn rollup_day_inner(
&self,
date: NaiveDate,
force: bool,
) -> MemoryResult<Vec<MemoryChunk>> {
let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
"SqliteMemoryProvider::rollup_day — no summarizer attached \
(call `with_summarizer` first)",
))?;
if !force && self.day_rollup_exists(date).await? {
tracing::debug!(date = %date, "rollup_day: existing rollup found, skipping");
return Ok(Vec::new());
}
let members = self.fetch_day_chunks(date).await?;
if members.is_empty() {
tracing::debug!(date = %date, "rollup_day: no chunks to summarise, no-op");
return Ok(Vec::new());
}
let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
let kind_label = format!("day {date}");
let ctx = cel_memory::SummaryContext {
kind_label: Some(kind_label.clone()),
note: Some(format!(
"Daily rollup for {date} ({} chunks)",
members.len()
)),
max_words: None,
};
let summary_text = summarizer
.summarize(&members, &ctx)
.await
.map_err(|e| match e {
cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
"summarizer received no input despite day having chunks".into(),
),
other => MemoryError::Provider(format!(
"summarizer {} failed: {other}",
summarizer.name()
)),
})?;
let new_chunk = NewMemoryChunk {
kind: ChunkKind::Rollup,
source: ChunkSource::System,
session_id: None,
project_root: None,
caller_id: "system".to_string(),
content: summary_text,
metadata: serde_json::json!({
"rollup_kind": "day",
"rollup_date": date.to_string(),
"member_count": member_ids.len(),
"summarizer": summarizer.name(),
}),
importance: None,
shareable: false,
pinned: false,
};
let written = self.write(new_chunk).await?;
self.link_rollup_members(&written.id, &member_ids).await?;
Ok(vec![written])
}
async fn rollup_rule_week_inner(
&self,
rule_id: &str,
week_start: NaiveDate,
force: bool,
) -> MemoryResult<MemoryChunk> {
let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
"SqliteMemoryProvider::rollup_rule_week — no summarizer attached \
(call `with_summarizer` first)",
))?;
if !force && self.rule_week_rollup_exists(rule_id, week_start).await? {
return Err(MemoryError::InvalidArgument(format!(
"rollup already exists for rule {rule_id} week {week_start}"
)));
}
let members = self.fetch_rule_week_chunks(rule_id, week_start).await?;
if members.is_empty() {
return Err(MemoryError::NotFound(format!(
"no fires for rule {rule_id} in week of {week_start}"
)));
}
let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
let kind_label = format!("week of {week_start} for rule {rule_id}");
let ctx = cel_memory::SummaryContext {
kind_label: Some(kind_label.clone()),
note: Some(format!(
"Weekly rollup for rule {rule_id} ({} fires)",
members.len()
)),
max_words: None,
};
let summary_text = summarizer
.summarize(&members, &ctx)
.await
.map_err(|e| match e {
cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
"summarizer received no input despite rule-week having chunks".into(),
),
other => MemoryError::Provider(format!(
"summarizer {} failed: {other}",
summarizer.name()
)),
})?;
let new_chunk = NewMemoryChunk {
kind: ChunkKind::Rollup,
source: ChunkSource::System,
session_id: None,
project_root: None,
caller_id: "system".to_string(),
content: summary_text,
metadata: serde_json::json!({
"rollup_kind": "rule_week",
"rollup_rule_id": rule_id,
"rollup_week_start": week_start.to_string(),
"member_count": member_ids.len(),
"summarizer": summarizer.name(),
}),
importance: None,
shareable: false,
pinned: false,
};
let written = self.write(new_chunk).await?;
self.link_rollup_members(&written.id, &member_ids).await?;
Ok(written)
}
}
fn now_ms() -> i64 {
Utc::now().timestamp_millis()
}
fn cache_key_for_query(query: &MemoryQuery) -> Option<u64> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let s = serde_json::to_string(query).ok()?;
let mut h = DefaultHasher::new();
s.hash(&mut h);
Some(h.finish())
}
fn dt_to_ms(t: DateTime<Utc>) -> i64 {
t.timestamp_millis()
}
fn ms_to_dt(ms: i64) -> DateTime<Utc> {
chrono::DateTime::<Utc>::from_timestamp_millis(ms)
.unwrap_or_else(|| DateTime::<Utc>::from_timestamp_millis(0).unwrap())
}
fn kind_str(k: ChunkKind) -> &'static str {
match k {
ChunkKind::Chat => "chat",
ChunkKind::Action => "action",
ChunkKind::Fire => "fire",
ChunkKind::Observation => "observation",
ChunkKind::Correction => "correction",
ChunkKind::JobSummary => "job_summary",
ChunkKind::Context => "context",
ChunkKind::Rollup => "rollup",
}
}
fn str_to_kind(s: &str) -> Result<ChunkKind, MemoryError> {
Ok(match s {
"chat" => ChunkKind::Chat,
"action" => ChunkKind::Action,
"fire" => ChunkKind::Fire,
"observation" => ChunkKind::Observation,
"correction" => ChunkKind::Correction,
"job_summary" => ChunkKind::JobSummary,
"context" => ChunkKind::Context,
"rollup" => ChunkKind::Rollup,
other => return Err(MemoryError::Storage(format!("unknown kind: {other}"))),
})
}
fn source_str(s: ChunkSource) -> &'static str {
match s {
ChunkSource::Embedded => "embedded",
ChunkSource::Mcp => "mcp",
ChunkSource::Gateway => "gateway",
ChunkSource::Matcher => "matcher",
ChunkSource::Perception => "perception",
ChunkSource::System => "system",
}
}
const LEGACY_PERCEPTION_SOURCE: &str = concat!("cor", "tex");
fn str_to_source(s: &str) -> Result<ChunkSource, MemoryError> {
Ok(match s {
"embedded" => ChunkSource::Embedded,
"mcp" => ChunkSource::Mcp,
"gateway" => ChunkSource::Gateway,
"matcher" => ChunkSource::Matcher,
"perception" | LEGACY_PERCEPTION_SOURCE => ChunkSource::Perception,
"system" => ChunkSource::System,
other => return Err(MemoryError::Storage(format!("unknown source: {other}"))),
})
}
fn tier_str(t: MemoryTier) -> &'static str {
match t {
MemoryTier::Session => "session",
MemoryTier::LongTerm => "long_term",
}
}
fn str_to_tier(s: &str) -> Result<MemoryTier, MemoryError> {
Ok(match s {
"session" => MemoryTier::Session,
"long_term" => MemoryTier::LongTerm,
other => return Err(MemoryError::Storage(format!("unknown tier: {other}"))),
})
}
fn outcome_str(o: SessionOutcome) -> &'static str {
match o {
SessionOutcome::Open => "open",
SessionOutcome::Success => "success",
SessionOutcome::Failure => "failure",
SessionOutcome::Aborted => "aborted",
}
}
fn str_to_outcome(s: &str) -> Result<SessionOutcome, MemoryError> {
Ok(match s {
"open" => SessionOutcome::Open,
"success" => SessionOutcome::Success,
"failure" => SessionOutcome::Failure,
"aborted" => SessionOutcome::Aborted,
other => return Err(MemoryError::Storage(format!("unknown outcome: {other}"))),
})
}
fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryChunk> {
let metadata_str: String = row.get("metadata")?;
let metadata: serde_json::Value =
serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
let shareable = row.get::<_, i64>("shareable").unwrap_or(0) != 0;
Ok(MemoryChunk {
id: row.get("id")?,
created_at: ms_to_dt(row.get::<_, i64>("created_at")?),
kind: str_to_kind(&row.get::<_, String>("kind")?).unwrap_or(ChunkKind::Chat),
tier: str_to_tier(&row.get::<_, String>("tier")?).unwrap_or(MemoryTier::Session),
source: str_to_source(&row.get::<_, String>("source")?).unwrap_or(ChunkSource::System),
session_id: row.get("session_id")?,
project_root: row.get("project_root")?,
caller_id: row.get("caller_id")?,
content: row.get("content")?,
metadata,
importance: row.get::<_, f64>("importance")? as f32,
pinned: row.get::<_, i64>("pinned")? != 0,
shareable,
superseded_by: row.get("superseded_by")?,
embedding_model: row.get("embedding_model")?,
embedding_dim: row.get::<_, i64>("embedding_dim")? as u32,
})
}
#[async_trait]
impl MemoryProvider for SqliteMemoryProvider {
async fn retrieve(&self, query: MemoryQuery) -> MemoryResult<Vec<MemoryChunk>> {
if query.text.trim().is_empty() {
return Err(MemoryError::InvalidArgument(
"query.text must not be empty".into(),
));
}
let cache_key = cache_key_for_query(&query);
if let Some(key) = cache_key {
if let Some(hit) = self.retrieve_cache.get(key) {
return Ok(hit);
}
}
let k = query.k.max(1);
let candidate_k = (3 * k).max(16);
let (w_vec, w_fts, w_rec, half_life_secs) = retrieval_weights(query.profile);
let q_embedding = self.embedder.embed(&query.text).await?;
let q_text = query.text.clone();
let conn = Arc::clone(&self.conn);
let compute =
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
let guard = conn.blocking_lock();
let v_json = serde_json::to_string(&q_embedding)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let vec_ids: Vec<String> = {
let sql = "SELECT v.chunk_id FROM memory_vec v
WHERE v.embedding MATCH ?
AND k = ?
ORDER BY distance";
let mut stmt = guard
.prepare(sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![v_json, candidate_k as i64], |r| {
r.get::<_, String>(0)
})
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
};
let fts_ids: Vec<String> = {
let sql = "SELECT chunk_id FROM memory_fts
WHERE memory_fts MATCH ?
ORDER BY rank
LIMIT ?";
let mut stmt = guard
.prepare(sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(
params![fts_query_escape(&q_text), candidate_k as i64],
|r| r.get::<_, String>(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
match r {
Ok(id) => out.push(id),
Err(_) => break,
}
}
out
};
let recency_ids: Vec<String> = {
let sql = "SELECT id FROM memory_chunks
ORDER BY created_at DESC LIMIT ?";
let mut stmt = guard
.prepare(sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
};
const RRF_K: f32 = 60.0;
let mut scores: std::collections::HashMap<String, f32> =
std::collections::HashMap::new();
for (rank, id) in vec_ids.iter().enumerate() {
*scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
}
for (rank, id) in fts_ids.iter().enumerate() {
*scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
}
let now_ms_val = now_ms();
let recency_rows: std::collections::HashMap<String, i64> = {
if recency_ids.is_empty() {
Default::default()
} else {
let placeholders = vec!["?"; recency_ids.len()].join(",");
let sql = format!(
"SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
);
let p: Vec<&dyn rusqlite::ToSql> = recency_ids
.iter()
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let mut stmt = guard
.prepare(&sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(p.as_slice(), |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
})
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut map = std::collections::HashMap::new();
for r in rows {
let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
map.insert(id, t);
}
map
}
};
for (id, created_ms) in &recency_rows {
let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
let recency = (-dt_secs / half_life_secs).exp();
*scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
}
let mut id_set: std::collections::HashSet<String> =
std::collections::HashSet::new();
id_set.extend(vec_ids);
id_set.extend(fts_ids);
id_set.extend(recency_ids);
if id_set.is_empty() {
return Ok(Vec::new());
}
let placeholders = vec!["?"; id_set.len()].join(",");
let select_sql = format!(
"SELECT id, created_at, kind, tier, source, session_id,
project_root, caller_id, content, metadata,
importance, pinned, shareable, superseded_by,
embedding_model, embedding_dim
FROM memory_chunks WHERE id IN ({placeholders})"
);
let ids_vec: Vec<String> = id_set.into_iter().collect();
let candidates: Vec<MemoryChunk> = {
let p: Vec<&dyn rusqlite::ToSql> =
ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
let mut stmt = guard
.prepare(&select_sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(p.as_slice(), row_to_chunk)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
};
let mut filtered: Vec<MemoryChunk> = candidates
.into_iter()
.filter(|c| chunk_matches_query(c, &query))
.collect();
filtered.sort_by(|a, b| {
let sa = scores.get(&a.id).copied().unwrap_or(0.0);
let sb = scores.get(&b.id).copied().unwrap_or(0.0);
sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
});
filtered.truncate(k);
Ok(filtered)
});
let result = compute
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
if let Some(key) = cache_key {
self.retrieve_cache.insert(key, result.clone());
}
Ok(result)
}
async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
let conn = Arc::clone(&self.conn);
let chunk_id = chunk_id.to_string();
let res: Result<Option<MemoryChunk>, MemoryError> =
tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, created_at, kind, tier, source, session_id,
project_root, caller_id, content, metadata,
importance, pinned, shareable, superseded_by,
embedding_model, embedding_dim
FROM memory_chunks WHERE id = ?",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let row = stmt
.query_row(params![chunk_id], row_to_chunk)
.optional()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(row)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
res
}
async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
let conn = Arc::clone(&self.conn);
let session_id = session_id.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, started_at, ended_at, caller_id, title, summary,
outcome, metadata
FROM memory_sessions WHERE id = ?",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let row = stmt
.query_row(params![session_id], |r| {
let metadata_str: String = r.get("metadata")?;
let metadata: serde_json::Value =
serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
Ok(MemorySession {
id: r.get("id")?,
started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
caller_id: r.get("caller_id")?,
title: r.get("title")?,
summary: r.get("summary")?,
outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
.unwrap_or(SessionOutcome::Aborted),
metadata,
})
})
.optional()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(row)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
let guard = conn.blocking_lock();
let mut sql = String::from(
"SELECT id, started_at, ended_at, caller_id, title, summary,
outcome, metadata
FROM memory_sessions WHERE 1=1",
);
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(c) = &filter.caller_id {
sql.push_str(" AND caller_id = ?");
params_vec.push(Box::new(c.clone()));
}
if let Some(o) = filter.outcome {
sql.push_str(" AND outcome = ?");
params_vec.push(Box::new(outcome_str(o).to_string()));
} else if filter.open_only {
sql.push_str(" AND outcome = ?");
params_vec.push(Box::new("open".to_string()));
}
if let Some(since) = filter.since {
sql.push_str(" AND started_at >= ?");
params_vec.push(Box::new(dt_to_ms(since)));
}
if let Some(until) = filter.until {
sql.push_str(" AND started_at <= ?");
params_vec.push(Box::new(dt_to_ms(until)));
}
sql.push_str(" ORDER BY started_at DESC");
if let Some(n) = filter.limit {
sql.push_str(" LIMIT ?");
params_vec.push(Box::new(n as i64));
}
let mut stmt = guard
.prepare(&sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
let rows = stmt
.query_map(p.as_slice(), |r| {
let metadata_str: String = r.get("metadata")?;
let metadata: serde_json::Value =
serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
Ok(MemorySession {
id: r.get("id")?,
started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
caller_id: r.get("caller_id")?,
title: r.get("title")?,
summary: r.get("summary")?,
outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
.unwrap_or(SessionOutcome::Aborted),
metadata,
})
})
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
Ok(out)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
if new_chunk.content.trim().is_empty() {
return Err(MemoryError::InvalidArgument(
"content must not be empty".into(),
));
}
self.invalidate_retrieve_cache();
if let Some(hook) = &self.write_hook {
match hook.before_write(&new_chunk).await? {
cel_memory::WriteDecision::Allow => {}
cel_memory::WriteDecision::Redact { reason } => {
return Ok(MemoryChunk {
id: Uuid::now_v7().to_string(),
created_at: Utc::now(),
kind: new_chunk.kind,
tier: MemoryTier::Session,
source: new_chunk.source,
session_id: new_chunk.session_id,
project_root: new_chunk.project_root,
caller_id: new_chunk.caller_id,
content: format!("<redacted: {reason}>"),
metadata: serde_json::json!({"redacted": true, "reason": reason}),
importance: 0.0,
pinned: false,
shareable: false,
superseded_by: None,
embedding_model: "none".into(),
embedding_dim: 0,
});
}
}
}
let id = Uuid::now_v7().to_string();
let created_at_ms = now_ms();
let importance = cel_memory::importance::score(&new_chunk);
let embedder_dim = self.embedder.dim();
let embedder_name = self.embedder.model_name().to_string();
let embedding = self.embedder.embed(&new_chunk.content).await?;
if embedding.len() != embedder_dim {
return Err(MemoryError::Internal(format!(
"embedder produced dim {}, declared {}",
embedding.len(),
embedder_dim
)));
}
let chunk = MemoryChunk {
id: id.clone(),
created_at: ms_to_dt(created_at_ms),
kind: new_chunk.kind,
tier: MemoryTier::Session,
source: new_chunk.source,
session_id: new_chunk.session_id.clone(),
project_root: new_chunk.project_root.clone(),
caller_id: new_chunk.caller_id.clone(),
content: new_chunk.content.clone(),
metadata: new_chunk.metadata.clone(),
importance,
pinned: new_chunk.pinned,
shareable: new_chunk.shareable,
superseded_by: None,
embedding_model: embedder_name.clone(),
embedding_dim: embedder_dim as u32,
};
let conn = Arc::clone(&self.conn);
let chunk_for_blocking = chunk.clone();
let embedding_clone = embedding.clone();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"INSERT INTO memory_chunks(
id, created_at, kind, tier, source, session_id, project_root,
caller_id, content, metadata, importance, pinned, shareable,
superseded_by, embedding_model, embedding_dim
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
params![
chunk_for_blocking.id,
created_at_ms,
kind_str(chunk_for_blocking.kind),
tier_str(chunk_for_blocking.tier),
source_str(chunk_for_blocking.source),
chunk_for_blocking.session_id,
chunk_for_blocking.project_root,
chunk_for_blocking.caller_id,
chunk_for_blocking.content,
serde_json::to_string(&chunk_for_blocking.metadata)
.unwrap_or_else(|_| "null".into()),
chunk_for_blocking.importance as f64,
if chunk_for_blocking.pinned { 1 } else { 0 },
if new_chunk.shareable { 1 } else { 0 },
Option::<String>::None,
chunk_for_blocking.embedding_model,
chunk_for_blocking.embedding_dim as i64,
],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let v_json = serde_json::to_string(&embedding_clone)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
params![chunk_for_blocking.id, v_json],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
Ok(chunk)
}
async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
if chunks.is_empty() {
return Ok(Vec::new());
}
if chunks.len() == 1 {
let nc = chunks.into_iter().next().expect("len == 1");
return Ok(vec![self.write(nc).await?]);
}
self.invalidate_retrieve_cache();
if self.write_hook.is_some() {
let mut out = Vec::with_capacity(chunks.len());
for nc in chunks {
out.push(self.write(nc).await?);
}
return Ok(out);
}
for (i, nc) in chunks.iter().enumerate() {
if nc.content.trim().is_empty() {
return Err(MemoryError::InvalidArgument(format!(
"chunks[{i}].content must not be empty"
)));
}
}
let embedder_dim = self.embedder.dim();
let embedder_name = self.embedder.model_name().to_string();
let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
let embeddings = self.embedder.embed_batch(&texts).await?;
if embeddings.len() != chunks.len() {
return Err(MemoryError::Internal(format!(
"embedder returned {} vectors for {} inputs",
embeddings.len(),
chunks.len()
)));
}
for (i, v) in embeddings.iter().enumerate() {
if v.len() != embedder_dim {
return Err(MemoryError::Internal(format!(
"embedder produced dim {} for chunk {i}, declared {}",
v.len(),
embedder_dim
)));
}
}
let now_ms_val = now_ms();
let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
Vec::with_capacity(chunks.len());
for (nc, embedding) in chunks.into_iter().zip(embeddings) {
let id = Uuid::now_v7().to_string();
let importance = cel_memory::importance::score(&nc);
let metadata_json =
serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
let mc = MemoryChunk {
id,
created_at: ms_to_dt(now_ms_val),
kind: nc.kind,
tier: MemoryTier::Session,
source: nc.source,
session_id: nc.session_id,
project_root: nc.project_root,
caller_id: nc.caller_id,
content: nc.content,
metadata: nc.metadata,
importance,
pinned: nc.pinned,
shareable: nc.shareable,
superseded_by: None,
embedding_model: embedder_name.clone(),
embedding_dim: embedder_dim as u32,
};
owned.push((mc, embedding, nc.shareable, metadata_json));
}
let conn = Arc::clone(&self.conn);
let inserted =
tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::with_capacity(owned.len());
for (mc, embedding, shareable, metadata_json) in &owned {
tx.execute(
"INSERT INTO memory_chunks(
id, created_at, kind, tier, source, session_id, project_root,
caller_id, content, metadata, importance, pinned, shareable,
superseded_by, embedding_model, embedding_dim
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
params![
mc.id,
now_ms_val,
kind_str(mc.kind),
tier_str(mc.tier),
source_str(mc.source),
mc.session_id,
mc.project_root,
mc.caller_id,
mc.content,
metadata_json,
mc.importance as f64,
if mc.pinned { 1 } else { 0 },
if *shareable { 1 } else { 0 },
Option::<String>::None,
mc.embedding_model,
mc.embedding_dim as i64,
],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let v_json = serde_json::to_string(embedding)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
params![mc.id, v_json],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
out.push(mc.clone());
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(out)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
Ok(inserted)
}
async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
let session = MemorySession {
id: Uuid::now_v7().to_string(),
started_at: Utc::now(),
ended_at: None,
caller_id: init.caller_id.clone(),
title: init.title.clone(),
summary: None,
outcome: SessionOutcome::Open,
metadata: init.metadata.clone(),
};
let conn = Arc::clone(&self.conn);
let s = session.clone();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO memory_sessions(
id, started_at, ended_at, caller_id, title, summary,
outcome, metadata
) VALUES (?,?,?,?,?,?,?,?)",
params![
s.id,
dt_to_ms(s.started_at),
Option::<i64>::None,
s.caller_id,
s.title,
Option::<String>::None,
outcome_str(s.outcome),
serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
Ok(session)
}
async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
let resolved = match outcome {
SessionOutcome::Open => SessionOutcome::Aborted,
other => other,
};
let conn = Arc::clone(&self.conn);
let sid = session_id.to_string();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
let n = guard
.execute(
"UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
params![now_ms(), outcome_str(resolved), sid],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if n == 0 {
return Err(MemoryError::NotFound(format!("session {sid}")));
}
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
let conn = Arc::clone(&self.conn);
let sid = session_id.to_string();
let new_title = title.to_string();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
let n = guard
.execute(
"UPDATE memory_sessions SET title = ? WHERE id = ?",
params![new_title, sid],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if n == 0 {
return Err(MemoryError::NotFound(format!("session {sid}")));
}
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
self.invalidate_retrieve_cache();
let conn = Arc::clone(&self.conn);
let id = chunk_id.to_string();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
let n = guard
.execute(
"UPDATE memory_chunks SET pinned = ? WHERE id = ?",
params![if pinned { 1 } else { 0 }, id],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if n == 0 {
return Err(MemoryError::NotFound(format!("chunk {id}")));
}
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
self.invalidate_retrieve_cache();
let conn = Arc::clone(&self.conn);
let id = chunk_id.to_string();
let clamped = importance.clamp(0.0, 1.0) as f64;
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
let n = guard
.execute(
"UPDATE memory_chunks SET importance = ? WHERE id = ?",
params![clamped, id],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if n == 0 {
return Err(MemoryError::NotFound(format!("chunk {id}")));
}
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
self.invalidate_retrieve_cache();
let conn = Arc::clone(&self.conn);
let old = old_id.to_string();
let new = new_id.to_string();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
let new_exists: i64 = guard
.query_row(
"SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
params![new],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if new_exists == 0 {
return Err(MemoryError::NotFound(format!("new chunk {new}")));
}
let n = guard
.execute(
"UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
params![new, old],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if n == 0 {
return Err(MemoryError::NotFound(format!("old chunk {old}")));
}
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn record_access(
&self,
chunk_id: &str,
retrieved_by: &str,
used: bool,
) -> MemoryResult<()> {
let conn = Arc::clone(&self.conn);
let id = chunk_id.to_string();
let by = retrieved_by.to_string();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
let exists: i64 = guard
.query_row(
"SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
params![id],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if exists == 0 {
return Err(MemoryError::NotFound(format!("chunk {id}")));
}
guard
.execute(
"INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
VALUES (?, ?, ?, '', 0, ?)",
params![now_ms(), id, by, if used { 1 } else { 0 }],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
self.invalidate_retrieve_cache();
let conn = Arc::clone(&self.conn);
let id = chunk_id.to_string();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let n = tx
.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
if n == 0 {
return Err(MemoryError::NotFound(format!("chunk {id}")));
}
tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
VALUES (?,?,?, '{}')",
params![now_ms(), id, eviction_reason_str(reason)],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn delete_matching(
&self,
predicate: MemoryPredicate,
reason: EvictionReason,
) -> MemoryResult<usize> {
if predicate.is_empty() {
return Ok(0);
}
self.invalidate_retrieve_cache();
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
let (where_clause, params_vec) = build_chunk_predicate(&predicate);
let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let ids: Vec<String> = {
let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
let mut stmt = tx
.prepare(&select_sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(p.as_slice(), |r| r.get::<_, String>(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
};
let now = now_ms();
let reason_s = eviction_reason_str(reason);
for id in &ids {
tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
VALUES (?, ?, ?, '{}')",
params![now, id, reason_s],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(ids.len())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn purge_all(&self) -> MemoryResult<PurgeReport> {
self.invalidate_retrieve_cache();
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let chunks: i64 = tx
.query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let sessions: i64 = tx
.query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let access: i64 = tx
.query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let evictions: i64 = tx
.query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_chunks", [])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_vec", [])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_sessions", [])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_access_log", [])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_eviction_log", [])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_summary_members", [])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(PurgeReport {
chunks_deleted: chunks as usize,
sessions_deleted: sessions as usize,
access_log_deleted: access as usize,
eviction_log_deleted: evictions as usize,
})
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
"SqliteMemoryProvider::summarize_session — no summarizer attached \
(call `with_summarizer` first)",
))?;
let session = self
.get_session(session_id)
.await?
.ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
let members = self.fetch_session_chunks(session_id).await?;
if members.is_empty() {
return Err(MemoryError::InvalidArgument(format!(
"session {session_id} has no chunks to summarize"
)));
}
let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
let ctx = cel_memory::SummaryContext {
kind_label: Some("session".into()),
note: session
.title
.as_ref()
.map(|t| format!("session title: {t}")),
max_words: None,
};
let summary_text = summarizer
.summarize(&members, &ctx)
.await
.map_err(|e| match e {
cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
"summarizer received no input despite session having chunks".into(),
),
other => MemoryError::Provider(format!(
"summarizer {} failed: {other}",
summarizer.name()
)),
})?;
let new_chunk = NewMemoryChunk {
kind: ChunkKind::JobSummary,
source: ChunkSource::Embedded,
session_id: Some(session_id.to_string()),
project_root: members.iter().find_map(|c| c.project_root.clone()),
caller_id: session.caller_id.clone(),
content: summary_text,
metadata: serde_json::json!({
"session_id": session_id,
"member_count": member_ids.len(),
"summarizer": summarizer.name(),
}),
importance: None,
shareable: false,
pinned: false,
};
let written = self.write(new_chunk).await?;
let conn = Arc::clone(&self.conn);
let summary_id = written.id.clone();
let member_ids_for_insert = member_ids.clone();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
for mid in &member_ids_for_insert {
tx.execute(
"INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
VALUES (?, ?)",
params![summary_id, mid],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
let conn = Arc::clone(&self.conn);
let sid = session_id.to_string();
let stored_summary = written.content.clone();
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
let guard = conn.blocking_lock();
guard
.execute(
"UPDATE memory_sessions SET summary = ? WHERE id = ?",
params![stored_summary, sid],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(())
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
Ok(written)
}
async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
self.rollup_day_inner(date, false).await
}
async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
self.rollup_day_inner(date, true).await
}
async fn rollup_rule_week(
&self,
rule_id: &str,
week_start: NaiveDate,
) -> MemoryResult<MemoryChunk> {
self.rollup_rule_week_inner(rule_id, week_start, false)
.await
}
async fn rollup_rule_week_forced(
&self,
rule_id: &str,
week_start: NaiveDate,
) -> MemoryResult<MemoryChunk> {
self.rollup_rule_week_inner(rule_id, week_start, true).await
}
async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
const RETENTION_DAYS: i64 = 30;
let cutoff_ms =
(chrono::Utc::now() - chrono::Duration::days(RETENTION_DAYS)).timestamp_millis();
self.invalidate_retrieve_cache();
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<AgingReport, MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let ids: Vec<String> = {
let mut stmt = tx
.prepare(
"SELECT id FROM memory_chunks
WHERE pinned = 0
AND kind != 'correction'
AND created_at < ?",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![cutoff_ms], |r| r.get::<_, String>(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
};
let now = now_ms();
let reason_s = eviction_reason_str(EvictionReason::Aging);
for id in &ids {
tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
VALUES (?, ?, ?, '{}')",
params![now, id, reason_s],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let deleted = ids.len();
Ok(AgingReport {
tier_promoted: 0, deleted,
bytes_reclaimed: 0,
deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
})
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn re_embed_all(&self, target_model: &str) -> MemoryResult<ReEmbedReport> {
let started = std::time::Instant::now();
if self.embedder.model_name() != target_model {
return Err(MemoryError::InvalidArgument(format!(
"configured embedder is '{}', re_embed_all target_model is '{}'",
self.embedder.model_name(),
target_model
)));
}
self.invalidate_retrieve_cache();
let embedder = Arc::clone(&self.embedder);
let embedder_dim = embedder.dim();
let embedder_name = embedder.model_name().to_string();
let conn = Arc::clone(&self.conn);
let rows: Vec<(String, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String)>, MemoryError> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, content FROM memory_chunks
WHERE embedding_model != 'none'
ORDER BY created_at ASC",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mapped = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for row in mapped {
out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
Ok(out)
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
let total = rows.len();
if total == 0 {
return Ok(ReEmbedReport {
total: 0,
succeeded: 0,
failed: 0,
elapsed_ms: started.elapsed().as_millis() as u64,
});
}
let texts: Vec<String> = rows.iter().map(|(_, content)| content.clone()).collect();
let embeddings = embedder.embed_batch(&texts).await?;
if embeddings.len() != total {
return Err(MemoryError::Internal(format!(
"embedder returned {} vectors for {} inputs",
embeddings.len(),
total
)));
}
let conn = Arc::clone(&self.conn);
let (succeeded, failed) =
tokio::task::spawn_blocking(move || -> Result<(usize, usize), MemoryError> {
let mut guard = conn.blocking_lock();
let tx = guard
.transaction()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut succeeded = 0usize;
let mut failed = 0usize;
for (i, (id, _)) in rows.iter().enumerate() {
let embedding = &embeddings[i];
if embedding.len() != embedder_dim {
failed += 1;
continue;
}
let v_json = serde_json::to_string(embedding)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"UPDATE memory_chunks SET embedding_model = ?, embedding_dim = ? WHERE id = ?",
params![embedder_name, embedder_dim as i64, id],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
tx.execute(
"UPDATE memory_vec SET embedding = ? WHERE chunk_id = ?",
params![v_json, id],
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
succeeded += 1;
}
tx.commit()
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok((succeeded, failed))
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
Ok(ReEmbedReport {
total,
succeeded,
failed,
elapsed_ms: started.elapsed().as_millis() as u64,
})
}
async fn export(&self, filter: ExportFilter) -> MemoryResult<ExportBundle> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || -> Result<ExportBundle, MemoryError> {
let guard = conn.blocking_lock();
let (where_clause, params_vec) = match &filter.predicate {
Some(p) if !p.is_empty() => build_chunk_predicate(p),
_ => ("1=1".to_string(), Vec::new()),
};
let select_sql = format!(
"SELECT id, created_at, kind, tier, source, session_id,
project_root, caller_id, content, metadata,
importance, pinned, shareable, superseded_by,
embedding_model, embedding_dim
FROM memory_chunks WHERE {} ORDER BY created_at DESC",
where_clause
);
let chunks: Vec<MemoryChunk> = {
let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
let mut stmt = guard
.prepare(&select_sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(p.as_slice(), row_to_chunk)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
};
let sessions = if filter.include_sessions {
let session_ids: std::collections::HashSet<String> =
chunks.iter().filter_map(|c| c.session_id.clone()).collect();
if session_ids.is_empty() {
Vec::new()
} else {
let placeholders = vec!["?"; session_ids.len()].join(",");
let sql = format!(
"SELECT id, started_at, ended_at, caller_id, title, summary,
outcome, metadata
FROM memory_sessions WHERE id IN ({placeholders})"
);
let ids: Vec<String> = session_ids.into_iter().collect();
let p: Vec<&dyn rusqlite::ToSql> =
ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
let mut stmt = guard
.prepare(&sql)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map(p.as_slice(), |r| {
let metadata_str: String = r.get("metadata")?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
.unwrap_or(serde_json::Value::Null);
Ok(MemorySession {
id: r.get("id")?,
started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
caller_id: r.get("caller_id")?,
title: r.get("title")?,
summary: r.get("summary")?,
outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
.unwrap_or(SessionOutcome::Aborted),
metadata,
})
})
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
}
} else {
Vec::new()
};
let evictions = if filter.include_eviction_log {
let mut stmt = guard
.prepare(
"SELECT ts, chunk_id, reason, metadata
FROM memory_eviction_log ORDER BY ts DESC",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map([], |r| {
let reason_s: String = r.get("reason")?;
let metadata_s: String = r.get("metadata")?;
let metadata: serde_json::Value =
serde_json::from_str(&metadata_s).unwrap_or(serde_json::Value::Null);
Ok(EvictionEntry {
ts: ms_to_dt(r.get::<_, i64>("ts")?),
chunk_id: r.get("chunk_id")?,
reason: str_to_eviction_reason(&reason_s)
.unwrap_or(EvictionReason::UserDelete),
metadata,
})
})
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
} else {
Vec::new()
};
let accesses = if filter.include_access_log {
let mut stmt = guard
.prepare(
"SELECT ts, chunk_id, retrieved_by, query_hash, rank, used
FROM memory_access_log ORDER BY ts DESC",
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let rows = stmt
.query_map([], |r| {
Ok(AccessEntry {
ts: ms_to_dt(r.get::<_, i64>("ts")?),
chunk_id: r.get("chunk_id")?,
retrieved_by: r.get("retrieved_by")?,
query_hash: r.get("query_hash")?,
rank: r.get::<_, i64>("rank")? as usize,
used: r.get::<_, i64>("used")? != 0,
})
})
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
}
out
} else {
Vec::new()
};
Ok(ExportBundle {
chunks,
sessions,
evictions,
accesses,
})
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
async fn stats(&self) -> MemoryResult<MemoryStats> {
let conn = Arc::clone(&self.conn);
let model = self.embedder.model_name().to_string();
tokio::task::spawn_blocking(move || -> Result<MemoryStats, MemoryError> {
let guard = conn.blocking_lock();
let total: i64 = guard
.query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let session_tier: i64 = guard
.query_row(
"SELECT COUNT(*) FROM memory_chunks WHERE tier = 'session'",
[],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let lt_tier: i64 = guard
.query_row(
"SELECT COUNT(*) FROM memory_chunks WHERE tier = 'long_term'",
[],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let total_sessions: i64 = guard
.query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
.map_err(|e| MemoryError::Storage(e.to_string()))?;
let open: i64 = guard
.query_row(
"SELECT COUNT(*) FROM memory_sessions WHERE outcome = 'open'",
[],
|r| r.get(0),
)
.map_err(|e| MemoryError::Storage(e.to_string()))?;
Ok(MemoryStats {
total_chunks: total as usize,
session_chunks: session_tier as usize,
long_term_chunks: lt_tier as usize,
total_sessions: total_sessions as usize,
open_sessions: open as usize,
db_bytes: 0, embedding_model: Some(model),
})
})
.await
.map_err(|e| MemoryError::Internal(format!("join: {e}")))?
}
}
fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
use cel_memory::RetrievalProfile::*;
match profile {
AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
}
}
fn fts_query_escape(raw: &str) -> String {
let escaped = raw.replace('"', "\"\"");
format!("\"{escaped}\"")
}
fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
if let Some(kinds) = &q.kinds {
if !kinds.contains(&c.kind) {
return false;
}
}
if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
return false;
}
if let Some(since) = q.since {
if c.created_at < since {
return false;
}
}
if let Some(until) = q.until {
if c.created_at > until {
return false;
}
}
if let Some(sid) = &q.session_id {
if c.session_id.as_deref() != Some(sid.as_str()) {
return false;
}
}
if let Some(prefix) = &q.project_root_prefix {
match &c.project_root {
Some(root) if root.starts_with(prefix.as_str()) => {}
_ => return false,
}
}
if let Some(min) = q.min_importance {
if c.importance < min {
return false;
}
}
match q.caller_scope {
cel_memory::CallerScope::Own => {
if c.caller_id != q.caller_id {
return false;
}
}
cel_memory::CallerScope::OwnPlusShared => {
if c.caller_id != q.caller_id && !c.shareable {
return false;
}
}
cel_memory::CallerScope::Global => {}
}
true
}
fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
let mut clauses: Vec<String> = vec!["1=1".to_string()];
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(kinds) = &p.kinds {
let placeholders = vec!["?"; kinds.len()].join(",");
clauses.push(format!("kind IN ({placeholders})"));
for k in kinds {
params.push(Box::new(kind_str(*k).to_string()));
}
}
if let Some(callers) = &p.callers {
let placeholders = vec!["?"; callers.len()].join(",");
clauses.push(format!("caller_id IN ({placeholders})"));
for c in callers {
params.push(Box::new(c.clone()));
}
}
if let Some(sids) = &p.session_ids {
let placeholders = vec!["?"; sids.len()].join(",");
clauses.push(format!("session_id IN ({placeholders})"));
for s in sids {
params.push(Box::new(s.clone()));
}
}
if let Some(prefix) = &p.project_root_prefix {
clauses.push("project_root LIKE ?".to_string());
params.push(Box::new(format!("{prefix}%")));
}
if let Some(before) = p.before {
clauses.push("created_at < ?".to_string());
params.push(Box::new(dt_to_ms(before)));
}
if let Some(after) = p.after {
clauses.push("created_at > ?".to_string());
params.push(Box::new(dt_to_ms(after)));
}
if let Some(pinned) = p.pinned {
clauses.push("pinned = ?".to_string());
params.push(Box::new(if pinned { 1_i64 } else { 0 }));
}
if let Some(below) = p.importance_below {
clauses.push("importance < ?".to_string());
params.push(Box::new(below as f64));
}
if let Some(needle) = &p.content_contains {
clauses.push("LOWER(content) LIKE ?".to_string());
params.push(Box::new(format!("%{}%", needle.to_lowercase())));
}
(clauses.join(" AND "), params)
}
fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
Ok(match s {
"user_delete" => EvictionReason::UserDelete,
"aging" => EvictionReason::Aging,
"low_importance" => EvictionReason::LowImportance,
"redact_rule" => EvictionReason::RedactRule,
"storage_cap" => EvictionReason::StorageCap,
"purge_all" => EvictionReason::PurgeAll,
other => {
return Err(MemoryError::Storage(format!(
"unknown eviction reason: {other}"
)))
}
})
}
fn eviction_reason_str(r: EvictionReason) -> &'static str {
match r {
EvictionReason::UserDelete => "user_delete",
EvictionReason::Aging => "aging",
EvictionReason::LowImportance => "low_importance",
EvictionReason::RedactRule => "redact_rule",
EvictionReason::StorageCap => "storage_cap",
EvictionReason::PurgeAll => "purge_all",
}
}
#[allow(dead_code)]
fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}