1use std::path::Path;
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use cel_memory::{
23 AccessEntry, AgingReport, CallerScope, ChunkKind, ChunkSource, EvictionEntry, EvictionReason,
24 ExportBundle, ExportFilter, MemoryChunk, MemoryError, MemoryPredicate, MemoryProvider,
25 MemoryQuery, MemorySession, MemoryStats, MemoryTier, NewMemoryChunk, NewMemorySession,
26 PurgeReport, ReEmbedReport, Result as MemoryResult, SessionFilter, SessionOutcome,
27};
28use chrono::{DateTime, NaiveDate, Utc};
29use rusqlite::{params, Connection, OptionalExtension};
30use tokio::sync::Mutex;
31use uuid::Uuid;
32
33use crate::cache::RetrieveCache;
34use crate::embedder::Embedder;
35use crate::error::SqliteMemoryError;
36use crate::migrations;
37
38const RETRIEVE_CACHE_CAPACITY: usize = 256;
42const RETRIEVE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
46
47pub struct SqliteMemoryProvider {
49 conn: Arc<Mutex<Connection>>,
50 embedder: Arc<dyn Embedder>,
51 write_hook: Option<Arc<dyn cel_memory::MemoryWriteHook>>,
56 summarizer: Option<Arc<dyn cel_memory::Summarizer>>,
62 retrieve_cache: Arc<RetrieveCache<Vec<MemoryChunk>>>,
65}
66
67impl SqliteMemoryProvider {
68 pub async fn open(
78 path: impl AsRef<Path>,
79 embedder: Arc<dyn Embedder>,
80 ) -> Result<Self, SqliteMemoryError> {
81 let path = path.as_ref().to_path_buf();
82 crate::vec_extension::register();
83 let conn = tokio::task::spawn_blocking(move || -> Result<Connection, SqliteMemoryError> {
84 let mut c = Connection::open(&path)?;
85 c.pragma_update(None, "journal_mode", "WAL")?;
87 c.pragma_update(None, "synchronous", "NORMAL")?;
88 migrations::run(&mut c)?;
89 Ok(c)
90 })
91 .await
92 .map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
93 Ok(Self {
94 conn: Arc::new(Mutex::new(conn)),
95 embedder,
96 write_hook: None,
97 summarizer: None,
98 retrieve_cache: Arc::new(RetrieveCache::new(
99 RETRIEVE_CACHE_CAPACITY,
100 RETRIEVE_CACHE_TTL,
101 )),
102 })
103 }
104
105 pub fn with_write_hook(mut self, hook: Arc<dyn cel_memory::MemoryWriteHook>) -> Self {
110 self.write_hook = Some(hook);
111 self
112 }
113
114 pub fn with_summarizer(mut self, summarizer: Arc<dyn cel_memory::Summarizer>) -> Self {
125 self.summarizer = Some(summarizer);
126 self
127 }
128
129 #[doc(hidden)]
135 pub fn conn_for_test(&self) -> Arc<tokio::sync::Mutex<rusqlite::Connection>> {
136 Arc::clone(&self.conn)
137 }
138
139 fn invalidate_retrieve_cache(&self) {
142 self.retrieve_cache.clear();
143 }
144
145 pub async fn open_in_memory(embedder: Arc<dyn Embedder>) -> Result<Self, SqliteMemoryError> {
147 crate::vec_extension::register();
148 let conn = tokio::task::spawn_blocking(|| -> Result<Connection, SqliteMemoryError> {
149 let mut c = Connection::open_in_memory()?;
150 migrations::run(&mut c)?;
151 Ok(c)
152 })
153 .await
154 .map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
155 Ok(Self {
156 conn: Arc::new(Mutex::new(conn)),
157 embedder,
158 write_hook: None,
159 summarizer: None,
160 retrieve_cache: Arc::new(RetrieveCache::new(
161 RETRIEVE_CACHE_CAPACITY,
162 RETRIEVE_CACHE_TTL,
163 )),
164 })
165 }
166
167 async fn fetch_session_chunks(&self, session_id: &str) -> MemoryResult<Vec<MemoryChunk>> {
179 let conn = Arc::clone(&self.conn);
180 let sid = session_id.to_string();
181 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
182 let guard = conn.blocking_lock();
183 let mut stmt = guard
184 .prepare(
185 "SELECT id, created_at, kind, tier, source, session_id,
186 project_root, caller_id, content, metadata,
187 importance, pinned, shareable, superseded_by,
188 embedding_model, embedding_dim
189 FROM memory_chunks
190 WHERE session_id = ?
191 AND kind != 'job_summary'
192 ORDER BY created_at ASC",
193 )
194 .map_err(|e| MemoryError::Storage(e.to_string()))?;
195 let rows = stmt
196 .query_map(params![sid], row_to_chunk)
197 .map_err(|e| MemoryError::Storage(e.to_string()))?;
198 let mut out = Vec::new();
199 for r in rows {
200 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
201 }
202 Ok(out)
203 })
204 .await
205 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
206 }
207
208 async fn fetch_day_chunks(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
214 let start = date
215 .and_hms_opt(0, 0, 0)
216 .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
217 .and_utc()
218 .timestamp_millis();
219 let end = date
220 .succ_opt()
221 .ok_or_else(|| MemoryError::InvalidArgument(format!("date overflow: {date}")))?
222 .and_hms_opt(0, 0, 0)
223 .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
224 .and_utc()
225 .timestamp_millis();
226 let conn = Arc::clone(&self.conn);
227 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
228 let guard = conn.blocking_lock();
229 let mut stmt = guard
230 .prepare(
231 "SELECT id, created_at, kind, tier, source, session_id,
232 project_root, caller_id, content, metadata,
233 importance, pinned, superseded_by,
234 embedding_model, embedding_dim
235 FROM memory_chunks
236 WHERE created_at >= ?
237 AND created_at < ?
238 AND kind != 'rollup'
239 ORDER BY created_at ASC",
240 )
241 .map_err(|e| MemoryError::Storage(e.to_string()))?;
242 let rows = stmt
243 .query_map(params![start, end], row_to_chunk)
244 .map_err(|e| MemoryError::Storage(e.to_string()))?;
245 let mut out = Vec::new();
246 for r in rows {
247 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
248 }
249 Ok(out)
250 })
251 .await
252 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
253 }
254
255 async fn fetch_rule_week_chunks(
262 &self,
263 rule_id: &str,
264 week_start: NaiveDate,
265 ) -> MemoryResult<Vec<MemoryChunk>> {
266 let start = week_start
267 .and_hms_opt(0, 0, 0)
268 .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {week_start}")))?
269 .and_utc()
270 .timestamp_millis();
271 let end_date = week_start
272 .checked_add_days(chrono::Days::new(7))
273 .ok_or_else(|| MemoryError::InvalidArgument(format!("week overflow: {week_start}")))?;
274 let end = end_date
275 .and_hms_opt(0, 0, 0)
276 .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {end_date}")))?
277 .and_utc()
278 .timestamp_millis();
279 let conn = Arc::clone(&self.conn);
280 let rid = rule_id.to_string();
281 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
282 let guard = conn.blocking_lock();
283 let mut stmt = guard
284 .prepare(
285 "SELECT id, created_at, kind, tier, source, session_id,
286 project_root, caller_id, content, metadata,
287 importance, pinned, superseded_by,
288 embedding_model, embedding_dim
289 FROM memory_chunks
290 WHERE kind = 'fire'
291 AND created_at >= ?
292 AND created_at < ?
293 AND json_extract(metadata, '$.rule_id') = ?
294 ORDER BY created_at ASC",
295 )
296 .map_err(|e| MemoryError::Storage(e.to_string()))?;
297 let rows = stmt
298 .query_map(params![start, end, rid], row_to_chunk)
299 .map_err(|e| MemoryError::Storage(e.to_string()))?;
300 let mut out = Vec::new();
301 for r in rows {
302 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
303 }
304 Ok(out)
305 })
306 .await
307 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
308 }
309
310 async fn day_rollup_exists(&self, date: NaiveDate) -> MemoryResult<bool> {
314 let date_s = date.to_string();
315 let conn = Arc::clone(&self.conn);
316 tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
317 let guard = conn.blocking_lock();
318 let count: i64 = guard
319 .query_row(
320 "SELECT COUNT(*) FROM memory_chunks
321 WHERE kind = 'rollup'
322 AND json_extract(metadata, '$.rollup_date') = ?",
323 params![date_s],
324 |r| r.get(0),
325 )
326 .map_err(|e| MemoryError::Storage(e.to_string()))?;
327 Ok(count > 0)
328 })
329 .await
330 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
331 }
332
333 async fn rule_week_rollup_exists(
337 &self,
338 rule_id: &str,
339 week_start: NaiveDate,
340 ) -> MemoryResult<bool> {
341 let week_s = week_start.to_string();
342 let rid = rule_id.to_string();
343 let conn = Arc::clone(&self.conn);
344 tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
345 let guard = conn.blocking_lock();
346 let count: i64 = guard
347 .query_row(
348 "SELECT COUNT(*) FROM memory_chunks
349 WHERE kind = 'rollup'
350 AND json_extract(metadata, '$.rollup_rule_id') = ?
351 AND json_extract(metadata, '$.rollup_week_start') = ?",
352 params![rid, week_s],
353 |r| r.get(0),
354 )
355 .map_err(|e| MemoryError::Storage(e.to_string()))?;
356 Ok(count > 0)
357 })
358 .await
359 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
360 }
361
362 async fn link_rollup_members(
366 &self,
367 rollup_id: &str,
368 member_ids: &[String],
369 ) -> MemoryResult<()> {
370 let rid = rollup_id.to_string();
371 let members: Vec<String> = member_ids.to_vec();
372 let conn = Arc::clone(&self.conn);
373 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
374 let mut guard = conn.blocking_lock();
375 let tx = guard
376 .transaction()
377 .map_err(|e| MemoryError::Storage(e.to_string()))?;
378 for mid in &members {
379 tx.execute(
380 "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
381 VALUES (?, ?)",
382 params![rid, mid],
383 )
384 .map_err(|e| MemoryError::Storage(e.to_string()))?;
385 }
386 tx.commit()
387 .map_err(|e| MemoryError::Storage(e.to_string()))?;
388 Ok(())
389 })
390 .await
391 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
392 }
393
394 async fn rollup_day_inner(
408 &self,
409 date: NaiveDate,
410 force: bool,
411 ) -> MemoryResult<Vec<MemoryChunk>> {
412 let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
413 "SqliteMemoryProvider::rollup_day — no summarizer attached \
414 (call `with_summarizer` first)",
415 ))?;
416
417 if !force && self.day_rollup_exists(date).await? {
418 tracing::debug!(date = %date, "rollup_day: existing rollup found, skipping");
419 return Ok(Vec::new());
420 }
421
422 let members = self.fetch_day_chunks(date).await?;
423 if members.is_empty() {
424 tracing::debug!(date = %date, "rollup_day: no chunks to summarise, no-op");
425 return Ok(Vec::new());
426 }
427 let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
428
429 let kind_label = format!("day {date}");
430 let ctx = cel_memory::SummaryContext {
431 kind_label: Some(kind_label.clone()),
432 note: Some(format!(
433 "Daily rollup for {date} ({} chunks)",
434 members.len()
435 )),
436 max_words: None,
437 };
438 let summary_text = summarizer
439 .summarize(&members, &ctx)
440 .await
441 .map_err(|e| match e {
442 cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
443 "summarizer received no input despite day having chunks".into(),
444 ),
445 other => MemoryError::Provider(format!(
446 "summarizer {} failed: {other}",
447 summarizer.name()
448 )),
449 })?;
450
451 let new_chunk = NewMemoryChunk {
455 kind: ChunkKind::Rollup,
456 source: ChunkSource::System,
457 session_id: None,
458 project_root: None,
459 caller_id: "system".to_string(),
460 content: summary_text,
461 metadata: serde_json::json!({
462 "rollup_kind": "day",
463 "rollup_date": date.to_string(),
464 "member_count": member_ids.len(),
465 "summarizer": summarizer.name(),
466 }),
467 importance: None,
468 shareable: false,
469 pinned: false,
470 };
471 let written = self.write(new_chunk).await?;
472 self.link_rollup_members(&written.id, &member_ids).await?;
473 Ok(vec![written])
474 }
475
476 async fn rollup_rule_week_inner(
487 &self,
488 rule_id: &str,
489 week_start: NaiveDate,
490 force: bool,
491 ) -> MemoryResult<MemoryChunk> {
492 let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
493 "SqliteMemoryProvider::rollup_rule_week — no summarizer attached \
494 (call `with_summarizer` first)",
495 ))?;
496
497 if !force && self.rule_week_rollup_exists(rule_id, week_start).await? {
498 return Err(MemoryError::InvalidArgument(format!(
499 "rollup already exists for rule {rule_id} week {week_start}"
500 )));
501 }
502
503 let members = self.fetch_rule_week_chunks(rule_id, week_start).await?;
504 if members.is_empty() {
505 return Err(MemoryError::NotFound(format!(
506 "no fires for rule {rule_id} in week of {week_start}"
507 )));
508 }
509 let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
510
511 let kind_label = format!("week of {week_start} for rule {rule_id}");
512 let ctx = cel_memory::SummaryContext {
513 kind_label: Some(kind_label.clone()),
514 note: Some(format!(
515 "Weekly rollup for rule {rule_id} ({} fires)",
516 members.len()
517 )),
518 max_words: None,
519 };
520 let summary_text = summarizer
521 .summarize(&members, &ctx)
522 .await
523 .map_err(|e| match e {
524 cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
525 "summarizer received no input despite rule-week having chunks".into(),
526 ),
527 other => MemoryError::Provider(format!(
528 "summarizer {} failed: {other}",
529 summarizer.name()
530 )),
531 })?;
532
533 let new_chunk = NewMemoryChunk {
534 kind: ChunkKind::Rollup,
535 source: ChunkSource::System,
536 session_id: None,
537 project_root: None,
538 caller_id: "system".to_string(),
539 content: summary_text,
540 metadata: serde_json::json!({
541 "rollup_kind": "rule_week",
542 "rollup_rule_id": rule_id,
543 "rollup_week_start": week_start.to_string(),
544 "member_count": member_ids.len(),
545 "summarizer": summarizer.name(),
546 }),
547 importance: None,
548 shareable: false,
549 pinned: false,
550 };
551 let written = self.write(new_chunk).await?;
552 self.link_rollup_members(&written.id, &member_ids).await?;
553 Ok(written)
554 }
555}
556
557fn now_ms() -> i64 {
558 Utc::now().timestamp_millis()
559}
560
561fn cache_key_for_query(query: &MemoryQuery) -> Option<u64> {
567 use std::collections::hash_map::DefaultHasher;
568 use std::hash::{Hash, Hasher};
569 let s = serde_json::to_string(query).ok()?;
570 let mut h = DefaultHasher::new();
571 s.hash(&mut h);
572 Some(h.finish())
573}
574
575fn dt_to_ms(t: DateTime<Utc>) -> i64 {
576 t.timestamp_millis()
577}
578
579fn ms_to_dt(ms: i64) -> DateTime<Utc> {
580 chrono::DateTime::<Utc>::from_timestamp_millis(ms)
581 .unwrap_or_else(|| DateTime::<Utc>::from_timestamp_millis(0).unwrap())
582}
583
584fn kind_str(k: ChunkKind) -> &'static str {
585 match k {
586 ChunkKind::Chat => "chat",
587 ChunkKind::Action => "action",
588 ChunkKind::Fire => "fire",
589 ChunkKind::Observation => "observation",
590 ChunkKind::Correction => "correction",
591 ChunkKind::JobSummary => "job_summary",
592 ChunkKind::Context => "context",
593 ChunkKind::Rollup => "rollup",
594 }
595}
596
597fn str_to_kind(s: &str) -> Result<ChunkKind, MemoryError> {
598 Ok(match s {
599 "chat" => ChunkKind::Chat,
600 "action" => ChunkKind::Action,
601 "fire" => ChunkKind::Fire,
602 "observation" => ChunkKind::Observation,
603 "correction" => ChunkKind::Correction,
604 "job_summary" => ChunkKind::JobSummary,
605 "context" => ChunkKind::Context,
606 "rollup" => ChunkKind::Rollup,
607 other => return Err(MemoryError::Storage(format!("unknown kind: {other}"))),
608 })
609}
610
611fn source_str(s: ChunkSource) -> &'static str {
612 match s {
613 ChunkSource::Embedded => "embedded",
614 ChunkSource::Mcp => "mcp",
615 ChunkSource::Gateway => "gateway",
616 ChunkSource::Matcher => "matcher",
617 ChunkSource::Cortex => "cortex",
618 ChunkSource::System => "system",
619 }
620}
621
622fn str_to_source(s: &str) -> Result<ChunkSource, MemoryError> {
623 Ok(match s {
624 "embedded" => ChunkSource::Embedded,
625 "mcp" => ChunkSource::Mcp,
626 "gateway" => ChunkSource::Gateway,
627 "matcher" => ChunkSource::Matcher,
628 "cortex" => ChunkSource::Cortex,
629 "system" => ChunkSource::System,
630 other => return Err(MemoryError::Storage(format!("unknown source: {other}"))),
631 })
632}
633
634fn tier_str(t: MemoryTier) -> &'static str {
635 match t {
636 MemoryTier::Session => "session",
637 MemoryTier::LongTerm => "long_term",
638 }
639}
640
641fn str_to_tier(s: &str) -> Result<MemoryTier, MemoryError> {
642 Ok(match s {
643 "session" => MemoryTier::Session,
644 "long_term" => MemoryTier::LongTerm,
645 other => return Err(MemoryError::Storage(format!("unknown tier: {other}"))),
646 })
647}
648
649fn outcome_str(o: SessionOutcome) -> &'static str {
650 match o {
651 SessionOutcome::Open => "open",
652 SessionOutcome::Success => "success",
653 SessionOutcome::Failure => "failure",
654 SessionOutcome::Aborted => "aborted",
655 }
656}
657
658fn str_to_outcome(s: &str) -> Result<SessionOutcome, MemoryError> {
659 Ok(match s {
660 "open" => SessionOutcome::Open,
661 "success" => SessionOutcome::Success,
662 "failure" => SessionOutcome::Failure,
663 "aborted" => SessionOutcome::Aborted,
664 other => return Err(MemoryError::Storage(format!("unknown outcome: {other}"))),
665 })
666}
667
668fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryChunk> {
669 let metadata_str: String = row.get("metadata")?;
670 let metadata: serde_json::Value =
671 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
672 let shareable = row.get::<_, i64>("shareable").unwrap_or(0) != 0;
676 Ok(MemoryChunk {
677 id: row.get("id")?,
678 created_at: ms_to_dt(row.get::<_, i64>("created_at")?),
679 kind: str_to_kind(&row.get::<_, String>("kind")?).unwrap_or(ChunkKind::Chat),
680 tier: str_to_tier(&row.get::<_, String>("tier")?).unwrap_or(MemoryTier::Session),
681 source: str_to_source(&row.get::<_, String>("source")?).unwrap_or(ChunkSource::System),
682 session_id: row.get("session_id")?,
683 project_root: row.get("project_root")?,
684 caller_id: row.get("caller_id")?,
685 content: row.get("content")?,
686 metadata,
687 importance: row.get::<_, f64>("importance")? as f32,
688 pinned: row.get::<_, i64>("pinned")? != 0,
689 shareable,
690 superseded_by: row.get("superseded_by")?,
691 embedding_model: row.get("embedding_model")?,
692 embedding_dim: row.get::<_, i64>("embedding_dim")? as u32,
693 })
694}
695
696#[async_trait]
697impl MemoryProvider for SqliteMemoryProvider {
698 async fn retrieve(&self, query: MemoryQuery) -> MemoryResult<Vec<MemoryChunk>> {
701 if query.text.trim().is_empty() {
718 return Err(MemoryError::InvalidArgument(
719 "query.text must not be empty".into(),
720 ));
721 }
722
723 let cache_key = cache_key_for_query(&query);
726 if let Some(key) = cache_key {
727 if let Some(hit) = self.retrieve_cache.get(key) {
728 return Ok(hit);
729 }
730 }
731 let k = query.k.max(1);
732 let candidate_k = (3 * k).max(16);
735
736 let (w_vec, w_fts, w_rec, half_life_secs) = retrieval_weights(query.profile);
737
738 let q_embedding = self
739 .embedder
740 .embed(&query.text)
741 .await
742 .map_err(|e| MemoryError::Storage(e.to_string()))?;
743 let q_text = query.text.clone();
744 let conn = Arc::clone(&self.conn);
745
746 let compute =
747 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
748 let guard = conn.blocking_lock();
749
750 let v_json = serde_json::to_string(&q_embedding)
756 .map_err(|e| MemoryError::Storage(e.to_string()))?;
757 let vec_ids: Vec<String> = {
758 let sql = "SELECT v.chunk_id FROM memory_vec v
759 WHERE v.embedding MATCH ?
760 AND k = ?
761 ORDER BY distance";
762 let mut stmt = guard
763 .prepare(sql)
764 .map_err(|e| MemoryError::Storage(e.to_string()))?;
765 let rows = stmt
766 .query_map(params![v_json, candidate_k as i64], |r| {
767 r.get::<_, String>(0)
768 })
769 .map_err(|e| MemoryError::Storage(e.to_string()))?;
770 let mut out = Vec::new();
771 for r in rows {
772 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
773 }
774 out
775 };
776
777 let fts_ids: Vec<String> = {
780 let sql = "SELECT chunk_id FROM memory_fts
781 WHERE memory_fts MATCH ?
782 ORDER BY rank
783 LIMIT ?";
784 let mut stmt = guard
785 .prepare(sql)
786 .map_err(|e| MemoryError::Storage(e.to_string()))?;
787 let rows = stmt
788 .query_map(
789 params![fts_query_escape(&q_text), candidate_k as i64],
790 |r| r.get::<_, String>(0),
791 )
792 .map_err(|e| MemoryError::Storage(e.to_string()))?;
793 let mut out = Vec::new();
794 for r in rows {
795 match r {
799 Ok(id) => out.push(id),
800 Err(_) => break,
801 }
802 }
803 out
804 };
805
806 let recency_ids: Vec<String> = {
810 let sql = "SELECT id FROM memory_chunks
811 ORDER BY created_at DESC LIMIT ?";
812 let mut stmt = guard
813 .prepare(sql)
814 .map_err(|e| MemoryError::Storage(e.to_string()))?;
815 let rows = stmt
816 .query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
817 .map_err(|e| MemoryError::Storage(e.to_string()))?;
818 let mut out = Vec::new();
819 for r in rows {
820 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
821 }
822 out
823 };
824
825 const RRF_K: f32 = 60.0;
827 let mut scores: std::collections::HashMap<String, f32> =
828 std::collections::HashMap::new();
829 for (rank, id) in vec_ids.iter().enumerate() {
830 *scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
831 }
832 for (rank, id) in fts_ids.iter().enumerate() {
833 *scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
834 }
835 let now_ms_val = now_ms();
839 let recency_rows: std::collections::HashMap<String, i64> = {
840 if recency_ids.is_empty() {
841 Default::default()
842 } else {
843 let placeholders = vec!["?"; recency_ids.len()].join(",");
844 let sql = format!(
845 "SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
846 );
847 let p: Vec<&dyn rusqlite::ToSql> = recency_ids
848 .iter()
849 .map(|s| s as &dyn rusqlite::ToSql)
850 .collect();
851 let mut stmt = guard
852 .prepare(&sql)
853 .map_err(|e| MemoryError::Storage(e.to_string()))?;
854 let rows = stmt
855 .query_map(p.as_slice(), |r| {
856 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
857 })
858 .map_err(|e| MemoryError::Storage(e.to_string()))?;
859 let mut map = std::collections::HashMap::new();
860 for r in rows {
861 let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
862 map.insert(id, t);
863 }
864 map
865 }
866 };
867 for (id, created_ms) in &recency_rows {
868 let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
869 let recency = (-dt_secs / half_life_secs).exp();
870 *scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
871 }
872
873 let mut id_set: std::collections::HashSet<String> =
875 std::collections::HashSet::new();
876 id_set.extend(vec_ids);
877 id_set.extend(fts_ids);
878 id_set.extend(recency_ids);
879 if id_set.is_empty() {
880 return Ok(Vec::new());
881 }
882 let placeholders = vec!["?"; id_set.len()].join(",");
883 let select_sql = format!(
884 "SELECT id, created_at, kind, tier, source, session_id,
885 project_root, caller_id, content, metadata,
886 importance, pinned, shareable, superseded_by,
887 embedding_model, embedding_dim
888 FROM memory_chunks WHERE id IN ({placeholders})"
889 );
890 let ids_vec: Vec<String> = id_set.into_iter().collect();
891 let candidates: Vec<MemoryChunk> = {
892 let p: Vec<&dyn rusqlite::ToSql> =
893 ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
894 let mut stmt = guard
895 .prepare(&select_sql)
896 .map_err(|e| MemoryError::Storage(e.to_string()))?;
897 let rows = stmt
898 .query_map(p.as_slice(), row_to_chunk)
899 .map_err(|e| MemoryError::Storage(e.to_string()))?;
900 let mut out = Vec::new();
901 for r in rows {
902 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
903 }
904 out
905 };
906
907 let mut filtered: Vec<MemoryChunk> = candidates
910 .into_iter()
911 .filter(|c| chunk_matches_query(c, &query))
912 .collect();
913
914 filtered.sort_by(|a, b| {
916 let sa = scores.get(&a.id).copied().unwrap_or(0.0);
917 let sb = scores.get(&b.id).copied().unwrap_or(0.0);
918 sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
919 });
920 filtered.truncate(k);
921 Ok(filtered)
922 });
923 let result = compute
924 .await
925 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
926
927 if let Some(key) = cache_key {
931 self.retrieve_cache.insert(key, result.clone());
932 }
933 Ok(result)
934 }
935
936 async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
937 let conn = Arc::clone(&self.conn);
938 let chunk_id = chunk_id.to_string();
939 let res: Result<Option<MemoryChunk>, MemoryError> =
940 tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
941 let guard = conn.blocking_lock();
942 let mut stmt = guard
943 .prepare(
944 "SELECT id, created_at, kind, tier, source, session_id,
945 project_root, caller_id, content, metadata,
946 importance, pinned, shareable, superseded_by,
947 embedding_model, embedding_dim
948 FROM memory_chunks WHERE id = ?",
949 )
950 .map_err(|e| MemoryError::Storage(e.to_string()))?;
951 let row = stmt
952 .query_row(params![chunk_id], row_to_chunk)
953 .optional()
954 .map_err(|e| MemoryError::Storage(e.to_string()))?;
955 Ok(row)
956 })
957 .await
958 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
959 res
960 }
961
962 async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
963 let conn = Arc::clone(&self.conn);
964 let session_id = session_id.to_string();
965 tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
966 let guard = conn.blocking_lock();
967 let mut stmt = guard
968 .prepare(
969 "SELECT id, started_at, ended_at, caller_id, title, summary,
970 outcome, metadata
971 FROM memory_sessions WHERE id = ?",
972 )
973 .map_err(|e| MemoryError::Storage(e.to_string()))?;
974 let row = stmt
975 .query_row(params![session_id], |r| {
976 let metadata_str: String = r.get("metadata")?;
977 let metadata: serde_json::Value =
978 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
979 Ok(MemorySession {
980 id: r.get("id")?,
981 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
982 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
983 caller_id: r.get("caller_id")?,
984 title: r.get("title")?,
985 summary: r.get("summary")?,
986 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
987 .unwrap_or(SessionOutcome::Aborted),
988 metadata,
989 })
990 })
991 .optional()
992 .map_err(|e| MemoryError::Storage(e.to_string()))?;
993 Ok(row)
994 })
995 .await
996 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
997 }
998
999 async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
1000 let conn = Arc::clone(&self.conn);
1001 tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
1002 let guard = conn.blocking_lock();
1003 let mut sql = String::from(
1007 "SELECT id, started_at, ended_at, caller_id, title, summary,
1008 outcome, metadata
1009 FROM memory_sessions WHERE 1=1",
1010 );
1011 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1012 if let Some(c) = &filter.caller_id {
1013 sql.push_str(" AND caller_id = ?");
1014 params_vec.push(Box::new(c.clone()));
1015 }
1016 if let Some(o) = filter.outcome {
1017 sql.push_str(" AND outcome = ?");
1018 params_vec.push(Box::new(outcome_str(o).to_string()));
1019 } else if filter.open_only {
1020 sql.push_str(" AND outcome = ?");
1021 params_vec.push(Box::new("open".to_string()));
1022 }
1023 if let Some(since) = filter.since {
1024 sql.push_str(" AND started_at >= ?");
1025 params_vec.push(Box::new(dt_to_ms(since)));
1026 }
1027 if let Some(until) = filter.until {
1028 sql.push_str(" AND started_at <= ?");
1029 params_vec.push(Box::new(dt_to_ms(until)));
1030 }
1031 sql.push_str(" ORDER BY started_at DESC");
1032 if let Some(n) = filter.limit {
1033 sql.push_str(" LIMIT ?");
1034 params_vec.push(Box::new(n as i64));
1035 }
1036
1037 let mut stmt = guard
1038 .prepare(&sql)
1039 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1040 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1041 let rows = stmt
1042 .query_map(p.as_slice(), |r| {
1043 let metadata_str: String = r.get("metadata")?;
1044 let metadata: serde_json::Value =
1045 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
1046 Ok(MemorySession {
1047 id: r.get("id")?,
1048 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1049 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1050 caller_id: r.get("caller_id")?,
1051 title: r.get("title")?,
1052 summary: r.get("summary")?,
1053 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1054 .unwrap_or(SessionOutcome::Aborted),
1055 metadata,
1056 })
1057 })
1058 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1059 let mut out = Vec::new();
1060 for row in rows {
1061 out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1062 }
1063 Ok(out)
1064 })
1065 .await
1066 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1067 }
1068
1069 async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
1072 if new_chunk.content.trim().is_empty() {
1073 return Err(MemoryError::InvalidArgument(
1074 "content must not be empty".into(),
1075 ));
1076 }
1077 self.invalidate_retrieve_cache();
1081
1082 if let Some(hook) = &self.write_hook {
1087 match hook.before_write(&new_chunk).await? {
1088 cel_memory::WriteDecision::Allow => {}
1089 cel_memory::WriteDecision::Redact { reason } => {
1090 return Ok(MemoryChunk {
1091 id: Uuid::now_v7().to_string(),
1092 created_at: Utc::now(),
1093 kind: new_chunk.kind,
1094 tier: MemoryTier::Session,
1095 source: new_chunk.source,
1096 session_id: new_chunk.session_id,
1097 project_root: new_chunk.project_root,
1098 caller_id: new_chunk.caller_id,
1099 content: format!("<redacted: {reason}>"),
1100 metadata: serde_json::json!({"redacted": true, "reason": reason}),
1101 importance: 0.0,
1102 pinned: false,
1103 shareable: false,
1104 superseded_by: None,
1105 embedding_model: "none".into(),
1106 embedding_dim: 0,
1107 });
1108 }
1109 }
1110 }
1111
1112 let id = Uuid::now_v7().to_string();
1113 let created_at_ms = now_ms();
1114 let importance = cel_memory::importance::score(&new_chunk);
1118 let embedder_dim = self.embedder.dim();
1119 let embedder_name = self.embedder.model_name().to_string();
1120 let embedding = self
1123 .embedder
1124 .embed(&new_chunk.content)
1125 .await
1126 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1127 if embedding.len() != embedder_dim {
1128 return Err(MemoryError::Internal(format!(
1129 "embedder produced dim {}, declared {}",
1130 embedding.len(),
1131 embedder_dim
1132 )));
1133 }
1134
1135 let chunk = MemoryChunk {
1136 id: id.clone(),
1137 created_at: ms_to_dt(created_at_ms),
1138 kind: new_chunk.kind,
1139 tier: MemoryTier::Session,
1140 source: new_chunk.source,
1141 session_id: new_chunk.session_id.clone(),
1142 project_root: new_chunk.project_root.clone(),
1143 caller_id: new_chunk.caller_id.clone(),
1144 content: new_chunk.content.clone(),
1145 metadata: new_chunk.metadata.clone(),
1146 importance,
1147 pinned: new_chunk.pinned,
1148 shareable: new_chunk.shareable,
1149 superseded_by: None,
1150 embedding_model: embedder_name.clone(),
1151 embedding_dim: embedder_dim as u32,
1152 };
1153
1154 let conn = Arc::clone(&self.conn);
1155 let chunk_for_blocking = chunk.clone();
1156 let embedding_clone = embedding.clone();
1157 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1158 let mut guard = conn.blocking_lock();
1159 let tx = guard
1160 .transaction()
1161 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1162 tx.execute(
1163 "INSERT INTO memory_chunks(
1164 id, created_at, kind, tier, source, session_id, project_root,
1165 caller_id, content, metadata, importance, pinned, shareable,
1166 superseded_by, embedding_model, embedding_dim
1167 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1168 params![
1169 chunk_for_blocking.id,
1170 created_at_ms,
1171 kind_str(chunk_for_blocking.kind),
1172 tier_str(chunk_for_blocking.tier),
1173 source_str(chunk_for_blocking.source),
1174 chunk_for_blocking.session_id,
1175 chunk_for_blocking.project_root,
1176 chunk_for_blocking.caller_id,
1177 chunk_for_blocking.content,
1178 serde_json::to_string(&chunk_for_blocking.metadata)
1179 .unwrap_or_else(|_| "null".into()),
1180 chunk_for_blocking.importance as f64,
1181 if chunk_for_blocking.pinned { 1 } else { 0 },
1182 if new_chunk.shareable { 1 } else { 0 },
1183 Option::<String>::None,
1184 chunk_for_blocking.embedding_model,
1185 chunk_for_blocking.embedding_dim as i64,
1186 ],
1187 )
1188 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1189 let v_json = serde_json::to_string(&embedding_clone)
1193 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1194 tx.execute(
1195 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1196 params![chunk_for_blocking.id, v_json],
1197 )
1198 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1199 tx.commit()
1200 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1201 Ok(())
1202 })
1203 .await
1204 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1205
1206 Ok(chunk)
1207 }
1208
1209 async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
1210 if chunks.is_empty() {
1215 return Ok(Vec::new());
1216 }
1217 if chunks.len() == 1 {
1218 let nc = chunks.into_iter().next().expect("len == 1");
1219 return Ok(vec![self.write(nc).await?]);
1220 }
1221 self.invalidate_retrieve_cache();
1222
1223 if self.write_hook.is_some() {
1228 let mut out = Vec::with_capacity(chunks.len());
1229 for nc in chunks {
1230 out.push(self.write(nc).await?);
1231 }
1232 return Ok(out);
1233 }
1234
1235 for (i, nc) in chunks.iter().enumerate() {
1238 if nc.content.trim().is_empty() {
1239 return Err(MemoryError::InvalidArgument(format!(
1240 "chunks[{i}].content must not be empty"
1241 )));
1242 }
1243 }
1244
1245 let embedder_dim = self.embedder.dim();
1246 let embedder_name = self.embedder.model_name().to_string();
1247
1248 let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
1253 let embeddings = self
1254 .embedder
1255 .embed_batch(&texts)
1256 .await
1257 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1258 if embeddings.len() != chunks.len() {
1259 return Err(MemoryError::Internal(format!(
1260 "embedder returned {} vectors for {} inputs",
1261 embeddings.len(),
1262 chunks.len()
1263 )));
1264 }
1265 for (i, v) in embeddings.iter().enumerate() {
1266 if v.len() != embedder_dim {
1267 return Err(MemoryError::Internal(format!(
1268 "embedder produced dim {} for chunk {i}, declared {}",
1269 v.len(),
1270 embedder_dim
1271 )));
1272 }
1273 }
1274
1275 let now_ms_val = now_ms();
1278 let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
1279 Vec::with_capacity(chunks.len());
1280 for (nc, embedding) in chunks.into_iter().zip(embeddings) {
1281 let id = Uuid::now_v7().to_string();
1282 let importance = cel_memory::importance::score(&nc);
1283 let metadata_json =
1284 serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
1285 let mc = MemoryChunk {
1286 id,
1287 created_at: ms_to_dt(now_ms_val),
1288 kind: nc.kind,
1289 tier: MemoryTier::Session,
1290 source: nc.source,
1291 session_id: nc.session_id,
1292 project_root: nc.project_root,
1293 caller_id: nc.caller_id,
1294 content: nc.content,
1295 metadata: nc.metadata,
1296 importance,
1297 pinned: nc.pinned,
1298 shareable: nc.shareable,
1299 superseded_by: None,
1300 embedding_model: embedder_name.clone(),
1301 embedding_dim: embedder_dim as u32,
1302 };
1303 owned.push((mc, embedding, nc.shareable, metadata_json));
1304 }
1305
1306 let conn = Arc::clone(&self.conn);
1307 let inserted =
1308 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
1309 let mut guard = conn.blocking_lock();
1310 let tx = guard
1311 .transaction()
1312 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1313 let mut out = Vec::with_capacity(owned.len());
1314 for (mc, embedding, shareable, metadata_json) in &owned {
1315 tx.execute(
1316 "INSERT INTO memory_chunks(
1317 id, created_at, kind, tier, source, session_id, project_root,
1318 caller_id, content, metadata, importance, pinned, shareable,
1319 superseded_by, embedding_model, embedding_dim
1320 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1321 params![
1322 mc.id,
1323 now_ms_val,
1324 kind_str(mc.kind),
1325 tier_str(mc.tier),
1326 source_str(mc.source),
1327 mc.session_id,
1328 mc.project_root,
1329 mc.caller_id,
1330 mc.content,
1331 metadata_json,
1332 mc.importance as f64,
1333 if mc.pinned { 1 } else { 0 },
1334 if *shareable { 1 } else { 0 },
1335 Option::<String>::None,
1336 mc.embedding_model,
1337 mc.embedding_dim as i64,
1338 ],
1339 )
1340 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1341 let v_json = serde_json::to_string(embedding)
1342 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1343 tx.execute(
1344 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1345 params![mc.id, v_json],
1346 )
1347 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1348 out.push(mc.clone());
1349 }
1350 tx.commit()
1351 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1352 Ok(out)
1353 })
1354 .await
1355 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1356
1357 Ok(inserted)
1358 }
1359
1360 async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
1361 let session = MemorySession {
1362 id: Uuid::now_v7().to_string(),
1363 started_at: Utc::now(),
1364 ended_at: None,
1365 caller_id: init.caller_id.clone(),
1366 title: init.title.clone(),
1367 summary: None,
1368 outcome: SessionOutcome::Open,
1369 metadata: init.metadata.clone(),
1370 };
1371 let conn = Arc::clone(&self.conn);
1372 let s = session.clone();
1373 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1374 let guard = conn.blocking_lock();
1375 guard
1376 .execute(
1377 "INSERT INTO memory_sessions(
1378 id, started_at, ended_at, caller_id, title, summary,
1379 outcome, metadata
1380 ) VALUES (?,?,?,?,?,?,?,?)",
1381 params![
1382 s.id,
1383 dt_to_ms(s.started_at),
1384 Option::<i64>::None,
1385 s.caller_id,
1386 s.title,
1387 Option::<String>::None,
1388 outcome_str(s.outcome),
1389 serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
1390 ],
1391 )
1392 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1393 Ok(())
1394 })
1395 .await
1396 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1397 Ok(session)
1398 }
1399
1400 async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
1401 let resolved = match outcome {
1402 SessionOutcome::Open => SessionOutcome::Aborted,
1403 other => other,
1404 };
1405 let conn = Arc::clone(&self.conn);
1406 let sid = session_id.to_string();
1407 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1408 let guard = conn.blocking_lock();
1409 let n = guard
1410 .execute(
1411 "UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
1412 params![now_ms(), outcome_str(resolved), sid],
1413 )
1414 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1415 if n == 0 {
1416 return Err(MemoryError::NotFound(format!("session {sid}")));
1417 }
1418 Ok(())
1419 })
1420 .await
1421 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1422 }
1423
1424 async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
1425 let conn = Arc::clone(&self.conn);
1426 let sid = session_id.to_string();
1427 let new_title = title.to_string();
1428 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1429 let guard = conn.blocking_lock();
1430 let n = guard
1431 .execute(
1432 "UPDATE memory_sessions SET title = ? WHERE id = ?",
1433 params![new_title, sid],
1434 )
1435 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1436 if n == 0 {
1437 return Err(MemoryError::NotFound(format!("session {sid}")));
1438 }
1439 Ok(())
1440 })
1441 .await
1442 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1443 }
1444
1445 async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
1448 self.invalidate_retrieve_cache();
1449 let conn = Arc::clone(&self.conn);
1450 let id = chunk_id.to_string();
1451 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1452 let guard = conn.blocking_lock();
1453 let n = guard
1454 .execute(
1455 "UPDATE memory_chunks SET pinned = ? WHERE id = ?",
1456 params![if pinned { 1 } else { 0 }, id],
1457 )
1458 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1459 if n == 0 {
1460 return Err(MemoryError::NotFound(format!("chunk {id}")));
1461 }
1462 Ok(())
1463 })
1464 .await
1465 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1466 }
1467
1468 async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
1469 self.invalidate_retrieve_cache();
1470 let conn = Arc::clone(&self.conn);
1471 let id = chunk_id.to_string();
1472 let clamped = importance.clamp(0.0, 1.0) as f64;
1473 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1474 let guard = conn.blocking_lock();
1475 let n = guard
1476 .execute(
1477 "UPDATE memory_chunks SET importance = ? WHERE id = ?",
1478 params![clamped, id],
1479 )
1480 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1481 if n == 0 {
1482 return Err(MemoryError::NotFound(format!("chunk {id}")));
1483 }
1484 Ok(())
1485 })
1486 .await
1487 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1488 }
1489
1490 async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
1491 self.invalidate_retrieve_cache();
1492 let conn = Arc::clone(&self.conn);
1493 let old = old_id.to_string();
1494 let new = new_id.to_string();
1495 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1496 let guard = conn.blocking_lock();
1497 let new_exists: i64 = guard
1501 .query_row(
1502 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1503 params![new],
1504 |r| r.get(0),
1505 )
1506 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1507 if new_exists == 0 {
1508 return Err(MemoryError::NotFound(format!("new chunk {new}")));
1509 }
1510 let n = guard
1511 .execute(
1512 "UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
1513 params![new, old],
1514 )
1515 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1516 if n == 0 {
1517 return Err(MemoryError::NotFound(format!("old chunk {old}")));
1518 }
1519 Ok(())
1520 })
1521 .await
1522 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1523 }
1524
1525 async fn record_access(
1526 &self,
1527 chunk_id: &str,
1528 retrieved_by: &str,
1529 used: bool,
1530 ) -> MemoryResult<()> {
1531 let conn = Arc::clone(&self.conn);
1532 let id = chunk_id.to_string();
1533 let by = retrieved_by.to_string();
1534 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1535 let guard = conn.blocking_lock();
1536 let exists: i64 = guard
1537 .query_row(
1538 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1539 params![id],
1540 |r| r.get(0),
1541 )
1542 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1543 if exists == 0 {
1544 return Err(MemoryError::NotFound(format!("chunk {id}")));
1545 }
1546 guard
1547 .execute(
1548 "INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
1549 VALUES (?, ?, ?, '', 0, ?)",
1550 params![now_ms(), id, by, if used { 1 } else { 0 }],
1551 )
1552 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1553 Ok(())
1554 })
1555 .await
1556 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1557 }
1558
1559 async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
1562 self.invalidate_retrieve_cache();
1563 let conn = Arc::clone(&self.conn);
1564 let id = chunk_id.to_string();
1565 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1566 let mut guard = conn.blocking_lock();
1567 let tx = guard
1568 .transaction()
1569 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1570 let n = tx
1571 .execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1572 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1573 if n == 0 {
1574 return Err(MemoryError::NotFound(format!("chunk {id}")));
1575 }
1576 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1577 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1578 tx.execute(
1579 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1580 VALUES (?,?,?, '{}')",
1581 params![now_ms(), id, eviction_reason_str(reason)],
1582 )
1583 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1584 tx.commit()
1585 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1586 Ok(())
1587 })
1588 .await
1589 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1590 }
1591
1592 async fn delete_matching(
1593 &self,
1594 predicate: MemoryPredicate,
1595 reason: EvictionReason,
1596 ) -> MemoryResult<usize> {
1597 if predicate.is_empty() {
1602 return Ok(0);
1603 }
1604 self.invalidate_retrieve_cache();
1605 let conn = Arc::clone(&self.conn);
1606 tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
1607 let (where_clause, params_vec) = build_chunk_predicate(&predicate);
1608 let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
1609 let mut guard = conn.blocking_lock();
1610 let tx = guard
1611 .transaction()
1612 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1613 let ids: Vec<String> = {
1614 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1615 let mut stmt = tx
1616 .prepare(&select_sql)
1617 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1618 let rows = stmt
1619 .query_map(p.as_slice(), |r| r.get::<_, String>(0))
1620 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1621 let mut out = Vec::new();
1622 for r in rows {
1623 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1624 }
1625 out
1626 };
1627 let now = now_ms();
1628 let reason_s = eviction_reason_str(reason);
1629 for id in &ids {
1630 tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1631 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1632 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1633 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1634 tx.execute(
1635 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1636 VALUES (?, ?, ?, '{}')",
1637 params![now, id, reason_s],
1638 )
1639 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1640 }
1641 tx.commit()
1642 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1643 Ok(ids.len())
1644 })
1645 .await
1646 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1647 }
1648
1649 async fn purge_all(&self) -> MemoryResult<PurgeReport> {
1650 self.invalidate_retrieve_cache();
1651 let conn = Arc::clone(&self.conn);
1652 tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
1653 let mut guard = conn.blocking_lock();
1654 let tx = guard
1655 .transaction()
1656 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1657 let chunks: i64 = tx
1658 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
1659 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1660 let sessions: i64 = tx
1661 .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
1662 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1663 let access: i64 = tx
1664 .query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
1665 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1666 let evictions: i64 = tx
1667 .query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
1668 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1669
1670 tx.execute("DELETE FROM memory_chunks", [])
1671 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1672 tx.execute("DELETE FROM memory_vec", [])
1673 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1674 tx.execute("DELETE FROM memory_sessions", [])
1675 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1676 tx.execute("DELETE FROM memory_access_log", [])
1677 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1678 tx.execute("DELETE FROM memory_eviction_log", [])
1679 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1680 tx.execute("DELETE FROM memory_summary_members", [])
1681 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1682 tx.commit()
1683 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1684
1685 Ok(PurgeReport {
1686 chunks_deleted: chunks as usize,
1687 sessions_deleted: sessions as usize,
1688 access_log_deleted: access as usize,
1689 eviction_log_deleted: evictions as usize,
1690 })
1691 })
1692 .await
1693 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1694 }
1695
1696 async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
1699 let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
1703 "SqliteMemoryProvider::summarize_session — no summarizer attached \
1704 (call `with_summarizer` first)",
1705 ))?;
1706
1707 let session = self
1711 .get_session(session_id)
1712 .await?
1713 .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
1714
1715 let members = self.fetch_session_chunks(session_id).await?;
1719 if members.is_empty() {
1720 return Err(MemoryError::InvalidArgument(format!(
1725 "session {session_id} has no chunks to summarize"
1726 )));
1727 }
1728 let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1729
1730 let ctx = cel_memory::SummaryContext {
1733 kind_label: Some("session".into()),
1734 note: session
1735 .title
1736 .as_ref()
1737 .map(|t| format!("session title: {t}")),
1738 max_words: None,
1739 };
1740 let summary_text = summarizer
1741 .summarize(&members, &ctx)
1742 .await
1743 .map_err(|e| match e {
1744 cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
1745 "summarizer received no input despite session having chunks".into(),
1746 ),
1747 other => MemoryError::Provider(format!(
1748 "summarizer {} failed: {other}",
1749 summarizer.name()
1750 )),
1751 })?;
1752
1753 let new_chunk = NewMemoryChunk {
1760 kind: ChunkKind::JobSummary,
1761 source: ChunkSource::Embedded,
1762 session_id: Some(session_id.to_string()),
1763 project_root: members.iter().find_map(|c| c.project_root.clone()),
1764 caller_id: session.caller_id.clone(),
1765 content: summary_text,
1766 metadata: serde_json::json!({
1767 "session_id": session_id,
1768 "member_count": member_ids.len(),
1769 "summarizer": summarizer.name(),
1770 }),
1771 importance: None,
1775 shareable: false,
1776 pinned: false,
1777 };
1778 let written = self.write(new_chunk).await?;
1779
1780 let conn = Arc::clone(&self.conn);
1785 let summary_id = written.id.clone();
1786 let member_ids_for_insert = member_ids.clone();
1787 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1788 let mut guard = conn.blocking_lock();
1789 let tx = guard
1790 .transaction()
1791 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1792 for mid in &member_ids_for_insert {
1793 tx.execute(
1794 "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
1795 VALUES (?, ?)",
1796 params![summary_id, mid],
1797 )
1798 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1799 }
1800 tx.commit()
1801 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1802 Ok(())
1803 })
1804 .await
1805 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1806
1807 let conn = Arc::clone(&self.conn);
1811 let sid = session_id.to_string();
1812 let stored_summary = written.content.clone();
1813 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1814 let guard = conn.blocking_lock();
1815 guard
1816 .execute(
1817 "UPDATE memory_sessions SET summary = ? WHERE id = ?",
1818 params![stored_summary, sid],
1819 )
1820 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1821 Ok(())
1822 })
1823 .await
1824 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1825
1826 Ok(written)
1827 }
1828
1829 async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1830 self.rollup_day_inner(date, false).await
1831 }
1832
1833 async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1834 self.rollup_day_inner(date, true).await
1835 }
1836
1837 async fn rollup_rule_week(
1838 &self,
1839 rule_id: &str,
1840 week_start: NaiveDate,
1841 ) -> MemoryResult<MemoryChunk> {
1842 self.rollup_rule_week_inner(rule_id, week_start, false)
1843 .await
1844 }
1845
1846 async fn rollup_rule_week_forced(
1847 &self,
1848 rule_id: &str,
1849 week_start: NaiveDate,
1850 ) -> MemoryResult<MemoryChunk> {
1851 self.rollup_rule_week_inner(rule_id, week_start, true).await
1852 }
1853
1854 async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
1857 const RETENTION_DAYS: i64 = 30;
1863 let cutoff_ms =
1864 (chrono::Utc::now() - chrono::Duration::days(RETENTION_DAYS)).timestamp_millis();
1865 self.invalidate_retrieve_cache();
1866 let conn = Arc::clone(&self.conn);
1867 tokio::task::spawn_blocking(move || -> Result<AgingReport, MemoryError> {
1868 let mut guard = conn.blocking_lock();
1869 let tx = guard
1870 .transaction()
1871 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1872 let ids: Vec<String> = {
1873 let mut stmt = tx
1874 .prepare(
1875 "SELECT id FROM memory_chunks
1876 WHERE pinned = 0
1877 AND kind != 'correction'
1878 AND created_at < ?",
1879 )
1880 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1881 let rows = stmt
1882 .query_map(params![cutoff_ms], |r| r.get::<_, String>(0))
1883 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1884 let mut out = Vec::new();
1885 for r in rows {
1886 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1887 }
1888 out
1889 };
1890 let now = now_ms();
1891 let reason_s = eviction_reason_str(EvictionReason::Aging);
1892 for id in &ids {
1893 tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1894 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1895 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1896 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1897 tx.execute(
1898 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1899 VALUES (?, ?, ?, '{}')",
1900 params![now, id, reason_s],
1901 )
1902 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1903 }
1904 tx.commit()
1905 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1906 let deleted = ids.len();
1907 Ok(AgingReport {
1908 tier_promoted: 0, deleted,
1910 bytes_reclaimed: 0,
1911 deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
1912 })
1913 })
1914 .await
1915 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1916 }
1917 async fn re_embed_all(&self, _target_model: &str) -> MemoryResult<ReEmbedReport> {
1918 Err(MemoryError::NotImplemented(
1919 "SqliteMemoryProvider::re_embed_all — Phase 4",
1920 ))
1921 }
1922 async fn export(&self, filter: ExportFilter) -> MemoryResult<ExportBundle> {
1923 let conn = Arc::clone(&self.conn);
1924 tokio::task::spawn_blocking(move || -> Result<ExportBundle, MemoryError> {
1925 let guard = conn.blocking_lock();
1926
1927 let (where_clause, params_vec) = match &filter.predicate {
1929 Some(p) if !p.is_empty() => build_chunk_predicate(p),
1930 _ => ("1=1".to_string(), Vec::new()),
1931 };
1932 let select_sql = format!(
1933 "SELECT id, created_at, kind, tier, source, session_id,
1934 project_root, caller_id, content, metadata,
1935 importance, pinned, shareable, superseded_by,
1936 embedding_model, embedding_dim
1937 FROM memory_chunks WHERE {} ORDER BY created_at DESC",
1938 where_clause
1939 );
1940 let chunks: Vec<MemoryChunk> = {
1941 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1942 let mut stmt = guard
1943 .prepare(&select_sql)
1944 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1945 let rows = stmt
1946 .query_map(p.as_slice(), row_to_chunk)
1947 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1948 let mut out = Vec::new();
1949 for r in rows {
1950 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1951 }
1952 out
1953 };
1954
1955 let sessions = if filter.include_sessions {
1958 let session_ids: std::collections::HashSet<String> =
1959 chunks.iter().filter_map(|c| c.session_id.clone()).collect();
1960 if session_ids.is_empty() {
1961 Vec::new()
1962 } else {
1963 let placeholders = vec!["?"; session_ids.len()].join(",");
1964 let sql = format!(
1965 "SELECT id, started_at, ended_at, caller_id, title, summary,
1966 outcome, metadata
1967 FROM memory_sessions WHERE id IN ({placeholders})"
1968 );
1969 let ids: Vec<String> = session_ids.into_iter().collect();
1970 let p: Vec<&dyn rusqlite::ToSql> =
1971 ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
1972 let mut stmt = guard
1973 .prepare(&sql)
1974 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1975 let rows = stmt
1976 .query_map(p.as_slice(), |r| {
1977 let metadata_str: String = r.get("metadata")?;
1978 let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
1979 .unwrap_or(serde_json::Value::Null);
1980 Ok(MemorySession {
1981 id: r.get("id")?,
1982 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1983 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1984 caller_id: r.get("caller_id")?,
1985 title: r.get("title")?,
1986 summary: r.get("summary")?,
1987 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1988 .unwrap_or(SessionOutcome::Aborted),
1989 metadata,
1990 })
1991 })
1992 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1993 let mut out = Vec::new();
1994 for r in rows {
1995 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1996 }
1997 out
1998 }
1999 } else {
2000 Vec::new()
2001 };
2002
2003 let evictions = if filter.include_eviction_log {
2004 let mut stmt = guard
2005 .prepare(
2006 "SELECT ts, chunk_id, reason, metadata
2007 FROM memory_eviction_log ORDER BY ts DESC",
2008 )
2009 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2010 let rows = stmt
2011 .query_map([], |r| {
2012 let reason_s: String = r.get("reason")?;
2013 let metadata_s: String = r.get("metadata")?;
2014 let metadata: serde_json::Value =
2015 serde_json::from_str(&metadata_s).unwrap_or(serde_json::Value::Null);
2016 Ok(EvictionEntry {
2017 ts: ms_to_dt(r.get::<_, i64>("ts")?),
2018 chunk_id: r.get("chunk_id")?,
2019 reason: str_to_eviction_reason(&reason_s)
2020 .unwrap_or(EvictionReason::UserDelete),
2021 metadata,
2022 })
2023 })
2024 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2025 let mut out = Vec::new();
2026 for r in rows {
2027 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2028 }
2029 out
2030 } else {
2031 Vec::new()
2032 };
2033
2034 let accesses = if filter.include_access_log {
2035 let mut stmt = guard
2036 .prepare(
2037 "SELECT ts, chunk_id, retrieved_by, query_hash, rank, used
2038 FROM memory_access_log ORDER BY ts DESC",
2039 )
2040 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2041 let rows = stmt
2042 .query_map([], |r| {
2043 Ok(AccessEntry {
2044 ts: ms_to_dt(r.get::<_, i64>("ts")?),
2045 chunk_id: r.get("chunk_id")?,
2046 retrieved_by: r.get("retrieved_by")?,
2047 query_hash: r.get("query_hash")?,
2048 rank: r.get::<_, i64>("rank")? as usize,
2049 used: r.get::<_, i64>("used")? != 0,
2050 })
2051 })
2052 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2053 let mut out = Vec::new();
2054 for r in rows {
2055 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2056 }
2057 out
2058 } else {
2059 Vec::new()
2060 };
2061
2062 Ok(ExportBundle {
2063 chunks,
2064 sessions,
2065 evictions,
2066 accesses,
2067 })
2068 })
2069 .await
2070 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2071 }
2072
2073 async fn stats(&self) -> MemoryResult<MemoryStats> {
2074 let conn = Arc::clone(&self.conn);
2075 let model = self.embedder.model_name().to_string();
2076 tokio::task::spawn_blocking(move || -> Result<MemoryStats, MemoryError> {
2077 let guard = conn.blocking_lock();
2078 let total: i64 = guard
2079 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
2080 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2081 let session_tier: i64 = guard
2082 .query_row(
2083 "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'session'",
2084 [],
2085 |r| r.get(0),
2086 )
2087 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2088 let lt_tier: i64 = guard
2089 .query_row(
2090 "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'long_term'",
2091 [],
2092 |r| r.get(0),
2093 )
2094 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2095 let total_sessions: i64 = guard
2096 .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
2097 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2098 let open: i64 = guard
2099 .query_row(
2100 "SELECT COUNT(*) FROM memory_sessions WHERE outcome = 'open'",
2101 [],
2102 |r| r.get(0),
2103 )
2104 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2105 Ok(MemoryStats {
2106 total_chunks: total as usize,
2107 session_chunks: session_tier as usize,
2108 long_term_chunks: lt_tier as usize,
2109 total_sessions: total_sessions as usize,
2110 open_sessions: open as usize,
2111 db_bytes: 0, embedding_model: Some(model),
2113 })
2114 })
2115 .await
2116 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2117 }
2118}
2119
2120fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
2125 use cel_memory::RetrievalProfile::*;
2126 match profile {
2127 AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2130 AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2132 NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2135 NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2138 AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2140 UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2142 }
2143}
2144
2145fn fts_query_escape(raw: &str) -> String {
2150 let escaped = raw.replace('"', "\"\"");
2151 format!("\"{escaped}\"")
2152}
2153
2154fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2157 if let Some(kinds) = &q.kinds {
2159 if !kinds.contains(&c.kind) {
2160 return false;
2161 }
2162 }
2163 if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2165 return false;
2166 }
2167 if let Some(since) = q.since {
2169 if c.created_at < since {
2170 return false;
2171 }
2172 }
2173 if let Some(until) = q.until {
2174 if c.created_at > until {
2175 return false;
2176 }
2177 }
2178 if let Some(sid) = &q.session_id {
2180 if c.session_id.as_deref() != Some(sid.as_str()) {
2181 return false;
2182 }
2183 }
2184 if let Some(prefix) = &q.project_root_prefix {
2186 match &c.project_root {
2187 Some(root) if root.starts_with(prefix.as_str()) => {}
2188 _ => return false,
2189 }
2190 }
2191 if let Some(min) = q.min_importance {
2193 if c.importance < min {
2194 return false;
2195 }
2196 }
2197 match q.caller_scope {
2203 cel_memory::CallerScope::Own => {
2204 if c.caller_id != q.caller_id {
2205 return false;
2206 }
2207 }
2208 cel_memory::CallerScope::OwnPlusShared => {
2209 if c.caller_id != q.caller_id && !c.shareable {
2210 return false;
2211 }
2212 }
2213 cel_memory::CallerScope::Global => {}
2214 }
2215 true
2216}
2217
2218fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2225 let mut clauses: Vec<String> = vec!["1=1".to_string()];
2226 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2227 if let Some(kinds) = &p.kinds {
2228 let placeholders = vec!["?"; kinds.len()].join(",");
2229 clauses.push(format!("kind IN ({placeholders})"));
2230 for k in kinds {
2231 params.push(Box::new(kind_str(*k).to_string()));
2232 }
2233 }
2234 if let Some(callers) = &p.callers {
2235 let placeholders = vec!["?"; callers.len()].join(",");
2236 clauses.push(format!("caller_id IN ({placeholders})"));
2237 for c in callers {
2238 params.push(Box::new(c.clone()));
2239 }
2240 }
2241 if let Some(sids) = &p.session_ids {
2242 let placeholders = vec!["?"; sids.len()].join(",");
2243 clauses.push(format!("session_id IN ({placeholders})"));
2244 for s in sids {
2245 params.push(Box::new(s.clone()));
2246 }
2247 }
2248 if let Some(prefix) = &p.project_root_prefix {
2249 clauses.push("project_root LIKE ?".to_string());
2250 params.push(Box::new(format!("{prefix}%")));
2251 }
2252 if let Some(before) = p.before {
2253 clauses.push("created_at < ?".to_string());
2254 params.push(Box::new(dt_to_ms(before)));
2255 }
2256 if let Some(after) = p.after {
2257 clauses.push("created_at > ?".to_string());
2258 params.push(Box::new(dt_to_ms(after)));
2259 }
2260 if let Some(pinned) = p.pinned {
2261 clauses.push("pinned = ?".to_string());
2262 params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2263 }
2264 if let Some(below) = p.importance_below {
2265 clauses.push("importance < ?".to_string());
2266 params.push(Box::new(below as f64));
2267 }
2268 if let Some(needle) = &p.content_contains {
2269 clauses.push("LOWER(content) LIKE ?".to_string());
2270 params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2271 }
2272 (clauses.join(" AND "), params)
2273}
2274
2275fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2279 Ok(match s {
2280 "user_delete" => EvictionReason::UserDelete,
2281 "aging" => EvictionReason::Aging,
2282 "low_importance" => EvictionReason::LowImportance,
2283 "redact_rule" => EvictionReason::RedactRule,
2284 "storage_cap" => EvictionReason::StorageCap,
2285 "purge_all" => EvictionReason::PurgeAll,
2286 other => {
2287 return Err(MemoryError::Storage(format!(
2288 "unknown eviction reason: {other}"
2289 )))
2290 }
2291 })
2292}
2293
2294fn eviction_reason_str(r: EvictionReason) -> &'static str {
2295 match r {
2296 EvictionReason::UserDelete => "user_delete",
2297 EvictionReason::Aging => "aging",
2298 EvictionReason::LowImportance => "low_importance",
2299 EvictionReason::RedactRule => "redact_rule",
2300 EvictionReason::StorageCap => "storage_cap",
2301 EvictionReason::PurgeAll => "purge_all",
2302 }
2303}
2304
2305#[allow(dead_code)]
2307fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}