1use std::path::Path;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use cel_memory::{
24 AccessEntry, AgingReport, CallerScope, ChunkKind, ChunkSource, EvictionEntry, EvictionReason,
25 ExportBundle, ExportFilter, MemoryChunk, MemoryError, MemoryPredicate, MemoryProvider,
26 MemoryQuery, MemorySession, MemoryStats, MemoryTier, NewMemoryChunk, NewMemorySession,
27 PurgeReport, ReEmbedReport, Result as MemoryResult, SessionFilter, SessionOutcome,
28};
29use chrono::{DateTime, NaiveDate, Utc};
30use rusqlite::{params, Connection, OptionalExtension};
31use tokio::sync::Mutex;
32use uuid::Uuid;
33
34use crate::cache::RetrieveCache;
35use crate::embedder::Embedder;
36use crate::error::SqliteMemoryError;
37use crate::migrations;
38
39const RETRIEVE_CACHE_CAPACITY: usize = 256;
43const RETRIEVE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
47
48pub struct SqliteMemoryProvider {
50 conn: Arc<Mutex<Connection>>,
51 embedder: Arc<dyn Embedder>,
52 write_hook: Option<Arc<dyn cel_memory::MemoryWriteHook>>,
57 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() {
719 return Err(MemoryError::InvalidArgument(
720 "query.text must not be empty".into(),
721 ));
722 }
723
724 let cache_key = cache_key_for_query(&query);
727 if let Some(key) = cache_key {
728 if let Some(hit) = self.retrieve_cache.get(key) {
729 return Ok(hit);
730 }
731 }
732 let k = query.k.max(1);
733 let candidate_k = (3 * k).max(16);
736
737 let (w_vec, w_fts, w_rec, half_life_secs) = retrieval_weights(query.profile);
738
739 let q_embedding = self
740 .embedder
741 .embed(&query.text)
742 .await
743 .map_err(|e| MemoryError::Storage(e.to_string()))?;
744 let q_text = query.text.clone();
745 let conn = Arc::clone(&self.conn);
746
747 let compute =
748 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
749 let guard = conn.blocking_lock();
750
751 let v_json = serde_json::to_string(&q_embedding)
757 .map_err(|e| MemoryError::Storage(e.to_string()))?;
758 let vec_ids: Vec<String> = {
759 let sql = "SELECT v.chunk_id FROM memory_vec v
760 WHERE v.embedding MATCH ?
761 AND k = ?
762 ORDER BY distance";
763 let mut stmt = guard
764 .prepare(sql)
765 .map_err(|e| MemoryError::Storage(e.to_string()))?;
766 let rows = stmt
767 .query_map(params![v_json, candidate_k as i64], |r| {
768 r.get::<_, String>(0)
769 })
770 .map_err(|e| MemoryError::Storage(e.to_string()))?;
771 let mut out = Vec::new();
772 for r in rows {
773 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
774 }
775 out
776 };
777
778 let fts_ids: Vec<String> = {
781 let sql = "SELECT chunk_id FROM memory_fts
782 WHERE memory_fts MATCH ?
783 ORDER BY rank
784 LIMIT ?";
785 let mut stmt = guard
786 .prepare(sql)
787 .map_err(|e| MemoryError::Storage(e.to_string()))?;
788 let rows = stmt
789 .query_map(
790 params![fts_query_escape(&q_text), candidate_k as i64],
791 |r| r.get::<_, String>(0),
792 )
793 .map_err(|e| MemoryError::Storage(e.to_string()))?;
794 let mut out = Vec::new();
795 for r in rows {
796 match r {
800 Ok(id) => out.push(id),
801 Err(_) => break,
802 }
803 }
804 out
805 };
806
807 let recency_ids: Vec<String> = {
811 let sql = "SELECT id FROM memory_chunks
812 ORDER BY created_at DESC LIMIT ?";
813 let mut stmt = guard
814 .prepare(sql)
815 .map_err(|e| MemoryError::Storage(e.to_string()))?;
816 let rows = stmt
817 .query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
818 .map_err(|e| MemoryError::Storage(e.to_string()))?;
819 let mut out = Vec::new();
820 for r in rows {
821 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
822 }
823 out
824 };
825
826 const RRF_K: f32 = 60.0;
828 let mut scores: std::collections::HashMap<String, f32> =
829 std::collections::HashMap::new();
830 for (rank, id) in vec_ids.iter().enumerate() {
831 *scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
832 }
833 for (rank, id) in fts_ids.iter().enumerate() {
834 *scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
835 }
836 let now_ms_val = now_ms();
840 let recency_rows: std::collections::HashMap<String, i64> = {
841 if recency_ids.is_empty() {
842 Default::default()
843 } else {
844 let placeholders = vec!["?"; recency_ids.len()].join(",");
845 let sql = format!(
846 "SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
847 );
848 let p: Vec<&dyn rusqlite::ToSql> = recency_ids
849 .iter()
850 .map(|s| s as &dyn rusqlite::ToSql)
851 .collect();
852 let mut stmt = guard
853 .prepare(&sql)
854 .map_err(|e| MemoryError::Storage(e.to_string()))?;
855 let rows = stmt
856 .query_map(p.as_slice(), |r| {
857 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
858 })
859 .map_err(|e| MemoryError::Storage(e.to_string()))?;
860 let mut map = std::collections::HashMap::new();
861 for r in rows {
862 let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
863 map.insert(id, t);
864 }
865 map
866 }
867 };
868 for (id, created_ms) in &recency_rows {
869 let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
870 let recency = (-dt_secs / half_life_secs).exp();
871 *scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
872 }
873
874 let mut id_set: std::collections::HashSet<String> =
876 std::collections::HashSet::new();
877 id_set.extend(vec_ids);
878 id_set.extend(fts_ids);
879 id_set.extend(recency_ids);
880 if id_set.is_empty() {
881 return Ok(Vec::new());
882 }
883 let placeholders = vec!["?"; id_set.len()].join(",");
884 let select_sql = format!(
885 "SELECT id, created_at, kind, tier, source, session_id,
886 project_root, caller_id, content, metadata,
887 importance, pinned, shareable, superseded_by,
888 embedding_model, embedding_dim
889 FROM memory_chunks WHERE id IN ({placeholders})"
890 );
891 let ids_vec: Vec<String> = id_set.into_iter().collect();
892 let candidates: Vec<MemoryChunk> = {
893 let p: Vec<&dyn rusqlite::ToSql> =
894 ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
895 let mut stmt = guard
896 .prepare(&select_sql)
897 .map_err(|e| MemoryError::Storage(e.to_string()))?;
898 let rows = stmt
899 .query_map(p.as_slice(), row_to_chunk)
900 .map_err(|e| MemoryError::Storage(e.to_string()))?;
901 let mut out = Vec::new();
902 for r in rows {
903 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
904 }
905 out
906 };
907
908 let mut filtered: Vec<MemoryChunk> = candidates
911 .into_iter()
912 .filter(|c| chunk_matches_query(c, &query))
913 .collect();
914
915 filtered.sort_by(|a, b| {
917 let sa = scores.get(&a.id).copied().unwrap_or(0.0);
918 let sb = scores.get(&b.id).copied().unwrap_or(0.0);
919 sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
920 });
921 filtered.truncate(k);
922 Ok(filtered)
923 });
924 let result = compute
925 .await
926 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
927
928 if let Some(key) = cache_key {
932 self.retrieve_cache.insert(key, result.clone());
933 }
934 Ok(result)
935 }
936
937 async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
938 let conn = Arc::clone(&self.conn);
939 let chunk_id = chunk_id.to_string();
940 let res: Result<Option<MemoryChunk>, MemoryError> =
941 tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
942 let guard = conn.blocking_lock();
943 let mut stmt = guard
944 .prepare(
945 "SELECT id, created_at, kind, tier, source, session_id,
946 project_root, caller_id, content, metadata,
947 importance, pinned, shareable, superseded_by,
948 embedding_model, embedding_dim
949 FROM memory_chunks WHERE id = ?",
950 )
951 .map_err(|e| MemoryError::Storage(e.to_string()))?;
952 let row = stmt
953 .query_row(params![chunk_id], row_to_chunk)
954 .optional()
955 .map_err(|e| MemoryError::Storage(e.to_string()))?;
956 Ok(row)
957 })
958 .await
959 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
960 res
961 }
962
963 async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
964 let conn = Arc::clone(&self.conn);
965 let session_id = session_id.to_string();
966 tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
967 let guard = conn.blocking_lock();
968 let mut stmt = guard
969 .prepare(
970 "SELECT id, started_at, ended_at, caller_id, title, summary,
971 outcome, metadata
972 FROM memory_sessions WHERE id = ?",
973 )
974 .map_err(|e| MemoryError::Storage(e.to_string()))?;
975 let row = stmt
976 .query_row(params![session_id], |r| {
977 let metadata_str: String = r.get("metadata")?;
978 let metadata: serde_json::Value =
979 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
980 Ok(MemorySession {
981 id: r.get("id")?,
982 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
983 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
984 caller_id: r.get("caller_id")?,
985 title: r.get("title")?,
986 summary: r.get("summary")?,
987 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
988 .unwrap_or(SessionOutcome::Aborted),
989 metadata,
990 })
991 })
992 .optional()
993 .map_err(|e| MemoryError::Storage(e.to_string()))?;
994 Ok(row)
995 })
996 .await
997 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
998 }
999
1000 async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
1001 let conn = Arc::clone(&self.conn);
1002 tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
1003 let guard = conn.blocking_lock();
1004 let mut sql = String::from(
1008 "SELECT id, started_at, ended_at, caller_id, title, summary,
1009 outcome, metadata
1010 FROM memory_sessions WHERE 1=1",
1011 );
1012 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1013 if let Some(c) = &filter.caller_id {
1014 sql.push_str(" AND caller_id = ?");
1015 params_vec.push(Box::new(c.clone()));
1016 }
1017 if let Some(o) = filter.outcome {
1018 sql.push_str(" AND outcome = ?");
1019 params_vec.push(Box::new(outcome_str(o).to_string()));
1020 } else if filter.open_only {
1021 sql.push_str(" AND outcome = ?");
1022 params_vec.push(Box::new("open".to_string()));
1023 }
1024 if let Some(since) = filter.since {
1025 sql.push_str(" AND started_at >= ?");
1026 params_vec.push(Box::new(dt_to_ms(since)));
1027 }
1028 if let Some(until) = filter.until {
1029 sql.push_str(" AND started_at <= ?");
1030 params_vec.push(Box::new(dt_to_ms(until)));
1031 }
1032 sql.push_str(" ORDER BY started_at DESC");
1033 if let Some(n) = filter.limit {
1034 sql.push_str(" LIMIT ?");
1035 params_vec.push(Box::new(n as i64));
1036 }
1037
1038 let mut stmt = guard
1039 .prepare(&sql)
1040 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1041 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1042 let rows = stmt
1043 .query_map(p.as_slice(), |r| {
1044 let metadata_str: String = r.get("metadata")?;
1045 let metadata: serde_json::Value =
1046 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
1047 Ok(MemorySession {
1048 id: r.get("id")?,
1049 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1050 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1051 caller_id: r.get("caller_id")?,
1052 title: r.get("title")?,
1053 summary: r.get("summary")?,
1054 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1055 .unwrap_or(SessionOutcome::Aborted),
1056 metadata,
1057 })
1058 })
1059 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1060 let mut out = Vec::new();
1061 for row in rows {
1062 out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1063 }
1064 Ok(out)
1065 })
1066 .await
1067 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1068 }
1069
1070 async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
1073 if new_chunk.content.trim().is_empty() {
1074 return Err(MemoryError::InvalidArgument(
1075 "content must not be empty".into(),
1076 ));
1077 }
1078 self.invalidate_retrieve_cache();
1082
1083 if let Some(hook) = &self.write_hook {
1088 match hook.before_write(&new_chunk).await? {
1089 cel_memory::WriteDecision::Allow => {}
1090 cel_memory::WriteDecision::Redact { reason } => {
1091 return Ok(MemoryChunk {
1092 id: Uuid::now_v7().to_string(),
1093 created_at: Utc::now(),
1094 kind: new_chunk.kind,
1095 tier: MemoryTier::Session,
1096 source: new_chunk.source,
1097 session_id: new_chunk.session_id,
1098 project_root: new_chunk.project_root,
1099 caller_id: new_chunk.caller_id,
1100 content: format!("<redacted: {reason}>"),
1101 metadata: serde_json::json!({"redacted": true, "reason": reason}),
1102 importance: 0.0,
1103 pinned: false,
1104 shareable: false,
1105 superseded_by: None,
1106 embedding_model: "none".into(),
1107 embedding_dim: 0,
1108 });
1109 }
1110 }
1111 }
1112
1113 let id = Uuid::now_v7().to_string();
1114 let created_at_ms = now_ms();
1115 let importance = cel_memory::importance::score(&new_chunk);
1119 let embedder_dim = self.embedder.dim();
1120 let embedder_name = self.embedder.model_name().to_string();
1121 let embedding = self
1124 .embedder
1125 .embed(&new_chunk.content)
1126 .await
1127 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1128 if embedding.len() != embedder_dim {
1129 return Err(MemoryError::Internal(format!(
1130 "embedder produced dim {}, declared {}",
1131 embedding.len(),
1132 embedder_dim
1133 )));
1134 }
1135
1136 let chunk = MemoryChunk {
1137 id: id.clone(),
1138 created_at: ms_to_dt(created_at_ms),
1139 kind: new_chunk.kind,
1140 tier: MemoryTier::Session,
1141 source: new_chunk.source,
1142 session_id: new_chunk.session_id.clone(),
1143 project_root: new_chunk.project_root.clone(),
1144 caller_id: new_chunk.caller_id.clone(),
1145 content: new_chunk.content.clone(),
1146 metadata: new_chunk.metadata.clone(),
1147 importance,
1148 pinned: new_chunk.pinned,
1149 shareable: new_chunk.shareable,
1150 superseded_by: None,
1151 embedding_model: embedder_name.clone(),
1152 embedding_dim: embedder_dim as u32,
1153 };
1154
1155 let conn = Arc::clone(&self.conn);
1156 let chunk_for_blocking = chunk.clone();
1157 let embedding_clone = embedding.clone();
1158 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1159 let mut guard = conn.blocking_lock();
1160 let tx = guard
1161 .transaction()
1162 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1163 tx.execute(
1164 "INSERT INTO memory_chunks(
1165 id, created_at, kind, tier, source, session_id, project_root,
1166 caller_id, content, metadata, importance, pinned, shareable,
1167 superseded_by, embedding_model, embedding_dim
1168 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1169 params![
1170 chunk_for_blocking.id,
1171 created_at_ms,
1172 kind_str(chunk_for_blocking.kind),
1173 tier_str(chunk_for_blocking.tier),
1174 source_str(chunk_for_blocking.source),
1175 chunk_for_blocking.session_id,
1176 chunk_for_blocking.project_root,
1177 chunk_for_blocking.caller_id,
1178 chunk_for_blocking.content,
1179 serde_json::to_string(&chunk_for_blocking.metadata)
1180 .unwrap_or_else(|_| "null".into()),
1181 chunk_for_blocking.importance as f64,
1182 if chunk_for_blocking.pinned { 1 } else { 0 },
1183 if new_chunk.shareable { 1 } else { 0 },
1184 Option::<String>::None,
1185 chunk_for_blocking.embedding_model,
1186 chunk_for_blocking.embedding_dim as i64,
1187 ],
1188 )
1189 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1190 let v_json = serde_json::to_string(&embedding_clone)
1194 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1195 tx.execute(
1196 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1197 params![chunk_for_blocking.id, v_json],
1198 )
1199 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1200 tx.commit()
1201 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1202 Ok(())
1203 })
1204 .await
1205 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1206
1207 Ok(chunk)
1208 }
1209
1210 async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
1211 if chunks.is_empty() {
1216 return Ok(Vec::new());
1217 }
1218 if chunks.len() == 1 {
1219 let nc = chunks.into_iter().next().expect("len == 1");
1220 return Ok(vec![self.write(nc).await?]);
1221 }
1222 self.invalidate_retrieve_cache();
1223
1224 if self.write_hook.is_some() {
1229 let mut out = Vec::with_capacity(chunks.len());
1230 for nc in chunks {
1231 out.push(self.write(nc).await?);
1232 }
1233 return Ok(out);
1234 }
1235
1236 for (i, nc) in chunks.iter().enumerate() {
1239 if nc.content.trim().is_empty() {
1240 return Err(MemoryError::InvalidArgument(format!(
1241 "chunks[{i}].content must not be empty"
1242 )));
1243 }
1244 }
1245
1246 let embedder_dim = self.embedder.dim();
1247 let embedder_name = self.embedder.model_name().to_string();
1248
1249 let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
1254 let embeddings = self
1255 .embedder
1256 .embed_batch(&texts)
1257 .await
1258 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1259 if embeddings.len() != chunks.len() {
1260 return Err(MemoryError::Internal(format!(
1261 "embedder returned {} vectors for {} inputs",
1262 embeddings.len(),
1263 chunks.len()
1264 )));
1265 }
1266 for (i, v) in embeddings.iter().enumerate() {
1267 if v.len() != embedder_dim {
1268 return Err(MemoryError::Internal(format!(
1269 "embedder produced dim {} for chunk {i}, declared {}",
1270 v.len(),
1271 embedder_dim
1272 )));
1273 }
1274 }
1275
1276 let now_ms_val = now_ms();
1279 let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
1280 Vec::with_capacity(chunks.len());
1281 for (nc, embedding) in chunks.into_iter().zip(embeddings) {
1282 let id = Uuid::now_v7().to_string();
1283 let importance = cel_memory::importance::score(&nc);
1284 let metadata_json =
1285 serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
1286 let mc = MemoryChunk {
1287 id,
1288 created_at: ms_to_dt(now_ms_val),
1289 kind: nc.kind,
1290 tier: MemoryTier::Session,
1291 source: nc.source,
1292 session_id: nc.session_id,
1293 project_root: nc.project_root,
1294 caller_id: nc.caller_id,
1295 content: nc.content,
1296 metadata: nc.metadata,
1297 importance,
1298 pinned: nc.pinned,
1299 shareable: nc.shareable,
1300 superseded_by: None,
1301 embedding_model: embedder_name.clone(),
1302 embedding_dim: embedder_dim as u32,
1303 };
1304 owned.push((mc, embedding, nc.shareable, metadata_json));
1305 }
1306
1307 let conn = Arc::clone(&self.conn);
1308 let inserted =
1309 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
1310 let mut guard = conn.blocking_lock();
1311 let tx = guard
1312 .transaction()
1313 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1314 let mut out = Vec::with_capacity(owned.len());
1315 for (mc, embedding, shareable, metadata_json) in &owned {
1316 tx.execute(
1317 "INSERT INTO memory_chunks(
1318 id, created_at, kind, tier, source, session_id, project_root,
1319 caller_id, content, metadata, importance, pinned, shareable,
1320 superseded_by, embedding_model, embedding_dim
1321 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1322 params![
1323 mc.id,
1324 now_ms_val,
1325 kind_str(mc.kind),
1326 tier_str(mc.tier),
1327 source_str(mc.source),
1328 mc.session_id,
1329 mc.project_root,
1330 mc.caller_id,
1331 mc.content,
1332 metadata_json,
1333 mc.importance as f64,
1334 if mc.pinned { 1 } else { 0 },
1335 if *shareable { 1 } else { 0 },
1336 Option::<String>::None,
1337 mc.embedding_model,
1338 mc.embedding_dim as i64,
1339 ],
1340 )
1341 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1342 let v_json = serde_json::to_string(embedding)
1343 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1344 tx.execute(
1345 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1346 params![mc.id, v_json],
1347 )
1348 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1349 out.push(mc.clone());
1350 }
1351 tx.commit()
1352 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1353 Ok(out)
1354 })
1355 .await
1356 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1357
1358 Ok(inserted)
1359 }
1360
1361 async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
1362 let session = MemorySession {
1363 id: Uuid::now_v7().to_string(),
1364 started_at: Utc::now(),
1365 ended_at: None,
1366 caller_id: init.caller_id.clone(),
1367 title: init.title.clone(),
1368 summary: None,
1369 outcome: SessionOutcome::Open,
1370 metadata: init.metadata.clone(),
1371 };
1372 let conn = Arc::clone(&self.conn);
1373 let s = session.clone();
1374 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1375 let guard = conn.blocking_lock();
1376 guard
1377 .execute(
1378 "INSERT INTO memory_sessions(
1379 id, started_at, ended_at, caller_id, title, summary,
1380 outcome, metadata
1381 ) VALUES (?,?,?,?,?,?,?,?)",
1382 params![
1383 s.id,
1384 dt_to_ms(s.started_at),
1385 Option::<i64>::None,
1386 s.caller_id,
1387 s.title,
1388 Option::<String>::None,
1389 outcome_str(s.outcome),
1390 serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
1391 ],
1392 )
1393 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1394 Ok(())
1395 })
1396 .await
1397 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1398 Ok(session)
1399 }
1400
1401 async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
1402 let resolved = match outcome {
1403 SessionOutcome::Open => SessionOutcome::Aborted,
1404 other => other,
1405 };
1406 let conn = Arc::clone(&self.conn);
1407 let sid = session_id.to_string();
1408 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1409 let guard = conn.blocking_lock();
1410 let n = guard
1411 .execute(
1412 "UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
1413 params![now_ms(), outcome_str(resolved), sid],
1414 )
1415 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1416 if n == 0 {
1417 return Err(MemoryError::NotFound(format!("session {sid}")));
1418 }
1419 Ok(())
1420 })
1421 .await
1422 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1423 }
1424
1425 async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
1426 let conn = Arc::clone(&self.conn);
1427 let sid = session_id.to_string();
1428 let new_title = title.to_string();
1429 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1430 let guard = conn.blocking_lock();
1431 let n = guard
1432 .execute(
1433 "UPDATE memory_sessions SET title = ? WHERE id = ?",
1434 params![new_title, sid],
1435 )
1436 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1437 if n == 0 {
1438 return Err(MemoryError::NotFound(format!("session {sid}")));
1439 }
1440 Ok(())
1441 })
1442 .await
1443 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1444 }
1445
1446 async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
1449 self.invalidate_retrieve_cache();
1450 let conn = Arc::clone(&self.conn);
1451 let id = chunk_id.to_string();
1452 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1453 let guard = conn.blocking_lock();
1454 let n = guard
1455 .execute(
1456 "UPDATE memory_chunks SET pinned = ? WHERE id = ?",
1457 params![if pinned { 1 } else { 0 }, id],
1458 )
1459 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1460 if n == 0 {
1461 return Err(MemoryError::NotFound(format!("chunk {id}")));
1462 }
1463 Ok(())
1464 })
1465 .await
1466 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1467 }
1468
1469 async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
1470 self.invalidate_retrieve_cache();
1471 let conn = Arc::clone(&self.conn);
1472 let id = chunk_id.to_string();
1473 let clamped = importance.clamp(0.0, 1.0) as f64;
1474 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1475 let guard = conn.blocking_lock();
1476 let n = guard
1477 .execute(
1478 "UPDATE memory_chunks SET importance = ? WHERE id = ?",
1479 params![clamped, id],
1480 )
1481 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1482 if n == 0 {
1483 return Err(MemoryError::NotFound(format!("chunk {id}")));
1484 }
1485 Ok(())
1486 })
1487 .await
1488 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1489 }
1490
1491 async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
1492 self.invalidate_retrieve_cache();
1493 let conn = Arc::clone(&self.conn);
1494 let old = old_id.to_string();
1495 let new = new_id.to_string();
1496 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1497 let guard = conn.blocking_lock();
1498 let new_exists: i64 = guard
1502 .query_row(
1503 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1504 params![new],
1505 |r| r.get(0),
1506 )
1507 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1508 if new_exists == 0 {
1509 return Err(MemoryError::NotFound(format!("new chunk {new}")));
1510 }
1511 let n = guard
1512 .execute(
1513 "UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
1514 params![new, old],
1515 )
1516 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1517 if n == 0 {
1518 return Err(MemoryError::NotFound(format!("old chunk {old}")));
1519 }
1520 Ok(())
1521 })
1522 .await
1523 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1524 }
1525
1526 async fn record_access(
1527 &self,
1528 chunk_id: &str,
1529 retrieved_by: &str,
1530 used: bool,
1531 ) -> MemoryResult<()> {
1532 let conn = Arc::clone(&self.conn);
1533 let id = chunk_id.to_string();
1534 let by = retrieved_by.to_string();
1535 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1536 let guard = conn.blocking_lock();
1537 let exists: i64 = guard
1538 .query_row(
1539 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1540 params![id],
1541 |r| r.get(0),
1542 )
1543 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1544 if exists == 0 {
1545 return Err(MemoryError::NotFound(format!("chunk {id}")));
1546 }
1547 guard
1548 .execute(
1549 "INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
1550 VALUES (?, ?, ?, '', 0, ?)",
1551 params![now_ms(), id, by, if used { 1 } else { 0 }],
1552 )
1553 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1554 Ok(())
1555 })
1556 .await
1557 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1558 }
1559
1560 async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
1563 self.invalidate_retrieve_cache();
1564 let conn = Arc::clone(&self.conn);
1565 let id = chunk_id.to_string();
1566 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1567 let mut guard = conn.blocking_lock();
1568 let tx = guard
1569 .transaction()
1570 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1571 let n = tx
1572 .execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1573 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1574 if n == 0 {
1575 return Err(MemoryError::NotFound(format!("chunk {id}")));
1576 }
1577 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1578 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1579 tx.execute(
1580 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1581 VALUES (?,?,?, '{}')",
1582 params![now_ms(), id, eviction_reason_str(reason)],
1583 )
1584 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1585 tx.commit()
1586 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1587 Ok(())
1588 })
1589 .await
1590 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1591 }
1592
1593 async fn delete_matching(
1594 &self,
1595 predicate: MemoryPredicate,
1596 reason: EvictionReason,
1597 ) -> MemoryResult<usize> {
1598 if predicate.is_empty() {
1603 return Ok(0);
1604 }
1605 self.invalidate_retrieve_cache();
1606 let conn = Arc::clone(&self.conn);
1607 tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
1608 let (where_clause, params_vec) = build_chunk_predicate(&predicate);
1609 let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
1610 let mut guard = conn.blocking_lock();
1611 let tx = guard
1612 .transaction()
1613 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1614 let ids: Vec<String> = {
1615 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1616 let mut stmt = tx
1617 .prepare(&select_sql)
1618 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1619 let rows = stmt
1620 .query_map(p.as_slice(), |r| r.get::<_, String>(0))
1621 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1622 let mut out = Vec::new();
1623 for r in rows {
1624 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1625 }
1626 out
1627 };
1628 let now = now_ms();
1629 let reason_s = eviction_reason_str(reason);
1630 for id in &ids {
1631 tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1632 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1633 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1634 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1635 tx.execute(
1636 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1637 VALUES (?, ?, ?, '{}')",
1638 params![now, id, reason_s],
1639 )
1640 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1641 }
1642 tx.commit()
1643 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1644 Ok(ids.len())
1645 })
1646 .await
1647 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1648 }
1649
1650 async fn purge_all(&self) -> MemoryResult<PurgeReport> {
1651 self.invalidate_retrieve_cache();
1652 let conn = Arc::clone(&self.conn);
1653 tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
1654 let mut guard = conn.blocking_lock();
1655 let tx = guard
1656 .transaction()
1657 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1658 let chunks: i64 = tx
1659 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
1660 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1661 let sessions: i64 = tx
1662 .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
1663 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1664 let access: i64 = tx
1665 .query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
1666 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1667 let evictions: i64 = tx
1668 .query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
1669 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1670
1671 tx.execute("DELETE FROM memory_chunks", [])
1672 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1673 tx.execute("DELETE FROM memory_vec", [])
1674 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1675 tx.execute("DELETE FROM memory_sessions", [])
1676 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1677 tx.execute("DELETE FROM memory_access_log", [])
1678 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1679 tx.execute("DELETE FROM memory_eviction_log", [])
1680 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1681 tx.execute("DELETE FROM memory_summary_members", [])
1682 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1683 tx.commit()
1684 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1685
1686 Ok(PurgeReport {
1687 chunks_deleted: chunks as usize,
1688 sessions_deleted: sessions as usize,
1689 access_log_deleted: access as usize,
1690 eviction_log_deleted: evictions as usize,
1691 })
1692 })
1693 .await
1694 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1695 }
1696
1697 async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
1700 let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
1704 "SqliteMemoryProvider::summarize_session — no summarizer attached \
1705 (call `with_summarizer` first)",
1706 ))?;
1707
1708 let session = self
1712 .get_session(session_id)
1713 .await?
1714 .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
1715
1716 let members = self.fetch_session_chunks(session_id).await?;
1720 if members.is_empty() {
1721 return Err(MemoryError::InvalidArgument(format!(
1726 "session {session_id} has no chunks to summarize"
1727 )));
1728 }
1729 let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1730
1731 let ctx = cel_memory::SummaryContext {
1734 kind_label: Some("session".into()),
1735 note: session
1736 .title
1737 .as_ref()
1738 .map(|t| format!("session title: {t}")),
1739 max_words: None,
1740 };
1741 let summary_text = summarizer
1742 .summarize(&members, &ctx)
1743 .await
1744 .map_err(|e| match e {
1745 cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
1746 "summarizer received no input despite session having chunks".into(),
1747 ),
1748 other => MemoryError::Provider(format!(
1749 "summarizer {} failed: {other}",
1750 summarizer.name()
1751 )),
1752 })?;
1753
1754 let new_chunk = NewMemoryChunk {
1761 kind: ChunkKind::JobSummary,
1762 source: ChunkSource::Embedded,
1763 session_id: Some(session_id.to_string()),
1764 project_root: members.iter().find_map(|c| c.project_root.clone()),
1765 caller_id: session.caller_id.clone(),
1766 content: summary_text,
1767 metadata: serde_json::json!({
1768 "session_id": session_id,
1769 "member_count": member_ids.len(),
1770 "summarizer": summarizer.name(),
1771 }),
1772 importance: None,
1776 shareable: false,
1777 pinned: false,
1778 };
1779 let written = self.write(new_chunk).await?;
1780
1781 let conn = Arc::clone(&self.conn);
1786 let summary_id = written.id.clone();
1787 let member_ids_for_insert = member_ids.clone();
1788 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1789 let mut guard = conn.blocking_lock();
1790 let tx = guard
1791 .transaction()
1792 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1793 for mid in &member_ids_for_insert {
1794 tx.execute(
1795 "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
1796 VALUES (?, ?)",
1797 params![summary_id, mid],
1798 )
1799 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1800 }
1801 tx.commit()
1802 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1803 Ok(())
1804 })
1805 .await
1806 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1807
1808 let conn = Arc::clone(&self.conn);
1812 let sid = session_id.to_string();
1813 let stored_summary = written.content.clone();
1814 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1815 let guard = conn.blocking_lock();
1816 guard
1817 .execute(
1818 "UPDATE memory_sessions SET summary = ? WHERE id = ?",
1819 params![stored_summary, sid],
1820 )
1821 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1822 Ok(())
1823 })
1824 .await
1825 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1826
1827 Ok(written)
1828 }
1829
1830 async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1831 self.rollup_day_inner(date, false).await
1832 }
1833
1834 async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1835 self.rollup_day_inner(date, true).await
1836 }
1837
1838 async fn rollup_rule_week(
1839 &self,
1840 rule_id: &str,
1841 week_start: NaiveDate,
1842 ) -> MemoryResult<MemoryChunk> {
1843 self.rollup_rule_week_inner(rule_id, week_start, false)
1844 .await
1845 }
1846
1847 async fn rollup_rule_week_forced(
1848 &self,
1849 rule_id: &str,
1850 week_start: NaiveDate,
1851 ) -> MemoryResult<MemoryChunk> {
1852 self.rollup_rule_week_inner(rule_id, week_start, true).await
1853 }
1854
1855 async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
1858 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) {
2124 use cel_memory::RetrievalProfile::*;
2125 match profile {
2126 AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2129 AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2131 NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2134 NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2137 AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2139 UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2141 }
2142}
2143
2144fn fts_query_escape(raw: &str) -> String {
2149 let escaped = raw.replace('"', "\"\"");
2150 format!("\"{escaped}\"")
2151}
2152
2153fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2156 if let Some(kinds) = &q.kinds {
2158 if !kinds.contains(&c.kind) {
2159 return false;
2160 }
2161 }
2162 if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2164 return false;
2165 }
2166 if let Some(since) = q.since {
2168 if c.created_at < since {
2169 return false;
2170 }
2171 }
2172 if let Some(until) = q.until {
2173 if c.created_at > until {
2174 return false;
2175 }
2176 }
2177 if let Some(sid) = &q.session_id {
2179 if c.session_id.as_deref() != Some(sid.as_str()) {
2180 return false;
2181 }
2182 }
2183 if let Some(prefix) = &q.project_root_prefix {
2185 match &c.project_root {
2186 Some(root) if root.starts_with(prefix.as_str()) => {}
2187 _ => return false,
2188 }
2189 }
2190 if let Some(min) = q.min_importance {
2192 if c.importance < min {
2193 return false;
2194 }
2195 }
2196 match q.caller_scope {
2202 cel_memory::CallerScope::Own => {
2203 if c.caller_id != q.caller_id {
2204 return false;
2205 }
2206 }
2207 cel_memory::CallerScope::OwnPlusShared => {
2208 if c.caller_id != q.caller_id && !c.shareable {
2209 return false;
2210 }
2211 }
2212 cel_memory::CallerScope::Global => {}
2213 }
2214 true
2215}
2216
2217fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2224 let mut clauses: Vec<String> = vec!["1=1".to_string()];
2225 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2226 if let Some(kinds) = &p.kinds {
2227 let placeholders = vec!["?"; kinds.len()].join(",");
2228 clauses.push(format!("kind IN ({placeholders})"));
2229 for k in kinds {
2230 params.push(Box::new(kind_str(*k).to_string()));
2231 }
2232 }
2233 if let Some(callers) = &p.callers {
2234 let placeholders = vec!["?"; callers.len()].join(",");
2235 clauses.push(format!("caller_id IN ({placeholders})"));
2236 for c in callers {
2237 params.push(Box::new(c.clone()));
2238 }
2239 }
2240 if let Some(sids) = &p.session_ids {
2241 let placeholders = vec!["?"; sids.len()].join(",");
2242 clauses.push(format!("session_id IN ({placeholders})"));
2243 for s in sids {
2244 params.push(Box::new(s.clone()));
2245 }
2246 }
2247 if let Some(prefix) = &p.project_root_prefix {
2248 clauses.push("project_root LIKE ?".to_string());
2249 params.push(Box::new(format!("{prefix}%")));
2250 }
2251 if let Some(before) = p.before {
2252 clauses.push("created_at < ?".to_string());
2253 params.push(Box::new(dt_to_ms(before)));
2254 }
2255 if let Some(after) = p.after {
2256 clauses.push("created_at > ?".to_string());
2257 params.push(Box::new(dt_to_ms(after)));
2258 }
2259 if let Some(pinned) = p.pinned {
2260 clauses.push("pinned = ?".to_string());
2261 params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2262 }
2263 if let Some(below) = p.importance_below {
2264 clauses.push("importance < ?".to_string());
2265 params.push(Box::new(below as f64));
2266 }
2267 if let Some(needle) = &p.content_contains {
2268 clauses.push("LOWER(content) LIKE ?".to_string());
2269 params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2270 }
2271 (clauses.join(" AND "), params)
2272}
2273
2274fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2278 Ok(match s {
2279 "user_delete" => EvictionReason::UserDelete,
2280 "aging" => EvictionReason::Aging,
2281 "low_importance" => EvictionReason::LowImportance,
2282 "redact_rule" => EvictionReason::RedactRule,
2283 "storage_cap" => EvictionReason::StorageCap,
2284 "purge_all" => EvictionReason::PurgeAll,
2285 other => {
2286 return Err(MemoryError::Storage(format!(
2287 "unknown eviction reason: {other}"
2288 )))
2289 }
2290 })
2291}
2292
2293fn eviction_reason_str(r: EvictionReason) -> &'static str {
2294 match r {
2295 EvictionReason::UserDelete => "user_delete",
2296 EvictionReason::Aging => "aging",
2297 EvictionReason::LowImportance => "low_importance",
2298 EvictionReason::RedactRule => "redact_rule",
2299 EvictionReason::StorageCap => "storage_cap",
2300 EvictionReason::PurgeAll => "purge_all",
2301 }
2302}
2303
2304#[allow(dead_code)]
2306fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}