Skip to main content

cel_memory_sqlite/
provider.rs

1//! `SqliteMemoryProvider` — the SQLite + vector backing storage.
2//!
3//! Implements the full [`cel_memory::MemoryProvider`] surface:
4//!
5//! - `open` loads sqlite-vec, runs migrations, holds the connection.
6//! - `write` / `get` / `stats` / `purge_all` plus session lifecycle.
7//! - Hybrid (vector + FTS + recency) `retrieve`, fronted by a TTL+LRU cache.
8//! - `summarize_session` / `rollup_day` / `rollup_rule_week` via an injected
9//!   [`cel_memory::Summarizer`] (these return `Err(NotImplemented)` only when
10//!   no summarizer is attached).
11//! - `run_aging_sweep` and `export`.
12//! - `re_embed_all` is the one method still returning `Err(NotImplemented)`.
13//!
14//! The provider is `Send + Sync` and lives behind an `Arc<dyn MemoryProvider>` —
15//! the same surface as [`BasicMemoryProvider`], so swapping it in is one line.
16//!
17//! [`BasicMemoryProvider`]: cel_memory::BasicMemoryProvider
18
19use std::path::Path;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use cel_memory::{
24    AccessEntry, AgingReport, CallerScope, ChunkKind, ChunkSource, EvictionEntry, EvictionReason,
25    ExportBundle, ExportFilter, MemoryChunk, MemoryError, MemoryPredicate, MemoryProvider,
26    MemoryQuery, MemorySession, MemoryStats, MemoryTier, NewMemoryChunk, NewMemorySession,
27    PurgeReport, ReEmbedReport, Result as MemoryResult, SessionFilter, SessionOutcome,
28};
29use chrono::{DateTime, NaiveDate, Utc};
30use rusqlite::{params, Connection, OptionalExtension};
31use tokio::sync::Mutex;
32use uuid::Uuid;
33
34use crate::cache::RetrieveCache;
35use crate::embedder::Embedder;
36use crate::error::SqliteMemoryError;
37use crate::migrations;
38
39/// Default capacity of the per-provider retrieve cache. A "heavy" session
40/// is ~30 retrievals/min; 256 buys ~8 minutes of distinct queries before
41/// LRU eviction. Tuned for the embedded agent + NL compiler hot path.
42const RETRIEVE_CACHE_CAPACITY: usize = 256;
43/// Default TTL of cached retrieve results. Long enough to absorb a user
44/// turn cluster, short enough that a write 30s ago will be visible to the
45/// next matching read even if the eager `clear` was missed.
46const RETRIEVE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
47
48/// SQLite-backed [`MemoryProvider`].
49pub struct SqliteMemoryProvider {
50    conn: Arc<Mutex<Connection>>,
51    embedder: Arc<dyn Embedder>,
52    /// Optional pre-write hook (memory_write_attempted governance seam).
53    /// When unset, every write proceeds verbatim. When set, the provider
54    /// consults the hook before persisting each chunk; on `Redact`, the
55    /// chunk is dropped and a redaction record is logged.
56    write_hook: Option<Arc<dyn cel_memory::MemoryWriteHook>>,
57    /// Optional summarizer used by [`MemoryProvider::summarize_session`]
58    /// and the rollup methods. When unset, those methods fall through to
59    /// [`MemoryError::NotImplemented`] when no summarizer is configured. Attach
60    /// one via [`Self::with_summarizer`].
61    summarizer: Option<Arc<dyn cel_memory::Summarizer>>,
62    /// Small TTL+LRU cache for [`MemoryProvider::retrieve`] results. See
63    /// `crate::cache` for the contract.
64    retrieve_cache: Arc<RetrieveCache<Vec<MemoryChunk>>>,
65}
66
67impl SqliteMemoryProvider {
68    /// Open or create a memory database at the given path. Loads
69    /// `sqlite-vec` into the connection, runs pending migrations, returns
70    /// a ready-to-use provider.
71    ///
72    /// The provided [`Embedder`] determines the dimensionality used at
73    /// write time. The migration schema currently hard-codes `FLOAT[384]`
74    /// for `memory_vec`; if the embedder's dim is different, writes that
75    /// produce embeddings will fail with `DimMismatch`. Future migrations
76    /// will make the dim configurable.
77    pub async fn open(
78        path: impl AsRef<Path>,
79        embedder: Arc<dyn Embedder>,
80    ) -> Result<Self, SqliteMemoryError> {
81        let path = path.as_ref().to_path_buf();
82        crate::vec_extension::register();
83        let conn = tokio::task::spawn_blocking(move || -> Result<Connection, SqliteMemoryError> {
84            let mut c = Connection::open(&path)?;
85            // WAL mode for concurrent reads while a writer is active.
86            c.pragma_update(None, "journal_mode", "WAL")?;
87            c.pragma_update(None, "synchronous", "NORMAL")?;
88            migrations::run(&mut c)?;
89            Ok(c)
90        })
91        .await
92        .map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
93        Ok(Self {
94            conn: Arc::new(Mutex::new(conn)),
95            embedder,
96            write_hook: None,
97            summarizer: None,
98            retrieve_cache: Arc::new(RetrieveCache::new(
99                RETRIEVE_CACHE_CAPACITY,
100                RETRIEVE_CACHE_TTL,
101            )),
102        })
103    }
104
105    /// Attach a [`MemoryWriteHook`](cel_memory::MemoryWriteHook) consulted
106    /// before every write. Use this to connect policy checks, redaction rules,
107    /// or application-specific write filters.
108    pub fn with_write_hook(mut self, hook: Arc<dyn cel_memory::MemoryWriteHook>) -> Self {
109        self.write_hook = Some(hook);
110        self
111    }
112
113    /// Attach a [`Summarizer`](cel_memory::Summarizer) used by
114    /// [`MemoryProvider::summarize_session`], `rollup_day`, and
115    /// `rollup_rule_week`. Applications pass a concrete downstream
116    /// [`cel_memory::Summarizer`] implementation; tests can pass a
117    /// [`cel_memory::MockSummarizer`].
118    ///
119    /// Calling [`MemoryProvider::summarize_session`] without first
120    /// attaching a summarizer returns
121    /// [`cel_memory::MemoryError::NotImplemented`] when summarization is not
122    /// configured.
123    pub fn with_summarizer(mut self, summarizer: Arc<dyn cel_memory::Summarizer>) -> Self {
124        self.summarizer = Some(summarizer);
125        self
126    }
127
128    /// Test-only accessor for the underlying connection Arc, used by the
129    /// smoke tests to backdate `created_at` for aging-sweep tests. Marked
130    /// `#[doc(hidden)]` and behind `cfg(any(test, feature = "test-support"))`
131    /// so production code can't grab the lock and bypass the provider's
132    /// transactional guarantees.
133    #[doc(hidden)]
134    pub fn conn_for_test(&self) -> Arc<tokio::sync::Mutex<rusqlite::Connection>> {
135        Arc::clone(&self.conn)
136    }
137
138    /// Drop every cached retrieve result. Called by every mutator on the
139    /// provider so reads following a write never observe stale rankings.
140    fn invalidate_retrieve_cache(&self) {
141        self.retrieve_cache.clear();
142    }
143
144    /// Open an in-memory database for tests.
145    pub async fn open_in_memory(embedder: Arc<dyn Embedder>) -> Result<Self, SqliteMemoryError> {
146        crate::vec_extension::register();
147        let conn = tokio::task::spawn_blocking(|| -> Result<Connection, SqliteMemoryError> {
148            let mut c = Connection::open_in_memory()?;
149            migrations::run(&mut c)?;
150            Ok(c)
151        })
152        .await
153        .map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
154        Ok(Self {
155            conn: Arc::new(Mutex::new(conn)),
156            embedder,
157            write_hook: None,
158            summarizer: None,
159            retrieve_cache: Arc::new(RetrieveCache::new(
160                RETRIEVE_CACHE_CAPACITY,
161                RETRIEVE_CACHE_TTL,
162            )),
163        })
164    }
165
166    /// Fetch every chunk attached to a session, ordered oldest-first.
167    ///
168    /// Used by [`MemoryProvider::summarize_session`]; kept as a private
169    /// helper rather than a trait method because the only call site
170    /// today is summarization and the `MemoryQuery` surface would force
171    /// caller-id and text constraints that don't apply here. Promote to
172    /// the trait if a second caller appears.
173    ///
174    /// Excludes existing `JobSummary` chunks for the same session so
175    /// re-summarization doesn't snowball — each call re-summarizes the
176    /// raw history, not the prior summary.
177    async fn fetch_session_chunks(&self, session_id: &str) -> MemoryResult<Vec<MemoryChunk>> {
178        let conn = Arc::clone(&self.conn);
179        let sid = session_id.to_string();
180        tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
181            let guard = conn.blocking_lock();
182            let mut stmt = guard
183                .prepare(
184                    "SELECT id, created_at, kind, tier, source, session_id,
185                                project_root, caller_id, content, metadata,
186                                importance, pinned, shareable, superseded_by,
187                                embedding_model, embedding_dim
188                         FROM memory_chunks
189                         WHERE session_id = ?
190                           AND kind != 'job_summary'
191                         ORDER BY created_at ASC",
192                )
193                .map_err(|e| MemoryError::Storage(e.to_string()))?;
194            let rows = stmt
195                .query_map(params![sid], row_to_chunk)
196                .map_err(|e| MemoryError::Storage(e.to_string()))?;
197            let mut out = Vec::new();
198            for r in rows {
199                out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
200            }
201            Ok(out)
202        })
203        .await
204        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
205    }
206
207    /// Fetch every chunk whose `created_at` falls within the UTC date
208    /// `date` (00:00:00 inclusive → next day 00:00:00 exclusive). Excludes
209    /// `Rollup` chunks so re-running the daily rollup doesn't snowball.
210    /// Ordered oldest-first so the summarizer sees the day as it
211    /// unfolded.
212    async fn fetch_day_chunks(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
213        let start = date
214            .and_hms_opt(0, 0, 0)
215            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
216            .and_utc()
217            .timestamp_millis();
218        let end = date
219            .succ_opt()
220            .ok_or_else(|| MemoryError::InvalidArgument(format!("date overflow: {date}")))?
221            .and_hms_opt(0, 0, 0)
222            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
223            .and_utc()
224            .timestamp_millis();
225        let conn = Arc::clone(&self.conn);
226        tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
227            let guard = conn.blocking_lock();
228            let mut stmt = guard
229                .prepare(
230                    "SELECT id, created_at, kind, tier, source, session_id,
231                                project_root, caller_id, content, metadata,
232                                importance, pinned, superseded_by,
233                                embedding_model, embedding_dim
234                         FROM memory_chunks
235                         WHERE created_at >= ?
236                           AND created_at < ?
237                           AND kind != 'rollup'
238                         ORDER BY created_at ASC",
239                )
240                .map_err(|e| MemoryError::Storage(e.to_string()))?;
241            let rows = stmt
242                .query_map(params![start, end], row_to_chunk)
243                .map_err(|e| MemoryError::Storage(e.to_string()))?;
244            let mut out = Vec::new();
245            for r in rows {
246                out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
247            }
248            Ok(out)
249        })
250        .await
251        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
252    }
253
254    /// Fetch every `Fire` chunk for `rule_id` whose `created_at` falls
255    /// within the ISO week starting at `week_start` (a Monday, by
256    /// convention; this code does not enforce that — it just sums the
257    /// 7-day window). The rule id is stored on the chunk's
258    /// `metadata.rule_id` field, written by the matcher consumer task.
259    /// Excludes prior `Rollup` chunks to avoid snowball.
260    async fn fetch_rule_week_chunks(
261        &self,
262        rule_id: &str,
263        week_start: NaiveDate,
264    ) -> MemoryResult<Vec<MemoryChunk>> {
265        let start = week_start
266            .and_hms_opt(0, 0, 0)
267            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {week_start}")))?
268            .and_utc()
269            .timestamp_millis();
270        let end_date = week_start
271            .checked_add_days(chrono::Days::new(7))
272            .ok_or_else(|| MemoryError::InvalidArgument(format!("week overflow: {week_start}")))?;
273        let end = end_date
274            .and_hms_opt(0, 0, 0)
275            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {end_date}")))?
276            .and_utc()
277            .timestamp_millis();
278        let conn = Arc::clone(&self.conn);
279        let rid = rule_id.to_string();
280        tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
281            let guard = conn.blocking_lock();
282            let mut stmt = guard
283                .prepare(
284                    "SELECT id, created_at, kind, tier, source, session_id,
285                                project_root, caller_id, content, metadata,
286                                importance, pinned, superseded_by,
287                                embedding_model, embedding_dim
288                         FROM memory_chunks
289                         WHERE kind = 'fire'
290                           AND created_at >= ?
291                           AND created_at < ?
292                           AND json_extract(metadata, '$.rule_id') = ?
293                         ORDER BY created_at ASC",
294                )
295                .map_err(|e| MemoryError::Storage(e.to_string()))?;
296            let rows = stmt
297                .query_map(params![start, end, rid], row_to_chunk)
298                .map_err(|e| MemoryError::Storage(e.to_string()))?;
299            let mut out = Vec::new();
300            for r in rows {
301                out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
302            }
303            Ok(out)
304        })
305        .await
306        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
307    }
308
309    /// True if a `Rollup` chunk already exists for `date` (matched via
310    /// `metadata.rollup_date = '<YYYY-MM-DD>'`). Used to short-circuit
311    /// the day-rollup cron pass when force=false.
312    async fn day_rollup_exists(&self, date: NaiveDate) -> MemoryResult<bool> {
313        let date_s = date.to_string();
314        let conn = Arc::clone(&self.conn);
315        tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
316            let guard = conn.blocking_lock();
317            let count: i64 = guard
318                .query_row(
319                    "SELECT COUNT(*) FROM memory_chunks
320                         WHERE kind = 'rollup'
321                           AND json_extract(metadata, '$.rollup_date') = ?",
322                    params![date_s],
323                    |r| r.get(0),
324                )
325                .map_err(|e| MemoryError::Storage(e.to_string()))?;
326            Ok(count > 0)
327        })
328        .await
329        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
330    }
331
332    /// True if a `Rollup` chunk already exists for (`rule_id`,
333    /// `week_start`). Matched via `metadata.rollup_rule_id` +
334    /// `metadata.rollup_week_start`.
335    async fn rule_week_rollup_exists(
336        &self,
337        rule_id: &str,
338        week_start: NaiveDate,
339    ) -> MemoryResult<bool> {
340        let week_s = week_start.to_string();
341        let rid = rule_id.to_string();
342        let conn = Arc::clone(&self.conn);
343        tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
344            let guard = conn.blocking_lock();
345            let count: i64 = guard
346                .query_row(
347                    "SELECT COUNT(*) FROM memory_chunks
348                         WHERE kind = 'rollup'
349                           AND json_extract(metadata, '$.rollup_rule_id') = ?
350                           AND json_extract(metadata, '$.rollup_week_start') = ?",
351                    params![rid, week_s],
352                    |r| r.get(0),
353                )
354                .map_err(|e| MemoryError::Storage(e.to_string()))?;
355            Ok(count > 0)
356        })
357        .await
358        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
359    }
360
361    /// Insert one row per (rollup_id, member_id) into
362    /// `memory_summary_members`. INSERT OR IGNORE so a re-run is
363    /// idempotent on the link table.
364    async fn link_rollup_members(
365        &self,
366        rollup_id: &str,
367        member_ids: &[String],
368    ) -> MemoryResult<()> {
369        let rid = rollup_id.to_string();
370        let members: Vec<String> = member_ids.to_vec();
371        let conn = Arc::clone(&self.conn);
372        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
373            let mut guard = conn.blocking_lock();
374            let tx = guard
375                .transaction()
376                .map_err(|e| MemoryError::Storage(e.to_string()))?;
377            for mid in &members {
378                tx.execute(
379                    "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
380                         VALUES (?, ?)",
381                    params![rid, mid],
382                )
383                .map_err(|e| MemoryError::Storage(e.to_string()))?;
384            }
385            tx.commit()
386                .map_err(|e| MemoryError::Storage(e.to_string()))?;
387            Ok(())
388        })
389        .await
390        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
391    }
392
393    /// Shared implementation behind both [`MemoryProvider::rollup_day`]
394    /// and [`MemoryProvider::rollup_day_forced`].
395    ///
396    /// Groups the day's chunks (UTC) and produces a single `Rollup` chunk
397    /// per call. When `force=false`, a no-op `Ok(vec![])` is returned if
398    /// a rollup already exists for `date`. When `force=true`, a fresh
399    /// rollup is always produced.
400    ///
401    /// Returns the summary as a `NotImplemented` error if no summarizer has
402    /// been attached. Returns `Ok(vec![])` if the day has no
403    /// non-rollup chunks — there's nothing to summarise and the cron
404    /// sweeper should treat this as a successful no-op.
405    async fn rollup_day_inner(
406        &self,
407        date: NaiveDate,
408        force: bool,
409    ) -> MemoryResult<Vec<MemoryChunk>> {
410        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
411            "SqliteMemoryProvider::rollup_day — no summarizer attached \
412             (call `with_summarizer` first)",
413        ))?;
414
415        if !force && self.day_rollup_exists(date).await? {
416            tracing::debug!(date = %date, "rollup_day: existing rollup found, skipping");
417            return Ok(Vec::new());
418        }
419
420        let members = self.fetch_day_chunks(date).await?;
421        if members.is_empty() {
422            tracing::debug!(date = %date, "rollup_day: no chunks to summarise, no-op");
423            return Ok(Vec::new());
424        }
425        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
426
427        let kind_label = format!("day {date}");
428        let ctx = cel_memory::SummaryContext {
429            kind_label: Some(kind_label.clone()),
430            note: Some(format!(
431                "Daily rollup for {date} ({} chunks)",
432                members.len()
433            )),
434            max_words: None,
435        };
436        let summary_text = summarizer
437            .summarize(&members, &ctx)
438            .await
439            .map_err(|e| match e {
440                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
441                    "summarizer received no input despite day having chunks".into(),
442                ),
443                other => MemoryError::Provider(format!(
444                    "summarizer {} failed: {other}",
445                    summarizer.name()
446                )),
447            })?;
448
449        // Pick a representative caller_id for the rollup. We use "system"
450        // because daily rollups span every caller; the rollup is provider-
451        // synthesized, not attributable to one upstream client.
452        let new_chunk = NewMemoryChunk {
453            kind: ChunkKind::Rollup,
454            source: ChunkSource::System,
455            session_id: None,
456            project_root: None,
457            caller_id: "system".to_string(),
458            content: summary_text,
459            metadata: serde_json::json!({
460                "rollup_kind": "day",
461                "rollup_date": date.to_string(),
462                "member_count": member_ids.len(),
463                "summarizer": summarizer.name(),
464            }),
465            importance: None,
466            shareable: false,
467            pinned: false,
468        };
469        let written = self.write(new_chunk).await?;
470        self.link_rollup_members(&written.id, &member_ids).await?;
471        Ok(vec![written])
472    }
473
474    /// Shared implementation behind both
475    /// [`MemoryProvider::rollup_rule_week`] and
476    /// [`MemoryProvider::rollup_rule_week_forced`].
477    ///
478    /// Groups all `Fire` chunks for `rule_id` in the 7-day window
479    /// starting at `week_start` and produces one `Rollup` chunk. When
480    /// `force=false`, returns `Err(InvalidArgument)` if a rollup exists
481    /// (caller can decide whether to surface or suppress). Returns
482    /// `Err(NotFound)` if no fires exist in the window — the cron sweeper
483    /// should treat this as expected and skip the call.
484    async fn rollup_rule_week_inner(
485        &self,
486        rule_id: &str,
487        week_start: NaiveDate,
488        force: bool,
489    ) -> MemoryResult<MemoryChunk> {
490        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
491            "SqliteMemoryProvider::rollup_rule_week — no summarizer attached \
492             (call `with_summarizer` first)",
493        ))?;
494
495        if !force && self.rule_week_rollup_exists(rule_id, week_start).await? {
496            return Err(MemoryError::InvalidArgument(format!(
497                "rollup already exists for rule {rule_id} week {week_start}"
498            )));
499        }
500
501        let members = self.fetch_rule_week_chunks(rule_id, week_start).await?;
502        if members.is_empty() {
503            return Err(MemoryError::NotFound(format!(
504                "no fires for rule {rule_id} in week of {week_start}"
505            )));
506        }
507        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
508
509        let kind_label = format!("week of {week_start} for rule {rule_id}");
510        let ctx = cel_memory::SummaryContext {
511            kind_label: Some(kind_label.clone()),
512            note: Some(format!(
513                "Weekly rollup for rule {rule_id} ({} fires)",
514                members.len()
515            )),
516            max_words: None,
517        };
518        let summary_text = summarizer
519            .summarize(&members, &ctx)
520            .await
521            .map_err(|e| match e {
522                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
523                    "summarizer received no input despite rule-week having chunks".into(),
524                ),
525                other => MemoryError::Provider(format!(
526                    "summarizer {} failed: {other}",
527                    summarizer.name()
528                )),
529            })?;
530
531        let new_chunk = NewMemoryChunk {
532            kind: ChunkKind::Rollup,
533            source: ChunkSource::System,
534            session_id: None,
535            project_root: None,
536            caller_id: "system".to_string(),
537            content: summary_text,
538            metadata: serde_json::json!({
539                "rollup_kind": "rule_week",
540                "rollup_rule_id": rule_id,
541                "rollup_week_start": week_start.to_string(),
542                "member_count": member_ids.len(),
543                "summarizer": summarizer.name(),
544            }),
545            importance: None,
546            shareable: false,
547            pinned: false,
548        };
549        let written = self.write(new_chunk).await?;
550        self.link_rollup_members(&written.id, &member_ids).await?;
551        Ok(written)
552    }
553}
554
555fn now_ms() -> i64 {
556    Utc::now().timestamp_millis()
557}
558
559/// Stable cache key for a [`MemoryQuery`]. We round-trip through
560/// `serde_json` so every field — including `caller_id`, `profile`, all
561/// filters, `k`, `include_rollups`, `min_importance` — contributes to the
562/// hash. JSON is overkill for a key but the surface area is small and the
563/// `Serialize` derive is already paid for.
564fn cache_key_for_query(query: &MemoryQuery) -> Option<u64> {
565    use std::collections::hash_map::DefaultHasher;
566    use std::hash::{Hash, Hasher};
567    let s = serde_json::to_string(query).ok()?;
568    let mut h = DefaultHasher::new();
569    s.hash(&mut h);
570    Some(h.finish())
571}
572
573fn dt_to_ms(t: DateTime<Utc>) -> i64 {
574    t.timestamp_millis()
575}
576
577fn ms_to_dt(ms: i64) -> DateTime<Utc> {
578    chrono::DateTime::<Utc>::from_timestamp_millis(ms)
579        .unwrap_or_else(|| DateTime::<Utc>::from_timestamp_millis(0).unwrap())
580}
581
582fn kind_str(k: ChunkKind) -> &'static str {
583    match k {
584        ChunkKind::Chat => "chat",
585        ChunkKind::Action => "action",
586        ChunkKind::Fire => "fire",
587        ChunkKind::Observation => "observation",
588        ChunkKind::Correction => "correction",
589        ChunkKind::JobSummary => "job_summary",
590        ChunkKind::Context => "context",
591        ChunkKind::Rollup => "rollup",
592    }
593}
594
595fn str_to_kind(s: &str) -> Result<ChunkKind, MemoryError> {
596    Ok(match s {
597        "chat" => ChunkKind::Chat,
598        "action" => ChunkKind::Action,
599        "fire" => ChunkKind::Fire,
600        "observation" => ChunkKind::Observation,
601        "correction" => ChunkKind::Correction,
602        "job_summary" => ChunkKind::JobSummary,
603        "context" => ChunkKind::Context,
604        "rollup" => ChunkKind::Rollup,
605        other => return Err(MemoryError::Storage(format!("unknown kind: {other}"))),
606    })
607}
608
609fn source_str(s: ChunkSource) -> &'static str {
610    match s {
611        ChunkSource::Embedded => "embedded",
612        ChunkSource::Mcp => "mcp",
613        ChunkSource::Gateway => "gateway",
614        ChunkSource::Matcher => "matcher",
615        ChunkSource::Perception => "perception",
616        ChunkSource::System => "system",
617    }
618}
619
620const LEGACY_PERCEPTION_SOURCE: &str = concat!("cor", "tex");
621
622fn str_to_source(s: &str) -> Result<ChunkSource, MemoryError> {
623    Ok(match s {
624        "embedded" => ChunkSource::Embedded,
625        "mcp" => ChunkSource::Mcp,
626        "gateway" => ChunkSource::Gateway,
627        "matcher" => ChunkSource::Matcher,
628        "perception" | LEGACY_PERCEPTION_SOURCE => ChunkSource::Perception,
629        "system" => ChunkSource::System,
630        other => return Err(MemoryError::Storage(format!("unknown source: {other}"))),
631    })
632}
633
634fn tier_str(t: MemoryTier) -> &'static str {
635    match t {
636        MemoryTier::Session => "session",
637        MemoryTier::LongTerm => "long_term",
638    }
639}
640
641fn str_to_tier(s: &str) -> Result<MemoryTier, MemoryError> {
642    Ok(match s {
643        "session" => MemoryTier::Session,
644        "long_term" => MemoryTier::LongTerm,
645        other => return Err(MemoryError::Storage(format!("unknown tier: {other}"))),
646    })
647}
648
649fn outcome_str(o: SessionOutcome) -> &'static str {
650    match o {
651        SessionOutcome::Open => "open",
652        SessionOutcome::Success => "success",
653        SessionOutcome::Failure => "failure",
654        SessionOutcome::Aborted => "aborted",
655    }
656}
657
658fn str_to_outcome(s: &str) -> Result<SessionOutcome, MemoryError> {
659    Ok(match s {
660        "open" => SessionOutcome::Open,
661        "success" => SessionOutcome::Success,
662        "failure" => SessionOutcome::Failure,
663        "aborted" => SessionOutcome::Aborted,
664        other => return Err(MemoryError::Storage(format!("unknown outcome: {other}"))),
665    })
666}
667
668fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryChunk> {
669    let metadata_str: String = row.get("metadata")?;
670    let metadata: serde_json::Value =
671        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
672    // shareable column is `INTEGER NOT NULL DEFAULT 0` in 001_initial.sql.
673    // Read tolerantly (missing column → false) so a hand-rolled SELECT that
674    // forgets to include `shareable` doesn't fail the whole row decode.
675    let shareable = row.get::<_, i64>("shareable").unwrap_or(0) != 0;
676    Ok(MemoryChunk {
677        id: row.get("id")?,
678        created_at: ms_to_dt(row.get::<_, i64>("created_at")?),
679        kind: str_to_kind(&row.get::<_, String>("kind")?).unwrap_or(ChunkKind::Chat),
680        tier: str_to_tier(&row.get::<_, String>("tier")?).unwrap_or(MemoryTier::Session),
681        source: str_to_source(&row.get::<_, String>("source")?).unwrap_or(ChunkSource::System),
682        session_id: row.get("session_id")?,
683        project_root: row.get("project_root")?,
684        caller_id: row.get("caller_id")?,
685        content: row.get("content")?,
686        metadata,
687        importance: row.get::<_, f64>("importance")? as f32,
688        pinned: row.get::<_, i64>("pinned")? != 0,
689        shareable,
690        superseded_by: row.get("superseded_by")?,
691        embedding_model: row.get("embedding_model")?,
692        embedding_dim: row.get::<_, i64>("embedding_dim")? as u32,
693    })
694}
695
696#[async_trait]
697impl MemoryProvider for SqliteMemoryProvider {
698    // ───────────── Reads ─────────────
699
700    async fn retrieve(&self, query: MemoryQuery) -> MemoryResult<Vec<MemoryChunk>> {
701        // Hybrid retrieval — reciprocal-rank fusion of vector + FTS, decayed
702        // by recency:
703        //   score = w_vec*rrf(rank_vec) + w_fts*rrf(rank_fts)
704        //         + w_rec*exp(-Δt/half_life)
705        //
706        // Three sub-retrievals run in one connection (we're already
707        // serialising through the Mutex anyway; parallelism would only
708        // help if we had a connection pool):
709        //   1. sqlite-vec k-NN against the query embedding
710        //   2. FTS5 BM25 against the query text
711        //   3. recency-only pass (newest-first within the filter window)
712        //
713        // The three rankings get fused via Reciprocal Rank Fusion (RRF)
714        // with profile-driven weights. Filters (kind / scope / time /
715        // project_root) are applied at SQL-time inside the candidate
716        // queries; min_importance + include_rollups are applied
717        // post-fusion since they're metadata signals, not ranking ones.
718        if query.text.trim().is_empty() {
719            return Err(MemoryError::InvalidArgument(
720                "query.text must not be empty".into(),
721            ));
722        }
723
724        // Hot-path cache. The cache is invalidated eagerly by every
725        // mutator (`write`, `delete`, …), with a 30 s TTL as a backstop.
726        let cache_key = cache_key_for_query(&query);
727        if let Some(key) = cache_key {
728            if let Some(hit) = self.retrieve_cache.get(key) {
729                return Ok(hit);
730            }
731        }
732        let k = query.k.max(1);
733        // We over-fetch each sub-retrieval (3*k) so RRF has enough
734        // candidates to merge without dropping near-misses.
735        let candidate_k = (3 * k).max(16);
736
737        let (w_vec, w_fts, w_rec, half_life_secs) = retrieval_weights(query.profile);
738
739        let q_embedding = self
740            .embedder
741            .embed(&query.text)
742            .await
743            .map_err(|e| MemoryError::Storage(e.to_string()))?;
744        let q_text = query.text.clone();
745        let conn = Arc::clone(&self.conn);
746
747        let compute =
748            tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
749                let guard = conn.blocking_lock();
750
751                // Sub-retrieval 1: vector k-NN. sqlite-vec accepts the embedding
752                // as a JSON-array string. We use `vec_distance_l2` ordering
753                // (default for the `MATCH` operator). The WHERE filter is
754                // applied via a join because vec0 virtual tables don't honor
755                // arbitrary predicates on adjoining columns at MATCH time.
756                let v_json = serde_json::to_string(&q_embedding)
757                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
758                let vec_ids: Vec<String> = {
759                    let sql = "SELECT v.chunk_id FROM memory_vec v
760                           WHERE v.embedding MATCH ?
761                             AND k = ?
762                           ORDER BY distance";
763                    let mut stmt = guard
764                        .prepare(sql)
765                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
766                    let rows = stmt
767                        .query_map(params![v_json, candidate_k as i64], |r| {
768                            r.get::<_, String>(0)
769                        })
770                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
771                    let mut out = Vec::new();
772                    for r in rows {
773                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
774                    }
775                    out
776                };
777
778                // Sub-retrieval 2: FTS5 BM25 over the same query text. FTS5
779                // ranks lower-distance as higher relevance, hence ASC by rank.
780                let fts_ids: Vec<String> = {
781                    let sql = "SELECT chunk_id FROM memory_fts
782                           WHERE memory_fts MATCH ?
783                           ORDER BY rank
784                           LIMIT ?";
785                    let mut stmt = guard
786                        .prepare(sql)
787                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
788                    let rows = stmt
789                        .query_map(
790                            params![fts_query_escape(&q_text), candidate_k as i64],
791                            |r| r.get::<_, String>(0),
792                        )
793                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
794                    let mut out = Vec::new();
795                    for r in rows {
796                        // FTS may return no-match if the tokenizer drops the
797                        // whole query (e.g., punctuation-only). Surface as
798                        // empty list rather than panic.
799                        match r {
800                            Ok(id) => out.push(id),
801                            Err(_) => break,
802                        }
803                    }
804                    out
805                };
806
807                // Sub-retrieval 3: recency. Newest-first within the filter
808                // window. This is the same candidate set we'll join the
809                // chunk rows from.
810                let recency_ids: Vec<String> = {
811                    let sql = "SELECT id FROM memory_chunks
812                           ORDER BY created_at DESC LIMIT ?";
813                    let mut stmt = guard
814                        .prepare(sql)
815                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
816                    let rows = stmt
817                        .query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
818                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
819                    let mut out = Vec::new();
820                    for r in rows {
821                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
822                    }
823                    out
824                };
825
826                // RRF fusion. The constant 60 is the standard RRF prior.
827                const RRF_K: f32 = 60.0;
828                let mut scores: std::collections::HashMap<String, f32> =
829                    std::collections::HashMap::new();
830                for (rank, id) in vec_ids.iter().enumerate() {
831                    *scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
832                }
833                for (rank, id) in fts_ids.iter().enumerate() {
834                    *scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
835                }
836                // Recency is keyed off the chunk's `created_at` directly.
837                // Skip the score contribution for chunks we don't have a row
838                // for yet (rare race: scored vector for a deleted chunk).
839                let now_ms_val = now_ms();
840                let recency_rows: std::collections::HashMap<String, i64> = {
841                    if recency_ids.is_empty() {
842                        Default::default()
843                    } else {
844                        let placeholders = vec!["?"; recency_ids.len()].join(",");
845                        let sql = format!(
846                            "SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
847                        );
848                        let p: Vec<&dyn rusqlite::ToSql> = recency_ids
849                            .iter()
850                            .map(|s| s as &dyn rusqlite::ToSql)
851                            .collect();
852                        let mut stmt = guard
853                            .prepare(&sql)
854                            .map_err(|e| MemoryError::Storage(e.to_string()))?;
855                        let rows = stmt
856                            .query_map(p.as_slice(), |r| {
857                                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
858                            })
859                            .map_err(|e| MemoryError::Storage(e.to_string()))?;
860                        let mut map = std::collections::HashMap::new();
861                        for r in rows {
862                            let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
863                            map.insert(id, t);
864                        }
865                        map
866                    }
867                };
868                for (id, created_ms) in &recency_rows {
869                    let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
870                    let recency = (-dt_secs / half_life_secs).exp();
871                    *scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
872                }
873
874                // Materialise candidate chunks from the union of ID sets.
875                let mut id_set: std::collections::HashSet<String> =
876                    std::collections::HashSet::new();
877                id_set.extend(vec_ids);
878                id_set.extend(fts_ids);
879                id_set.extend(recency_ids);
880                if id_set.is_empty() {
881                    return Ok(Vec::new());
882                }
883                let placeholders = vec!["?"; id_set.len()].join(",");
884                let select_sql = format!(
885                    "SELECT id, created_at, kind, tier, source, session_id,
886                        project_root, caller_id, content, metadata,
887                        importance, pinned, shareable, superseded_by,
888                        embedding_model, embedding_dim
889                 FROM memory_chunks WHERE id IN ({placeholders})"
890                );
891                let ids_vec: Vec<String> = id_set.into_iter().collect();
892                let candidates: Vec<MemoryChunk> = {
893                    let p: Vec<&dyn rusqlite::ToSql> =
894                        ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
895                    let mut stmt = guard
896                        .prepare(&select_sql)
897                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
898                    let rows = stmt
899                        .query_map(p.as_slice(), row_to_chunk)
900                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
901                    let mut out = Vec::new();
902                    for r in rows {
903                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
904                    }
905                    out
906                };
907
908                // Apply non-ranking filters now (kind, session, scope, time,
909                // project_root, min_importance, include_rollups).
910                let mut filtered: Vec<MemoryChunk> = candidates
911                    .into_iter()
912                    .filter(|c| chunk_matches_query(c, &query))
913                    .collect();
914
915                // Sort by fused score descending; truncate to k.
916                filtered.sort_by(|a, b| {
917                    let sa = scores.get(&a.id).copied().unwrap_or(0.0);
918                    let sb = scores.get(&b.id).copied().unwrap_or(0.0);
919                    sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
920                });
921                filtered.truncate(k);
922                Ok(filtered)
923            });
924        let result = compute
925            .await
926            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
927
928        // Populate the cache after a successful computation. Errors are
929        // never cached — they're cheap to recompute and stale errors would
930        // mask transient lock contention.
931        if let Some(key) = cache_key {
932            self.retrieve_cache.insert(key, result.clone());
933        }
934        Ok(result)
935    }
936
937    async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
938        let conn = Arc::clone(&self.conn);
939        let chunk_id = chunk_id.to_string();
940        let res: Result<Option<MemoryChunk>, MemoryError> =
941            tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
942                let guard = conn.blocking_lock();
943                let mut stmt = guard
944                    .prepare(
945                        "SELECT id, created_at, kind, tier, source, session_id,
946                                project_root, caller_id, content, metadata,
947                                importance, pinned, shareable, superseded_by,
948                                embedding_model, embedding_dim
949                         FROM memory_chunks WHERE id = ?",
950                    )
951                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
952                let row = stmt
953                    .query_row(params![chunk_id], row_to_chunk)
954                    .optional()
955                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
956                Ok(row)
957            })
958            .await
959            .map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
960        res
961    }
962
963    async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
964        let conn = Arc::clone(&self.conn);
965        let session_id = session_id.to_string();
966        tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
967            let guard = conn.blocking_lock();
968            let mut stmt = guard
969                .prepare(
970                    "SELECT id, started_at, ended_at, caller_id, title, summary,
971                            outcome, metadata
972                     FROM memory_sessions WHERE id = ?",
973                )
974                .map_err(|e| MemoryError::Storage(e.to_string()))?;
975            let row = stmt
976                .query_row(params![session_id], |r| {
977                    let metadata_str: String = r.get("metadata")?;
978                    let metadata: serde_json::Value =
979                        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
980                    Ok(MemorySession {
981                        id: r.get("id")?,
982                        started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
983                        ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
984                        caller_id: r.get("caller_id")?,
985                        title: r.get("title")?,
986                        summary: r.get("summary")?,
987                        outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
988                            .unwrap_or(SessionOutcome::Aborted),
989                        metadata,
990                    })
991                })
992                .optional()
993                .map_err(|e| MemoryError::Storage(e.to_string()))?;
994            Ok(row)
995        })
996        .await
997        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
998    }
999
1000    async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
1001        let conn = Arc::clone(&self.conn);
1002        tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
1003            let guard = conn.blocking_lock();
1004            // Build a parameterized query. Each Option<...> on the filter
1005            // adds a single `AND col op ?` clause. The matcher takes
1006            // owned params so we don't fight rusqlite's lifetime rules.
1007            let mut sql = String::from(
1008                "SELECT id, started_at, ended_at, caller_id, title, summary,
1009                        outcome, metadata
1010                 FROM memory_sessions WHERE 1=1",
1011            );
1012            let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1013            if let Some(c) = &filter.caller_id {
1014                sql.push_str(" AND caller_id = ?");
1015                params_vec.push(Box::new(c.clone()));
1016            }
1017            if let Some(o) = filter.outcome {
1018                sql.push_str(" AND outcome = ?");
1019                params_vec.push(Box::new(outcome_str(o).to_string()));
1020            } else if filter.open_only {
1021                sql.push_str(" AND outcome = ?");
1022                params_vec.push(Box::new("open".to_string()));
1023            }
1024            if let Some(since) = filter.since {
1025                sql.push_str(" AND started_at >= ?");
1026                params_vec.push(Box::new(dt_to_ms(since)));
1027            }
1028            if let Some(until) = filter.until {
1029                sql.push_str(" AND started_at <= ?");
1030                params_vec.push(Box::new(dt_to_ms(until)));
1031            }
1032            sql.push_str(" ORDER BY started_at DESC");
1033            if let Some(n) = filter.limit {
1034                sql.push_str(" LIMIT ?");
1035                params_vec.push(Box::new(n as i64));
1036            }
1037
1038            let mut stmt = guard
1039                .prepare(&sql)
1040                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1041            let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1042            let rows = stmt
1043                .query_map(p.as_slice(), |r| {
1044                    let metadata_str: String = r.get("metadata")?;
1045                    let metadata: serde_json::Value =
1046                        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
1047                    Ok(MemorySession {
1048                        id: r.get("id")?,
1049                        started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1050                        ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1051                        caller_id: r.get("caller_id")?,
1052                        title: r.get("title")?,
1053                        summary: r.get("summary")?,
1054                        outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1055                            .unwrap_or(SessionOutcome::Aborted),
1056                        metadata,
1057                    })
1058                })
1059                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1060            let mut out = Vec::new();
1061            for row in rows {
1062                out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1063            }
1064            Ok(out)
1065        })
1066        .await
1067        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1068    }
1069
1070    // ───────────── Writes ─────────────
1071
1072    async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
1073        if new_chunk.content.trim().is_empty() {
1074            return Err(MemoryError::InvalidArgument(
1075                "content must not be empty".into(),
1076            ));
1077        }
1078        // Eager invalidation. Combined with the conn-mutex serialising
1079        // every read against this write, any retrieve started after this
1080        // point observes the post-write state. See `cache.rs` docs.
1081        self.invalidate_retrieve_cache();
1082
1083        // Pre-write hook: rule-matcher seam. On Redact, return a synthetic
1084        // chunk without persisting (and without embedding — that's the
1085        // expensive part). The caller sees an Ok-with-redacted-marker
1086        // chunk; the SQL store stays untouched.
1087        if let Some(hook) = &self.write_hook {
1088            match hook.before_write(&new_chunk).await? {
1089                cel_memory::WriteDecision::Allow => {}
1090                cel_memory::WriteDecision::Redact { reason } => {
1091                    return Ok(MemoryChunk {
1092                        id: Uuid::now_v7().to_string(),
1093                        created_at: Utc::now(),
1094                        kind: new_chunk.kind,
1095                        tier: MemoryTier::Session,
1096                        source: new_chunk.source,
1097                        session_id: new_chunk.session_id,
1098                        project_root: new_chunk.project_root,
1099                        caller_id: new_chunk.caller_id,
1100                        content: format!("<redacted: {reason}>"),
1101                        metadata: serde_json::json!({"redacted": true, "reason": reason}),
1102                        importance: 0.0,
1103                        pinned: false,
1104                        shareable: false,
1105                        superseded_by: None,
1106                        embedding_model: "none".into(),
1107                        embedding_dim: 0,
1108                    });
1109                }
1110            }
1111        }
1112
1113        let id = Uuid::now_v7().to_string();
1114        let created_at_ms = now_ms();
1115        // Score via the shared heuristic (cel_memory::importance::score).
1116        // If the caller supplied an explicit value, it's honored after clamp;
1117        // otherwise the kind + metadata drive the score.
1118        let importance = cel_memory::importance::score(&new_chunk);
1119        let embedder_dim = self.embedder.dim();
1120        let embedder_name = self.embedder.model_name().to_string();
1121        // Embed the content. If this fails we don't store the chunk —
1122        // chunks without vectors don't participate in retrieval.
1123        let embedding = self
1124            .embedder
1125            .embed(&new_chunk.content)
1126            .await
1127            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1128        if embedding.len() != embedder_dim {
1129            return Err(MemoryError::Internal(format!(
1130                "embedder produced dim {}, declared {}",
1131                embedding.len(),
1132                embedder_dim
1133            )));
1134        }
1135
1136        let chunk = MemoryChunk {
1137            id: id.clone(),
1138            created_at: ms_to_dt(created_at_ms),
1139            kind: new_chunk.kind,
1140            tier: MemoryTier::Session,
1141            source: new_chunk.source,
1142            session_id: new_chunk.session_id.clone(),
1143            project_root: new_chunk.project_root.clone(),
1144            caller_id: new_chunk.caller_id.clone(),
1145            content: new_chunk.content.clone(),
1146            metadata: new_chunk.metadata.clone(),
1147            importance,
1148            pinned: new_chunk.pinned,
1149            shareable: new_chunk.shareable,
1150            superseded_by: None,
1151            embedding_model: embedder_name.clone(),
1152            embedding_dim: embedder_dim as u32,
1153        };
1154
1155        let conn = Arc::clone(&self.conn);
1156        let chunk_for_blocking = chunk.clone();
1157        let embedding_clone = embedding.clone();
1158        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1159            let mut guard = conn.blocking_lock();
1160            let tx = guard
1161                .transaction()
1162                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1163            tx.execute(
1164                "INSERT INTO memory_chunks(
1165                    id, created_at, kind, tier, source, session_id, project_root,
1166                    caller_id, content, metadata, importance, pinned, shareable,
1167                    superseded_by, embedding_model, embedding_dim
1168                ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1169                params![
1170                    chunk_for_blocking.id,
1171                    created_at_ms,
1172                    kind_str(chunk_for_blocking.kind),
1173                    tier_str(chunk_for_blocking.tier),
1174                    source_str(chunk_for_blocking.source),
1175                    chunk_for_blocking.session_id,
1176                    chunk_for_blocking.project_root,
1177                    chunk_for_blocking.caller_id,
1178                    chunk_for_blocking.content,
1179                    serde_json::to_string(&chunk_for_blocking.metadata)
1180                        .unwrap_or_else(|_| "null".into()),
1181                    chunk_for_blocking.importance as f64,
1182                    if chunk_for_blocking.pinned { 1 } else { 0 },
1183                    if new_chunk.shareable { 1 } else { 0 },
1184                    Option::<String>::None,
1185                    chunk_for_blocking.embedding_model,
1186                    chunk_for_blocking.embedding_dim as i64,
1187                ],
1188            )
1189            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1190            // memory_vec insert. sqlite-vec accepts vectors as JSON-array
1191            // text or as a packed BLOB; JSON is simpler and the conversion
1192            // cost is irrelevant for v1.
1193            let v_json = serde_json::to_string(&embedding_clone)
1194                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1195            tx.execute(
1196                "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1197                params![chunk_for_blocking.id, v_json],
1198            )
1199            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1200            tx.commit()
1201                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1202            Ok(())
1203        })
1204        .await
1205        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1206
1207        Ok(chunk)
1208    }
1209
1210    async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
1211        // Batched path: one embedding call, one transaction, one Mutex hold.
1212        // Single-chunk path delegates to `write` (which already runs the
1213        // single-call optimisation through the embedder; no point batching
1214        // a batch of one).
1215        if chunks.is_empty() {
1216            return Ok(Vec::new());
1217        }
1218        if chunks.len() == 1 {
1219            let nc = chunks.into_iter().next().expect("len == 1");
1220            return Ok(vec![self.write(nc).await?]);
1221        }
1222        self.invalidate_retrieve_cache();
1223
1224        // Hook-present path: per-chunk evaluation is required so redacted
1225        // chunks don't get embedded. Fall back to sequential writes to
1226        // preserve input → output ordering without adding per-chunk
1227        // bookkeeping to the batched insert.
1228        if self.write_hook.is_some() {
1229            let mut out = Vec::with_capacity(chunks.len());
1230            for nc in chunks {
1231                out.push(self.write(nc).await?);
1232            }
1233            return Ok(out);
1234        }
1235
1236        // Validate content non-empty up front so we don't half-embed a
1237        // batch and then fail mid-transaction.
1238        for (i, nc) in chunks.iter().enumerate() {
1239            if nc.content.trim().is_empty() {
1240                return Err(MemoryError::InvalidArgument(format!(
1241                    "chunks[{i}].content must not be empty"
1242                )));
1243            }
1244        }
1245
1246        let embedder_dim = self.embedder.dim();
1247        let embedder_name = self.embedder.model_name().to_string();
1248
1249        // One batched embedding call. The injected `Embedder` implementation
1250        // (fastembed, OpenAI, Voyage) provides the real batching;
1251        // `MockEmbedder` falls back to the trait's default impl which is
1252        // sequential but at least keeps the contract honest.
1253        let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
1254        let embeddings = self
1255            .embedder
1256            .embed_batch(&texts)
1257            .await
1258            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1259        if embeddings.len() != chunks.len() {
1260            return Err(MemoryError::Internal(format!(
1261                "embedder returned {} vectors for {} inputs",
1262                embeddings.len(),
1263                chunks.len()
1264            )));
1265        }
1266        for (i, v) in embeddings.iter().enumerate() {
1267            if v.len() != embedder_dim {
1268                return Err(MemoryError::Internal(format!(
1269                    "embedder produced dim {} for chunk {i}, declared {}",
1270                    v.len(),
1271                    embedder_dim
1272                )));
1273            }
1274        }
1275
1276        // Build all MemoryChunks now so the spawn_blocking payload owns
1277        // everything it needs.
1278        let now_ms_val = now_ms();
1279        let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
1280            Vec::with_capacity(chunks.len());
1281        for (nc, embedding) in chunks.into_iter().zip(embeddings) {
1282            let id = Uuid::now_v7().to_string();
1283            let importance = cel_memory::importance::score(&nc);
1284            let metadata_json =
1285                serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
1286            let mc = MemoryChunk {
1287                id,
1288                created_at: ms_to_dt(now_ms_val),
1289                kind: nc.kind,
1290                tier: MemoryTier::Session,
1291                source: nc.source,
1292                session_id: nc.session_id,
1293                project_root: nc.project_root,
1294                caller_id: nc.caller_id,
1295                content: nc.content,
1296                metadata: nc.metadata,
1297                importance,
1298                pinned: nc.pinned,
1299                shareable: nc.shareable,
1300                superseded_by: None,
1301                embedding_model: embedder_name.clone(),
1302                embedding_dim: embedder_dim as u32,
1303            };
1304            owned.push((mc, embedding, nc.shareable, metadata_json));
1305        }
1306
1307        let conn = Arc::clone(&self.conn);
1308        let inserted =
1309            tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
1310                let mut guard = conn.blocking_lock();
1311                let tx = guard
1312                    .transaction()
1313                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1314                let mut out = Vec::with_capacity(owned.len());
1315                for (mc, embedding, shareable, metadata_json) in &owned {
1316                    tx.execute(
1317                        "INSERT INTO memory_chunks(
1318                        id, created_at, kind, tier, source, session_id, project_root,
1319                        caller_id, content, metadata, importance, pinned, shareable,
1320                        superseded_by, embedding_model, embedding_dim
1321                    ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1322                        params![
1323                            mc.id,
1324                            now_ms_val,
1325                            kind_str(mc.kind),
1326                            tier_str(mc.tier),
1327                            source_str(mc.source),
1328                            mc.session_id,
1329                            mc.project_root,
1330                            mc.caller_id,
1331                            mc.content,
1332                            metadata_json,
1333                            mc.importance as f64,
1334                            if mc.pinned { 1 } else { 0 },
1335                            if *shareable { 1 } else { 0 },
1336                            Option::<String>::None,
1337                            mc.embedding_model,
1338                            mc.embedding_dim as i64,
1339                        ],
1340                    )
1341                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1342                    let v_json = serde_json::to_string(embedding)
1343                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1344                    tx.execute(
1345                        "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1346                        params![mc.id, v_json],
1347                    )
1348                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1349                    out.push(mc.clone());
1350                }
1351                tx.commit()
1352                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1353                Ok(out)
1354            })
1355            .await
1356            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1357
1358        Ok(inserted)
1359    }
1360
1361    async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
1362        let session = MemorySession {
1363            id: Uuid::now_v7().to_string(),
1364            started_at: Utc::now(),
1365            ended_at: None,
1366            caller_id: init.caller_id.clone(),
1367            title: init.title.clone(),
1368            summary: None,
1369            outcome: SessionOutcome::Open,
1370            metadata: init.metadata.clone(),
1371        };
1372        let conn = Arc::clone(&self.conn);
1373        let s = session.clone();
1374        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1375            let guard = conn.blocking_lock();
1376            guard
1377                .execute(
1378                    "INSERT INTO memory_sessions(
1379                        id, started_at, ended_at, caller_id, title, summary,
1380                        outcome, metadata
1381                    ) VALUES (?,?,?,?,?,?,?,?)",
1382                    params![
1383                        s.id,
1384                        dt_to_ms(s.started_at),
1385                        Option::<i64>::None,
1386                        s.caller_id,
1387                        s.title,
1388                        Option::<String>::None,
1389                        outcome_str(s.outcome),
1390                        serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
1391                    ],
1392                )
1393                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1394            Ok(())
1395        })
1396        .await
1397        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1398        Ok(session)
1399    }
1400
1401    async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
1402        let resolved = match outcome {
1403            SessionOutcome::Open => SessionOutcome::Aborted,
1404            other => other,
1405        };
1406        let conn = Arc::clone(&self.conn);
1407        let sid = session_id.to_string();
1408        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1409            let guard = conn.blocking_lock();
1410            let n = guard
1411                .execute(
1412                    "UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
1413                    params![now_ms(), outcome_str(resolved), sid],
1414                )
1415                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1416            if n == 0 {
1417                return Err(MemoryError::NotFound(format!("session {sid}")));
1418            }
1419            Ok(())
1420        })
1421        .await
1422        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1423    }
1424
1425    async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
1426        let conn = Arc::clone(&self.conn);
1427        let sid = session_id.to_string();
1428        let new_title = title.to_string();
1429        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1430            let guard = conn.blocking_lock();
1431            let n = guard
1432                .execute(
1433                    "UPDATE memory_sessions SET title = ? WHERE id = ?",
1434                    params![new_title, sid],
1435                )
1436                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1437            if n == 0 {
1438                return Err(MemoryError::NotFound(format!("session {sid}")));
1439            }
1440            Ok(())
1441        })
1442        .await
1443        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1444    }
1445
1446    // ───────────── Updates ─────────────
1447
1448    async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
1449        self.invalidate_retrieve_cache();
1450        let conn = Arc::clone(&self.conn);
1451        let id = chunk_id.to_string();
1452        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1453            let guard = conn.blocking_lock();
1454            let n = guard
1455                .execute(
1456                    "UPDATE memory_chunks SET pinned = ? WHERE id = ?",
1457                    params![if pinned { 1 } else { 0 }, id],
1458                )
1459                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1460            if n == 0 {
1461                return Err(MemoryError::NotFound(format!("chunk {id}")));
1462            }
1463            Ok(())
1464        })
1465        .await
1466        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1467    }
1468
1469    async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
1470        self.invalidate_retrieve_cache();
1471        let conn = Arc::clone(&self.conn);
1472        let id = chunk_id.to_string();
1473        let clamped = importance.clamp(0.0, 1.0) as f64;
1474        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1475            let guard = conn.blocking_lock();
1476            let n = guard
1477                .execute(
1478                    "UPDATE memory_chunks SET importance = ? WHERE id = ?",
1479                    params![clamped, id],
1480                )
1481                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1482            if n == 0 {
1483                return Err(MemoryError::NotFound(format!("chunk {id}")));
1484            }
1485            Ok(())
1486        })
1487        .await
1488        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1489    }
1490
1491    async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
1492        self.invalidate_retrieve_cache();
1493        let conn = Arc::clone(&self.conn);
1494        let old = old_id.to_string();
1495        let new = new_id.to_string();
1496        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1497            let guard = conn.blocking_lock();
1498            // Both chunks must exist. We don't enforce a foreign-key relation
1499            // at the SQL layer (memory_chunks.superseded_by is plain TEXT) so
1500            // verify by hand to keep the contract clean.
1501            let new_exists: i64 = guard
1502                .query_row(
1503                    "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1504                    params![new],
1505                    |r| r.get(0),
1506                )
1507                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1508            if new_exists == 0 {
1509                return Err(MemoryError::NotFound(format!("new chunk {new}")));
1510            }
1511            let n = guard
1512                .execute(
1513                    "UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
1514                    params![new, old],
1515                )
1516                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1517            if n == 0 {
1518                return Err(MemoryError::NotFound(format!("old chunk {old}")));
1519            }
1520            Ok(())
1521        })
1522        .await
1523        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1524    }
1525
1526    async fn record_access(
1527        &self,
1528        chunk_id: &str,
1529        retrieved_by: &str,
1530        used: bool,
1531    ) -> MemoryResult<()> {
1532        let conn = Arc::clone(&self.conn);
1533        let id = chunk_id.to_string();
1534        let by = retrieved_by.to_string();
1535        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1536            let guard = conn.blocking_lock();
1537            let exists: i64 = guard
1538                .query_row(
1539                    "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1540                    params![id],
1541                    |r| r.get(0),
1542                )
1543                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1544            if exists == 0 {
1545                return Err(MemoryError::NotFound(format!("chunk {id}")));
1546            }
1547            guard
1548                .execute(
1549                    "INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
1550                     VALUES (?, ?, ?, '', 0, ?)",
1551                    params![now_ms(), id, by, if used { 1 } else { 0 }],
1552                )
1553                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1554            Ok(())
1555        })
1556        .await
1557        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1558    }
1559
1560    // ───────────── Deletes ─────────────
1561
1562    async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
1563        self.invalidate_retrieve_cache();
1564        let conn = Arc::clone(&self.conn);
1565        let id = chunk_id.to_string();
1566        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1567            let mut guard = conn.blocking_lock();
1568            let tx = guard
1569                .transaction()
1570                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1571            let n = tx
1572                .execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1573                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1574            if n == 0 {
1575                return Err(MemoryError::NotFound(format!("chunk {id}")));
1576            }
1577            tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1578                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1579            tx.execute(
1580                "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1581                 VALUES (?,?,?, '{}')",
1582                params![now_ms(), id, eviction_reason_str(reason)],
1583            )
1584            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1585            tx.commit()
1586                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1587            Ok(())
1588        })
1589        .await
1590        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1591    }
1592
1593    async fn delete_matching(
1594        &self,
1595        predicate: MemoryPredicate,
1596        reason: EvictionReason,
1597    ) -> MemoryResult<usize> {
1598        // Footgun guard: an empty predicate would match every chunk; callers
1599        // who want "delete everything" must use `purge_all`. Mirrors
1600        // BasicMemoryProvider's behavior so the locked trait surface
1601        // is consistent across providers.
1602        if predicate.is_empty() {
1603            return Ok(0);
1604        }
1605        self.invalidate_retrieve_cache();
1606        let conn = Arc::clone(&self.conn);
1607        tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
1608            let (where_clause, params_vec) = build_chunk_predicate(&predicate);
1609            let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
1610            let mut guard = conn.blocking_lock();
1611            let tx = guard
1612                .transaction()
1613                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1614            let ids: Vec<String> = {
1615                let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1616                let mut stmt = tx
1617                    .prepare(&select_sql)
1618                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1619                let rows = stmt
1620                    .query_map(p.as_slice(), |r| r.get::<_, String>(0))
1621                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1622                let mut out = Vec::new();
1623                for r in rows {
1624                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1625                }
1626                out
1627            };
1628            let now = now_ms();
1629            let reason_s = eviction_reason_str(reason);
1630            for id in &ids {
1631                tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1632                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1633                tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1634                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1635                tx.execute(
1636                    "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1637                     VALUES (?, ?, ?, '{}')",
1638                    params![now, id, reason_s],
1639                )
1640                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1641            }
1642            tx.commit()
1643                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1644            Ok(ids.len())
1645        })
1646        .await
1647        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1648    }
1649
1650    async fn purge_all(&self) -> MemoryResult<PurgeReport> {
1651        self.invalidate_retrieve_cache();
1652        let conn = Arc::clone(&self.conn);
1653        tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
1654            let mut guard = conn.blocking_lock();
1655            let tx = guard
1656                .transaction()
1657                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1658            let chunks: i64 = tx
1659                .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
1660                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1661            let sessions: i64 = tx
1662                .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
1663                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1664            let access: i64 = tx
1665                .query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
1666                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1667            let evictions: i64 = tx
1668                .query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
1669                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1670
1671            tx.execute("DELETE FROM memory_chunks", [])
1672                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1673            tx.execute("DELETE FROM memory_vec", [])
1674                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1675            tx.execute("DELETE FROM memory_sessions", [])
1676                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1677            tx.execute("DELETE FROM memory_access_log", [])
1678                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1679            tx.execute("DELETE FROM memory_eviction_log", [])
1680                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1681            tx.execute("DELETE FROM memory_summary_members", [])
1682                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1683            tx.commit()
1684                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1685
1686            Ok(PurgeReport {
1687                chunks_deleted: chunks as usize,
1688                sessions_deleted: sessions as usize,
1689                access_log_deleted: access as usize,
1690                eviction_log_deleted: evictions as usize,
1691            })
1692        })
1693        .await
1694        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1695    }
1696
1697    // ───────────── Summarization ─────────────
1698
1699    async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
1700        // Honest signal when no summarizer is wired: callers that don't opt
1701        // into summarization keep their `NotImplemented` branch.
1702        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
1703            "SqliteMemoryProvider::summarize_session — no summarizer attached \
1704             (call `with_summarizer` first)",
1705        ))?;
1706
1707        // 1. Confirm the session exists. Returns NotFound with a clear
1708        //    message so callers can distinguish "unknown id" from
1709        //    "summarizer failed".
1710        let session = self
1711            .get_session(session_id)
1712            .await?
1713            .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
1714
1715        // 2. Pull the constituent chunks in chronological order —
1716        //    oldest-first feed so the model can read the conversation
1717        //    as it unfolded.
1718        let members = self.fetch_session_chunks(session_id).await?;
1719        if members.is_empty() {
1720            // No chunks to summarize — the model would hallucinate, and
1721            // the summary_members link table would be empty. Surface as
1722            // InvalidArgument so callers can decide whether to silently
1723            // skip or surface to the user.
1724            return Err(MemoryError::InvalidArgument(format!(
1725                "session {session_id} has no chunks to summarize"
1726            )));
1727        }
1728        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1729
1730        // 3. Call the summarizer. NoInput should be impossible here
1731        //    given the check above, but we still translate honestly.
1732        let ctx = cel_memory::SummaryContext {
1733            kind_label: Some("session".into()),
1734            note: session
1735                .title
1736                .as_ref()
1737                .map(|t| format!("session title: {t}")),
1738            max_words: None,
1739        };
1740        let summary_text = summarizer
1741            .summarize(&members, &ctx)
1742            .await
1743            .map_err(|e| match e {
1744                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
1745                    "summarizer received no input despite session having chunks".into(),
1746                ),
1747                other => MemoryError::Provider(format!(
1748                    "summarizer {} failed: {other}",
1749                    summarizer.name()
1750                )),
1751            })?;
1752
1753        // 4. Persist the summary as a fresh JobSummary chunk via the
1754        //    public write path so importance scoring and the
1755        //    embedding pipeline both stay consistent with every other
1756        //    write. We use the session's caller_id so the summary
1757        //    inherits scope, and stamp metadata with the member ids
1758        //    and session id for cross-reference.
1759        let new_chunk = NewMemoryChunk {
1760            kind: ChunkKind::JobSummary,
1761            source: ChunkSource::Embedded,
1762            session_id: Some(session_id.to_string()),
1763            project_root: members.iter().find_map(|c| c.project_root.clone()),
1764            caller_id: session.caller_id.clone(),
1765            content: summary_text,
1766            metadata: serde_json::json!({
1767                "session_id": session_id,
1768                "member_count": member_ids.len(),
1769                "summarizer": summarizer.name(),
1770            }),
1771            // Honor the importance default (+0.2 for JobSummary off baseline)
1772            // by leaving `importance: None` — the importance scorer
1773            // applied inside `write` will land on 0.7.
1774            importance: None,
1775            shareable: false,
1776            pinned: false,
1777        };
1778        let written = self.write(new_chunk).await?;
1779
1780        // 5. Link summary → members in memory_summary_members. One row
1781        //    per member. We use INSERT OR IGNORE so repeated calls (or
1782        //    a flaky retry) don't blow up on the composite primary
1783        //    key.
1784        let conn = Arc::clone(&self.conn);
1785        let summary_id = written.id.clone();
1786        let member_ids_for_insert = member_ids.clone();
1787        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1788            let mut guard = conn.blocking_lock();
1789            let tx = guard
1790                .transaction()
1791                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1792            for mid in &member_ids_for_insert {
1793                tx.execute(
1794                    "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
1795                         VALUES (?, ?)",
1796                    params![summary_id, mid],
1797                )
1798                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1799            }
1800            tx.commit()
1801                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1802            Ok(())
1803        })
1804        .await
1805        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1806
1807        // 6. Backfill the session's `summary` column so the
1808        //    `MemorySession` record exposes the latest synthesis
1809        //    without a join.
1810        let conn = Arc::clone(&self.conn);
1811        let sid = session_id.to_string();
1812        let stored_summary = written.content.clone();
1813        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1814            let guard = conn.blocking_lock();
1815            guard
1816                .execute(
1817                    "UPDATE memory_sessions SET summary = ? WHERE id = ?",
1818                    params![stored_summary, sid],
1819                )
1820                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1821            Ok(())
1822        })
1823        .await
1824        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1825
1826        Ok(written)
1827    }
1828
1829    async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1830        self.rollup_day_inner(date, false).await
1831    }
1832
1833    async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1834        self.rollup_day_inner(date, true).await
1835    }
1836
1837    async fn rollup_rule_week(
1838        &self,
1839        rule_id: &str,
1840        week_start: NaiveDate,
1841    ) -> MemoryResult<MemoryChunk> {
1842        self.rollup_rule_week_inner(rule_id, week_start, false)
1843            .await
1844    }
1845
1846    async fn rollup_rule_week_forced(
1847        &self,
1848        rule_id: &str,
1849        week_start: NaiveDate,
1850    ) -> MemoryResult<MemoryChunk> {
1851        self.rollup_rule_week_inner(rule_id, week_start, true).await
1852    }
1853
1854    // ───────────── Maintenance ─────────────
1855
1856    async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
1857        // v1 sweep: delete non-pinned non-correction chunks older than
1858        // the retention horizon. Matches BasicMemoryProvider's heuristic
1859        // (30 days) so the trait surface stays consistent across providers.
1860        // A future revision can replace this with an importance-aware policy.
1861        const RETENTION_DAYS: i64 = 30;
1862        let cutoff_ms =
1863            (chrono::Utc::now() - chrono::Duration::days(RETENTION_DAYS)).timestamp_millis();
1864        self.invalidate_retrieve_cache();
1865        let conn = Arc::clone(&self.conn);
1866        tokio::task::spawn_blocking(move || -> Result<AgingReport, MemoryError> {
1867            let mut guard = conn.blocking_lock();
1868            let tx = guard
1869                .transaction()
1870                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1871            let ids: Vec<String> = {
1872                let mut stmt = tx
1873                    .prepare(
1874                        "SELECT id FROM memory_chunks
1875                         WHERE pinned = 0
1876                           AND kind != 'correction'
1877                           AND created_at < ?",
1878                    )
1879                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1880                let rows = stmt
1881                    .query_map(params![cutoff_ms], |r| r.get::<_, String>(0))
1882                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1883                let mut out = Vec::new();
1884                for r in rows {
1885                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1886                }
1887                out
1888            };
1889            let now = now_ms();
1890            let reason_s = eviction_reason_str(EvictionReason::Aging);
1891            for id in &ids {
1892                tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1893                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1894                tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1895                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1896                tx.execute(
1897                    "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1898                     VALUES (?, ?, ?, '{}')",
1899                    params![now, id, reason_s],
1900                )
1901                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1902            }
1903            tx.commit()
1904                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1905            let deleted = ids.len();
1906            Ok(AgingReport {
1907                tier_promoted: 0, // session→long_term transition is Phase 3 work
1908                deleted,
1909                bytes_reclaimed: 0,
1910                deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
1911            })
1912        })
1913        .await
1914        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1915    }
1916    async fn re_embed_all(&self, _target_model: &str) -> MemoryResult<ReEmbedReport> {
1917        Err(MemoryError::NotImplemented(
1918            "SqliteMemoryProvider::re_embed_all — Phase 4",
1919        ))
1920    }
1921    async fn export(&self, filter: ExportFilter) -> MemoryResult<ExportBundle> {
1922        let conn = Arc::clone(&self.conn);
1923        tokio::task::spawn_blocking(move || -> Result<ExportBundle, MemoryError> {
1924            let guard = conn.blocking_lock();
1925
1926            // Chunks first; honor the optional predicate.
1927            let (where_clause, params_vec) = match &filter.predicate {
1928                Some(p) if !p.is_empty() => build_chunk_predicate(p),
1929                _ => ("1=1".to_string(), Vec::new()),
1930            };
1931            let select_sql = format!(
1932                "SELECT id, created_at, kind, tier, source, session_id,
1933                        project_root, caller_id, content, metadata,
1934                        importance, pinned, shareable, superseded_by,
1935                        embedding_model, embedding_dim
1936                 FROM memory_chunks WHERE {} ORDER BY created_at DESC",
1937                where_clause
1938            );
1939            let chunks: Vec<MemoryChunk> = {
1940                let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1941                let mut stmt = guard
1942                    .prepare(&select_sql)
1943                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1944                let rows = stmt
1945                    .query_map(p.as_slice(), row_to_chunk)
1946                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1947                let mut out = Vec::new();
1948                for r in rows {
1949                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1950                }
1951                out
1952            };
1953
1954            // Sessions: only those referenced by the included chunks, and
1955            // only if include_sessions is set.
1956            let sessions = if filter.include_sessions {
1957                let session_ids: std::collections::HashSet<String> =
1958                    chunks.iter().filter_map(|c| c.session_id.clone()).collect();
1959                if session_ids.is_empty() {
1960                    Vec::new()
1961                } else {
1962                    let placeholders = vec!["?"; session_ids.len()].join(",");
1963                    let sql = format!(
1964                        "SELECT id, started_at, ended_at, caller_id, title, summary,
1965                                outcome, metadata
1966                         FROM memory_sessions WHERE id IN ({placeholders})"
1967                    );
1968                    let ids: Vec<String> = session_ids.into_iter().collect();
1969                    let p: Vec<&dyn rusqlite::ToSql> =
1970                        ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
1971                    let mut stmt = guard
1972                        .prepare(&sql)
1973                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1974                    let rows = stmt
1975                        .query_map(p.as_slice(), |r| {
1976                            let metadata_str: String = r.get("metadata")?;
1977                            let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
1978                                .unwrap_or(serde_json::Value::Null);
1979                            Ok(MemorySession {
1980                                id: r.get("id")?,
1981                                started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1982                                ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1983                                caller_id: r.get("caller_id")?,
1984                                title: r.get("title")?,
1985                                summary: r.get("summary")?,
1986                                outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1987                                    .unwrap_or(SessionOutcome::Aborted),
1988                                metadata,
1989                            })
1990                        })
1991                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1992                    let mut out = Vec::new();
1993                    for r in rows {
1994                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1995                    }
1996                    out
1997                }
1998            } else {
1999                Vec::new()
2000            };
2001
2002            let evictions = if filter.include_eviction_log {
2003                let mut stmt = guard
2004                    .prepare(
2005                        "SELECT ts, chunk_id, reason, metadata
2006                         FROM memory_eviction_log ORDER BY ts DESC",
2007                    )
2008                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2009                let rows = stmt
2010                    .query_map([], |r| {
2011                        let reason_s: String = r.get("reason")?;
2012                        let metadata_s: String = r.get("metadata")?;
2013                        let metadata: serde_json::Value =
2014                            serde_json::from_str(&metadata_s).unwrap_or(serde_json::Value::Null);
2015                        Ok(EvictionEntry {
2016                            ts: ms_to_dt(r.get::<_, i64>("ts")?),
2017                            chunk_id: r.get("chunk_id")?,
2018                            reason: str_to_eviction_reason(&reason_s)
2019                                .unwrap_or(EvictionReason::UserDelete),
2020                            metadata,
2021                        })
2022                    })
2023                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2024                let mut out = Vec::new();
2025                for r in rows {
2026                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2027                }
2028                out
2029            } else {
2030                Vec::new()
2031            };
2032
2033            let accesses = if filter.include_access_log {
2034                let mut stmt = guard
2035                    .prepare(
2036                        "SELECT ts, chunk_id, retrieved_by, query_hash, rank, used
2037                         FROM memory_access_log ORDER BY ts DESC",
2038                    )
2039                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2040                let rows = stmt
2041                    .query_map([], |r| {
2042                        Ok(AccessEntry {
2043                            ts: ms_to_dt(r.get::<_, i64>("ts")?),
2044                            chunk_id: r.get("chunk_id")?,
2045                            retrieved_by: r.get("retrieved_by")?,
2046                            query_hash: r.get("query_hash")?,
2047                            rank: r.get::<_, i64>("rank")? as usize,
2048                            used: r.get::<_, i64>("used")? != 0,
2049                        })
2050                    })
2051                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2052                let mut out = Vec::new();
2053                for r in rows {
2054                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2055                }
2056                out
2057            } else {
2058                Vec::new()
2059            };
2060
2061            Ok(ExportBundle {
2062                chunks,
2063                sessions,
2064                evictions,
2065                accesses,
2066            })
2067        })
2068        .await
2069        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2070    }
2071
2072    async fn stats(&self) -> MemoryResult<MemoryStats> {
2073        let conn = Arc::clone(&self.conn);
2074        let model = self.embedder.model_name().to_string();
2075        tokio::task::spawn_blocking(move || -> Result<MemoryStats, MemoryError> {
2076            let guard = conn.blocking_lock();
2077            let total: i64 = guard
2078                .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
2079                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2080            let session_tier: i64 = guard
2081                .query_row(
2082                    "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'session'",
2083                    [],
2084                    |r| r.get(0),
2085                )
2086                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2087            let lt_tier: i64 = guard
2088                .query_row(
2089                    "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'long_term'",
2090                    [],
2091                    |r| r.get(0),
2092                )
2093                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2094            let total_sessions: i64 = guard
2095                .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
2096                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2097            let open: i64 = guard
2098                .query_row(
2099                    "SELECT COUNT(*) FROM memory_sessions WHERE outcome = 'open'",
2100                    [],
2101                    |r| r.get(0),
2102                )
2103                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2104            Ok(MemoryStats {
2105                total_chunks: total as usize,
2106                session_chunks: session_tier as usize,
2107                long_term_chunks: lt_tier as usize,
2108                total_sessions: total_sessions as usize,
2109                open_sessions: open as usize,
2110                db_bytes: 0, // caller can compute file size separately
2111                embedding_model: Some(model),
2112            })
2113        })
2114        .await
2115        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2116    }
2117}
2118
2119/// Per-profile retrieval weights. Tuned defaults, not exposed via the trait
2120/// yet, so callers configure profiles via the `RetrievalProfile` enum only.
2121/// Returns `(w_vec, w_fts, w_rec, half_life_seconds)`.
2122fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
2123    use cel_memory::RetrievalProfile::*;
2124    match profile {
2125        // Embedded agent's per-turn retrieval — semantic-heavy, short
2126        // recency half-life (7 days).
2127        AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2128        // Long-horizon job context — heavier weight on long_term + summaries.
2129        AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2130        // NL compiler: similar prior rules. Keyword match dominates because
2131        // users are looking for "the rule that mentions ~/Workspace".
2132        NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2133        // NL compiler: similar prior fires. Same balance, slightly longer
2134        // half-life because rule history compounds.
2135        NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2136        // Audit / Activity tab — keyword-dominant, wide window.
2137        AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2138        // User free-text search — like audit but with a longer half-life.
2139        UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2140    }
2141}
2142
2143/// Sanitise the query string for FTS5. FTS5's query language treats
2144/// punctuation and special characters as operators; the simplest safe
2145/// approach is to quote the entire query as a phrase and escape any
2146/// internal double-quote.
2147fn fts_query_escape(raw: &str) -> String {
2148    let escaped = raw.replace('"', "\"\"");
2149    format!("\"{escaped}\"")
2150}
2151
2152/// Apply the non-ranking filters from a `MemoryQuery` to a candidate
2153/// chunk. Returns true if the chunk should be included in the result.
2154fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2155    // Kind filter
2156    if let Some(kinds) = &q.kinds {
2157        if !kinds.contains(&c.kind) {
2158            return false;
2159        }
2160    }
2161    // Rollups gate
2162    if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2163        return false;
2164    }
2165    // Time bounds
2166    if let Some(since) = q.since {
2167        if c.created_at < since {
2168            return false;
2169        }
2170    }
2171    if let Some(until) = q.until {
2172        if c.created_at > until {
2173            return false;
2174        }
2175    }
2176    // Session
2177    if let Some(sid) = &q.session_id {
2178        if c.session_id.as_deref() != Some(sid.as_str()) {
2179            return false;
2180        }
2181    }
2182    // Project root prefix
2183    if let Some(prefix) = &q.project_root_prefix {
2184        match &c.project_root {
2185            Some(root) if root.starts_with(prefix.as_str()) => {}
2186            _ => return false,
2187        }
2188    }
2189    // Min importance
2190    if let Some(min) = q.min_importance {
2191        if c.importance < min {
2192            return false;
2193        }
2194    }
2195    // Caller scope. `Own` restricts to the caller's own chunks.
2196    // `OwnPlusShared` (Phase 4) permits the caller's own chunks *plus* any
2197    // chunk tagged `shareable=true` from another caller. `Global` permits
2198    // everything — granted only to privileged surfaces (Memory tab,
2199    // audit timeline).
2200    match q.caller_scope {
2201        cel_memory::CallerScope::Own => {
2202            if c.caller_id != q.caller_id {
2203                return false;
2204            }
2205        }
2206        cel_memory::CallerScope::OwnPlusShared => {
2207            if c.caller_id != q.caller_id && !c.shareable {
2208                return false;
2209            }
2210        }
2211        cel_memory::CallerScope::Global => {}
2212    }
2213    true
2214}
2215
2216/// Translate a [`MemoryPredicate`] into a parameterized WHERE clause for
2217/// `memory_chunks`. Returns the clause body (without the leading `WHERE`)
2218/// and the parameter vector. The clause is composed of `AND`-joined
2219/// sub-clauses, defaulting to `1=1` if (somehow) every predicate field is
2220/// `None` — but [`MemoryPredicate::is_empty`] should have short-circuited
2221/// before this is called.
2222fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2223    let mut clauses: Vec<String> = vec!["1=1".to_string()];
2224    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2225    if let Some(kinds) = &p.kinds {
2226        let placeholders = vec!["?"; kinds.len()].join(",");
2227        clauses.push(format!("kind IN ({placeholders})"));
2228        for k in kinds {
2229            params.push(Box::new(kind_str(*k).to_string()));
2230        }
2231    }
2232    if let Some(callers) = &p.callers {
2233        let placeholders = vec!["?"; callers.len()].join(",");
2234        clauses.push(format!("caller_id IN ({placeholders})"));
2235        for c in callers {
2236            params.push(Box::new(c.clone()));
2237        }
2238    }
2239    if let Some(sids) = &p.session_ids {
2240        let placeholders = vec!["?"; sids.len()].join(",");
2241        clauses.push(format!("session_id IN ({placeholders})"));
2242        for s in sids {
2243            params.push(Box::new(s.clone()));
2244        }
2245    }
2246    if let Some(prefix) = &p.project_root_prefix {
2247        clauses.push("project_root LIKE ?".to_string());
2248        params.push(Box::new(format!("{prefix}%")));
2249    }
2250    if let Some(before) = p.before {
2251        clauses.push("created_at < ?".to_string());
2252        params.push(Box::new(dt_to_ms(before)));
2253    }
2254    if let Some(after) = p.after {
2255        clauses.push("created_at > ?".to_string());
2256        params.push(Box::new(dt_to_ms(after)));
2257    }
2258    if let Some(pinned) = p.pinned {
2259        clauses.push("pinned = ?".to_string());
2260        params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2261    }
2262    if let Some(below) = p.importance_below {
2263        clauses.push("importance < ?".to_string());
2264        params.push(Box::new(below as f64));
2265    }
2266    if let Some(needle) = &p.content_contains {
2267        clauses.push("LOWER(content) LIKE ?".to_string());
2268        params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2269    }
2270    (clauses.join(" AND "), params)
2271}
2272
2273/// Inverse of [`eviction_reason_str`]. Unknown strings (e.g., from a future
2274/// schema version) fall back to `UserDelete` rather than panicking; the
2275/// caller is expected to log + carry on.
2276fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2277    Ok(match s {
2278        "user_delete" => EvictionReason::UserDelete,
2279        "aging" => EvictionReason::Aging,
2280        "low_importance" => EvictionReason::LowImportance,
2281        "redact_rule" => EvictionReason::RedactRule,
2282        "storage_cap" => EvictionReason::StorageCap,
2283        "purge_all" => EvictionReason::PurgeAll,
2284        other => {
2285            return Err(MemoryError::Storage(format!(
2286                "unknown eviction reason: {other}"
2287            )))
2288        }
2289    })
2290}
2291
2292fn eviction_reason_str(r: EvictionReason) -> &'static str {
2293    match r {
2294        EvictionReason::UserDelete => "user_delete",
2295        EvictionReason::Aging => "aging",
2296        EvictionReason::LowImportance => "low_importance",
2297        EvictionReason::RedactRule => "redact_rule",
2298        EvictionReason::StorageCap => "storage_cap",
2299        EvictionReason::PurgeAll => "purge_all",
2300    }
2301}
2302
2303// Silence unused-import warnings when `record_access` etc. aren't wired.
2304#[allow(dead_code)]
2305fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}