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`] — preserving the contract for daemons
60    /// that don't enable summarization. Attach 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. The daemon wires this to the rule matcher so
107    /// `redact_memory`-style rules can suppress writes that mention
108    /// sensitive content.
109    pub fn with_write_hook(mut self, hook: Arc<dyn cel_memory::MemoryWriteHook>) -> Self {
110        self.write_hook = Some(hook);
111        self
112    }
113
114    /// Attach a [`Summarizer`](cel_memory::Summarizer) used by
115    /// [`MemoryProvider::summarize_session`], `rollup_day`, and
116    /// `rollup_rule_week`. Daemons pass a concrete summarizer (a downstream
117    /// [`cel_memory::Summarizer`] impl); tests pass a
118    /// [`cel_memory::MockSummarizer`].
119    ///
120    /// Calling [`MemoryProvider::summarize_session`] without first
121    /// attaching a summarizer returns
122    /// [`cel_memory::MemoryError::NotImplemented`] — preserving the v1
123    /// contract for daemons that opt out of summarization.
124    pub fn with_summarizer(mut self, summarizer: Arc<dyn cel_memory::Summarizer>) -> Self {
125        self.summarizer = Some(summarizer);
126        self
127    }
128
129    /// Test-only accessor for the underlying connection Arc, used by the
130    /// smoke tests to backdate `created_at` for aging-sweep tests. Marked
131    /// `#[doc(hidden)]` and behind `cfg(any(test, feature = "test-support"))`
132    /// so production code can't grab the lock and bypass the provider's
133    /// transactional guarantees.
134    #[doc(hidden)]
135    pub fn conn_for_test(&self) -> Arc<tokio::sync::Mutex<rusqlite::Connection>> {
136        Arc::clone(&self.conn)
137    }
138
139    /// Drop every cached retrieve result. Called by every mutator on the
140    /// provider so reads following a write never observe stale rankings.
141    fn invalidate_retrieve_cache(&self) {
142        self.retrieve_cache.clear();
143    }
144
145    /// Open an in-memory database for tests.
146    pub async fn open_in_memory(embedder: Arc<dyn Embedder>) -> Result<Self, SqliteMemoryError> {
147        crate::vec_extension::register();
148        let conn = tokio::task::spawn_blocking(|| -> Result<Connection, SqliteMemoryError> {
149            let mut c = Connection::open_in_memory()?;
150            migrations::run(&mut c)?;
151            Ok(c)
152        })
153        .await
154        .map_err(|e| SqliteMemoryError::BlockingJoin(e.to_string()))??;
155        Ok(Self {
156            conn: Arc::new(Mutex::new(conn)),
157            embedder,
158            write_hook: None,
159            summarizer: None,
160            retrieve_cache: Arc::new(RetrieveCache::new(
161                RETRIEVE_CACHE_CAPACITY,
162                RETRIEVE_CACHE_TTL,
163            )),
164        })
165    }
166
167    /// Fetch every chunk attached to a session, ordered oldest-first.
168    ///
169    /// Used by [`MemoryProvider::summarize_session`]; kept as a private
170    /// helper rather than a trait method because the only call site
171    /// today is summarization and the `MemoryQuery` surface would force
172    /// caller-id and text constraints that don't apply here. Promote to
173    /// the trait if a second caller appears.
174    ///
175    /// Excludes existing `JobSummary` chunks for the same session so
176    /// re-summarization doesn't snowball — each call re-summarizes the
177    /// raw history, not the prior summary.
178    async fn fetch_session_chunks(&self, session_id: &str) -> MemoryResult<Vec<MemoryChunk>> {
179        let conn = Arc::clone(&self.conn);
180        let sid = session_id.to_string();
181        tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
182            let guard = conn.blocking_lock();
183            let mut stmt = guard
184                .prepare(
185                    "SELECT id, created_at, kind, tier, source, session_id,
186                                project_root, caller_id, content, metadata,
187                                importance, pinned, shareable, superseded_by,
188                                embedding_model, embedding_dim
189                         FROM memory_chunks
190                         WHERE session_id = ?
191                           AND kind != 'job_summary'
192                         ORDER BY created_at ASC",
193                )
194                .map_err(|e| MemoryError::Storage(e.to_string()))?;
195            let rows = stmt
196                .query_map(params![sid], row_to_chunk)
197                .map_err(|e| MemoryError::Storage(e.to_string()))?;
198            let mut out = Vec::new();
199            for r in rows {
200                out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
201            }
202            Ok(out)
203        })
204        .await
205        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
206    }
207
208    /// Fetch every chunk whose `created_at` falls within the UTC date
209    /// `date` (00:00:00 inclusive → next day 00:00:00 exclusive). Excludes
210    /// `Rollup` chunks so re-running the daily rollup doesn't snowball.
211    /// Ordered oldest-first so the summarizer sees the day as it
212    /// unfolded.
213    async fn fetch_day_chunks(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
214        let start = date
215            .and_hms_opt(0, 0, 0)
216            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
217            .and_utc()
218            .timestamp_millis();
219        let end = date
220            .succ_opt()
221            .ok_or_else(|| MemoryError::InvalidArgument(format!("date overflow: {date}")))?
222            .and_hms_opt(0, 0, 0)
223            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {date}")))?
224            .and_utc()
225            .timestamp_millis();
226        let conn = Arc::clone(&self.conn);
227        tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
228            let guard = conn.blocking_lock();
229            let mut stmt = guard
230                .prepare(
231                    "SELECT id, created_at, kind, tier, source, session_id,
232                                project_root, caller_id, content, metadata,
233                                importance, pinned, superseded_by,
234                                embedding_model, embedding_dim
235                         FROM memory_chunks
236                         WHERE created_at >= ?
237                           AND created_at < ?
238                           AND kind != 'rollup'
239                         ORDER BY created_at ASC",
240                )
241                .map_err(|e| MemoryError::Storage(e.to_string()))?;
242            let rows = stmt
243                .query_map(params![start, end], row_to_chunk)
244                .map_err(|e| MemoryError::Storage(e.to_string()))?;
245            let mut out = Vec::new();
246            for r in rows {
247                out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
248            }
249            Ok(out)
250        })
251        .await
252        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
253    }
254
255    /// Fetch every `Fire` chunk for `rule_id` whose `created_at` falls
256    /// within the ISO week starting at `week_start` (a Monday, by
257    /// convention; this code does not enforce that — it just sums the
258    /// 7-day window). The rule id is stored on the chunk's
259    /// `metadata.rule_id` field, written by the matcher consumer task.
260    /// Excludes prior `Rollup` chunks to avoid snowball.
261    async fn fetch_rule_week_chunks(
262        &self,
263        rule_id: &str,
264        week_start: NaiveDate,
265    ) -> MemoryResult<Vec<MemoryChunk>> {
266        let start = week_start
267            .and_hms_opt(0, 0, 0)
268            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {week_start}")))?
269            .and_utc()
270            .timestamp_millis();
271        let end_date = week_start
272            .checked_add_days(chrono::Days::new(7))
273            .ok_or_else(|| MemoryError::InvalidArgument(format!("week overflow: {week_start}")))?;
274        let end = end_date
275            .and_hms_opt(0, 0, 0)
276            .ok_or_else(|| MemoryError::InvalidArgument(format!("invalid date: {end_date}")))?
277            .and_utc()
278            .timestamp_millis();
279        let conn = Arc::clone(&self.conn);
280        let rid = rule_id.to_string();
281        tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
282            let guard = conn.blocking_lock();
283            let mut stmt = guard
284                .prepare(
285                    "SELECT id, created_at, kind, tier, source, session_id,
286                                project_root, caller_id, content, metadata,
287                                importance, pinned, superseded_by,
288                                embedding_model, embedding_dim
289                         FROM memory_chunks
290                         WHERE kind = 'fire'
291                           AND created_at >= ?
292                           AND created_at < ?
293                           AND json_extract(metadata, '$.rule_id') = ?
294                         ORDER BY created_at ASC",
295                )
296                .map_err(|e| MemoryError::Storage(e.to_string()))?;
297            let rows = stmt
298                .query_map(params![start, end, rid], row_to_chunk)
299                .map_err(|e| MemoryError::Storage(e.to_string()))?;
300            let mut out = Vec::new();
301            for r in rows {
302                out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
303            }
304            Ok(out)
305        })
306        .await
307        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
308    }
309
310    /// True if a `Rollup` chunk already exists for `date` (matched via
311    /// `metadata.rollup_date = '<YYYY-MM-DD>'`). Used to short-circuit
312    /// the day-rollup cron pass when force=false.
313    async fn day_rollup_exists(&self, date: NaiveDate) -> MemoryResult<bool> {
314        let date_s = date.to_string();
315        let conn = Arc::clone(&self.conn);
316        tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
317            let guard = conn.blocking_lock();
318            let count: i64 = guard
319                .query_row(
320                    "SELECT COUNT(*) FROM memory_chunks
321                         WHERE kind = 'rollup'
322                           AND json_extract(metadata, '$.rollup_date') = ?",
323                    params![date_s],
324                    |r| r.get(0),
325                )
326                .map_err(|e| MemoryError::Storage(e.to_string()))?;
327            Ok(count > 0)
328        })
329        .await
330        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
331    }
332
333    /// True if a `Rollup` chunk already exists for (`rule_id`,
334    /// `week_start`). Matched via `metadata.rollup_rule_id` +
335    /// `metadata.rollup_week_start`.
336    async fn rule_week_rollup_exists(
337        &self,
338        rule_id: &str,
339        week_start: NaiveDate,
340    ) -> MemoryResult<bool> {
341        let week_s = week_start.to_string();
342        let rid = rule_id.to_string();
343        let conn = Arc::clone(&self.conn);
344        tokio::task::spawn_blocking(move || -> Result<bool, MemoryError> {
345            let guard = conn.blocking_lock();
346            let count: i64 = guard
347                .query_row(
348                    "SELECT COUNT(*) FROM memory_chunks
349                         WHERE kind = 'rollup'
350                           AND json_extract(metadata, '$.rollup_rule_id') = ?
351                           AND json_extract(metadata, '$.rollup_week_start') = ?",
352                    params![rid, week_s],
353                    |r| r.get(0),
354                )
355                .map_err(|e| MemoryError::Storage(e.to_string()))?;
356            Ok(count > 0)
357        })
358        .await
359        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
360    }
361
362    /// Insert one row per (rollup_id, member_id) into
363    /// `memory_summary_members`. INSERT OR IGNORE so a re-run is
364    /// idempotent on the link table.
365    async fn link_rollup_members(
366        &self,
367        rollup_id: &str,
368        member_ids: &[String],
369    ) -> MemoryResult<()> {
370        let rid = rollup_id.to_string();
371        let members: Vec<String> = member_ids.to_vec();
372        let conn = Arc::clone(&self.conn);
373        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
374            let mut guard = conn.blocking_lock();
375            let tx = guard
376                .transaction()
377                .map_err(|e| MemoryError::Storage(e.to_string()))?;
378            for mid in &members {
379                tx.execute(
380                    "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
381                         VALUES (?, ?)",
382                    params![rid, mid],
383                )
384                .map_err(|e| MemoryError::Storage(e.to_string()))?;
385            }
386            tx.commit()
387                .map_err(|e| MemoryError::Storage(e.to_string()))?;
388            Ok(())
389        })
390        .await
391        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
392    }
393
394    /// Shared implementation behind both [`MemoryProvider::rollup_day`]
395    /// and [`MemoryProvider::rollup_day_forced`].
396    ///
397    /// Groups the day's chunks (UTC) and produces a single `Rollup` chunk
398    /// per call. When `force=false`, a no-op `Ok(vec![])` is returned if
399    /// a rollup already exists for `date`. When `force=true`, a fresh
400    /// rollup is always produced.
401    ///
402    /// Returns the summary as a `NotImplemented` error if no summarizer
403    /// has been attached (preserving the v1 contract for daemons that
404    /// opt out of summarization). Returns `Ok(vec![])` if the day has no
405    /// non-rollup chunks — there's nothing to summarise and the cron
406    /// sweeper should treat this as a successful no-op.
407    async fn rollup_day_inner(
408        &self,
409        date: NaiveDate,
410        force: bool,
411    ) -> MemoryResult<Vec<MemoryChunk>> {
412        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
413            "SqliteMemoryProvider::rollup_day — no summarizer attached \
414             (call `with_summarizer` first)",
415        ))?;
416
417        if !force && self.day_rollup_exists(date).await? {
418            tracing::debug!(date = %date, "rollup_day: existing rollup found, skipping");
419            return Ok(Vec::new());
420        }
421
422        let members = self.fetch_day_chunks(date).await?;
423        if members.is_empty() {
424            tracing::debug!(date = %date, "rollup_day: no chunks to summarise, no-op");
425            return Ok(Vec::new());
426        }
427        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
428
429        let kind_label = format!("day {date}");
430        let ctx = cel_memory::SummaryContext {
431            kind_label: Some(kind_label.clone()),
432            note: Some(format!(
433                "Daily rollup for {date} ({} chunks)",
434                members.len()
435            )),
436            max_words: None,
437        };
438        let summary_text = summarizer
439            .summarize(&members, &ctx)
440            .await
441            .map_err(|e| match e {
442                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
443                    "summarizer received no input despite day having chunks".into(),
444                ),
445                other => MemoryError::Provider(format!(
446                    "summarizer {} failed: {other}",
447                    summarizer.name()
448                )),
449            })?;
450
451        // Pick a representative caller_id for the rollup. We use "system"
452        // because daily rollups span every caller; the rollup is daemon-
453        // synthesised, not attributable to one upstream client.
454        let new_chunk = NewMemoryChunk {
455            kind: ChunkKind::Rollup,
456            source: ChunkSource::System,
457            session_id: None,
458            project_root: None,
459            caller_id: "system".to_string(),
460            content: summary_text,
461            metadata: serde_json::json!({
462                "rollup_kind": "day",
463                "rollup_date": date.to_string(),
464                "member_count": member_ids.len(),
465                "summarizer": summarizer.name(),
466            }),
467            importance: None,
468            shareable: false,
469            pinned: false,
470        };
471        let written = self.write(new_chunk).await?;
472        self.link_rollup_members(&written.id, &member_ids).await?;
473        Ok(vec![written])
474    }
475
476    /// Shared implementation behind both
477    /// [`MemoryProvider::rollup_rule_week`] and
478    /// [`MemoryProvider::rollup_rule_week_forced`].
479    ///
480    /// Groups all `Fire` chunks for `rule_id` in the 7-day window
481    /// starting at `week_start` and produces one `Rollup` chunk. When
482    /// `force=false`, returns `Err(InvalidArgument)` if a rollup exists
483    /// (caller can decide whether to surface or suppress). Returns
484    /// `Err(NotFound)` if no fires exist in the window — the cron sweeper
485    /// should treat this as expected and skip the call.
486    async fn rollup_rule_week_inner(
487        &self,
488        rule_id: &str,
489        week_start: NaiveDate,
490        force: bool,
491    ) -> MemoryResult<MemoryChunk> {
492        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
493            "SqliteMemoryProvider::rollup_rule_week — no summarizer attached \
494             (call `with_summarizer` first)",
495        ))?;
496
497        if !force && self.rule_week_rollup_exists(rule_id, week_start).await? {
498            return Err(MemoryError::InvalidArgument(format!(
499                "rollup already exists for rule {rule_id} week {week_start}"
500            )));
501        }
502
503        let members = self.fetch_rule_week_chunks(rule_id, week_start).await?;
504        if members.is_empty() {
505            return Err(MemoryError::NotFound(format!(
506                "no fires for rule {rule_id} in week of {week_start}"
507            )));
508        }
509        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
510
511        let kind_label = format!("week of {week_start} for rule {rule_id}");
512        let ctx = cel_memory::SummaryContext {
513            kind_label: Some(kind_label.clone()),
514            note: Some(format!(
515                "Weekly rollup for rule {rule_id} ({} fires)",
516                members.len()
517            )),
518            max_words: None,
519        };
520        let summary_text = summarizer
521            .summarize(&members, &ctx)
522            .await
523            .map_err(|e| match e {
524                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
525                    "summarizer received no input despite rule-week having chunks".into(),
526                ),
527                other => MemoryError::Provider(format!(
528                    "summarizer {} failed: {other}",
529                    summarizer.name()
530                )),
531            })?;
532
533        let new_chunk = NewMemoryChunk {
534            kind: ChunkKind::Rollup,
535            source: ChunkSource::System,
536            session_id: None,
537            project_root: None,
538            caller_id: "system".to_string(),
539            content: summary_text,
540            metadata: serde_json::json!({
541                "rollup_kind": "rule_week",
542                "rollup_rule_id": rule_id,
543                "rollup_week_start": week_start.to_string(),
544                "member_count": member_ids.len(),
545                "summarizer": summarizer.name(),
546            }),
547            importance: None,
548            shareable: false,
549            pinned: false,
550        };
551        let written = self.write(new_chunk).await?;
552        self.link_rollup_members(&written.id, &member_ids).await?;
553        Ok(written)
554    }
555}
556
557fn now_ms() -> i64 {
558    Utc::now().timestamp_millis()
559}
560
561/// Stable cache key for a [`MemoryQuery`]. We round-trip through
562/// `serde_json` so every field — including `caller_id`, `profile`, all
563/// filters, `k`, `include_rollups`, `min_importance` — contributes to the
564/// hash. JSON is overkill for a key but the surface area is small and the
565/// `Serialize` derive is already paid for.
566fn cache_key_for_query(query: &MemoryQuery) -> Option<u64> {
567    use std::collections::hash_map::DefaultHasher;
568    use std::hash::{Hash, Hasher};
569    let s = serde_json::to_string(query).ok()?;
570    let mut h = DefaultHasher::new();
571    s.hash(&mut h);
572    Some(h.finish())
573}
574
575fn dt_to_ms(t: DateTime<Utc>) -> i64 {
576    t.timestamp_millis()
577}
578
579fn ms_to_dt(ms: i64) -> DateTime<Utc> {
580    chrono::DateTime::<Utc>::from_timestamp_millis(ms)
581        .unwrap_or_else(|| DateTime::<Utc>::from_timestamp_millis(0).unwrap())
582}
583
584fn kind_str(k: ChunkKind) -> &'static str {
585    match k {
586        ChunkKind::Chat => "chat",
587        ChunkKind::Action => "action",
588        ChunkKind::Fire => "fire",
589        ChunkKind::Observation => "observation",
590        ChunkKind::Correction => "correction",
591        ChunkKind::JobSummary => "job_summary",
592        ChunkKind::Context => "context",
593        ChunkKind::Rollup => "rollup",
594    }
595}
596
597fn str_to_kind(s: &str) -> Result<ChunkKind, MemoryError> {
598    Ok(match s {
599        "chat" => ChunkKind::Chat,
600        "action" => ChunkKind::Action,
601        "fire" => ChunkKind::Fire,
602        "observation" => ChunkKind::Observation,
603        "correction" => ChunkKind::Correction,
604        "job_summary" => ChunkKind::JobSummary,
605        "context" => ChunkKind::Context,
606        "rollup" => ChunkKind::Rollup,
607        other => return Err(MemoryError::Storage(format!("unknown kind: {other}"))),
608    })
609}
610
611fn source_str(s: ChunkSource) -> &'static str {
612    match s {
613        ChunkSource::Embedded => "embedded",
614        ChunkSource::Mcp => "mcp",
615        ChunkSource::Gateway => "gateway",
616        ChunkSource::Matcher => "matcher",
617        ChunkSource::Cortex => "cortex",
618        ChunkSource::System => "system",
619    }
620}
621
622fn str_to_source(s: &str) -> Result<ChunkSource, MemoryError> {
623    Ok(match s {
624        "embedded" => ChunkSource::Embedded,
625        "mcp" => ChunkSource::Mcp,
626        "gateway" => ChunkSource::Gateway,
627        "matcher" => ChunkSource::Matcher,
628        "cortex" => ChunkSource::Cortex,
629        "system" => ChunkSource::System,
630        other => return Err(MemoryError::Storage(format!("unknown source: {other}"))),
631    })
632}
633
634fn tier_str(t: MemoryTier) -> &'static str {
635    match t {
636        MemoryTier::Session => "session",
637        MemoryTier::LongTerm => "long_term",
638    }
639}
640
641fn str_to_tier(s: &str) -> Result<MemoryTier, MemoryError> {
642    Ok(match s {
643        "session" => MemoryTier::Session,
644        "long_term" => MemoryTier::LongTerm,
645        other => return Err(MemoryError::Storage(format!("unknown tier: {other}"))),
646    })
647}
648
649fn outcome_str(o: SessionOutcome) -> &'static str {
650    match o {
651        SessionOutcome::Open => "open",
652        SessionOutcome::Success => "success",
653        SessionOutcome::Failure => "failure",
654        SessionOutcome::Aborted => "aborted",
655    }
656}
657
658fn str_to_outcome(s: &str) -> Result<SessionOutcome, MemoryError> {
659    Ok(match s {
660        "open" => SessionOutcome::Open,
661        "success" => SessionOutcome::Success,
662        "failure" => SessionOutcome::Failure,
663        "aborted" => SessionOutcome::Aborted,
664        other => return Err(MemoryError::Storage(format!("unknown outcome: {other}"))),
665    })
666}
667
668fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryChunk> {
669    let metadata_str: String = row.get("metadata")?;
670    let metadata: serde_json::Value =
671        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
672    // 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 the daemon hasn't wired a summarizer —
1701        // preserves the v1 contract so existing callers that don't
1702        // opt into summarization keep their `NotImplemented` branch.
1703        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
1704            "SqliteMemoryProvider::summarize_session — no summarizer attached \
1705             (call `with_summarizer` first)",
1706        ))?;
1707
1708        // 1. Confirm the session exists. Returns NotFound with a clear
1709        //    message so callers can distinguish "unknown id" from
1710        //    "summarizer failed".
1711        let session = self
1712            .get_session(session_id)
1713            .await?
1714            .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
1715
1716        // 2. Pull the constituent chunks in chronological order —
1717        //    oldest-first feed so the model can read the conversation
1718        //    as it unfolded.
1719        let members = self.fetch_session_chunks(session_id).await?;
1720        if members.is_empty() {
1721            // No chunks to summarize — the model would hallucinate, and
1722            // the summary_members link table would be empty. Surface as
1723            // InvalidArgument so callers can decide whether to silently
1724            // skip or surface to the user.
1725            return Err(MemoryError::InvalidArgument(format!(
1726                "session {session_id} has no chunks to summarize"
1727            )));
1728        }
1729        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1730
1731        // 3. Call the summarizer. NoInput should be impossible here
1732        //    given the check above, but we still translate honestly.
1733        let ctx = cel_memory::SummaryContext {
1734            kind_label: Some("session".into()),
1735            note: session
1736                .title
1737                .as_ref()
1738                .map(|t| format!("session title: {t}")),
1739            max_words: None,
1740        };
1741        let summary_text = summarizer
1742            .summarize(&members, &ctx)
1743            .await
1744            .map_err(|e| match e {
1745                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
1746                    "summarizer received no input despite session having chunks".into(),
1747                ),
1748                other => MemoryError::Provider(format!(
1749                    "summarizer {} failed: {other}",
1750                    summarizer.name()
1751                )),
1752            })?;
1753
1754        // 4. Persist the summary as a fresh JobSummary chunk via the
1755        //    public write path so importance scoring and the
1756        //    embedding pipeline both stay consistent with every other
1757        //    write. We use the session's caller_id so the summary
1758        //    inherits scope, and stamp metadata with the member ids
1759        //    and session id for cross-reference.
1760        let new_chunk = NewMemoryChunk {
1761            kind: ChunkKind::JobSummary,
1762            source: ChunkSource::Embedded,
1763            session_id: Some(session_id.to_string()),
1764            project_root: members.iter().find_map(|c| c.project_root.clone()),
1765            caller_id: session.caller_id.clone(),
1766            content: summary_text,
1767            metadata: serde_json::json!({
1768                "session_id": session_id,
1769                "member_count": member_ids.len(),
1770                "summarizer": summarizer.name(),
1771            }),
1772            // Honor the importance default (+0.2 for JobSummary off baseline)
1773            // by leaving `importance: None` — the importance scorer
1774            // applied inside `write` will land on 0.7.
1775            importance: None,
1776            shareable: false,
1777            pinned: false,
1778        };
1779        let written = self.write(new_chunk).await?;
1780
1781        // 5. Link summary → members in memory_summary_members. One row
1782        //    per member. We use INSERT OR IGNORE so repeated calls (or
1783        //    a flaky retry) don't blow up on the composite primary
1784        //    key.
1785        let conn = Arc::clone(&self.conn);
1786        let summary_id = written.id.clone();
1787        let member_ids_for_insert = member_ids.clone();
1788        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1789            let mut guard = conn.blocking_lock();
1790            let tx = guard
1791                .transaction()
1792                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1793            for mid in &member_ids_for_insert {
1794                tx.execute(
1795                    "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
1796                         VALUES (?, ?)",
1797                    params![summary_id, mid],
1798                )
1799                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1800            }
1801            tx.commit()
1802                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1803            Ok(())
1804        })
1805        .await
1806        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1807
1808        // 6. Backfill the session's `summary` column so the
1809        //    `MemorySession` record exposes the latest synthesis
1810        //    without a join.
1811        let conn = Arc::clone(&self.conn);
1812        let sid = session_id.to_string();
1813        let stored_summary = written.content.clone();
1814        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1815            let guard = conn.blocking_lock();
1816            guard
1817                .execute(
1818                    "UPDATE memory_sessions SET summary = ? WHERE id = ?",
1819                    params![stored_summary, sid],
1820                )
1821                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1822            Ok(())
1823        })
1824        .await
1825        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1826
1827        Ok(written)
1828    }
1829
1830    async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1831        self.rollup_day_inner(date, false).await
1832    }
1833
1834    async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1835        self.rollup_day_inner(date, true).await
1836    }
1837
1838    async fn rollup_rule_week(
1839        &self,
1840        rule_id: &str,
1841        week_start: NaiveDate,
1842    ) -> MemoryResult<MemoryChunk> {
1843        self.rollup_rule_week_inner(rule_id, week_start, false)
1844            .await
1845    }
1846
1847    async fn rollup_rule_week_forced(
1848        &self,
1849        rule_id: &str,
1850        week_start: NaiveDate,
1851    ) -> MemoryResult<MemoryChunk> {
1852        self.rollup_rule_week_inner(rule_id, week_start, true).await
1853    }
1854
1855    // ───────────── Maintenance ─────────────
1856
1857    async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
1858        // v1 sweep: delete non-pinned non-correction chunks older than
1859        // the retention horizon. Matches BasicMemoryProvider's heuristic
1860        // (30 days) so the trait surface stays consistent across providers.
1861        // A future revision can replace this with an importance-aware policy.
1862        const RETENTION_DAYS: i64 = 30;
1863        let cutoff_ms =
1864            (chrono::Utc::now() - chrono::Duration::days(RETENTION_DAYS)).timestamp_millis();
1865        self.invalidate_retrieve_cache();
1866        let conn = Arc::clone(&self.conn);
1867        tokio::task::spawn_blocking(move || -> Result<AgingReport, MemoryError> {
1868            let mut guard = conn.blocking_lock();
1869            let tx = guard
1870                .transaction()
1871                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1872            let ids: Vec<String> = {
1873                let mut stmt = tx
1874                    .prepare(
1875                        "SELECT id FROM memory_chunks
1876                         WHERE pinned = 0
1877                           AND kind != 'correction'
1878                           AND created_at < ?",
1879                    )
1880                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1881                let rows = stmt
1882                    .query_map(params![cutoff_ms], |r| r.get::<_, String>(0))
1883                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1884                let mut out = Vec::new();
1885                for r in rows {
1886                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1887                }
1888                out
1889            };
1890            let now = now_ms();
1891            let reason_s = eviction_reason_str(EvictionReason::Aging);
1892            for id in &ids {
1893                tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1894                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1895                tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1896                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1897                tx.execute(
1898                    "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1899                     VALUES (?, ?, ?, '{}')",
1900                    params![now, id, reason_s],
1901                )
1902                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1903            }
1904            tx.commit()
1905                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1906            let deleted = ids.len();
1907            Ok(AgingReport {
1908                tier_promoted: 0, // session→long_term transition is Phase 3 work
1909                deleted,
1910                bytes_reclaimed: 0,
1911                deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
1912            })
1913        })
1914        .await
1915        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1916    }
1917    async fn re_embed_all(&self, _target_model: &str) -> MemoryResult<ReEmbedReport> {
1918        Err(MemoryError::NotImplemented(
1919            "SqliteMemoryProvider::re_embed_all — Phase 4",
1920        ))
1921    }
1922    async fn export(&self, filter: ExportFilter) -> MemoryResult<ExportBundle> {
1923        let conn = Arc::clone(&self.conn);
1924        tokio::task::spawn_blocking(move || -> Result<ExportBundle, MemoryError> {
1925            let guard = conn.blocking_lock();
1926
1927            // Chunks first; honor the optional predicate.
1928            let (where_clause, params_vec) = match &filter.predicate {
1929                Some(p) if !p.is_empty() => build_chunk_predicate(p),
1930                _ => ("1=1".to_string(), Vec::new()),
1931            };
1932            let select_sql = format!(
1933                "SELECT id, created_at, kind, tier, source, session_id,
1934                        project_root, caller_id, content, metadata,
1935                        importance, pinned, shareable, superseded_by,
1936                        embedding_model, embedding_dim
1937                 FROM memory_chunks WHERE {} ORDER BY created_at DESC",
1938                where_clause
1939            );
1940            let chunks: Vec<MemoryChunk> = {
1941                let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1942                let mut stmt = guard
1943                    .prepare(&select_sql)
1944                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1945                let rows = stmt
1946                    .query_map(p.as_slice(), row_to_chunk)
1947                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1948                let mut out = Vec::new();
1949                for r in rows {
1950                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1951                }
1952                out
1953            };
1954
1955            // Sessions: only those referenced by the included chunks, and
1956            // only if include_sessions is set.
1957            let sessions = if filter.include_sessions {
1958                let session_ids: std::collections::HashSet<String> =
1959                    chunks.iter().filter_map(|c| c.session_id.clone()).collect();
1960                if session_ids.is_empty() {
1961                    Vec::new()
1962                } else {
1963                    let placeholders = vec!["?"; session_ids.len()].join(",");
1964                    let sql = format!(
1965                        "SELECT id, started_at, ended_at, caller_id, title, summary,
1966                                outcome, metadata
1967                         FROM memory_sessions WHERE id IN ({placeholders})"
1968                    );
1969                    let ids: Vec<String> = session_ids.into_iter().collect();
1970                    let p: Vec<&dyn rusqlite::ToSql> =
1971                        ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
1972                    let mut stmt = guard
1973                        .prepare(&sql)
1974                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1975                    let rows = stmt
1976                        .query_map(p.as_slice(), |r| {
1977                            let metadata_str: String = r.get("metadata")?;
1978                            let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
1979                                .unwrap_or(serde_json::Value::Null);
1980                            Ok(MemorySession {
1981                                id: r.get("id")?,
1982                                started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1983                                ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1984                                caller_id: r.get("caller_id")?,
1985                                title: r.get("title")?,
1986                                summary: r.get("summary")?,
1987                                outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1988                                    .unwrap_or(SessionOutcome::Aborted),
1989                                metadata,
1990                            })
1991                        })
1992                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1993                    let mut out = Vec::new();
1994                    for r in rows {
1995                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1996                    }
1997                    out
1998                }
1999            } else {
2000                Vec::new()
2001            };
2002
2003            let evictions = if filter.include_eviction_log {
2004                let mut stmt = guard
2005                    .prepare(
2006                        "SELECT ts, chunk_id, reason, metadata
2007                         FROM memory_eviction_log ORDER BY ts DESC",
2008                    )
2009                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2010                let rows = stmt
2011                    .query_map([], |r| {
2012                        let reason_s: String = r.get("reason")?;
2013                        let metadata_s: String = r.get("metadata")?;
2014                        let metadata: serde_json::Value =
2015                            serde_json::from_str(&metadata_s).unwrap_or(serde_json::Value::Null);
2016                        Ok(EvictionEntry {
2017                            ts: ms_to_dt(r.get::<_, i64>("ts")?),
2018                            chunk_id: r.get("chunk_id")?,
2019                            reason: str_to_eviction_reason(&reason_s)
2020                                .unwrap_or(EvictionReason::UserDelete),
2021                            metadata,
2022                        })
2023                    })
2024                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2025                let mut out = Vec::new();
2026                for r in rows {
2027                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2028                }
2029                out
2030            } else {
2031                Vec::new()
2032            };
2033
2034            let accesses = if filter.include_access_log {
2035                let mut stmt = guard
2036                    .prepare(
2037                        "SELECT ts, chunk_id, retrieved_by, query_hash, rank, used
2038                         FROM memory_access_log ORDER BY ts DESC",
2039                    )
2040                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2041                let rows = stmt
2042                    .query_map([], |r| {
2043                        Ok(AccessEntry {
2044                            ts: ms_to_dt(r.get::<_, i64>("ts")?),
2045                            chunk_id: r.get("chunk_id")?,
2046                            retrieved_by: r.get("retrieved_by")?,
2047                            query_hash: r.get("query_hash")?,
2048                            rank: r.get::<_, i64>("rank")? as usize,
2049                            used: r.get::<_, i64>("used")? != 0,
2050                        })
2051                    })
2052                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2053                let mut out = Vec::new();
2054                for r in rows {
2055                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2056                }
2057                out
2058            } else {
2059                Vec::new()
2060            };
2061
2062            Ok(ExportBundle {
2063                chunks,
2064                sessions,
2065                evictions,
2066                accesses,
2067            })
2068        })
2069        .await
2070        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2071    }
2072
2073    async fn stats(&self) -> MemoryResult<MemoryStats> {
2074        let conn = Arc::clone(&self.conn);
2075        let model = self.embedder.model_name().to_string();
2076        tokio::task::spawn_blocking(move || -> Result<MemoryStats, MemoryError> {
2077            let guard = conn.blocking_lock();
2078            let total: i64 = guard
2079                .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
2080                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2081            let session_tier: i64 = guard
2082                .query_row(
2083                    "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'session'",
2084                    [],
2085                    |r| r.get(0),
2086                )
2087                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2088            let lt_tier: i64 = guard
2089                .query_row(
2090                    "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'long_term'",
2091                    [],
2092                    |r| r.get(0),
2093                )
2094                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2095            let total_sessions: i64 = guard
2096                .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
2097                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2098            let open: i64 = guard
2099                .query_row(
2100                    "SELECT COUNT(*) FROM memory_sessions WHERE outcome = 'open'",
2101                    [],
2102                    |r| r.get(0),
2103                )
2104                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2105            Ok(MemoryStats {
2106                total_chunks: total as usize,
2107                session_chunks: session_tier as usize,
2108                long_term_chunks: lt_tier as usize,
2109                total_sessions: total_sessions as usize,
2110                open_sessions: open as usize,
2111                db_bytes: 0, // computed by `cellar doctor` separately
2112                embedding_model: Some(model),
2113            })
2114        })
2115        .await
2116        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2117    }
2118}
2119
2120/// Per-profile retrieval weights. Tuned defaults, not exposed via the trait
2121/// yet, so callers configure profiles via the `RetrievalProfile` enum only.
2122/// Returns `(w_vec, w_fts, w_rec, half_life_seconds)`.
2123fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
2124    use cel_memory::RetrievalProfile::*;
2125    match profile {
2126        // Embedded agent's per-turn retrieval — semantic-heavy, short
2127        // recency half-life (7 days).
2128        AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2129        // Long-horizon job context — heavier weight on long_term + summaries.
2130        AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2131        // NL compiler: similar prior rules. Keyword match dominates because
2132        // users are looking for "the rule that mentions ~/Workspace".
2133        NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2134        // NL compiler: similar prior fires. Same balance, slightly longer
2135        // half-life because rule history compounds.
2136        NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2137        // Audit / Activity tab — keyword-dominant, wide window.
2138        AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2139        // User free-text search — like audit but with a longer half-life.
2140        UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2141    }
2142}
2143
2144/// Sanitise the query string for FTS5. FTS5's query language treats
2145/// punctuation and special characters as operators; the simplest safe
2146/// approach is to quote the entire query as a phrase and escape any
2147/// internal double-quote.
2148fn fts_query_escape(raw: &str) -> String {
2149    let escaped = raw.replace('"', "\"\"");
2150    format!("\"{escaped}\"")
2151}
2152
2153/// Apply the non-ranking filters from a `MemoryQuery` to a candidate
2154/// chunk. Returns true if the chunk should be included in the result.
2155fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2156    // Kind filter
2157    if let Some(kinds) = &q.kinds {
2158        if !kinds.contains(&c.kind) {
2159            return false;
2160        }
2161    }
2162    // Rollups gate
2163    if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2164        return false;
2165    }
2166    // Time bounds
2167    if let Some(since) = q.since {
2168        if c.created_at < since {
2169            return false;
2170        }
2171    }
2172    if let Some(until) = q.until {
2173        if c.created_at > until {
2174            return false;
2175        }
2176    }
2177    // Session
2178    if let Some(sid) = &q.session_id {
2179        if c.session_id.as_deref() != Some(sid.as_str()) {
2180            return false;
2181        }
2182    }
2183    // Project root prefix
2184    if let Some(prefix) = &q.project_root_prefix {
2185        match &c.project_root {
2186            Some(root) if root.starts_with(prefix.as_str()) => {}
2187            _ => return false,
2188        }
2189    }
2190    // Min importance
2191    if let Some(min) = q.min_importance {
2192        if c.importance < min {
2193            return false;
2194        }
2195    }
2196    // Caller scope. `Own` restricts to the caller's own chunks.
2197    // `OwnPlusShared` (Phase 4) permits the caller's own chunks *plus* any
2198    // chunk tagged `shareable=true` from another caller. `Global` permits
2199    // everything — granted only to privileged surfaces (Memory tab,
2200    // audit timeline).
2201    match q.caller_scope {
2202        cel_memory::CallerScope::Own => {
2203            if c.caller_id != q.caller_id {
2204                return false;
2205            }
2206        }
2207        cel_memory::CallerScope::OwnPlusShared => {
2208            if c.caller_id != q.caller_id && !c.shareable {
2209                return false;
2210            }
2211        }
2212        cel_memory::CallerScope::Global => {}
2213    }
2214    true
2215}
2216
2217/// Translate a [`MemoryPredicate`] into a parameterized WHERE clause for
2218/// `memory_chunks`. Returns the clause body (without the leading `WHERE`)
2219/// and the parameter vector. The clause is composed of `AND`-joined
2220/// sub-clauses, defaulting to `1=1` if (somehow) every predicate field is
2221/// `None` — but [`MemoryPredicate::is_empty`] should have short-circuited
2222/// before this is called.
2223fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2224    let mut clauses: Vec<String> = vec!["1=1".to_string()];
2225    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2226    if let Some(kinds) = &p.kinds {
2227        let placeholders = vec!["?"; kinds.len()].join(",");
2228        clauses.push(format!("kind IN ({placeholders})"));
2229        for k in kinds {
2230            params.push(Box::new(kind_str(*k).to_string()));
2231        }
2232    }
2233    if let Some(callers) = &p.callers {
2234        let placeholders = vec!["?"; callers.len()].join(",");
2235        clauses.push(format!("caller_id IN ({placeholders})"));
2236        for c in callers {
2237            params.push(Box::new(c.clone()));
2238        }
2239    }
2240    if let Some(sids) = &p.session_ids {
2241        let placeholders = vec!["?"; sids.len()].join(",");
2242        clauses.push(format!("session_id IN ({placeholders})"));
2243        for s in sids {
2244            params.push(Box::new(s.clone()));
2245        }
2246    }
2247    if let Some(prefix) = &p.project_root_prefix {
2248        clauses.push("project_root LIKE ?".to_string());
2249        params.push(Box::new(format!("{prefix}%")));
2250    }
2251    if let Some(before) = p.before {
2252        clauses.push("created_at < ?".to_string());
2253        params.push(Box::new(dt_to_ms(before)));
2254    }
2255    if let Some(after) = p.after {
2256        clauses.push("created_at > ?".to_string());
2257        params.push(Box::new(dt_to_ms(after)));
2258    }
2259    if let Some(pinned) = p.pinned {
2260        clauses.push("pinned = ?".to_string());
2261        params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2262    }
2263    if let Some(below) = p.importance_below {
2264        clauses.push("importance < ?".to_string());
2265        params.push(Box::new(below as f64));
2266    }
2267    if let Some(needle) = &p.content_contains {
2268        clauses.push("LOWER(content) LIKE ?".to_string());
2269        params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2270    }
2271    (clauses.join(" AND "), params)
2272}
2273
2274/// Inverse of [`eviction_reason_str`]. Unknown strings (e.g., from a future
2275/// schema version) fall back to `UserDelete` rather than panicking; the
2276/// caller is expected to log + carry on.
2277fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2278    Ok(match s {
2279        "user_delete" => EvictionReason::UserDelete,
2280        "aging" => EvictionReason::Aging,
2281        "low_importance" => EvictionReason::LowImportance,
2282        "redact_rule" => EvictionReason::RedactRule,
2283        "storage_cap" => EvictionReason::StorageCap,
2284        "purge_all" => EvictionReason::PurgeAll,
2285        other => {
2286            return Err(MemoryError::Storage(format!(
2287                "unknown eviction reason: {other}"
2288            )))
2289        }
2290    })
2291}
2292
2293fn eviction_reason_str(r: EvictionReason) -> &'static str {
2294    match r {
2295        EvictionReason::UserDelete => "user_delete",
2296        EvictionReason::Aging => "aging",
2297        EvictionReason::LowImportance => "low_importance",
2298        EvictionReason::RedactRule => "redact_rule",
2299        EvictionReason::StorageCap => "storage_cap",
2300        EvictionReason::PurgeAll => "purge_all",
2301    }
2302}
2303
2304// Silence unused-import warnings when `record_access` etc. aren't wired.
2305#[allow(dead_code)]
2306fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}