1use std::path::Path;
21use std::sync::Arc;
22
23use async_trait::async_trait;
24use cel_memory::{
25 AccessEntry, AgingReport, CallerScope, ChunkKind, ChunkSource, EvictionEntry, EvictionReason,
26 ExportBundle, ExportFilter, MemoryChunk, MemoryError, MemoryPredicate, MemoryProvider,
27 MemoryQuery, MemorySession, MemoryStats, MemoryTier, NewMemoryChunk, NewMemorySession,
28 PurgeReport, ReEmbedReport, Result as MemoryResult, SessionFilter, SessionOutcome,
29};
30use chrono::{DateTime, NaiveDate, Utc};
31use rusqlite::{params, Connection, OptionalExtension};
32use tokio::sync::Mutex;
33use uuid::Uuid;
34
35use crate::cache::RetrieveCache;
36use crate::embedder::Embedder;
37use crate::error::SqliteMemoryError;
38use crate::migrations;
39
40const RETRIEVE_CACHE_CAPACITY: usize = 256;
44const RETRIEVE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
48
49pub struct SqliteMemoryProvider {
51 conn: Arc<Mutex<Connection>>,
52 embedder: Arc<dyn Embedder>,
53 write_hook: Option<Arc<dyn cel_memory::MemoryWriteHook>>,
58 summarizer: Option<Arc<dyn cel_memory::Summarizer>>,
63 retrieve_cache: Arc<RetrieveCache<Vec<MemoryChunk>>>,
66}
67
68impl SqliteMemoryProvider {
69 pub async fn open(
79 path: impl AsRef<Path>,
80 embedder: Arc<dyn Embedder>,
81 ) -> Result<Self, SqliteMemoryError> {
82 let path = path.as_ref().to_path_buf();
83 crate::vec_extension::register();
84 let conn = tokio::task::spawn_blocking(move || -> Result<Connection, SqliteMemoryError> {
85 let mut c = Connection::open(&path)?;
86 c.pragma_update(None, "journal_mode", "WAL")?;
88 c.pragma_update(None, "synchronous", "NORMAL")?;
89 migrations::run(&mut c)?;
90 Ok(c)
91 })
92 .await
93 .map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
94 Ok(Self {
95 conn: Arc::new(Mutex::new(conn)),
96 embedder,
97 write_hook: None,
98 summarizer: None,
99 retrieve_cache: Arc::new(RetrieveCache::new(
100 RETRIEVE_CACHE_CAPACITY,
101 RETRIEVE_CACHE_TTL,
102 )),
103 })
104 }
105
106 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(
407 &self,
408 date: NaiveDate,
409 force: bool,
410 ) -> MemoryResult<Vec<MemoryChunk>> {
411 let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
412 "SqliteMemoryProvider::rollup_day — no summarizer attached \
413 (call `with_summarizer` first)",
414 ))?;
415
416 if !force && self.day_rollup_exists(date).await? {
417 tracing::debug!(date = %date, "rollup_day: existing rollup found, skipping");
418 return Ok(Vec::new());
419 }
420
421 let members = self.fetch_day_chunks(date).await?;
422 if members.is_empty() {
423 tracing::debug!(date = %date, "rollup_day: no chunks to summarise, no-op");
424 return Ok(Vec::new());
425 }
426 let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
427
428 let kind_label = format!("day {date}");
429 let ctx = cel_memory::SummaryContext {
430 kind_label: Some(kind_label.clone()),
431 note: Some(format!(
432 "Daily rollup for {date} ({} chunks)",
433 members.len()
434 )),
435 max_words: None,
436 };
437 let summary_text = summarizer
438 .summarize(&members, &ctx)
439 .await
440 .map_err(|e| match e {
441 cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
442 "summarizer received no input despite day having chunks".into(),
443 ),
444 other => MemoryError::Provider(format!(
445 "summarizer {} failed: {other}",
446 summarizer.name()
447 )),
448 })?;
449
450 let new_chunk = NewMemoryChunk {
454 kind: ChunkKind::Rollup,
455 source: ChunkSource::System,
456 session_id: None,
457 project_root: None,
458 caller_id: "system".to_string(),
459 content: summary_text,
460 metadata: serde_json::json!({
461 "rollup_kind": "day",
462 "rollup_date": date.to_string(),
463 "member_count": member_ids.len(),
464 "summarizer": summarizer.name(),
465 }),
466 importance: None,
467 shareable: false,
468 pinned: false,
469 };
470 let written = self.write(new_chunk).await?;
471 self.link_rollup_members(&written.id, &member_ids).await?;
472 Ok(vec![written])
473 }
474
475 async fn rollup_rule_week_inner(
486 &self,
487 rule_id: &str,
488 week_start: NaiveDate,
489 force: bool,
490 ) -> MemoryResult<MemoryChunk> {
491 let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
492 "SqliteMemoryProvider::rollup_rule_week — no summarizer attached \
493 (call `with_summarizer` first)",
494 ))?;
495
496 if !force && self.rule_week_rollup_exists(rule_id, week_start).await? {
497 return Err(MemoryError::InvalidArgument(format!(
498 "rollup already exists for rule {rule_id} week {week_start}"
499 )));
500 }
501
502 let members = self.fetch_rule_week_chunks(rule_id, week_start).await?;
503 if members.is_empty() {
504 return Err(MemoryError::NotFound(format!(
505 "no fires for rule {rule_id} in week of {week_start}"
506 )));
507 }
508 let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
509
510 let kind_label = format!("week of {week_start} for rule {rule_id}");
511 let ctx = cel_memory::SummaryContext {
512 kind_label: Some(kind_label.clone()),
513 note: Some(format!(
514 "Weekly rollup for rule {rule_id} ({} fires)",
515 members.len()
516 )),
517 max_words: None,
518 };
519 let summary_text = summarizer
520 .summarize(&members, &ctx)
521 .await
522 .map_err(|e| match e {
523 cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
524 "summarizer received no input despite rule-week having chunks".into(),
525 ),
526 other => MemoryError::Provider(format!(
527 "summarizer {} failed: {other}",
528 summarizer.name()
529 )),
530 })?;
531
532 let new_chunk = NewMemoryChunk {
533 kind: ChunkKind::Rollup,
534 source: ChunkSource::System,
535 session_id: None,
536 project_root: None,
537 caller_id: "system".to_string(),
538 content: summary_text,
539 metadata: serde_json::json!({
540 "rollup_kind": "rule_week",
541 "rollup_rule_id": rule_id,
542 "rollup_week_start": week_start.to_string(),
543 "member_count": member_ids.len(),
544 "summarizer": summarizer.name(),
545 }),
546 importance: None,
547 shareable: false,
548 pinned: false,
549 };
550 let written = self.write(new_chunk).await?;
551 self.link_rollup_members(&written.id, &member_ids).await?;
552 Ok(written)
553 }
554}
555
556fn now_ms() -> i64 {
557 Utc::now().timestamp_millis()
558}
559
560fn cache_key_for_query(query: &MemoryQuery) -> Option<u64> {
566 use std::collections::hash_map::DefaultHasher;
567 use std::hash::{Hash, Hasher};
568 let s = serde_json::to_string(query).ok()?;
569 let mut h = DefaultHasher::new();
570 s.hash(&mut h);
571 Some(h.finish())
572}
573
574fn dt_to_ms(t: DateTime<Utc>) -> i64 {
575 t.timestamp_millis()
576}
577
578fn ms_to_dt(ms: i64) -> DateTime<Utc> {
579 chrono::DateTime::<Utc>::from_timestamp_millis(ms)
580 .unwrap_or_else(|| DateTime::<Utc>::from_timestamp_millis(0).unwrap())
581}
582
583fn kind_str(k: ChunkKind) -> &'static str {
584 match k {
585 ChunkKind::Chat => "chat",
586 ChunkKind::Action => "action",
587 ChunkKind::Fire => "fire",
588 ChunkKind::Observation => "observation",
589 ChunkKind::Correction => "correction",
590 ChunkKind::JobSummary => "job_summary",
591 ChunkKind::Context => "context",
592 ChunkKind::Rollup => "rollup",
593 }
594}
595
596fn str_to_kind(s: &str) -> Result<ChunkKind, MemoryError> {
597 Ok(match s {
598 "chat" => ChunkKind::Chat,
599 "action" => ChunkKind::Action,
600 "fire" => ChunkKind::Fire,
601 "observation" => ChunkKind::Observation,
602 "correction" => ChunkKind::Correction,
603 "job_summary" => ChunkKind::JobSummary,
604 "context" => ChunkKind::Context,
605 "rollup" => ChunkKind::Rollup,
606 other => return Err(MemoryError::Storage(format!("unknown kind: {other}"))),
607 })
608}
609
610fn source_str(s: ChunkSource) -> &'static str {
611 match s {
612 ChunkSource::Embedded => "embedded",
613 ChunkSource::Mcp => "mcp",
614 ChunkSource::Gateway => "gateway",
615 ChunkSource::Matcher => "matcher",
616 ChunkSource::Perception => "perception",
617 ChunkSource::System => "system",
618 }
619}
620
621const LEGACY_PERCEPTION_SOURCE: &str = concat!("cor", "tex");
622
623fn str_to_source(s: &str) -> Result<ChunkSource, MemoryError> {
624 Ok(match s {
625 "embedded" => ChunkSource::Embedded,
626 "mcp" => ChunkSource::Mcp,
627 "gateway" => ChunkSource::Gateway,
628 "matcher" => ChunkSource::Matcher,
629 "perception" | LEGACY_PERCEPTION_SOURCE => ChunkSource::Perception,
630 "system" => ChunkSource::System,
631 other => return Err(MemoryError::Storage(format!("unknown source: {other}"))),
632 })
633}
634
635fn tier_str(t: MemoryTier) -> &'static str {
636 match t {
637 MemoryTier::Session => "session",
638 MemoryTier::LongTerm => "long_term",
639 }
640}
641
642fn str_to_tier(s: &str) -> Result<MemoryTier, MemoryError> {
643 Ok(match s {
644 "session" => MemoryTier::Session,
645 "long_term" => MemoryTier::LongTerm,
646 other => return Err(MemoryError::Storage(format!("unknown tier: {other}"))),
647 })
648}
649
650fn outcome_str(o: SessionOutcome) -> &'static str {
651 match o {
652 SessionOutcome::Open => "open",
653 SessionOutcome::Success => "success",
654 SessionOutcome::Failure => "failure",
655 SessionOutcome::Aborted => "aborted",
656 }
657}
658
659fn str_to_outcome(s: &str) -> Result<SessionOutcome, MemoryError> {
660 Ok(match s {
661 "open" => SessionOutcome::Open,
662 "success" => SessionOutcome::Success,
663 "failure" => SessionOutcome::Failure,
664 "aborted" => SessionOutcome::Aborted,
665 other => return Err(MemoryError::Storage(format!("unknown outcome: {other}"))),
666 })
667}
668
669fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryChunk> {
670 let metadata_str: String = row.get("metadata")?;
671 let metadata: serde_json::Value =
672 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
673 let shareable = row.get::<_, i64>("shareable").unwrap_or(0) != 0;
677 Ok(MemoryChunk {
678 id: row.get("id")?,
679 created_at: ms_to_dt(row.get::<_, i64>("created_at")?),
680 kind: str_to_kind(&row.get::<_, String>("kind")?).unwrap_or(ChunkKind::Chat),
681 tier: str_to_tier(&row.get::<_, String>("tier")?).unwrap_or(MemoryTier::Session),
682 source: str_to_source(&row.get::<_, String>("source")?).unwrap_or(ChunkSource::System),
683 session_id: row.get("session_id")?,
684 project_root: row.get("project_root")?,
685 caller_id: row.get("caller_id")?,
686 content: row.get("content")?,
687 metadata,
688 importance: row.get::<_, f64>("importance")? as f32,
689 pinned: row.get::<_, i64>("pinned")? != 0,
690 shareable,
691 superseded_by: row.get("superseded_by")?,
692 embedding_model: row.get("embedding_model")?,
693 embedding_dim: row.get::<_, i64>("embedding_dim")? as u32,
694 })
695}
696
697#[async_trait]
698impl MemoryProvider for SqliteMemoryProvider {
699 async fn retrieve(&self, query: MemoryQuery) -> MemoryResult<Vec<MemoryChunk>> {
702 if query.text.trim().is_empty() {
720 return Err(MemoryError::InvalidArgument(
721 "query.text must not be empty".into(),
722 ));
723 }
724
725 let cache_key = cache_key_for_query(&query);
728 if let Some(key) = cache_key {
729 if let Some(hit) = self.retrieve_cache.get(key) {
730 return Ok(hit);
731 }
732 }
733 let k = query.k.max(1);
734 let candidate_k = (3 * k).max(16);
737
738 let (w_vec, w_fts, w_rec, half_life_secs) = retrieval_weights(query.profile);
739
740 let q_embedding = self
741 .embedder
742 .embed(&query.text)
743 .await
744 .map_err(|e| MemoryError::Storage(e.to_string()))?;
745 let q_text = query.text.clone();
746 let conn = Arc::clone(&self.conn);
747
748 let compute =
749 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
750 let guard = conn.blocking_lock();
751
752 let v_json = serde_json::to_string(&q_embedding)
758 .map_err(|e| MemoryError::Storage(e.to_string()))?;
759 let vec_ids: Vec<String> = {
760 let sql = "SELECT v.chunk_id FROM memory_vec v
761 WHERE v.embedding MATCH ?
762 AND k = ?
763 ORDER BY distance";
764 let mut stmt = guard
765 .prepare(sql)
766 .map_err(|e| MemoryError::Storage(e.to_string()))?;
767 let rows = stmt
768 .query_map(params![v_json, candidate_k as i64], |r| {
769 r.get::<_, String>(0)
770 })
771 .map_err(|e| MemoryError::Storage(e.to_string()))?;
772 let mut out = Vec::new();
773 for r in rows {
774 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
775 }
776 out
777 };
778
779 let fts_ids: Vec<String> = {
782 let sql = "SELECT chunk_id FROM memory_fts
783 WHERE memory_fts MATCH ?
784 ORDER BY rank
785 LIMIT ?";
786 let mut stmt = guard
787 .prepare(sql)
788 .map_err(|e| MemoryError::Storage(e.to_string()))?;
789 let rows = stmt
790 .query_map(
791 params![fts_query_escape(&q_text), candidate_k as i64],
792 |r| r.get::<_, String>(0),
793 )
794 .map_err(|e| MemoryError::Storage(e.to_string()))?;
795 let mut out = Vec::new();
796 for r in rows {
797 match r {
801 Ok(id) => out.push(id),
802 Err(_) => break,
803 }
804 }
805 out
806 };
807
808 let recency_ids: Vec<String> = {
812 let sql = "SELECT id FROM memory_chunks
813 ORDER BY created_at DESC LIMIT ?";
814 let mut stmt = guard
815 .prepare(sql)
816 .map_err(|e| MemoryError::Storage(e.to_string()))?;
817 let rows = stmt
818 .query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
819 .map_err(|e| MemoryError::Storage(e.to_string()))?;
820 let mut out = Vec::new();
821 for r in rows {
822 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
823 }
824 out
825 };
826
827 const RRF_K: f32 = 60.0;
829 let mut scores: std::collections::HashMap<String, f32> =
830 std::collections::HashMap::new();
831 for (rank, id) in vec_ids.iter().enumerate() {
832 *scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
833 }
834 for (rank, id) in fts_ids.iter().enumerate() {
835 *scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
836 }
837 let now_ms_val = now_ms();
841 let recency_rows: std::collections::HashMap<String, i64> = {
842 if recency_ids.is_empty() {
843 Default::default()
844 } else {
845 let placeholders = vec!["?"; recency_ids.len()].join(",");
846 let sql = format!(
847 "SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
848 );
849 let p: Vec<&dyn rusqlite::ToSql> = recency_ids
850 .iter()
851 .map(|s| s as &dyn rusqlite::ToSql)
852 .collect();
853 let mut stmt = guard
854 .prepare(&sql)
855 .map_err(|e| MemoryError::Storage(e.to_string()))?;
856 let rows = stmt
857 .query_map(p.as_slice(), |r| {
858 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
859 })
860 .map_err(|e| MemoryError::Storage(e.to_string()))?;
861 let mut map = std::collections::HashMap::new();
862 for r in rows {
863 let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
864 map.insert(id, t);
865 }
866 map
867 }
868 };
869 for (id, created_ms) in &recency_rows {
870 let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
871 let recency = (-dt_secs / half_life_secs).exp();
872 *scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
873 }
874
875 let mut id_set: std::collections::HashSet<String> =
877 std::collections::HashSet::new();
878 id_set.extend(vec_ids);
879 id_set.extend(fts_ids);
880 id_set.extend(recency_ids);
881 if id_set.is_empty() {
882 return Ok(Vec::new());
883 }
884 let placeholders = vec!["?"; id_set.len()].join(",");
885 let select_sql = format!(
886 "SELECT id, created_at, kind, tier, source, session_id,
887 project_root, caller_id, content, metadata,
888 importance, pinned, shareable, superseded_by,
889 embedding_model, embedding_dim
890 FROM memory_chunks WHERE id IN ({placeholders})"
891 );
892 let ids_vec: Vec<String> = id_set.into_iter().collect();
893 let candidates: Vec<MemoryChunk> = {
894 let p: Vec<&dyn rusqlite::ToSql> =
895 ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
896 let mut stmt = guard
897 .prepare(&select_sql)
898 .map_err(|e| MemoryError::Storage(e.to_string()))?;
899 let rows = stmt
900 .query_map(p.as_slice(), row_to_chunk)
901 .map_err(|e| MemoryError::Storage(e.to_string()))?;
902 let mut out = Vec::new();
903 for r in rows {
904 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
905 }
906 out
907 };
908
909 let mut filtered: Vec<MemoryChunk> = candidates
912 .into_iter()
913 .filter(|c| chunk_matches_query(c, &query))
914 .collect();
915
916 filtered.sort_by(|a, b| {
918 let sa = scores.get(&a.id).copied().unwrap_or(0.0);
919 let sb = scores.get(&b.id).copied().unwrap_or(0.0);
920 sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
921 });
922 filtered.truncate(k);
923 Ok(filtered)
924 });
925 let result = compute
926 .await
927 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
928
929 if let Some(key) = cache_key {
933 self.retrieve_cache.insert(key, result.clone());
934 }
935 Ok(result)
936 }
937
938 async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
939 let conn = Arc::clone(&self.conn);
940 let chunk_id = chunk_id.to_string();
941 let res: Result<Option<MemoryChunk>, MemoryError> =
942 tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
943 let guard = conn.blocking_lock();
944 let mut stmt = guard
945 .prepare(
946 "SELECT id, created_at, kind, tier, source, session_id,
947 project_root, caller_id, content, metadata,
948 importance, pinned, shareable, superseded_by,
949 embedding_model, embedding_dim
950 FROM memory_chunks WHERE id = ?",
951 )
952 .map_err(|e| MemoryError::Storage(e.to_string()))?;
953 let row = stmt
954 .query_row(params![chunk_id], row_to_chunk)
955 .optional()
956 .map_err(|e| MemoryError::Storage(e.to_string()))?;
957 Ok(row)
958 })
959 .await
960 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
961 res
962 }
963
964 async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
965 let conn = Arc::clone(&self.conn);
966 let session_id = session_id.to_string();
967 tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
968 let guard = conn.blocking_lock();
969 let mut stmt = guard
970 .prepare(
971 "SELECT id, started_at, ended_at, caller_id, title, summary,
972 outcome, metadata
973 FROM memory_sessions WHERE id = ?",
974 )
975 .map_err(|e| MemoryError::Storage(e.to_string()))?;
976 let row = stmt
977 .query_row(params![session_id], |r| {
978 let metadata_str: String = r.get("metadata")?;
979 let metadata: serde_json::Value =
980 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
981 Ok(MemorySession {
982 id: r.get("id")?,
983 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
984 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
985 caller_id: r.get("caller_id")?,
986 title: r.get("title")?,
987 summary: r.get("summary")?,
988 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
989 .unwrap_or(SessionOutcome::Aborted),
990 metadata,
991 })
992 })
993 .optional()
994 .map_err(|e| MemoryError::Storage(e.to_string()))?;
995 Ok(row)
996 })
997 .await
998 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
999 }
1000
1001 async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
1002 let conn = Arc::clone(&self.conn);
1003 tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
1004 let guard = conn.blocking_lock();
1005 let mut sql = String::from(
1009 "SELECT id, started_at, ended_at, caller_id, title, summary,
1010 outcome, metadata
1011 FROM memory_sessions WHERE 1=1",
1012 );
1013 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1014 if let Some(c) = &filter.caller_id {
1015 sql.push_str(" AND caller_id = ?");
1016 params_vec.push(Box::new(c.clone()));
1017 }
1018 if let Some(o) = filter.outcome {
1019 sql.push_str(" AND outcome = ?");
1020 params_vec.push(Box::new(outcome_str(o).to_string()));
1021 } else if filter.open_only {
1022 sql.push_str(" AND outcome = ?");
1023 params_vec.push(Box::new("open".to_string()));
1024 }
1025 if let Some(since) = filter.since {
1026 sql.push_str(" AND started_at >= ?");
1027 params_vec.push(Box::new(dt_to_ms(since)));
1028 }
1029 if let Some(until) = filter.until {
1030 sql.push_str(" AND started_at <= ?");
1031 params_vec.push(Box::new(dt_to_ms(until)));
1032 }
1033 sql.push_str(" ORDER BY started_at DESC");
1034 if let Some(n) = filter.limit {
1035 sql.push_str(" LIMIT ?");
1036 params_vec.push(Box::new(n as i64));
1037 }
1038
1039 let mut stmt = guard
1040 .prepare(&sql)
1041 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1042 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1043 let rows = stmt
1044 .query_map(p.as_slice(), |r| {
1045 let metadata_str: String = r.get("metadata")?;
1046 let metadata: serde_json::Value =
1047 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
1048 Ok(MemorySession {
1049 id: r.get("id")?,
1050 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1051 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1052 caller_id: r.get("caller_id")?,
1053 title: r.get("title")?,
1054 summary: r.get("summary")?,
1055 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1056 .unwrap_or(SessionOutcome::Aborted),
1057 metadata,
1058 })
1059 })
1060 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1061 let mut out = Vec::new();
1062 for row in rows {
1063 out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1064 }
1065 Ok(out)
1066 })
1067 .await
1068 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1069 }
1070
1071 async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
1074 if new_chunk.content.trim().is_empty() {
1075 return Err(MemoryError::InvalidArgument(
1076 "content must not be empty".into(),
1077 ));
1078 }
1079 self.invalidate_retrieve_cache();
1083
1084 if let Some(hook) = &self.write_hook {
1089 match hook.before_write(&new_chunk).await? {
1090 cel_memory::WriteDecision::Allow => {}
1091 cel_memory::WriteDecision::Redact { reason } => {
1092 return Ok(MemoryChunk {
1093 id: Uuid::now_v7().to_string(),
1094 created_at: Utc::now(),
1095 kind: new_chunk.kind,
1096 tier: MemoryTier::Session,
1097 source: new_chunk.source,
1098 session_id: new_chunk.session_id,
1099 project_root: new_chunk.project_root,
1100 caller_id: new_chunk.caller_id,
1101 content: format!("<redacted: {reason}>"),
1102 metadata: serde_json::json!({"redacted": true, "reason": reason}),
1103 importance: 0.0,
1104 pinned: false,
1105 shareable: false,
1106 superseded_by: None,
1107 embedding_model: "none".into(),
1108 embedding_dim: 0,
1109 });
1110 }
1111 }
1112 }
1113
1114 let id = Uuid::now_v7().to_string();
1115 let created_at_ms = now_ms();
1116 let importance = cel_memory::importance::score(&new_chunk);
1120 let embedder_dim = self.embedder.dim();
1121 let embedder_name = self.embedder.model_name().to_string();
1122 let embedding = self
1125 .embedder
1126 .embed(&new_chunk.content)
1127 .await
1128 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1129 if embedding.len() != embedder_dim {
1130 return Err(MemoryError::Internal(format!(
1131 "embedder produced dim {}, declared {}",
1132 embedding.len(),
1133 embedder_dim
1134 )));
1135 }
1136
1137 let chunk = MemoryChunk {
1138 id: id.clone(),
1139 created_at: ms_to_dt(created_at_ms),
1140 kind: new_chunk.kind,
1141 tier: MemoryTier::Session,
1142 source: new_chunk.source,
1143 session_id: new_chunk.session_id.clone(),
1144 project_root: new_chunk.project_root.clone(),
1145 caller_id: new_chunk.caller_id.clone(),
1146 content: new_chunk.content.clone(),
1147 metadata: new_chunk.metadata.clone(),
1148 importance,
1149 pinned: new_chunk.pinned,
1150 shareable: new_chunk.shareable,
1151 superseded_by: None,
1152 embedding_model: embedder_name.clone(),
1153 embedding_dim: embedder_dim as u32,
1154 };
1155
1156 let conn = Arc::clone(&self.conn);
1157 let chunk_for_blocking = chunk.clone();
1158 let embedding_clone = embedding.clone();
1159 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1160 let mut guard = conn.blocking_lock();
1161 let tx = guard
1162 .transaction()
1163 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1164 tx.execute(
1165 "INSERT INTO memory_chunks(
1166 id, created_at, kind, tier, source, session_id, project_root,
1167 caller_id, content, metadata, importance, pinned, shareable,
1168 superseded_by, embedding_model, embedding_dim
1169 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1170 params![
1171 chunk_for_blocking.id,
1172 created_at_ms,
1173 kind_str(chunk_for_blocking.kind),
1174 tier_str(chunk_for_blocking.tier),
1175 source_str(chunk_for_blocking.source),
1176 chunk_for_blocking.session_id,
1177 chunk_for_blocking.project_root,
1178 chunk_for_blocking.caller_id,
1179 chunk_for_blocking.content,
1180 serde_json::to_string(&chunk_for_blocking.metadata)
1181 .unwrap_or_else(|_| "null".into()),
1182 chunk_for_blocking.importance as f64,
1183 if chunk_for_blocking.pinned { 1 } else { 0 },
1184 if new_chunk.shareable { 1 } else { 0 },
1185 Option::<String>::None,
1186 chunk_for_blocking.embedding_model,
1187 chunk_for_blocking.embedding_dim as i64,
1188 ],
1189 )
1190 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1191 let v_json = serde_json::to_string(&embedding_clone)
1195 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1196 tx.execute(
1197 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1198 params![chunk_for_blocking.id, v_json],
1199 )
1200 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1201 tx.commit()
1202 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1203 Ok(())
1204 })
1205 .await
1206 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1207
1208 Ok(chunk)
1209 }
1210
1211 async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
1212 if chunks.is_empty() {
1217 return Ok(Vec::new());
1218 }
1219 if chunks.len() == 1 {
1220 let nc = chunks.into_iter().next().expect("len == 1");
1221 return Ok(vec![self.write(nc).await?]);
1222 }
1223 self.invalidate_retrieve_cache();
1224
1225 if self.write_hook.is_some() {
1230 let mut out = Vec::with_capacity(chunks.len());
1231 for nc in chunks {
1232 out.push(self.write(nc).await?);
1233 }
1234 return Ok(out);
1235 }
1236
1237 for (i, nc) in chunks.iter().enumerate() {
1240 if nc.content.trim().is_empty() {
1241 return Err(MemoryError::InvalidArgument(format!(
1242 "chunks[{i}].content must not be empty"
1243 )));
1244 }
1245 }
1246
1247 let embedder_dim = self.embedder.dim();
1248 let embedder_name = self.embedder.model_name().to_string();
1249
1250 let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
1255 let embeddings = self
1256 .embedder
1257 .embed_batch(&texts)
1258 .await
1259 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1260 if embeddings.len() != chunks.len() {
1261 return Err(MemoryError::Internal(format!(
1262 "embedder returned {} vectors for {} inputs",
1263 embeddings.len(),
1264 chunks.len()
1265 )));
1266 }
1267 for (i, v) in embeddings.iter().enumerate() {
1268 if v.len() != embedder_dim {
1269 return Err(MemoryError::Internal(format!(
1270 "embedder produced dim {} for chunk {i}, declared {}",
1271 v.len(),
1272 embedder_dim
1273 )));
1274 }
1275 }
1276
1277 let now_ms_val = now_ms();
1280 let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
1281 Vec::with_capacity(chunks.len());
1282 for (nc, embedding) in chunks.into_iter().zip(embeddings) {
1283 let id = Uuid::now_v7().to_string();
1284 let importance = cel_memory::importance::score(&nc);
1285 let metadata_json =
1286 serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
1287 let mc = MemoryChunk {
1288 id,
1289 created_at: ms_to_dt(now_ms_val),
1290 kind: nc.kind,
1291 tier: MemoryTier::Session,
1292 source: nc.source,
1293 session_id: nc.session_id,
1294 project_root: nc.project_root,
1295 caller_id: nc.caller_id,
1296 content: nc.content,
1297 metadata: nc.metadata,
1298 importance,
1299 pinned: nc.pinned,
1300 shareable: nc.shareable,
1301 superseded_by: None,
1302 embedding_model: embedder_name.clone(),
1303 embedding_dim: embedder_dim as u32,
1304 };
1305 owned.push((mc, embedding, nc.shareable, metadata_json));
1306 }
1307
1308 let conn = Arc::clone(&self.conn);
1309 let inserted =
1310 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
1311 let mut guard = conn.blocking_lock();
1312 let tx = guard
1313 .transaction()
1314 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1315 let mut out = Vec::with_capacity(owned.len());
1316 for (mc, embedding, shareable, metadata_json) in &owned {
1317 tx.execute(
1318 "INSERT INTO memory_chunks(
1319 id, created_at, kind, tier, source, session_id, project_root,
1320 caller_id, content, metadata, importance, pinned, shareable,
1321 superseded_by, embedding_model, embedding_dim
1322 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1323 params![
1324 mc.id,
1325 now_ms_val,
1326 kind_str(mc.kind),
1327 tier_str(mc.tier),
1328 source_str(mc.source),
1329 mc.session_id,
1330 mc.project_root,
1331 mc.caller_id,
1332 mc.content,
1333 metadata_json,
1334 mc.importance as f64,
1335 if mc.pinned { 1 } else { 0 },
1336 if *shareable { 1 } else { 0 },
1337 Option::<String>::None,
1338 mc.embedding_model,
1339 mc.embedding_dim as i64,
1340 ],
1341 )
1342 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1343 let v_json = serde_json::to_string(embedding)
1344 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1345 tx.execute(
1346 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1347 params![mc.id, v_json],
1348 )
1349 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1350 out.push(mc.clone());
1351 }
1352 tx.commit()
1353 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1354 Ok(out)
1355 })
1356 .await
1357 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1358
1359 Ok(inserted)
1360 }
1361
1362 async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
1363 let session = MemorySession {
1364 id: Uuid::now_v7().to_string(),
1365 started_at: Utc::now(),
1366 ended_at: None,
1367 caller_id: init.caller_id.clone(),
1368 title: init.title.clone(),
1369 summary: None,
1370 outcome: SessionOutcome::Open,
1371 metadata: init.metadata.clone(),
1372 };
1373 let conn = Arc::clone(&self.conn);
1374 let s = session.clone();
1375 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1376 let guard = conn.blocking_lock();
1377 guard
1378 .execute(
1379 "INSERT INTO memory_sessions(
1380 id, started_at, ended_at, caller_id, title, summary,
1381 outcome, metadata
1382 ) VALUES (?,?,?,?,?,?,?,?)",
1383 params![
1384 s.id,
1385 dt_to_ms(s.started_at),
1386 Option::<i64>::None,
1387 s.caller_id,
1388 s.title,
1389 Option::<String>::None,
1390 outcome_str(s.outcome),
1391 serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
1392 ],
1393 )
1394 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1395 Ok(())
1396 })
1397 .await
1398 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1399 Ok(session)
1400 }
1401
1402 async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
1403 let resolved = match outcome {
1404 SessionOutcome::Open => SessionOutcome::Aborted,
1405 other => other,
1406 };
1407 let conn = Arc::clone(&self.conn);
1408 let sid = session_id.to_string();
1409 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1410 let guard = conn.blocking_lock();
1411 let n = guard
1412 .execute(
1413 "UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
1414 params![now_ms(), outcome_str(resolved), sid],
1415 )
1416 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1417 if n == 0 {
1418 return Err(MemoryError::NotFound(format!("session {sid}")));
1419 }
1420 Ok(())
1421 })
1422 .await
1423 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1424 }
1425
1426 async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
1427 let conn = Arc::clone(&self.conn);
1428 let sid = session_id.to_string();
1429 let new_title = title.to_string();
1430 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1431 let guard = conn.blocking_lock();
1432 let n = guard
1433 .execute(
1434 "UPDATE memory_sessions SET title = ? WHERE id = ?",
1435 params![new_title, sid],
1436 )
1437 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1438 if n == 0 {
1439 return Err(MemoryError::NotFound(format!("session {sid}")));
1440 }
1441 Ok(())
1442 })
1443 .await
1444 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1445 }
1446
1447 async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
1450 self.invalidate_retrieve_cache();
1451 let conn = Arc::clone(&self.conn);
1452 let id = chunk_id.to_string();
1453 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1454 let guard = conn.blocking_lock();
1455 let n = guard
1456 .execute(
1457 "UPDATE memory_chunks SET pinned = ? WHERE id = ?",
1458 params![if pinned { 1 } else { 0 }, id],
1459 )
1460 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1461 if n == 0 {
1462 return Err(MemoryError::NotFound(format!("chunk {id}")));
1463 }
1464 Ok(())
1465 })
1466 .await
1467 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1468 }
1469
1470 async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
1471 self.invalidate_retrieve_cache();
1472 let conn = Arc::clone(&self.conn);
1473 let id = chunk_id.to_string();
1474 let clamped = importance.clamp(0.0, 1.0) as f64;
1475 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1476 let guard = conn.blocking_lock();
1477 let n = guard
1478 .execute(
1479 "UPDATE memory_chunks SET importance = ? WHERE id = ?",
1480 params![clamped, id],
1481 )
1482 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1483 if n == 0 {
1484 return Err(MemoryError::NotFound(format!("chunk {id}")));
1485 }
1486 Ok(())
1487 })
1488 .await
1489 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1490 }
1491
1492 async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
1493 self.invalidate_retrieve_cache();
1494 let conn = Arc::clone(&self.conn);
1495 let old = old_id.to_string();
1496 let new = new_id.to_string();
1497 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1498 let guard = conn.blocking_lock();
1499 let new_exists: i64 = guard
1503 .query_row(
1504 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1505 params![new],
1506 |r| r.get(0),
1507 )
1508 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1509 if new_exists == 0 {
1510 return Err(MemoryError::NotFound(format!("new chunk {new}")));
1511 }
1512 let n = guard
1513 .execute(
1514 "UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
1515 params![new, old],
1516 )
1517 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1518 if n == 0 {
1519 return Err(MemoryError::NotFound(format!("old chunk {old}")));
1520 }
1521 Ok(())
1522 })
1523 .await
1524 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1525 }
1526
1527 async fn record_access(
1528 &self,
1529 chunk_id: &str,
1530 retrieved_by: &str,
1531 used: bool,
1532 ) -> MemoryResult<()> {
1533 let conn = Arc::clone(&self.conn);
1534 let id = chunk_id.to_string();
1535 let by = retrieved_by.to_string();
1536 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1537 let guard = conn.blocking_lock();
1538 let exists: i64 = guard
1539 .query_row(
1540 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1541 params![id],
1542 |r| r.get(0),
1543 )
1544 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1545 if exists == 0 {
1546 return Err(MemoryError::NotFound(format!("chunk {id}")));
1547 }
1548 guard
1549 .execute(
1550 "INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
1551 VALUES (?, ?, ?, '', 0, ?)",
1552 params![now_ms(), id, by, if used { 1 } else { 0 }],
1553 )
1554 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1555 Ok(())
1556 })
1557 .await
1558 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1559 }
1560
1561 async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
1564 self.invalidate_retrieve_cache();
1565 let conn = Arc::clone(&self.conn);
1566 let id = chunk_id.to_string();
1567 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1568 let mut guard = conn.blocking_lock();
1569 let tx = guard
1570 .transaction()
1571 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1572 let n = tx
1573 .execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1574 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1575 if n == 0 {
1576 return Err(MemoryError::NotFound(format!("chunk {id}")));
1577 }
1578 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1579 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1580 tx.execute(
1581 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1582 VALUES (?,?,?, '{}')",
1583 params![now_ms(), id, eviction_reason_str(reason)],
1584 )
1585 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1586 tx.commit()
1587 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1588 Ok(())
1589 })
1590 .await
1591 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1592 }
1593
1594 async fn delete_matching(
1595 &self,
1596 predicate: MemoryPredicate,
1597 reason: EvictionReason,
1598 ) -> MemoryResult<usize> {
1599 if predicate.is_empty() {
1604 return Ok(0);
1605 }
1606 self.invalidate_retrieve_cache();
1607 let conn = Arc::clone(&self.conn);
1608 tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
1609 let (where_clause, params_vec) = build_chunk_predicate(&predicate);
1610 let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
1611 let mut guard = conn.blocking_lock();
1612 let tx = guard
1613 .transaction()
1614 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1615 let ids: Vec<String> = {
1616 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1617 let mut stmt = tx
1618 .prepare(&select_sql)
1619 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1620 let rows = stmt
1621 .query_map(p.as_slice(), |r| r.get::<_, String>(0))
1622 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1623 let mut out = Vec::new();
1624 for r in rows {
1625 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1626 }
1627 out
1628 };
1629 let now = now_ms();
1630 let reason_s = eviction_reason_str(reason);
1631 for id in &ids {
1632 tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1633 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1634 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1635 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1636 tx.execute(
1637 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1638 VALUES (?, ?, ?, '{}')",
1639 params![now, id, reason_s],
1640 )
1641 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1642 }
1643 tx.commit()
1644 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1645 Ok(ids.len())
1646 })
1647 .await
1648 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1649 }
1650
1651 async fn purge_all(&self) -> MemoryResult<PurgeReport> {
1652 self.invalidate_retrieve_cache();
1653 let conn = Arc::clone(&self.conn);
1654 tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
1655 let mut guard = conn.blocking_lock();
1656 let tx = guard
1657 .transaction()
1658 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1659 let chunks: i64 = tx
1660 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
1661 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1662 let sessions: i64 = tx
1663 .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
1664 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1665 let access: i64 = tx
1666 .query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
1667 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1668 let evictions: i64 = tx
1669 .query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
1670 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1671
1672 tx.execute("DELETE FROM memory_chunks", [])
1673 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1674 tx.execute("DELETE FROM memory_vec", [])
1675 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1676 tx.execute("DELETE FROM memory_sessions", [])
1677 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1678 tx.execute("DELETE FROM memory_access_log", [])
1679 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1680 tx.execute("DELETE FROM memory_eviction_log", [])
1681 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1682 tx.execute("DELETE FROM memory_summary_members", [])
1683 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1684 tx.commit()
1685 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1686
1687 Ok(PurgeReport {
1688 chunks_deleted: chunks as usize,
1689 sessions_deleted: sessions as usize,
1690 access_log_deleted: access as usize,
1691 eviction_log_deleted: evictions as usize,
1692 })
1693 })
1694 .await
1695 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1696 }
1697
1698 async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
1701 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 let started = std::time::Instant::now();
1919 if self.embedder.model_name() != target_model {
1920 return Err(MemoryError::InvalidArgument(format!(
1921 "configured embedder is '{}', re_embed_all target_model is '{}'",
1922 self.embedder.model_name(),
1923 target_model
1924 )));
1925 }
1926 self.invalidate_retrieve_cache();
1927
1928 let embedder = Arc::clone(&self.embedder);
1929 let embedder_dim = embedder.dim();
1930 let embedder_name = embedder.model_name().to_string();
1931 let conn = Arc::clone(&self.conn);
1932
1933 let rows: Vec<(String, String)> =
1934 tokio::task::spawn_blocking(move || -> Result<Vec<(String, String)>, MemoryError> {
1935 let guard = conn.blocking_lock();
1936 let mut stmt = guard
1937 .prepare(
1938 "SELECT id, content FROM memory_chunks
1939 WHERE embedding_model != 'none'
1940 ORDER BY created_at ASC",
1941 )
1942 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1943 let mapped = stmt
1944 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
1945 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1946 let mut out = Vec::new();
1947 for row in mapped {
1948 out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1949 }
1950 Ok(out)
1951 })
1952 .await
1953 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1954
1955 let total = rows.len();
1956 if total == 0 {
1957 return Ok(ReEmbedReport {
1958 total: 0,
1959 succeeded: 0,
1960 failed: 0,
1961 elapsed_ms: started.elapsed().as_millis() as u64,
1962 });
1963 }
1964
1965 let texts: Vec<String> = rows.iter().map(|(_, content)| content.clone()).collect();
1966 let embeddings = embedder
1967 .embed_batch(&texts)
1968 .await
1969 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1970 if embeddings.len() != total {
1971 return Err(MemoryError::Internal(format!(
1972 "embedder returned {} vectors for {} inputs",
1973 embeddings.len(),
1974 total
1975 )));
1976 }
1977
1978 let conn = Arc::clone(&self.conn);
1979 let (succeeded, failed) =
1980 tokio::task::spawn_blocking(move || -> Result<(usize, usize), MemoryError> {
1981 let mut guard = conn.blocking_lock();
1982 let tx = guard
1983 .transaction()
1984 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1985 let mut succeeded = 0usize;
1986 let mut failed = 0usize;
1987 for (i, (id, _)) in rows.iter().enumerate() {
1988 let embedding = &embeddings[i];
1989 if embedding.len() != embedder_dim {
1990 failed += 1;
1991 continue;
1992 }
1993 let v_json = serde_json::to_string(embedding)
1994 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1995 tx.execute(
1996 "UPDATE memory_chunks SET embedding_model = ?, embedding_dim = ? WHERE id = ?",
1997 params![embedder_name, embedder_dim as i64, id],
1998 )
1999 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2000 tx.execute(
2001 "UPDATE memory_vec SET embedding = ? WHERE chunk_id = ?",
2002 params![v_json, id],
2003 )
2004 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2005 succeeded += 1;
2006 }
2007 tx.commit()
2008 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2009 Ok((succeeded, failed))
2010 })
2011 .await
2012 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
2013
2014 Ok(ReEmbedReport {
2015 total,
2016 succeeded,
2017 failed,
2018 elapsed_ms: started.elapsed().as_millis() as u64,
2019 })
2020 }
2021 async fn export(&self, filter: ExportFilter) -> MemoryResult<ExportBundle> {
2022 let conn = Arc::clone(&self.conn);
2023 tokio::task::spawn_blocking(move || -> Result<ExportBundle, MemoryError> {
2024 let guard = conn.blocking_lock();
2025
2026 let (where_clause, params_vec) = match &filter.predicate {
2028 Some(p) if !p.is_empty() => build_chunk_predicate(p),
2029 _ => ("1=1".to_string(), Vec::new()),
2030 };
2031 let select_sql = format!(
2032 "SELECT id, created_at, kind, tier, source, session_id,
2033 project_root, caller_id, content, metadata,
2034 importance, pinned, shareable, superseded_by,
2035 embedding_model, embedding_dim
2036 FROM memory_chunks WHERE {} ORDER BY created_at DESC",
2037 where_clause
2038 );
2039 let chunks: Vec<MemoryChunk> = {
2040 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
2041 let mut stmt = guard
2042 .prepare(&select_sql)
2043 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2044 let rows = stmt
2045 .query_map(p.as_slice(), row_to_chunk)
2046 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2047 let mut out = Vec::new();
2048 for r in rows {
2049 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2050 }
2051 out
2052 };
2053
2054 let sessions = if filter.include_sessions {
2057 let session_ids: std::collections::HashSet<String> =
2058 chunks.iter().filter_map(|c| c.session_id.clone()).collect();
2059 if session_ids.is_empty() {
2060 Vec::new()
2061 } else {
2062 let placeholders = vec!["?"; session_ids.len()].join(",");
2063 let sql = format!(
2064 "SELECT id, started_at, ended_at, caller_id, title, summary,
2065 outcome, metadata
2066 FROM memory_sessions WHERE id IN ({placeholders})"
2067 );
2068 let ids: Vec<String> = session_ids.into_iter().collect();
2069 let p: Vec<&dyn rusqlite::ToSql> =
2070 ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
2071 let mut stmt = guard
2072 .prepare(&sql)
2073 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2074 let rows = stmt
2075 .query_map(p.as_slice(), |r| {
2076 let metadata_str: String = r.get("metadata")?;
2077 let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
2078 .unwrap_or(serde_json::Value::Null);
2079 Ok(MemorySession {
2080 id: r.get("id")?,
2081 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
2082 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
2083 caller_id: r.get("caller_id")?,
2084 title: r.get("title")?,
2085 summary: r.get("summary")?,
2086 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
2087 .unwrap_or(SessionOutcome::Aborted),
2088 metadata,
2089 })
2090 })
2091 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2092 let mut out = Vec::new();
2093 for r in rows {
2094 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2095 }
2096 out
2097 }
2098 } else {
2099 Vec::new()
2100 };
2101
2102 let evictions = if filter.include_eviction_log {
2103 let mut stmt = guard
2104 .prepare(
2105 "SELECT ts, chunk_id, reason, metadata
2106 FROM memory_eviction_log ORDER BY ts DESC",
2107 )
2108 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2109 let rows = stmt
2110 .query_map([], |r| {
2111 let reason_s: String = r.get("reason")?;
2112 let metadata_s: String = r.get("metadata")?;
2113 let metadata: serde_json::Value =
2114 serde_json::from_str(&metadata_s).unwrap_or(serde_json::Value::Null);
2115 Ok(EvictionEntry {
2116 ts: ms_to_dt(r.get::<_, i64>("ts")?),
2117 chunk_id: r.get("chunk_id")?,
2118 reason: str_to_eviction_reason(&reason_s)
2119 .unwrap_or(EvictionReason::UserDelete),
2120 metadata,
2121 })
2122 })
2123 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2124 let mut out = Vec::new();
2125 for r in rows {
2126 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2127 }
2128 out
2129 } else {
2130 Vec::new()
2131 };
2132
2133 let accesses = if filter.include_access_log {
2134 let mut stmt = guard
2135 .prepare(
2136 "SELECT ts, chunk_id, retrieved_by, query_hash, rank, used
2137 FROM memory_access_log ORDER BY ts DESC",
2138 )
2139 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2140 let rows = stmt
2141 .query_map([], |r| {
2142 Ok(AccessEntry {
2143 ts: ms_to_dt(r.get::<_, i64>("ts")?),
2144 chunk_id: r.get("chunk_id")?,
2145 retrieved_by: r.get("retrieved_by")?,
2146 query_hash: r.get("query_hash")?,
2147 rank: r.get::<_, i64>("rank")? as usize,
2148 used: r.get::<_, i64>("used")? != 0,
2149 })
2150 })
2151 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2152 let mut out = Vec::new();
2153 for r in rows {
2154 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2155 }
2156 out
2157 } else {
2158 Vec::new()
2159 };
2160
2161 Ok(ExportBundle {
2162 chunks,
2163 sessions,
2164 evictions,
2165 accesses,
2166 })
2167 })
2168 .await
2169 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2170 }
2171
2172 async fn stats(&self) -> MemoryResult<MemoryStats> {
2173 let conn = Arc::clone(&self.conn);
2174 let model = self.embedder.model_name().to_string();
2175 tokio::task::spawn_blocking(move || -> Result<MemoryStats, MemoryError> {
2176 let guard = conn.blocking_lock();
2177 let total: i64 = guard
2178 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
2179 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2180 let session_tier: i64 = guard
2181 .query_row(
2182 "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'session'",
2183 [],
2184 |r| r.get(0),
2185 )
2186 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2187 let lt_tier: i64 = guard
2188 .query_row(
2189 "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'long_term'",
2190 [],
2191 |r| r.get(0),
2192 )
2193 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2194 let total_sessions: i64 = guard
2195 .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
2196 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2197 let open: i64 = guard
2198 .query_row(
2199 "SELECT COUNT(*) FROM memory_sessions WHERE outcome = 'open'",
2200 [],
2201 |r| r.get(0),
2202 )
2203 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2204 Ok(MemoryStats {
2205 total_chunks: total as usize,
2206 session_chunks: session_tier as usize,
2207 long_term_chunks: lt_tier as usize,
2208 total_sessions: total_sessions as usize,
2209 open_sessions: open as usize,
2210 db_bytes: 0, embedding_model: Some(model),
2212 })
2213 })
2214 .await
2215 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2216 }
2217}
2218
2219fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
2223 use cel_memory::RetrievalProfile::*;
2224 match profile {
2225 AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2228 AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2230 NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2233 NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2236 AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2238 UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2240 }
2241}
2242
2243fn fts_query_escape(raw: &str) -> String {
2248 let escaped = raw.replace('"', "\"\"");
2249 format!("\"{escaped}\"")
2250}
2251
2252fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2255 if let Some(kinds) = &q.kinds {
2257 if !kinds.contains(&c.kind) {
2258 return false;
2259 }
2260 }
2261 if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2263 return false;
2264 }
2265 if let Some(since) = q.since {
2267 if c.created_at < since {
2268 return false;
2269 }
2270 }
2271 if let Some(until) = q.until {
2272 if c.created_at > until {
2273 return false;
2274 }
2275 }
2276 if let Some(sid) = &q.session_id {
2278 if c.session_id.as_deref() != Some(sid.as_str()) {
2279 return false;
2280 }
2281 }
2282 if let Some(prefix) = &q.project_root_prefix {
2284 match &c.project_root {
2285 Some(root) if root.starts_with(prefix.as_str()) => {}
2286 _ => return false,
2287 }
2288 }
2289 if let Some(min) = q.min_importance {
2291 if c.importance < min {
2292 return false;
2293 }
2294 }
2295 match q.caller_scope {
2301 cel_memory::CallerScope::Own => {
2302 if c.caller_id != q.caller_id {
2303 return false;
2304 }
2305 }
2306 cel_memory::CallerScope::OwnPlusShared => {
2307 if c.caller_id != q.caller_id && !c.shareable {
2308 return false;
2309 }
2310 }
2311 cel_memory::CallerScope::Global => {}
2312 }
2313 true
2314}
2315
2316fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2323 let mut clauses: Vec<String> = vec!["1=1".to_string()];
2324 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2325 if let Some(kinds) = &p.kinds {
2326 let placeholders = vec!["?"; kinds.len()].join(",");
2327 clauses.push(format!("kind IN ({placeholders})"));
2328 for k in kinds {
2329 params.push(Box::new(kind_str(*k).to_string()));
2330 }
2331 }
2332 if let Some(callers) = &p.callers {
2333 let placeholders = vec!["?"; callers.len()].join(",");
2334 clauses.push(format!("caller_id IN ({placeholders})"));
2335 for c in callers {
2336 params.push(Box::new(c.clone()));
2337 }
2338 }
2339 if let Some(sids) = &p.session_ids {
2340 let placeholders = vec!["?"; sids.len()].join(",");
2341 clauses.push(format!("session_id IN ({placeholders})"));
2342 for s in sids {
2343 params.push(Box::new(s.clone()));
2344 }
2345 }
2346 if let Some(prefix) = &p.project_root_prefix {
2347 clauses.push("project_root LIKE ?".to_string());
2348 params.push(Box::new(format!("{prefix}%")));
2349 }
2350 if let Some(before) = p.before {
2351 clauses.push("created_at < ?".to_string());
2352 params.push(Box::new(dt_to_ms(before)));
2353 }
2354 if let Some(after) = p.after {
2355 clauses.push("created_at > ?".to_string());
2356 params.push(Box::new(dt_to_ms(after)));
2357 }
2358 if let Some(pinned) = p.pinned {
2359 clauses.push("pinned = ?".to_string());
2360 params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2361 }
2362 if let Some(below) = p.importance_below {
2363 clauses.push("importance < ?".to_string());
2364 params.push(Box::new(below as f64));
2365 }
2366 if let Some(needle) = &p.content_contains {
2367 clauses.push("LOWER(content) LIKE ?".to_string());
2368 params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2369 }
2370 (clauses.join(" AND "), params)
2371}
2372
2373fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2377 Ok(match s {
2378 "user_delete" => EvictionReason::UserDelete,
2379 "aging" => EvictionReason::Aging,
2380 "low_importance" => EvictionReason::LowImportance,
2381 "redact_rule" => EvictionReason::RedactRule,
2382 "storage_cap" => EvictionReason::StorageCap,
2383 "purge_all" => EvictionReason::PurgeAll,
2384 other => {
2385 return Err(MemoryError::Storage(format!(
2386 "unknown eviction reason: {other}"
2387 )))
2388 }
2389 })
2390}
2391
2392fn eviction_reason_str(r: EvictionReason) -> &'static str {
2393 match r {
2394 EvictionReason::UserDelete => "user_delete",
2395 EvictionReason::Aging => "aging",
2396 EvictionReason::LowImportance => "low_importance",
2397 EvictionReason::RedactRule => "redact_rule",
2398 EvictionReason::StorageCap => "storage_cap",
2399 EvictionReason::PurgeAll => "purge_all",
2400 }
2401}
2402
2403#[allow(dead_code)]
2405fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}