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::error::SqliteMemoryError;
37use crate::migrations;
38use cel_memory::Embedder;
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.embedder.embed(&query.text).await?;
741 let q_text = query.text.clone();
742 let conn = Arc::clone(&self.conn);
743
744 let compute =
745 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
746 let guard = conn.blocking_lock();
747
748 let v_json = serde_json::to_string(&q_embedding)
754 .map_err(|e| MemoryError::Storage(e.to_string()))?;
755 let vec_ids: Vec<String> = {
756 let sql = "SELECT v.chunk_id FROM memory_vec v
757 WHERE v.embedding MATCH ?
758 AND k = ?
759 ORDER BY distance";
760 let mut stmt = guard
761 .prepare(sql)
762 .map_err(|e| MemoryError::Storage(e.to_string()))?;
763 let rows = stmt
764 .query_map(params![v_json, candidate_k as i64], |r| {
765 r.get::<_, String>(0)
766 })
767 .map_err(|e| MemoryError::Storage(e.to_string()))?;
768 let mut out = Vec::new();
769 for r in rows {
770 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
771 }
772 out
773 };
774
775 let fts_ids: Vec<String> = {
778 let sql = "SELECT chunk_id FROM memory_fts
779 WHERE memory_fts MATCH ?
780 ORDER BY rank
781 LIMIT ?";
782 let mut stmt = guard
783 .prepare(sql)
784 .map_err(|e| MemoryError::Storage(e.to_string()))?;
785 let rows = stmt
786 .query_map(
787 params![fts_query_escape(&q_text), candidate_k as i64],
788 |r| r.get::<_, String>(0),
789 )
790 .map_err(|e| MemoryError::Storage(e.to_string()))?;
791 let mut out = Vec::new();
792 for r in rows {
793 match r {
797 Ok(id) => out.push(id),
798 Err(_) => break,
799 }
800 }
801 out
802 };
803
804 let recency_ids: Vec<String> = {
808 let sql = "SELECT id FROM memory_chunks
809 ORDER BY created_at DESC LIMIT ?";
810 let mut stmt = guard
811 .prepare(sql)
812 .map_err(|e| MemoryError::Storage(e.to_string()))?;
813 let rows = stmt
814 .query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
815 .map_err(|e| MemoryError::Storage(e.to_string()))?;
816 let mut out = Vec::new();
817 for r in rows {
818 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
819 }
820 out
821 };
822
823 const RRF_K: f32 = 60.0;
825 let mut scores: std::collections::HashMap<String, f32> =
826 std::collections::HashMap::new();
827 for (rank, id) in vec_ids.iter().enumerate() {
828 *scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
829 }
830 for (rank, id) in fts_ids.iter().enumerate() {
831 *scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
832 }
833 let now_ms_val = now_ms();
837 let recency_rows: std::collections::HashMap<String, i64> = {
838 if recency_ids.is_empty() {
839 Default::default()
840 } else {
841 let placeholders = vec!["?"; recency_ids.len()].join(",");
842 let sql = format!(
843 "SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
844 );
845 let p: Vec<&dyn rusqlite::ToSql> = recency_ids
846 .iter()
847 .map(|s| s as &dyn rusqlite::ToSql)
848 .collect();
849 let mut stmt = guard
850 .prepare(&sql)
851 .map_err(|e| MemoryError::Storage(e.to_string()))?;
852 let rows = stmt
853 .query_map(p.as_slice(), |r| {
854 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
855 })
856 .map_err(|e| MemoryError::Storage(e.to_string()))?;
857 let mut map = std::collections::HashMap::new();
858 for r in rows {
859 let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
860 map.insert(id, t);
861 }
862 map
863 }
864 };
865 for (id, created_ms) in &recency_rows {
866 let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
867 let recency = (-dt_secs / half_life_secs).exp();
868 *scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
869 }
870
871 let mut id_set: std::collections::HashSet<String> =
873 std::collections::HashSet::new();
874 id_set.extend(vec_ids);
875 id_set.extend(fts_ids);
876 id_set.extend(recency_ids);
877 if id_set.is_empty() {
878 return Ok(Vec::new());
879 }
880 let placeholders = vec!["?"; id_set.len()].join(",");
881 let select_sql = format!(
882 "SELECT id, created_at, kind, tier, source, session_id,
883 project_root, caller_id, content, metadata,
884 importance, pinned, shareable, superseded_by,
885 embedding_model, embedding_dim
886 FROM memory_chunks WHERE id IN ({placeholders})"
887 );
888 let ids_vec: Vec<String> = id_set.into_iter().collect();
889 let candidates: Vec<MemoryChunk> = {
890 let p: Vec<&dyn rusqlite::ToSql> =
891 ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
892 let mut stmt = guard
893 .prepare(&select_sql)
894 .map_err(|e| MemoryError::Storage(e.to_string()))?;
895 let rows = stmt
896 .query_map(p.as_slice(), row_to_chunk)
897 .map_err(|e| MemoryError::Storage(e.to_string()))?;
898 let mut out = Vec::new();
899 for r in rows {
900 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
901 }
902 out
903 };
904
905 let mut filtered: Vec<MemoryChunk> = candidates
908 .into_iter()
909 .filter(|c| chunk_matches_query(c, &query))
910 .collect();
911
912 filtered.sort_by(|a, b| {
914 let sa = scores.get(&a.id).copied().unwrap_or(0.0);
915 let sb = scores.get(&b.id).copied().unwrap_or(0.0);
916 sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
917 });
918 filtered.truncate(k);
919 Ok(filtered)
920 });
921 let result = compute
922 .await
923 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
924
925 if let Some(key) = cache_key {
929 self.retrieve_cache.insert(key, result.clone());
930 }
931 Ok(result)
932 }
933
934 async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
935 let conn = Arc::clone(&self.conn);
936 let chunk_id = chunk_id.to_string();
937 let res: Result<Option<MemoryChunk>, MemoryError> =
938 tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
939 let guard = conn.blocking_lock();
940 let mut stmt = guard
941 .prepare(
942 "SELECT id, created_at, kind, tier, source, session_id,
943 project_root, caller_id, content, metadata,
944 importance, pinned, shareable, superseded_by,
945 embedding_model, embedding_dim
946 FROM memory_chunks WHERE id = ?",
947 )
948 .map_err(|e| MemoryError::Storage(e.to_string()))?;
949 let row = stmt
950 .query_row(params![chunk_id], row_to_chunk)
951 .optional()
952 .map_err(|e| MemoryError::Storage(e.to_string()))?;
953 Ok(row)
954 })
955 .await
956 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
957 res
958 }
959
960 async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
961 let conn = Arc::clone(&self.conn);
962 let session_id = session_id.to_string();
963 tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
964 let guard = conn.blocking_lock();
965 let mut stmt = guard
966 .prepare(
967 "SELECT id, started_at, ended_at, caller_id, title, summary,
968 outcome, metadata
969 FROM memory_sessions WHERE id = ?",
970 )
971 .map_err(|e| MemoryError::Storage(e.to_string()))?;
972 let row = stmt
973 .query_row(params![session_id], |r| {
974 let metadata_str: String = r.get("metadata")?;
975 let metadata: serde_json::Value =
976 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
977 Ok(MemorySession {
978 id: r.get("id")?,
979 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
980 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
981 caller_id: r.get("caller_id")?,
982 title: r.get("title")?,
983 summary: r.get("summary")?,
984 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
985 .unwrap_or(SessionOutcome::Aborted),
986 metadata,
987 })
988 })
989 .optional()
990 .map_err(|e| MemoryError::Storage(e.to_string()))?;
991 Ok(row)
992 })
993 .await
994 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
995 }
996
997 async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
998 let conn = Arc::clone(&self.conn);
999 tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
1000 let guard = conn.blocking_lock();
1001 let mut sql = String::from(
1005 "SELECT id, started_at, ended_at, caller_id, title, summary,
1006 outcome, metadata
1007 FROM memory_sessions WHERE 1=1",
1008 );
1009 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1010 if let Some(c) = &filter.caller_id {
1011 sql.push_str(" AND caller_id = ?");
1012 params_vec.push(Box::new(c.clone()));
1013 }
1014 if let Some(o) = filter.outcome {
1015 sql.push_str(" AND outcome = ?");
1016 params_vec.push(Box::new(outcome_str(o).to_string()));
1017 } else if filter.open_only {
1018 sql.push_str(" AND outcome = ?");
1019 params_vec.push(Box::new("open".to_string()));
1020 }
1021 if let Some(since) = filter.since {
1022 sql.push_str(" AND started_at >= ?");
1023 params_vec.push(Box::new(dt_to_ms(since)));
1024 }
1025 if let Some(until) = filter.until {
1026 sql.push_str(" AND started_at <= ?");
1027 params_vec.push(Box::new(dt_to_ms(until)));
1028 }
1029 sql.push_str(" ORDER BY started_at DESC");
1030 if let Some(n) = filter.limit {
1031 sql.push_str(" LIMIT ?");
1032 params_vec.push(Box::new(n as i64));
1033 }
1034
1035 let mut stmt = guard
1036 .prepare(&sql)
1037 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1038 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1039 let rows = stmt
1040 .query_map(p.as_slice(), |r| {
1041 let metadata_str: String = r.get("metadata")?;
1042 let metadata: serde_json::Value =
1043 serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
1044 Ok(MemorySession {
1045 id: r.get("id")?,
1046 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1047 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1048 caller_id: r.get("caller_id")?,
1049 title: r.get("title")?,
1050 summary: r.get("summary")?,
1051 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1052 .unwrap_or(SessionOutcome::Aborted),
1053 metadata,
1054 })
1055 })
1056 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1057 let mut out = Vec::new();
1058 for row in rows {
1059 out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1060 }
1061 Ok(out)
1062 })
1063 .await
1064 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1065 }
1066
1067 async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
1070 if new_chunk.content.trim().is_empty() {
1071 return Err(MemoryError::InvalidArgument(
1072 "content must not be empty".into(),
1073 ));
1074 }
1075 self.invalidate_retrieve_cache();
1079
1080 if let Some(hook) = &self.write_hook {
1085 match hook.before_write(&new_chunk).await? {
1086 cel_memory::WriteDecision::Allow => {}
1087 cel_memory::WriteDecision::Redact { reason } => {
1088 return Ok(MemoryChunk {
1089 id: Uuid::now_v7().to_string(),
1090 created_at: Utc::now(),
1091 kind: new_chunk.kind,
1092 tier: MemoryTier::Session,
1093 source: new_chunk.source,
1094 session_id: new_chunk.session_id,
1095 project_root: new_chunk.project_root,
1096 caller_id: new_chunk.caller_id,
1097 content: format!("<redacted: {reason}>"),
1098 metadata: serde_json::json!({"redacted": true, "reason": reason}),
1099 importance: 0.0,
1100 pinned: false,
1101 shareable: false,
1102 superseded_by: None,
1103 embedding_model: "none".into(),
1104 embedding_dim: 0,
1105 });
1106 }
1107 }
1108 }
1109
1110 let id = Uuid::now_v7().to_string();
1111 let created_at_ms = now_ms();
1112 let importance = cel_memory::importance::score(&new_chunk);
1116 let embedder_dim = self.embedder.dim();
1117 let embedder_name = self.embedder.model_name().to_string();
1118 let embedding = self.embedder.embed(&new_chunk.content).await?;
1121 if embedding.len() != embedder_dim {
1122 return Err(MemoryError::Internal(format!(
1123 "embedder produced dim {}, declared {}",
1124 embedding.len(),
1125 embedder_dim
1126 )));
1127 }
1128
1129 let chunk = MemoryChunk {
1130 id: id.clone(),
1131 created_at: ms_to_dt(created_at_ms),
1132 kind: new_chunk.kind,
1133 tier: MemoryTier::Session,
1134 source: new_chunk.source,
1135 session_id: new_chunk.session_id.clone(),
1136 project_root: new_chunk.project_root.clone(),
1137 caller_id: new_chunk.caller_id.clone(),
1138 content: new_chunk.content.clone(),
1139 metadata: new_chunk.metadata.clone(),
1140 importance,
1141 pinned: new_chunk.pinned,
1142 shareable: new_chunk.shareable,
1143 superseded_by: None,
1144 embedding_model: embedder_name.clone(),
1145 embedding_dim: embedder_dim as u32,
1146 };
1147
1148 let conn = Arc::clone(&self.conn);
1149 let chunk_for_blocking = chunk.clone();
1150 let embedding_clone = embedding.clone();
1151 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1152 let mut guard = conn.blocking_lock();
1153 let tx = guard
1154 .transaction()
1155 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1156 tx.execute(
1157 "INSERT INTO memory_chunks(
1158 id, created_at, kind, tier, source, session_id, project_root,
1159 caller_id, content, metadata, importance, pinned, shareable,
1160 superseded_by, embedding_model, embedding_dim
1161 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1162 params![
1163 chunk_for_blocking.id,
1164 created_at_ms,
1165 kind_str(chunk_for_blocking.kind),
1166 tier_str(chunk_for_blocking.tier),
1167 source_str(chunk_for_blocking.source),
1168 chunk_for_blocking.session_id,
1169 chunk_for_blocking.project_root,
1170 chunk_for_blocking.caller_id,
1171 chunk_for_blocking.content,
1172 serde_json::to_string(&chunk_for_blocking.metadata)
1173 .unwrap_or_else(|_| "null".into()),
1174 chunk_for_blocking.importance as f64,
1175 if chunk_for_blocking.pinned { 1 } else { 0 },
1176 if new_chunk.shareable { 1 } else { 0 },
1177 Option::<String>::None,
1178 chunk_for_blocking.embedding_model,
1179 chunk_for_blocking.embedding_dim as i64,
1180 ],
1181 )
1182 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1183 let v_json = serde_json::to_string(&embedding_clone)
1187 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1188 tx.execute(
1189 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1190 params![chunk_for_blocking.id, v_json],
1191 )
1192 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1193 tx.commit()
1194 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1195 Ok(())
1196 })
1197 .await
1198 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1199
1200 Ok(chunk)
1201 }
1202
1203 async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
1204 if chunks.is_empty() {
1209 return Ok(Vec::new());
1210 }
1211 if chunks.len() == 1 {
1212 let nc = chunks.into_iter().next().expect("len == 1");
1213 return Ok(vec![self.write(nc).await?]);
1214 }
1215 self.invalidate_retrieve_cache();
1216
1217 if self.write_hook.is_some() {
1222 let mut out = Vec::with_capacity(chunks.len());
1223 for nc in chunks {
1224 out.push(self.write(nc).await?);
1225 }
1226 return Ok(out);
1227 }
1228
1229 for (i, nc) in chunks.iter().enumerate() {
1232 if nc.content.trim().is_empty() {
1233 return Err(MemoryError::InvalidArgument(format!(
1234 "chunks[{i}].content must not be empty"
1235 )));
1236 }
1237 }
1238
1239 let embedder_dim = self.embedder.dim();
1240 let embedder_name = self.embedder.model_name().to_string();
1241
1242 let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
1247 let embeddings = self.embedder.embed_batch(&texts).await?;
1248 if embeddings.len() != chunks.len() {
1249 return Err(MemoryError::Internal(format!(
1250 "embedder returned {} vectors for {} inputs",
1251 embeddings.len(),
1252 chunks.len()
1253 )));
1254 }
1255 for (i, v) in embeddings.iter().enumerate() {
1256 if v.len() != embedder_dim {
1257 return Err(MemoryError::Internal(format!(
1258 "embedder produced dim {} for chunk {i}, declared {}",
1259 v.len(),
1260 embedder_dim
1261 )));
1262 }
1263 }
1264
1265 let now_ms_val = now_ms();
1268 let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
1269 Vec::with_capacity(chunks.len());
1270 for (nc, embedding) in chunks.into_iter().zip(embeddings) {
1271 let id = Uuid::now_v7().to_string();
1272 let importance = cel_memory::importance::score(&nc);
1273 let metadata_json =
1274 serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
1275 let mc = MemoryChunk {
1276 id,
1277 created_at: ms_to_dt(now_ms_val),
1278 kind: nc.kind,
1279 tier: MemoryTier::Session,
1280 source: nc.source,
1281 session_id: nc.session_id,
1282 project_root: nc.project_root,
1283 caller_id: nc.caller_id,
1284 content: nc.content,
1285 metadata: nc.metadata,
1286 importance,
1287 pinned: nc.pinned,
1288 shareable: nc.shareable,
1289 superseded_by: None,
1290 embedding_model: embedder_name.clone(),
1291 embedding_dim: embedder_dim as u32,
1292 };
1293 owned.push((mc, embedding, nc.shareable, metadata_json));
1294 }
1295
1296 let conn = Arc::clone(&self.conn);
1297 let inserted =
1298 tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
1299 let mut guard = conn.blocking_lock();
1300 let tx = guard
1301 .transaction()
1302 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1303 let mut out = Vec::with_capacity(owned.len());
1304 for (mc, embedding, shareable, metadata_json) in &owned {
1305 tx.execute(
1306 "INSERT INTO memory_chunks(
1307 id, created_at, kind, tier, source, session_id, project_root,
1308 caller_id, content, metadata, importance, pinned, shareable,
1309 superseded_by, embedding_model, embedding_dim
1310 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1311 params![
1312 mc.id,
1313 now_ms_val,
1314 kind_str(mc.kind),
1315 tier_str(mc.tier),
1316 source_str(mc.source),
1317 mc.session_id,
1318 mc.project_root,
1319 mc.caller_id,
1320 mc.content,
1321 metadata_json,
1322 mc.importance as f64,
1323 if mc.pinned { 1 } else { 0 },
1324 if *shareable { 1 } else { 0 },
1325 Option::<String>::None,
1326 mc.embedding_model,
1327 mc.embedding_dim as i64,
1328 ],
1329 )
1330 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1331 let v_json = serde_json::to_string(embedding)
1332 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1333 tx.execute(
1334 "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1335 params![mc.id, v_json],
1336 )
1337 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1338 out.push(mc.clone());
1339 }
1340 tx.commit()
1341 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1342 Ok(out)
1343 })
1344 .await
1345 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1346
1347 Ok(inserted)
1348 }
1349
1350 async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
1351 let session = MemorySession {
1352 id: Uuid::now_v7().to_string(),
1353 started_at: Utc::now(),
1354 ended_at: None,
1355 caller_id: init.caller_id.clone(),
1356 title: init.title.clone(),
1357 summary: None,
1358 outcome: SessionOutcome::Open,
1359 metadata: init.metadata.clone(),
1360 };
1361 let conn = Arc::clone(&self.conn);
1362 let s = session.clone();
1363 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1364 let guard = conn.blocking_lock();
1365 guard
1366 .execute(
1367 "INSERT INTO memory_sessions(
1368 id, started_at, ended_at, caller_id, title, summary,
1369 outcome, metadata
1370 ) VALUES (?,?,?,?,?,?,?,?)",
1371 params![
1372 s.id,
1373 dt_to_ms(s.started_at),
1374 Option::<i64>::None,
1375 s.caller_id,
1376 s.title,
1377 Option::<String>::None,
1378 outcome_str(s.outcome),
1379 serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
1380 ],
1381 )
1382 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1383 Ok(())
1384 })
1385 .await
1386 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1387 Ok(session)
1388 }
1389
1390 async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
1391 let resolved = match outcome {
1392 SessionOutcome::Open => SessionOutcome::Aborted,
1393 other => other,
1394 };
1395 let conn = Arc::clone(&self.conn);
1396 let sid = session_id.to_string();
1397 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1398 let guard = conn.blocking_lock();
1399 let n = guard
1400 .execute(
1401 "UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
1402 params![now_ms(), outcome_str(resolved), sid],
1403 )
1404 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1405 if n == 0 {
1406 return Err(MemoryError::NotFound(format!("session {sid}")));
1407 }
1408 Ok(())
1409 })
1410 .await
1411 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1412 }
1413
1414 async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
1415 let conn = Arc::clone(&self.conn);
1416 let sid = session_id.to_string();
1417 let new_title = title.to_string();
1418 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1419 let guard = conn.blocking_lock();
1420 let n = guard
1421 .execute(
1422 "UPDATE memory_sessions SET title = ? WHERE id = ?",
1423 params![new_title, sid],
1424 )
1425 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1426 if n == 0 {
1427 return Err(MemoryError::NotFound(format!("session {sid}")));
1428 }
1429 Ok(())
1430 })
1431 .await
1432 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1433 }
1434
1435 async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
1438 self.invalidate_retrieve_cache();
1439 let conn = Arc::clone(&self.conn);
1440 let id = chunk_id.to_string();
1441 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1442 let guard = conn.blocking_lock();
1443 let n = guard
1444 .execute(
1445 "UPDATE memory_chunks SET pinned = ? WHERE id = ?",
1446 params![if pinned { 1 } else { 0 }, id],
1447 )
1448 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1449 if n == 0 {
1450 return Err(MemoryError::NotFound(format!("chunk {id}")));
1451 }
1452 Ok(())
1453 })
1454 .await
1455 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1456 }
1457
1458 async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
1459 self.invalidate_retrieve_cache();
1460 let conn = Arc::clone(&self.conn);
1461 let id = chunk_id.to_string();
1462 let clamped = importance.clamp(0.0, 1.0) as f64;
1463 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1464 let guard = conn.blocking_lock();
1465 let n = guard
1466 .execute(
1467 "UPDATE memory_chunks SET importance = ? WHERE id = ?",
1468 params![clamped, id],
1469 )
1470 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1471 if n == 0 {
1472 return Err(MemoryError::NotFound(format!("chunk {id}")));
1473 }
1474 Ok(())
1475 })
1476 .await
1477 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1478 }
1479
1480 async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
1481 self.invalidate_retrieve_cache();
1482 let conn = Arc::clone(&self.conn);
1483 let old = old_id.to_string();
1484 let new = new_id.to_string();
1485 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1486 let guard = conn.blocking_lock();
1487 let new_exists: i64 = guard
1491 .query_row(
1492 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1493 params![new],
1494 |r| r.get(0),
1495 )
1496 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1497 if new_exists == 0 {
1498 return Err(MemoryError::NotFound(format!("new chunk {new}")));
1499 }
1500 let n = guard
1501 .execute(
1502 "UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
1503 params![new, old],
1504 )
1505 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1506 if n == 0 {
1507 return Err(MemoryError::NotFound(format!("old chunk {old}")));
1508 }
1509 Ok(())
1510 })
1511 .await
1512 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1513 }
1514
1515 async fn record_access(
1516 &self,
1517 chunk_id: &str,
1518 retrieved_by: &str,
1519 used: bool,
1520 ) -> MemoryResult<()> {
1521 let conn = Arc::clone(&self.conn);
1522 let id = chunk_id.to_string();
1523 let by = retrieved_by.to_string();
1524 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1525 let guard = conn.blocking_lock();
1526 let exists: i64 = guard
1527 .query_row(
1528 "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1529 params![id],
1530 |r| r.get(0),
1531 )
1532 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1533 if exists == 0 {
1534 return Err(MemoryError::NotFound(format!("chunk {id}")));
1535 }
1536 guard
1537 .execute(
1538 "INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
1539 VALUES (?, ?, ?, '', 0, ?)",
1540 params![now_ms(), id, by, if used { 1 } else { 0 }],
1541 )
1542 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1543 Ok(())
1544 })
1545 .await
1546 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1547 }
1548
1549 async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
1552 self.invalidate_retrieve_cache();
1553 let conn = Arc::clone(&self.conn);
1554 let id = chunk_id.to_string();
1555 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1556 let mut guard = conn.blocking_lock();
1557 let tx = guard
1558 .transaction()
1559 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1560 let n = tx
1561 .execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1562 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1563 if n == 0 {
1564 return Err(MemoryError::NotFound(format!("chunk {id}")));
1565 }
1566 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1567 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1568 tx.execute(
1569 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1570 VALUES (?,?,?, '{}')",
1571 params![now_ms(), id, eviction_reason_str(reason)],
1572 )
1573 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1574 tx.commit()
1575 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1576 Ok(())
1577 })
1578 .await
1579 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1580 }
1581
1582 async fn delete_matching(
1583 &self,
1584 predicate: MemoryPredicate,
1585 reason: EvictionReason,
1586 ) -> MemoryResult<usize> {
1587 if predicate.is_empty() {
1592 return Ok(0);
1593 }
1594 self.invalidate_retrieve_cache();
1595 let conn = Arc::clone(&self.conn);
1596 tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
1597 let (where_clause, params_vec) = build_chunk_predicate(&predicate);
1598 let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
1599 let mut guard = conn.blocking_lock();
1600 let tx = guard
1601 .transaction()
1602 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1603 let ids: Vec<String> = {
1604 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1605 let mut stmt = tx
1606 .prepare(&select_sql)
1607 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1608 let rows = stmt
1609 .query_map(p.as_slice(), |r| r.get::<_, String>(0))
1610 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1611 let mut out = Vec::new();
1612 for r in rows {
1613 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1614 }
1615 out
1616 };
1617 let now = now_ms();
1618 let reason_s = eviction_reason_str(reason);
1619 for id in &ids {
1620 tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1621 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1622 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1623 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1624 tx.execute(
1625 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1626 VALUES (?, ?, ?, '{}')",
1627 params![now, id, reason_s],
1628 )
1629 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1630 }
1631 tx.commit()
1632 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1633 Ok(ids.len())
1634 })
1635 .await
1636 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1637 }
1638
1639 async fn purge_all(&self) -> MemoryResult<PurgeReport> {
1640 self.invalidate_retrieve_cache();
1641 let conn = Arc::clone(&self.conn);
1642 tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
1643 let mut guard = conn.blocking_lock();
1644 let tx = guard
1645 .transaction()
1646 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1647 let chunks: i64 = tx
1648 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
1649 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1650 let sessions: i64 = tx
1651 .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
1652 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1653 let access: i64 = tx
1654 .query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
1655 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1656 let evictions: i64 = tx
1657 .query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
1658 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1659
1660 tx.execute("DELETE FROM memory_chunks", [])
1661 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1662 tx.execute("DELETE FROM memory_vec", [])
1663 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1664 tx.execute("DELETE FROM memory_sessions", [])
1665 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1666 tx.execute("DELETE FROM memory_access_log", [])
1667 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1668 tx.execute("DELETE FROM memory_eviction_log", [])
1669 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1670 tx.execute("DELETE FROM memory_summary_members", [])
1671 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1672 tx.commit()
1673 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1674
1675 Ok(PurgeReport {
1676 chunks_deleted: chunks as usize,
1677 sessions_deleted: sessions as usize,
1678 access_log_deleted: access as usize,
1679 eviction_log_deleted: evictions as usize,
1680 })
1681 })
1682 .await
1683 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1684 }
1685
1686 async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
1689 let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
1692 "SqliteMemoryProvider::summarize_session — no summarizer attached \
1693 (call `with_summarizer` first)",
1694 ))?;
1695
1696 let session = self
1700 .get_session(session_id)
1701 .await?
1702 .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
1703
1704 let members = self.fetch_session_chunks(session_id).await?;
1708 if members.is_empty() {
1709 return Err(MemoryError::InvalidArgument(format!(
1714 "session {session_id} has no chunks to summarize"
1715 )));
1716 }
1717 let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1718
1719 let ctx = cel_memory::SummaryContext {
1722 kind_label: Some("session".into()),
1723 note: session
1724 .title
1725 .as_ref()
1726 .map(|t| format!("session title: {t}")),
1727 max_words: None,
1728 };
1729 let summary_text = summarizer
1730 .summarize(&members, &ctx)
1731 .await
1732 .map_err(|e| match e {
1733 cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
1734 "summarizer received no input despite session having chunks".into(),
1735 ),
1736 other => MemoryError::Provider(format!(
1737 "summarizer {} failed: {other}",
1738 summarizer.name()
1739 )),
1740 })?;
1741
1742 let new_chunk = NewMemoryChunk {
1749 kind: ChunkKind::JobSummary,
1750 source: ChunkSource::Embedded,
1751 session_id: Some(session_id.to_string()),
1752 project_root: members.iter().find_map(|c| c.project_root.clone()),
1753 caller_id: session.caller_id.clone(),
1754 content: summary_text,
1755 metadata: serde_json::json!({
1756 "session_id": session_id,
1757 "member_count": member_ids.len(),
1758 "summarizer": summarizer.name(),
1759 }),
1760 importance: None,
1764 shareable: false,
1765 pinned: false,
1766 };
1767 let written = self.write(new_chunk).await?;
1768
1769 let conn = Arc::clone(&self.conn);
1774 let summary_id = written.id.clone();
1775 let member_ids_for_insert = member_ids.clone();
1776 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1777 let mut guard = conn.blocking_lock();
1778 let tx = guard
1779 .transaction()
1780 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1781 for mid in &member_ids_for_insert {
1782 tx.execute(
1783 "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
1784 VALUES (?, ?)",
1785 params![summary_id, mid],
1786 )
1787 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1788 }
1789 tx.commit()
1790 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1791 Ok(())
1792 })
1793 .await
1794 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1795
1796 let conn = Arc::clone(&self.conn);
1800 let sid = session_id.to_string();
1801 let stored_summary = written.content.clone();
1802 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1803 let guard = conn.blocking_lock();
1804 guard
1805 .execute(
1806 "UPDATE memory_sessions SET summary = ? WHERE id = ?",
1807 params![stored_summary, sid],
1808 )
1809 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1810 Ok(())
1811 })
1812 .await
1813 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1814
1815 Ok(written)
1816 }
1817
1818 async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1819 self.rollup_day_inner(date, false).await
1820 }
1821
1822 async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1823 self.rollup_day_inner(date, true).await
1824 }
1825
1826 async fn rollup_rule_week(
1827 &self,
1828 rule_id: &str,
1829 week_start: NaiveDate,
1830 ) -> MemoryResult<MemoryChunk> {
1831 self.rollup_rule_week_inner(rule_id, week_start, false)
1832 .await
1833 }
1834
1835 async fn rollup_rule_week_forced(
1836 &self,
1837 rule_id: &str,
1838 week_start: NaiveDate,
1839 ) -> MemoryResult<MemoryChunk> {
1840 self.rollup_rule_week_inner(rule_id, week_start, true).await
1841 }
1842
1843 async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
1846 const RETENTION_DAYS: i64 = 30;
1851 let cutoff_ms =
1852 (chrono::Utc::now() - chrono::Duration::days(RETENTION_DAYS)).timestamp_millis();
1853 self.invalidate_retrieve_cache();
1854 let conn = Arc::clone(&self.conn);
1855 tokio::task::spawn_blocking(move || -> Result<AgingReport, MemoryError> {
1856 let mut guard = conn.blocking_lock();
1857 let tx = guard
1858 .transaction()
1859 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1860 let ids: Vec<String> = {
1861 let mut stmt = tx
1862 .prepare(
1863 "SELECT id FROM memory_chunks
1864 WHERE pinned = 0
1865 AND kind != 'correction'
1866 AND created_at < ?",
1867 )
1868 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1869 let rows = stmt
1870 .query_map(params![cutoff_ms], |r| r.get::<_, String>(0))
1871 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1872 let mut out = Vec::new();
1873 for r in rows {
1874 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1875 }
1876 out
1877 };
1878 let now = now_ms();
1879 let reason_s = eviction_reason_str(EvictionReason::Aging);
1880 for id in &ids {
1881 tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1882 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1883 tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1884 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1885 tx.execute(
1886 "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1887 VALUES (?, ?, ?, '{}')",
1888 params![now, id, reason_s],
1889 )
1890 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1891 }
1892 tx.commit()
1893 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1894 let deleted = ids.len();
1895 Ok(AgingReport {
1896 tier_promoted: 0, deleted,
1898 bytes_reclaimed: 0,
1899 deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
1900 })
1901 })
1902 .await
1903 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1904 }
1905 async fn re_embed_all(&self, target_model: &str) -> MemoryResult<ReEmbedReport> {
1906 let started = std::time::Instant::now();
1907 if self.embedder.model_name() != target_model {
1908 return Err(MemoryError::InvalidArgument(format!(
1909 "configured embedder is '{}', re_embed_all target_model is '{}'",
1910 self.embedder.model_name(),
1911 target_model
1912 )));
1913 }
1914 self.invalidate_retrieve_cache();
1915
1916 let embedder = Arc::clone(&self.embedder);
1917 let embedder_dim = embedder.dim();
1918 let embedder_name = embedder.model_name().to_string();
1919 let conn = Arc::clone(&self.conn);
1920
1921 let rows: Vec<(String, String)> =
1922 tokio::task::spawn_blocking(move || -> Result<Vec<(String, String)>, MemoryError> {
1923 let guard = conn.blocking_lock();
1924 let mut stmt = guard
1925 .prepare(
1926 "SELECT id, content FROM memory_chunks
1927 WHERE embedding_model != 'none'
1928 ORDER BY created_at ASC",
1929 )
1930 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1931 let mapped = stmt
1932 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
1933 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1934 let mut out = Vec::new();
1935 for row in mapped {
1936 out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1937 }
1938 Ok(out)
1939 })
1940 .await
1941 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1942
1943 let total = rows.len();
1944 if total == 0 {
1945 return Ok(ReEmbedReport {
1946 total: 0,
1947 succeeded: 0,
1948 failed: 0,
1949 elapsed_ms: started.elapsed().as_millis() as u64,
1950 });
1951 }
1952
1953 let texts: Vec<String> = rows.iter().map(|(_, content)| content.clone()).collect();
1954 let embeddings = embedder.embed_batch(&texts).await?;
1955 if embeddings.len() != total {
1956 return Err(MemoryError::Internal(format!(
1957 "embedder returned {} vectors for {} inputs",
1958 embeddings.len(),
1959 total
1960 )));
1961 }
1962
1963 let conn = Arc::clone(&self.conn);
1964 let (succeeded, failed) =
1965 tokio::task::spawn_blocking(move || -> Result<(usize, usize), MemoryError> {
1966 let mut guard = conn.blocking_lock();
1967 let tx = guard
1968 .transaction()
1969 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1970 let mut succeeded = 0usize;
1971 let mut failed = 0usize;
1972 for (i, (id, _)) in rows.iter().enumerate() {
1973 let embedding = &embeddings[i];
1974 if embedding.len() != embedder_dim {
1975 failed += 1;
1976 continue;
1977 }
1978 let v_json = serde_json::to_string(embedding)
1979 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1980 tx.execute(
1981 "UPDATE memory_chunks SET embedding_model = ?, embedding_dim = ? WHERE id = ?",
1982 params![embedder_name, embedder_dim as i64, id],
1983 )
1984 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1985 tx.execute(
1986 "UPDATE memory_vec SET embedding = ? WHERE chunk_id = ?",
1987 params![v_json, id],
1988 )
1989 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1990 succeeded += 1;
1991 }
1992 tx.commit()
1993 .map_err(|e| MemoryError::Storage(e.to_string()))?;
1994 Ok((succeeded, failed))
1995 })
1996 .await
1997 .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1998
1999 Ok(ReEmbedReport {
2000 total,
2001 succeeded,
2002 failed,
2003 elapsed_ms: started.elapsed().as_millis() as u64,
2004 })
2005 }
2006 async fn export(&self, filter: ExportFilter) -> MemoryResult<ExportBundle> {
2007 let conn = Arc::clone(&self.conn);
2008 tokio::task::spawn_blocking(move || -> Result<ExportBundle, MemoryError> {
2009 let guard = conn.blocking_lock();
2010
2011 let (where_clause, params_vec) = match &filter.predicate {
2013 Some(p) if !p.is_empty() => build_chunk_predicate(p),
2014 _ => ("1=1".to_string(), Vec::new()),
2015 };
2016 let select_sql = format!(
2017 "SELECT id, created_at, kind, tier, source, session_id,
2018 project_root, caller_id, content, metadata,
2019 importance, pinned, shareable, superseded_by,
2020 embedding_model, embedding_dim
2021 FROM memory_chunks WHERE {} ORDER BY created_at DESC",
2022 where_clause
2023 );
2024 let chunks: Vec<MemoryChunk> = {
2025 let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
2026 let mut stmt = guard
2027 .prepare(&select_sql)
2028 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2029 let rows = stmt
2030 .query_map(p.as_slice(), row_to_chunk)
2031 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2032 let mut out = Vec::new();
2033 for r in rows {
2034 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2035 }
2036 out
2037 };
2038
2039 let sessions = if filter.include_sessions {
2042 let session_ids: std::collections::HashSet<String> =
2043 chunks.iter().filter_map(|c| c.session_id.clone()).collect();
2044 if session_ids.is_empty() {
2045 Vec::new()
2046 } else {
2047 let placeholders = vec!["?"; session_ids.len()].join(",");
2048 let sql = format!(
2049 "SELECT id, started_at, ended_at, caller_id, title, summary,
2050 outcome, metadata
2051 FROM memory_sessions WHERE id IN ({placeholders})"
2052 );
2053 let ids: Vec<String> = session_ids.into_iter().collect();
2054 let p: Vec<&dyn rusqlite::ToSql> =
2055 ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
2056 let mut stmt = guard
2057 .prepare(&sql)
2058 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2059 let rows = stmt
2060 .query_map(p.as_slice(), |r| {
2061 let metadata_str: String = r.get("metadata")?;
2062 let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
2063 .unwrap_or(serde_json::Value::Null);
2064 Ok(MemorySession {
2065 id: r.get("id")?,
2066 started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
2067 ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
2068 caller_id: r.get("caller_id")?,
2069 title: r.get("title")?,
2070 summary: r.get("summary")?,
2071 outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
2072 .unwrap_or(SessionOutcome::Aborted),
2073 metadata,
2074 })
2075 })
2076 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2077 let mut out = Vec::new();
2078 for r in rows {
2079 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2080 }
2081 out
2082 }
2083 } else {
2084 Vec::new()
2085 };
2086
2087 let evictions = if filter.include_eviction_log {
2088 let mut stmt = guard
2089 .prepare(
2090 "SELECT ts, chunk_id, reason, metadata
2091 FROM memory_eviction_log ORDER BY ts DESC",
2092 )
2093 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2094 let rows = stmt
2095 .query_map([], |r| {
2096 let reason_s: String = r.get("reason")?;
2097 let metadata_s: String = r.get("metadata")?;
2098 let metadata: serde_json::Value =
2099 serde_json::from_str(&metadata_s).unwrap_or(serde_json::Value::Null);
2100 Ok(EvictionEntry {
2101 ts: ms_to_dt(r.get::<_, i64>("ts")?),
2102 chunk_id: r.get("chunk_id")?,
2103 reason: str_to_eviction_reason(&reason_s)
2104 .unwrap_or(EvictionReason::UserDelete),
2105 metadata,
2106 })
2107 })
2108 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2109 let mut out = Vec::new();
2110 for r in rows {
2111 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2112 }
2113 out
2114 } else {
2115 Vec::new()
2116 };
2117
2118 let accesses = if filter.include_access_log {
2119 let mut stmt = guard
2120 .prepare(
2121 "SELECT ts, chunk_id, retrieved_by, query_hash, rank, used
2122 FROM memory_access_log ORDER BY ts DESC",
2123 )
2124 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2125 let rows = stmt
2126 .query_map([], |r| {
2127 Ok(AccessEntry {
2128 ts: ms_to_dt(r.get::<_, i64>("ts")?),
2129 chunk_id: r.get("chunk_id")?,
2130 retrieved_by: r.get("retrieved_by")?,
2131 query_hash: r.get("query_hash")?,
2132 rank: r.get::<_, i64>("rank")? as usize,
2133 used: r.get::<_, i64>("used")? != 0,
2134 })
2135 })
2136 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2137 let mut out = Vec::new();
2138 for r in rows {
2139 out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2140 }
2141 out
2142 } else {
2143 Vec::new()
2144 };
2145
2146 Ok(ExportBundle {
2147 chunks,
2148 sessions,
2149 evictions,
2150 accesses,
2151 })
2152 })
2153 .await
2154 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2155 }
2156
2157 async fn stats(&self) -> MemoryResult<MemoryStats> {
2158 let conn = Arc::clone(&self.conn);
2159 let model = self.embedder.model_name().to_string();
2160 tokio::task::spawn_blocking(move || -> Result<MemoryStats, MemoryError> {
2161 let guard = conn.blocking_lock();
2162 let total: i64 = guard
2163 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
2164 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2165 let session_tier: i64 = guard
2166 .query_row(
2167 "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'session'",
2168 [],
2169 |r| r.get(0),
2170 )
2171 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2172 let lt_tier: i64 = guard
2173 .query_row(
2174 "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'long_term'",
2175 [],
2176 |r| r.get(0),
2177 )
2178 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2179 let total_sessions: i64 = guard
2180 .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
2181 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2182 let open: i64 = guard
2183 .query_row(
2184 "SELECT COUNT(*) FROM memory_sessions WHERE outcome = 'open'",
2185 [],
2186 |r| r.get(0),
2187 )
2188 .map_err(|e| MemoryError::Storage(e.to_string()))?;
2189 Ok(MemoryStats {
2190 total_chunks: total as usize,
2191 session_chunks: session_tier as usize,
2192 long_term_chunks: lt_tier as usize,
2193 total_sessions: total_sessions as usize,
2194 open_sessions: open as usize,
2195 db_bytes: 0, embedding_model: Some(model),
2197 })
2198 })
2199 .await
2200 .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2201 }
2202}
2203
2204fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
2208 use cel_memory::RetrievalProfile::*;
2209 match profile {
2210 AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2213 AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2215 NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2218 NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2221 AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2223 UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2225 }
2226}
2227
2228fn fts_query_escape(raw: &str) -> String {
2233 let escaped = raw.replace('"', "\"\"");
2234 format!("\"{escaped}\"")
2235}
2236
2237fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2240 if let Some(kinds) = &q.kinds {
2242 if !kinds.contains(&c.kind) {
2243 return false;
2244 }
2245 }
2246 if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2248 return false;
2249 }
2250 if let Some(since) = q.since {
2252 if c.created_at < since {
2253 return false;
2254 }
2255 }
2256 if let Some(until) = q.until {
2257 if c.created_at > until {
2258 return false;
2259 }
2260 }
2261 if let Some(sid) = &q.session_id {
2263 if c.session_id.as_deref() != Some(sid.as_str()) {
2264 return false;
2265 }
2266 }
2267 if let Some(prefix) = &q.project_root_prefix {
2269 match &c.project_root {
2270 Some(root) if root.starts_with(prefix.as_str()) => {}
2271 _ => return false,
2272 }
2273 }
2274 if let Some(min) = q.min_importance {
2276 if c.importance < min {
2277 return false;
2278 }
2279 }
2280 match q.caller_scope {
2286 cel_memory::CallerScope::Own => {
2287 if c.caller_id != q.caller_id {
2288 return false;
2289 }
2290 }
2291 cel_memory::CallerScope::OwnPlusShared => {
2292 if c.caller_id != q.caller_id && !c.shareable {
2293 return false;
2294 }
2295 }
2296 cel_memory::CallerScope::Global => {}
2297 }
2298 true
2299}
2300
2301fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2308 let mut clauses: Vec<String> = vec!["1=1".to_string()];
2309 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2310 if let Some(kinds) = &p.kinds {
2311 let placeholders = vec!["?"; kinds.len()].join(",");
2312 clauses.push(format!("kind IN ({placeholders})"));
2313 for k in kinds {
2314 params.push(Box::new(kind_str(*k).to_string()));
2315 }
2316 }
2317 if let Some(callers) = &p.callers {
2318 let placeholders = vec!["?"; callers.len()].join(",");
2319 clauses.push(format!("caller_id IN ({placeholders})"));
2320 for c in callers {
2321 params.push(Box::new(c.clone()));
2322 }
2323 }
2324 if let Some(sids) = &p.session_ids {
2325 let placeholders = vec!["?"; sids.len()].join(",");
2326 clauses.push(format!("session_id IN ({placeholders})"));
2327 for s in sids {
2328 params.push(Box::new(s.clone()));
2329 }
2330 }
2331 if let Some(prefix) = &p.project_root_prefix {
2332 clauses.push("project_root LIKE ?".to_string());
2333 params.push(Box::new(format!("{prefix}%")));
2334 }
2335 if let Some(before) = p.before {
2336 clauses.push("created_at < ?".to_string());
2337 params.push(Box::new(dt_to_ms(before)));
2338 }
2339 if let Some(after) = p.after {
2340 clauses.push("created_at > ?".to_string());
2341 params.push(Box::new(dt_to_ms(after)));
2342 }
2343 if let Some(pinned) = p.pinned {
2344 clauses.push("pinned = ?".to_string());
2345 params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2346 }
2347 if let Some(below) = p.importance_below {
2348 clauses.push("importance < ?".to_string());
2349 params.push(Box::new(below as f64));
2350 }
2351 if let Some(needle) = &p.content_contains {
2352 clauses.push("LOWER(content) LIKE ?".to_string());
2353 params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2354 }
2355 (clauses.join(" AND "), params)
2356}
2357
2358fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2362 Ok(match s {
2363 "user_delete" => EvictionReason::UserDelete,
2364 "aging" => EvictionReason::Aging,
2365 "low_importance" => EvictionReason::LowImportance,
2366 "redact_rule" => EvictionReason::RedactRule,
2367 "storage_cap" => EvictionReason::StorageCap,
2368 "purge_all" => EvictionReason::PurgeAll,
2369 other => {
2370 return Err(MemoryError::Storage(format!(
2371 "unknown eviction reason: {other}"
2372 )))
2373 }
2374 })
2375}
2376
2377fn eviction_reason_str(r: EvictionReason) -> &'static str {
2378 match r {
2379 EvictionReason::UserDelete => "user_delete",
2380 EvictionReason::Aging => "aging",
2381 EvictionReason::LowImportance => "low_importance",
2382 EvictionReason::RedactRule => "redact_rule",
2383 EvictionReason::StorageCap => "storage_cap",
2384 EvictionReason::PurgeAll => "purge_all",
2385 }
2386}
2387
2388#[allow(dead_code)]
2390fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}