Skip to main content

cel_memory_sqlite/
provider.rs

1//! `SqliteMemoryProvider` — the v1 Phase 0 backing storage.
2//!
3//! This implementation is the foundation the full Memory & Context Manager
4//! subsystem (`cellar-memory-manager.md` Phase 1+) fills in. Phase 0 ships:
5//!
6//! - Real `open` that loads sqlite-vec, runs migrations, holds the connection.
7//! - Real `write` / `get` / `stats` / `purge_all`.
8//! - `Err(NotImplemented)` for retrieval, summarization, rollups, re-embed,
9//!   maintenance methods — these need the hybrid retrieval pipeline and
10//!   the summarizer LLM client which are Phase 1+ work.
11//!
12//! The provider is `Send + Sync` and behind an `Arc<dyn MemoryProvider>` —
13//! identical surface to [`BasicMemoryProvider`] so the daemon's
14//! `wire_subsystems()` swap is one line.
15//!
16//! [`BasicMemoryProvider`]: cel_memory::BasicMemoryProvider
17
18use std::path::Path;
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use cel_memory::{
23    AccessEntry, AgingReport, CallerScope, ChunkKind, ChunkSource, EvictionEntry, EvictionReason,
24    ExportBundle, ExportFilter, MemoryChunk, MemoryError, MemoryPredicate, MemoryProvider,
25    MemoryQuery, MemorySession, MemoryStats, MemoryTier, NewMemoryChunk, NewMemorySession,
26    PurgeReport, ReEmbedReport, Result as MemoryResult, SessionFilter, SessionOutcome,
27};
28use chrono::{DateTime, NaiveDate, Utc};
29use rusqlite::{params, Connection, OptionalExtension};
30use tokio::sync::Mutex;
31use uuid::Uuid;
32
33use crate::cache::RetrieveCache;
34use crate::embedder::Embedder;
35use crate::error::SqliteMemoryError;
36use crate::migrations;
37
38/// Default capacity of the per-provider retrieve cache. A "heavy" session
39/// is ~30 retrievals/min; 256 buys ~8 minutes of distinct queries before
40/// LRU eviction. Tuned for the embedded agent + NL compiler hot path.
41const RETRIEVE_CACHE_CAPACITY: usize = 256;
42/// Default TTL of cached retrieve results. Long enough to absorb a user
43/// turn cluster, short enough that a write 30s ago will be visible to the
44/// next matching read even if the eager `clear` was missed.
45const RETRIEVE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
46
47/// SQLite-backed [`MemoryProvider`].
48pub struct SqliteMemoryProvider {
49    conn: Arc<Mutex<Connection>>,
50    embedder: Arc<dyn Embedder>,
51    /// Optional pre-write hook (memory_write_attempted governance seam).
52    /// When unset, every write proceeds verbatim. When set, the provider
53    /// consults the hook before persisting each chunk; on `Redact`, the
54    /// chunk is dropped and a redaction record is logged.
55    write_hook: Option<Arc<dyn cel_memory::MemoryWriteHook>>,
56    /// Optional summarizer used by [`MemoryProvider::summarize_session`]
57    /// (Phase 3). When unset, the provider falls through to
58    /// [`MemoryError::NotImplemented`] — preserving the v1 contract for
59    /// daemons that don't enable summarization. The daemon's default is
60    /// [`crate::build_default_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`] (and, once shipped,
116    /// `rollup_day` / `rollup_rule_week`). Daemons typically pass the
117    /// result of [`crate::build_default_summarizer`]; 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 per `cellar-memory-manager.md` §8:
702        //   score = w_vec*rrf(rank_vec) + w_fts*rrf(rank_fts)
703        //         + w_rec*exp(-Δt/half_life)
704        //
705        // Three sub-retrievals run in one connection (we're already
706        // serialising through the Mutex anyway; parallelism would only
707        // help if we had a connection pool):
708        //   1. sqlite-vec k-NN against the query embedding
709        //   2. FTS5 BM25 against the query text
710        //   3. recency-only pass (newest-first within the filter window)
711        //
712        // The three rankings get fused via Reciprocal Rank Fusion (RRF)
713        // with profile-driven weights. Filters (kind / scope / time /
714        // project_root) are applied at SQL-time inside the candidate
715        // queries; min_importance + include_rollups are applied
716        // post-fusion since they're metadata signals, not ranking ones.
717        if query.text.trim().is_empty() {
718            return Err(MemoryError::InvalidArgument(
719                "query.text must not be empty".into(),
720            ));
721        }
722
723        // Hot-path cache. The cache is invalidated eagerly by every
724        // mutator (`write`, `delete`, …), with a 30 s TTL as a backstop.
725        let cache_key = cache_key_for_query(&query);
726        if let Some(key) = cache_key {
727            if let Some(hit) = self.retrieve_cache.get(key) {
728                return Ok(hit);
729            }
730        }
731        let k = query.k.max(1);
732        // We over-fetch each sub-retrieval (3*k) so RRF has enough
733        // candidates to merge without dropping near-misses.
734        let candidate_k = (3 * k).max(16);
735
736        let (w_vec, w_fts, w_rec, half_life_secs) = retrieval_weights(query.profile);
737
738        let q_embedding = self
739            .embedder
740            .embed(&query.text)
741            .await
742            .map_err(|e| MemoryError::Storage(e.to_string()))?;
743        let q_text = query.text.clone();
744        let conn = Arc::clone(&self.conn);
745
746        let compute =
747            tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
748                let guard = conn.blocking_lock();
749
750                // Sub-retrieval 1: vector k-NN. sqlite-vec accepts the embedding
751                // as a JSON-array string. We use `vec_distance_l2` ordering
752                // (default for the `MATCH` operator). The WHERE filter is
753                // applied via a join because vec0 virtual tables don't honor
754                // arbitrary predicates on adjoining columns at MATCH time.
755                let v_json = serde_json::to_string(&q_embedding)
756                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
757                let vec_ids: Vec<String> = {
758                    let sql = "SELECT v.chunk_id FROM memory_vec v
759                           WHERE v.embedding MATCH ?
760                             AND k = ?
761                           ORDER BY distance";
762                    let mut stmt = guard
763                        .prepare(sql)
764                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
765                    let rows = stmt
766                        .query_map(params![v_json, candidate_k as i64], |r| {
767                            r.get::<_, String>(0)
768                        })
769                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
770                    let mut out = Vec::new();
771                    for r in rows {
772                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
773                    }
774                    out
775                };
776
777                // Sub-retrieval 2: FTS5 BM25 over the same query text. FTS5
778                // ranks lower-distance as higher relevance, hence ASC by rank.
779                let fts_ids: Vec<String> = {
780                    let sql = "SELECT chunk_id FROM memory_fts
781                           WHERE memory_fts MATCH ?
782                           ORDER BY rank
783                           LIMIT ?";
784                    let mut stmt = guard
785                        .prepare(sql)
786                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
787                    let rows = stmt
788                        .query_map(
789                            params![fts_query_escape(&q_text), candidate_k as i64],
790                            |r| r.get::<_, String>(0),
791                        )
792                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
793                    let mut out = Vec::new();
794                    for r in rows {
795                        // FTS may return no-match if the tokenizer drops the
796                        // whole query (e.g., punctuation-only). Surface as
797                        // empty list rather than panic.
798                        match r {
799                            Ok(id) => out.push(id),
800                            Err(_) => break,
801                        }
802                    }
803                    out
804                };
805
806                // Sub-retrieval 3: recency. Newest-first within the filter
807                // window. This is the same candidate set we'll join the
808                // chunk rows from.
809                let recency_ids: Vec<String> = {
810                    let sql = "SELECT id FROM memory_chunks
811                           ORDER BY created_at DESC LIMIT ?";
812                    let mut stmt = guard
813                        .prepare(sql)
814                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
815                    let rows = stmt
816                        .query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
817                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
818                    let mut out = Vec::new();
819                    for r in rows {
820                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
821                    }
822                    out
823                };
824
825                // RRF fusion. The constant 60 is the standard RRF prior.
826                const RRF_K: f32 = 60.0;
827                let mut scores: std::collections::HashMap<String, f32> =
828                    std::collections::HashMap::new();
829                for (rank, id) in vec_ids.iter().enumerate() {
830                    *scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
831                }
832                for (rank, id) in fts_ids.iter().enumerate() {
833                    *scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
834                }
835                // Recency is keyed off the chunk's `created_at` directly.
836                // Skip the score contribution for chunks we don't have a row
837                // for yet (rare race: scored vector for a deleted chunk).
838                let now_ms_val = now_ms();
839                let recency_rows: std::collections::HashMap<String, i64> = {
840                    if recency_ids.is_empty() {
841                        Default::default()
842                    } else {
843                        let placeholders = vec!["?"; recency_ids.len()].join(",");
844                        let sql = format!(
845                            "SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
846                        );
847                        let p: Vec<&dyn rusqlite::ToSql> = recency_ids
848                            .iter()
849                            .map(|s| s as &dyn rusqlite::ToSql)
850                            .collect();
851                        let mut stmt = guard
852                            .prepare(&sql)
853                            .map_err(|e| MemoryError::Storage(e.to_string()))?;
854                        let rows = stmt
855                            .query_map(p.as_slice(), |r| {
856                                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
857                            })
858                            .map_err(|e| MemoryError::Storage(e.to_string()))?;
859                        let mut map = std::collections::HashMap::new();
860                        for r in rows {
861                            let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
862                            map.insert(id, t);
863                        }
864                        map
865                    }
866                };
867                for (id, created_ms) in &recency_rows {
868                    let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
869                    let recency = (-dt_secs / half_life_secs).exp();
870                    *scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
871                }
872
873                // Materialise candidate chunks from the union of ID sets.
874                let mut id_set: std::collections::HashSet<String> =
875                    std::collections::HashSet::new();
876                id_set.extend(vec_ids);
877                id_set.extend(fts_ids);
878                id_set.extend(recency_ids);
879                if id_set.is_empty() {
880                    return Ok(Vec::new());
881                }
882                let placeholders = vec!["?"; id_set.len()].join(",");
883                let select_sql = format!(
884                    "SELECT id, created_at, kind, tier, source, session_id,
885                        project_root, caller_id, content, metadata,
886                        importance, pinned, shareable, superseded_by,
887                        embedding_model, embedding_dim
888                 FROM memory_chunks WHERE id IN ({placeholders})"
889                );
890                let ids_vec: Vec<String> = id_set.into_iter().collect();
891                let candidates: Vec<MemoryChunk> = {
892                    let p: Vec<&dyn rusqlite::ToSql> =
893                        ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
894                    let mut stmt = guard
895                        .prepare(&select_sql)
896                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
897                    let rows = stmt
898                        .query_map(p.as_slice(), row_to_chunk)
899                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
900                    let mut out = Vec::new();
901                    for r in rows {
902                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
903                    }
904                    out
905                };
906
907                // Apply non-ranking filters now (kind, session, scope, time,
908                // project_root, min_importance, include_rollups).
909                let mut filtered: Vec<MemoryChunk> = candidates
910                    .into_iter()
911                    .filter(|c| chunk_matches_query(c, &query))
912                    .collect();
913
914                // Sort by fused score descending; truncate to k.
915                filtered.sort_by(|a, b| {
916                    let sa = scores.get(&a.id).copied().unwrap_or(0.0);
917                    let sb = scores.get(&b.id).copied().unwrap_or(0.0);
918                    sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
919                });
920                filtered.truncate(k);
921                Ok(filtered)
922            });
923        let result = compute
924            .await
925            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
926
927        // Populate the cache after a successful computation. Errors are
928        // never cached — they're cheap to recompute and stale errors would
929        // mask transient lock contention.
930        if let Some(key) = cache_key {
931            self.retrieve_cache.insert(key, result.clone());
932        }
933        Ok(result)
934    }
935
936    async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
937        let conn = Arc::clone(&self.conn);
938        let chunk_id = chunk_id.to_string();
939        let res: Result<Option<MemoryChunk>, MemoryError> =
940            tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
941                let guard = conn.blocking_lock();
942                let mut stmt = guard
943                    .prepare(
944                        "SELECT id, created_at, kind, tier, source, session_id,
945                                project_root, caller_id, content, metadata,
946                                importance, pinned, shareable, superseded_by,
947                                embedding_model, embedding_dim
948                         FROM memory_chunks WHERE id = ?",
949                    )
950                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
951                let row = stmt
952                    .query_row(params![chunk_id], row_to_chunk)
953                    .optional()
954                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
955                Ok(row)
956            })
957            .await
958            .map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
959        res
960    }
961
962    async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
963        let conn = Arc::clone(&self.conn);
964        let session_id = session_id.to_string();
965        tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
966            let guard = conn.blocking_lock();
967            let mut stmt = guard
968                .prepare(
969                    "SELECT id, started_at, ended_at, caller_id, title, summary,
970                            outcome, metadata
971                     FROM memory_sessions WHERE id = ?",
972                )
973                .map_err(|e| MemoryError::Storage(e.to_string()))?;
974            let row = stmt
975                .query_row(params![session_id], |r| {
976                    let metadata_str: String = r.get("metadata")?;
977                    let metadata: serde_json::Value =
978                        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
979                    Ok(MemorySession {
980                        id: r.get("id")?,
981                        started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
982                        ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
983                        caller_id: r.get("caller_id")?,
984                        title: r.get("title")?,
985                        summary: r.get("summary")?,
986                        outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
987                            .unwrap_or(SessionOutcome::Aborted),
988                        metadata,
989                    })
990                })
991                .optional()
992                .map_err(|e| MemoryError::Storage(e.to_string()))?;
993            Ok(row)
994        })
995        .await
996        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
997    }
998
999    async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
1000        let conn = Arc::clone(&self.conn);
1001        tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
1002            let guard = conn.blocking_lock();
1003            // Build a parameterized query. Each Option<...> on the filter
1004            // adds a single `AND col op ?` clause. The matcher takes
1005            // owned params so we don't fight rusqlite's lifetime rules.
1006            let mut sql = String::from(
1007                "SELECT id, started_at, ended_at, caller_id, title, summary,
1008                        outcome, metadata
1009                 FROM memory_sessions WHERE 1=1",
1010            );
1011            let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1012            if let Some(c) = &filter.caller_id {
1013                sql.push_str(" AND caller_id = ?");
1014                params_vec.push(Box::new(c.clone()));
1015            }
1016            if let Some(o) = filter.outcome {
1017                sql.push_str(" AND outcome = ?");
1018                params_vec.push(Box::new(outcome_str(o).to_string()));
1019            } else if filter.open_only {
1020                sql.push_str(" AND outcome = ?");
1021                params_vec.push(Box::new("open".to_string()));
1022            }
1023            if let Some(since) = filter.since {
1024                sql.push_str(" AND started_at >= ?");
1025                params_vec.push(Box::new(dt_to_ms(since)));
1026            }
1027            if let Some(until) = filter.until {
1028                sql.push_str(" AND started_at <= ?");
1029                params_vec.push(Box::new(dt_to_ms(until)));
1030            }
1031            sql.push_str(" ORDER BY started_at DESC");
1032            if let Some(n) = filter.limit {
1033                sql.push_str(" LIMIT ?");
1034                params_vec.push(Box::new(n as i64));
1035            }
1036
1037            let mut stmt = guard
1038                .prepare(&sql)
1039                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1040            let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1041            let rows = stmt
1042                .query_map(p.as_slice(), |r| {
1043                    let metadata_str: String = r.get("metadata")?;
1044                    let metadata: serde_json::Value =
1045                        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
1046                    Ok(MemorySession {
1047                        id: r.get("id")?,
1048                        started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1049                        ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1050                        caller_id: r.get("caller_id")?,
1051                        title: r.get("title")?,
1052                        summary: r.get("summary")?,
1053                        outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1054                            .unwrap_or(SessionOutcome::Aborted),
1055                        metadata,
1056                    })
1057                })
1058                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1059            let mut out = Vec::new();
1060            for row in rows {
1061                out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1062            }
1063            Ok(out)
1064        })
1065        .await
1066        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1067    }
1068
1069    // ───────────── Writes ─────────────
1070
1071    async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
1072        if new_chunk.content.trim().is_empty() {
1073            return Err(MemoryError::InvalidArgument(
1074                "content must not be empty".into(),
1075            ));
1076        }
1077        // Eager invalidation. Combined with the conn-mutex serialising
1078        // every read against this write, any retrieve started after this
1079        // point observes the post-write state. See `cache.rs` docs.
1080        self.invalidate_retrieve_cache();
1081
1082        // Pre-write hook: rule-matcher seam. On Redact, return a synthetic
1083        // chunk without persisting (and without embedding — that's the
1084        // expensive part). The caller sees an Ok-with-redacted-marker
1085        // chunk; the SQL store stays untouched.
1086        if let Some(hook) = &self.write_hook {
1087            match hook.before_write(&new_chunk).await? {
1088                cel_memory::WriteDecision::Allow => {}
1089                cel_memory::WriteDecision::Redact { reason } => {
1090                    return Ok(MemoryChunk {
1091                        id: Uuid::now_v7().to_string(),
1092                        created_at: Utc::now(),
1093                        kind: new_chunk.kind,
1094                        tier: MemoryTier::Session,
1095                        source: new_chunk.source,
1096                        session_id: new_chunk.session_id,
1097                        project_root: new_chunk.project_root,
1098                        caller_id: new_chunk.caller_id,
1099                        content: format!("<redacted: {reason}>"),
1100                        metadata: serde_json::json!({"redacted": true, "reason": reason}),
1101                        importance: 0.0,
1102                        pinned: false,
1103                        shareable: false,
1104                        superseded_by: None,
1105                        embedding_model: "none".into(),
1106                        embedding_dim: 0,
1107                    });
1108                }
1109            }
1110        }
1111
1112        let id = Uuid::now_v7().to_string();
1113        let created_at_ms = now_ms();
1114        // Score via the shared heuristic (cel_memory::importance::score).
1115        // If the caller supplied an explicit value, it's honored after clamp;
1116        // otherwise the kind + metadata drive the score.
1117        let importance = cel_memory::importance::score(&new_chunk);
1118        let embedder_dim = self.embedder.dim();
1119        let embedder_name = self.embedder.model_name().to_string();
1120        // Embed the content. If this fails we don't store the chunk —
1121        // chunks without vectors don't participate in retrieval.
1122        let embedding = self
1123            .embedder
1124            .embed(&new_chunk.content)
1125            .await
1126            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1127        if embedding.len() != embedder_dim {
1128            return Err(MemoryError::Internal(format!(
1129                "embedder produced dim {}, declared {}",
1130                embedding.len(),
1131                embedder_dim
1132            )));
1133        }
1134
1135        let chunk = MemoryChunk {
1136            id: id.clone(),
1137            created_at: ms_to_dt(created_at_ms),
1138            kind: new_chunk.kind,
1139            tier: MemoryTier::Session,
1140            source: new_chunk.source,
1141            session_id: new_chunk.session_id.clone(),
1142            project_root: new_chunk.project_root.clone(),
1143            caller_id: new_chunk.caller_id.clone(),
1144            content: new_chunk.content.clone(),
1145            metadata: new_chunk.metadata.clone(),
1146            importance,
1147            pinned: new_chunk.pinned,
1148            shareable: new_chunk.shareable,
1149            superseded_by: None,
1150            embedding_model: embedder_name.clone(),
1151            embedding_dim: embedder_dim as u32,
1152        };
1153
1154        let conn = Arc::clone(&self.conn);
1155        let chunk_for_blocking = chunk.clone();
1156        let embedding_clone = embedding.clone();
1157        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1158            let mut guard = conn.blocking_lock();
1159            let tx = guard
1160                .transaction()
1161                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1162            tx.execute(
1163                "INSERT INTO memory_chunks(
1164                    id, created_at, kind, tier, source, session_id, project_root,
1165                    caller_id, content, metadata, importance, pinned, shareable,
1166                    superseded_by, embedding_model, embedding_dim
1167                ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1168                params![
1169                    chunk_for_blocking.id,
1170                    created_at_ms,
1171                    kind_str(chunk_for_blocking.kind),
1172                    tier_str(chunk_for_blocking.tier),
1173                    source_str(chunk_for_blocking.source),
1174                    chunk_for_blocking.session_id,
1175                    chunk_for_blocking.project_root,
1176                    chunk_for_blocking.caller_id,
1177                    chunk_for_blocking.content,
1178                    serde_json::to_string(&chunk_for_blocking.metadata)
1179                        .unwrap_or_else(|_| "null".into()),
1180                    chunk_for_blocking.importance as f64,
1181                    if chunk_for_blocking.pinned { 1 } else { 0 },
1182                    if new_chunk.shareable { 1 } else { 0 },
1183                    Option::<String>::None,
1184                    chunk_for_blocking.embedding_model,
1185                    chunk_for_blocking.embedding_dim as i64,
1186                ],
1187            )
1188            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1189            // memory_vec insert. sqlite-vec accepts vectors as JSON-array
1190            // text or as a packed BLOB; JSON is simpler and the conversion
1191            // cost is irrelevant for v1.
1192            let v_json = serde_json::to_string(&embedding_clone)
1193                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1194            tx.execute(
1195                "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1196                params![chunk_for_blocking.id, v_json],
1197            )
1198            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1199            tx.commit()
1200                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1201            Ok(())
1202        })
1203        .await
1204        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1205
1206        Ok(chunk)
1207    }
1208
1209    async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
1210        // Batched path: one embedding call, one transaction, one Mutex hold.
1211        // Single-chunk path delegates to `write` (which already runs the
1212        // single-call optimisation through the embedder; no point batching
1213        // a batch of one).
1214        if chunks.is_empty() {
1215            return Ok(Vec::new());
1216        }
1217        if chunks.len() == 1 {
1218            let nc = chunks.into_iter().next().expect("len == 1");
1219            return Ok(vec![self.write(nc).await?]);
1220        }
1221        self.invalidate_retrieve_cache();
1222
1223        // Hook-present path: per-chunk evaluation is required so redacted
1224        // chunks don't get embedded. Fall back to sequential writes to
1225        // preserve input → output ordering without adding per-chunk
1226        // bookkeeping to the batched insert.
1227        if self.write_hook.is_some() {
1228            let mut out = Vec::with_capacity(chunks.len());
1229            for nc in chunks {
1230                out.push(self.write(nc).await?);
1231            }
1232            return Ok(out);
1233        }
1234
1235        // Validate content non-empty up front so we don't half-embed a
1236        // batch and then fail mid-transaction.
1237        for (i, nc) in chunks.iter().enumerate() {
1238            if nc.content.trim().is_empty() {
1239                return Err(MemoryError::InvalidArgument(format!(
1240                    "chunks[{i}].content must not be empty"
1241                )));
1242            }
1243        }
1244
1245        let embedder_dim = self.embedder.dim();
1246        let embedder_name = self.embedder.model_name().to_string();
1247
1248        // One batched embedding call. The injected `Embedder` implementation
1249        // (fastembed, OpenAI, Voyage) provides the real batching;
1250        // `MockEmbedder` falls back to the trait's default impl which is
1251        // sequential but at least keeps the contract honest.
1252        let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
1253        let embeddings = self
1254            .embedder
1255            .embed_batch(&texts)
1256            .await
1257            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1258        if embeddings.len() != chunks.len() {
1259            return Err(MemoryError::Internal(format!(
1260                "embedder returned {} vectors for {} inputs",
1261                embeddings.len(),
1262                chunks.len()
1263            )));
1264        }
1265        for (i, v) in embeddings.iter().enumerate() {
1266            if v.len() != embedder_dim {
1267                return Err(MemoryError::Internal(format!(
1268                    "embedder produced dim {} for chunk {i}, declared {}",
1269                    v.len(),
1270                    embedder_dim
1271                )));
1272            }
1273        }
1274
1275        // Build all MemoryChunks now so the spawn_blocking payload owns
1276        // everything it needs.
1277        let now_ms_val = now_ms();
1278        let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
1279            Vec::with_capacity(chunks.len());
1280        for (nc, embedding) in chunks.into_iter().zip(embeddings) {
1281            let id = Uuid::now_v7().to_string();
1282            let importance = cel_memory::importance::score(&nc);
1283            let metadata_json =
1284                serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
1285            let mc = MemoryChunk {
1286                id,
1287                created_at: ms_to_dt(now_ms_val),
1288                kind: nc.kind,
1289                tier: MemoryTier::Session,
1290                source: nc.source,
1291                session_id: nc.session_id,
1292                project_root: nc.project_root,
1293                caller_id: nc.caller_id,
1294                content: nc.content,
1295                metadata: nc.metadata,
1296                importance,
1297                pinned: nc.pinned,
1298                shareable: nc.shareable,
1299                superseded_by: None,
1300                embedding_model: embedder_name.clone(),
1301                embedding_dim: embedder_dim as u32,
1302            };
1303            owned.push((mc, embedding, nc.shareable, metadata_json));
1304        }
1305
1306        let conn = Arc::clone(&self.conn);
1307        let inserted =
1308            tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
1309                let mut guard = conn.blocking_lock();
1310                let tx = guard
1311                    .transaction()
1312                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1313                let mut out = Vec::with_capacity(owned.len());
1314                for (mc, embedding, shareable, metadata_json) in &owned {
1315                    tx.execute(
1316                        "INSERT INTO memory_chunks(
1317                        id, created_at, kind, tier, source, session_id, project_root,
1318                        caller_id, content, metadata, importance, pinned, shareable,
1319                        superseded_by, embedding_model, embedding_dim
1320                    ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1321                        params![
1322                            mc.id,
1323                            now_ms_val,
1324                            kind_str(mc.kind),
1325                            tier_str(mc.tier),
1326                            source_str(mc.source),
1327                            mc.session_id,
1328                            mc.project_root,
1329                            mc.caller_id,
1330                            mc.content,
1331                            metadata_json,
1332                            mc.importance as f64,
1333                            if mc.pinned { 1 } else { 0 },
1334                            if *shareable { 1 } else { 0 },
1335                            Option::<String>::None,
1336                            mc.embedding_model,
1337                            mc.embedding_dim as i64,
1338                        ],
1339                    )
1340                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1341                    let v_json = serde_json::to_string(embedding)
1342                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1343                    tx.execute(
1344                        "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1345                        params![mc.id, v_json],
1346                    )
1347                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1348                    out.push(mc.clone());
1349                }
1350                tx.commit()
1351                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1352                Ok(out)
1353            })
1354            .await
1355            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1356
1357        Ok(inserted)
1358    }
1359
1360    async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
1361        let session = MemorySession {
1362            id: Uuid::now_v7().to_string(),
1363            started_at: Utc::now(),
1364            ended_at: None,
1365            caller_id: init.caller_id.clone(),
1366            title: init.title.clone(),
1367            summary: None,
1368            outcome: SessionOutcome::Open,
1369            metadata: init.metadata.clone(),
1370        };
1371        let conn = Arc::clone(&self.conn);
1372        let s = session.clone();
1373        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1374            let guard = conn.blocking_lock();
1375            guard
1376                .execute(
1377                    "INSERT INTO memory_sessions(
1378                        id, started_at, ended_at, caller_id, title, summary,
1379                        outcome, metadata
1380                    ) VALUES (?,?,?,?,?,?,?,?)",
1381                    params![
1382                        s.id,
1383                        dt_to_ms(s.started_at),
1384                        Option::<i64>::None,
1385                        s.caller_id,
1386                        s.title,
1387                        Option::<String>::None,
1388                        outcome_str(s.outcome),
1389                        serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
1390                    ],
1391                )
1392                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1393            Ok(())
1394        })
1395        .await
1396        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1397        Ok(session)
1398    }
1399
1400    async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
1401        let resolved = match outcome {
1402            SessionOutcome::Open => SessionOutcome::Aborted,
1403            other => other,
1404        };
1405        let conn = Arc::clone(&self.conn);
1406        let sid = session_id.to_string();
1407        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1408            let guard = conn.blocking_lock();
1409            let n = guard
1410                .execute(
1411                    "UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
1412                    params![now_ms(), outcome_str(resolved), sid],
1413                )
1414                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1415            if n == 0 {
1416                return Err(MemoryError::NotFound(format!("session {sid}")));
1417            }
1418            Ok(())
1419        })
1420        .await
1421        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1422    }
1423
1424    async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
1425        let conn = Arc::clone(&self.conn);
1426        let sid = session_id.to_string();
1427        let new_title = title.to_string();
1428        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1429            let guard = conn.blocking_lock();
1430            let n = guard
1431                .execute(
1432                    "UPDATE memory_sessions SET title = ? WHERE id = ?",
1433                    params![new_title, sid],
1434                )
1435                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1436            if n == 0 {
1437                return Err(MemoryError::NotFound(format!("session {sid}")));
1438            }
1439            Ok(())
1440        })
1441        .await
1442        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1443    }
1444
1445    // ───────────── Updates ─────────────
1446
1447    async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
1448        self.invalidate_retrieve_cache();
1449        let conn = Arc::clone(&self.conn);
1450        let id = chunk_id.to_string();
1451        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1452            let guard = conn.blocking_lock();
1453            let n = guard
1454                .execute(
1455                    "UPDATE memory_chunks SET pinned = ? WHERE id = ?",
1456                    params![if pinned { 1 } else { 0 }, id],
1457                )
1458                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1459            if n == 0 {
1460                return Err(MemoryError::NotFound(format!("chunk {id}")));
1461            }
1462            Ok(())
1463        })
1464        .await
1465        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1466    }
1467
1468    async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
1469        self.invalidate_retrieve_cache();
1470        let conn = Arc::clone(&self.conn);
1471        let id = chunk_id.to_string();
1472        let clamped = importance.clamp(0.0, 1.0) as f64;
1473        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1474            let guard = conn.blocking_lock();
1475            let n = guard
1476                .execute(
1477                    "UPDATE memory_chunks SET importance = ? WHERE id = ?",
1478                    params![clamped, id],
1479                )
1480                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1481            if n == 0 {
1482                return Err(MemoryError::NotFound(format!("chunk {id}")));
1483            }
1484            Ok(())
1485        })
1486        .await
1487        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1488    }
1489
1490    async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
1491        self.invalidate_retrieve_cache();
1492        let conn = Arc::clone(&self.conn);
1493        let old = old_id.to_string();
1494        let new = new_id.to_string();
1495        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1496            let guard = conn.blocking_lock();
1497            // Both chunks must exist. We don't enforce a foreign-key relation
1498            // at the SQL layer (memory_chunks.superseded_by is plain TEXT) so
1499            // verify by hand to keep the contract clean.
1500            let new_exists: i64 = guard
1501                .query_row(
1502                    "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1503                    params![new],
1504                    |r| r.get(0),
1505                )
1506                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1507            if new_exists == 0 {
1508                return Err(MemoryError::NotFound(format!("new chunk {new}")));
1509            }
1510            let n = guard
1511                .execute(
1512                    "UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
1513                    params![new, old],
1514                )
1515                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1516            if n == 0 {
1517                return Err(MemoryError::NotFound(format!("old chunk {old}")));
1518            }
1519            Ok(())
1520        })
1521        .await
1522        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1523    }
1524
1525    async fn record_access(
1526        &self,
1527        chunk_id: &str,
1528        retrieved_by: &str,
1529        used: bool,
1530    ) -> MemoryResult<()> {
1531        let conn = Arc::clone(&self.conn);
1532        let id = chunk_id.to_string();
1533        let by = retrieved_by.to_string();
1534        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1535            let guard = conn.blocking_lock();
1536            let exists: i64 = guard
1537                .query_row(
1538                    "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1539                    params![id],
1540                    |r| r.get(0),
1541                )
1542                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1543            if exists == 0 {
1544                return Err(MemoryError::NotFound(format!("chunk {id}")));
1545            }
1546            guard
1547                .execute(
1548                    "INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
1549                     VALUES (?, ?, ?, '', 0, ?)",
1550                    params![now_ms(), id, by, if used { 1 } else { 0 }],
1551                )
1552                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1553            Ok(())
1554        })
1555        .await
1556        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1557    }
1558
1559    // ───────────── Deletes ─────────────
1560
1561    async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
1562        self.invalidate_retrieve_cache();
1563        let conn = Arc::clone(&self.conn);
1564        let id = chunk_id.to_string();
1565        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1566            let mut guard = conn.blocking_lock();
1567            let tx = guard
1568                .transaction()
1569                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1570            let n = tx
1571                .execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1572                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1573            if n == 0 {
1574                return Err(MemoryError::NotFound(format!("chunk {id}")));
1575            }
1576            tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1577                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1578            tx.execute(
1579                "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1580                 VALUES (?,?,?, '{}')",
1581                params![now_ms(), id, eviction_reason_str(reason)],
1582            )
1583            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1584            tx.commit()
1585                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1586            Ok(())
1587        })
1588        .await
1589        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1590    }
1591
1592    async fn delete_matching(
1593        &self,
1594        predicate: MemoryPredicate,
1595        reason: EvictionReason,
1596    ) -> MemoryResult<usize> {
1597        // Footgun guard: an empty predicate would match every chunk; callers
1598        // who want "delete everything" must use `purge_all`. Mirrors
1599        // BasicMemoryProvider's behavior so the locked trait surface
1600        // is consistent across providers.
1601        if predicate.is_empty() {
1602            return Ok(0);
1603        }
1604        self.invalidate_retrieve_cache();
1605        let conn = Arc::clone(&self.conn);
1606        tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
1607            let (where_clause, params_vec) = build_chunk_predicate(&predicate);
1608            let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
1609            let mut guard = conn.blocking_lock();
1610            let tx = guard
1611                .transaction()
1612                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1613            let ids: Vec<String> = {
1614                let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1615                let mut stmt = tx
1616                    .prepare(&select_sql)
1617                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1618                let rows = stmt
1619                    .query_map(p.as_slice(), |r| r.get::<_, String>(0))
1620                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1621                let mut out = Vec::new();
1622                for r in rows {
1623                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1624                }
1625                out
1626            };
1627            let now = now_ms();
1628            let reason_s = eviction_reason_str(reason);
1629            for id in &ids {
1630                tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1631                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1632                tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1633                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1634                tx.execute(
1635                    "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1636                     VALUES (?, ?, ?, '{}')",
1637                    params![now, id, reason_s],
1638                )
1639                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1640            }
1641            tx.commit()
1642                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1643            Ok(ids.len())
1644        })
1645        .await
1646        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1647    }
1648
1649    async fn purge_all(&self) -> MemoryResult<PurgeReport> {
1650        self.invalidate_retrieve_cache();
1651        let conn = Arc::clone(&self.conn);
1652        tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
1653            let mut guard = conn.blocking_lock();
1654            let tx = guard
1655                .transaction()
1656                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1657            let chunks: i64 = tx
1658                .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
1659                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1660            let sessions: i64 = tx
1661                .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
1662                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1663            let access: i64 = tx
1664                .query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
1665                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1666            let evictions: i64 = tx
1667                .query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
1668                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1669
1670            tx.execute("DELETE FROM memory_chunks", [])
1671                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1672            tx.execute("DELETE FROM memory_vec", [])
1673                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1674            tx.execute("DELETE FROM memory_sessions", [])
1675                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1676            tx.execute("DELETE FROM memory_access_log", [])
1677                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1678            tx.execute("DELETE FROM memory_eviction_log", [])
1679                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1680            tx.execute("DELETE FROM memory_summary_members", [])
1681                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1682            tx.commit()
1683                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1684
1685            Ok(PurgeReport {
1686                chunks_deleted: chunks as usize,
1687                sessions_deleted: sessions as usize,
1688                access_log_deleted: access as usize,
1689                eviction_log_deleted: evictions as usize,
1690            })
1691        })
1692        .await
1693        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1694    }
1695
1696    // ───────────── Summarization ─────────────
1697
1698    async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
1699        // Honest signal when the daemon hasn't wired a summarizer —
1700        // preserves the v1 contract so existing callers that don't
1701        // opt into summarization keep their `NotImplemented` branch.
1702        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
1703            "SqliteMemoryProvider::summarize_session — no summarizer attached \
1704             (call `with_summarizer` first)",
1705        ))?;
1706
1707        // 1. Confirm the session exists. Returns NotFound with a clear
1708        //    message so callers can distinguish "unknown id" from
1709        //    "summarizer failed".
1710        let session = self
1711            .get_session(session_id)
1712            .await?
1713            .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
1714
1715        // 2. Pull the constituent chunks in chronological order. The
1716        //    Memory Manager plan §9.4 specifies oldest-first feed so
1717        //    the model can read the conversation as it unfolded.
1718        let members = self.fetch_session_chunks(session_id).await?;
1719        if members.is_empty() {
1720            // No chunks to summarize — the model would hallucinate, and
1721            // the summary_members link table would be empty. Surface as
1722            // InvalidArgument so callers can decide whether to silently
1723            // skip or surface to the user.
1724            return Err(MemoryError::InvalidArgument(format!(
1725                "session {session_id} has no chunks to summarize"
1726            )));
1727        }
1728        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1729
1730        // 3. Call the summarizer. NoInput should be impossible here
1731        //    given the check above, but we still translate honestly.
1732        let ctx = cel_memory::SummaryContext {
1733            kind_label: Some("session".into()),
1734            note: session
1735                .title
1736                .as_ref()
1737                .map(|t| format!("session title: {t}")),
1738            max_words: None,
1739        };
1740        let summary_text = summarizer
1741            .summarize(&members, &ctx)
1742            .await
1743            .map_err(|e| match e {
1744                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
1745                    "summarizer received no input despite session having chunks".into(),
1746                ),
1747                other => MemoryError::Provider(format!(
1748                    "summarizer {} failed: {other}",
1749                    summarizer.name()
1750                )),
1751            })?;
1752
1753        // 4. Persist the summary as a fresh JobSummary chunk via the
1754        //    public write path so importance scoring (§10.2) and the
1755        //    embedding pipeline both stay consistent with every other
1756        //    write. We use the session's caller_id so the summary
1757        //    inherits scope, and stamp metadata with the member ids
1758        //    and session id for cross-reference.
1759        let new_chunk = NewMemoryChunk {
1760            kind: ChunkKind::JobSummary,
1761            source: ChunkSource::Embedded,
1762            session_id: Some(session_id.to_string()),
1763            project_root: members.iter().find_map(|c| c.project_root.clone()),
1764            caller_id: session.caller_id.clone(),
1765            content: summary_text,
1766            metadata: serde_json::json!({
1767                "session_id": session_id,
1768                "member_count": member_ids.len(),
1769                "summarizer": summarizer.name(),
1770            }),
1771            // Honor the §10.2 default (+0.2 for JobSummary off baseline)
1772            // by leaving `importance: None` — the importance scorer
1773            // applied inside `write` will land on 0.7.
1774            importance: None,
1775            shareable: false,
1776            pinned: false,
1777        };
1778        let written = self.write(new_chunk).await?;
1779
1780        // 5. Link summary → members in memory_summary_members. One row
1781        //    per member. We use INSERT OR IGNORE so repeated calls (or
1782        //    a flaky retry) don't blow up on the composite primary
1783        //    key.
1784        let conn = Arc::clone(&self.conn);
1785        let summary_id = written.id.clone();
1786        let member_ids_for_insert = member_ids.clone();
1787        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1788            let mut guard = conn.blocking_lock();
1789            let tx = guard
1790                .transaction()
1791                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1792            for mid in &member_ids_for_insert {
1793                tx.execute(
1794                    "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
1795                         VALUES (?, ?)",
1796                    params![summary_id, mid],
1797                )
1798                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1799            }
1800            tx.commit()
1801                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1802            Ok(())
1803        })
1804        .await
1805        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1806
1807        // 6. Backfill the session's `summary` column so the
1808        //    `MemorySession` record exposes the latest synthesis
1809        //    without a join.
1810        let conn = Arc::clone(&self.conn);
1811        let sid = session_id.to_string();
1812        let stored_summary = written.content.clone();
1813        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1814            let guard = conn.blocking_lock();
1815            guard
1816                .execute(
1817                    "UPDATE memory_sessions SET summary = ? WHERE id = ?",
1818                    params![stored_summary, sid],
1819                )
1820                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1821            Ok(())
1822        })
1823        .await
1824        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1825
1826        Ok(written)
1827    }
1828
1829    async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1830        self.rollup_day_inner(date, false).await
1831    }
1832
1833    async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1834        self.rollup_day_inner(date, true).await
1835    }
1836
1837    async fn rollup_rule_week(
1838        &self,
1839        rule_id: &str,
1840        week_start: NaiveDate,
1841    ) -> MemoryResult<MemoryChunk> {
1842        self.rollup_rule_week_inner(rule_id, week_start, false)
1843            .await
1844    }
1845
1846    async fn rollup_rule_week_forced(
1847        &self,
1848        rule_id: &str,
1849        week_start: NaiveDate,
1850    ) -> MemoryResult<MemoryChunk> {
1851        self.rollup_rule_week_inner(rule_id, week_start, true).await
1852    }
1853
1854    // ───────────── Maintenance ─────────────
1855
1856    async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
1857        // v1 sweep: delete non-pinned non-correction chunks older than
1858        // the retention horizon. Matches BasicMemoryProvider's heuristic
1859        // (30 days) so the locked trait surface is consistent across
1860        // providers. Phase 3+ replaces this with the importance-aware
1861        // policy from cellar-memory-manager.md §10.
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 from
2121/// `cellar-memory-manager.md` §8.2; not exposed via the trait yet so
2122/// callers configure profiles via the `RetrievalProfile` enum only.
2123/// Returns `(w_vec, w_fts, w_rec, half_life_seconds)`.
2124fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
2125    use cel_memory::RetrievalProfile::*;
2126    match profile {
2127        // Embedded agent's per-turn retrieval — semantic-heavy, short
2128        // recency half-life (7 days).
2129        AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2130        // Long-horizon job context — heavier weight on long_term + summaries.
2131        AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2132        // NL compiler: similar prior rules. Keyword match dominates because
2133        // users are looking for "the rule that mentions ~/Workspace".
2134        NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2135        // NL compiler: similar prior fires. Same balance, slightly longer
2136        // half-life because rule history compounds.
2137        NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2138        // Audit / Activity tab — keyword-dominant, wide window.
2139        AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2140        // User free-text search — like audit but with a longer half-life.
2141        UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2142    }
2143}
2144
2145/// Sanitise the query string for FTS5. FTS5's query language treats
2146/// punctuation and special characters as operators; the simplest safe
2147/// approach is to quote the entire query as a phrase and escape any
2148/// internal double-quote.
2149fn fts_query_escape(raw: &str) -> String {
2150    let escaped = raw.replace('"', "\"\"");
2151    format!("\"{escaped}\"")
2152}
2153
2154/// Apply the non-ranking filters from a `MemoryQuery` to a candidate
2155/// chunk. Returns true if the chunk should be included in the result.
2156fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2157    // Kind filter
2158    if let Some(kinds) = &q.kinds {
2159        if !kinds.contains(&c.kind) {
2160            return false;
2161        }
2162    }
2163    // Rollups gate
2164    if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2165        return false;
2166    }
2167    // Time bounds
2168    if let Some(since) = q.since {
2169        if c.created_at < since {
2170            return false;
2171        }
2172    }
2173    if let Some(until) = q.until {
2174        if c.created_at > until {
2175            return false;
2176        }
2177    }
2178    // Session
2179    if let Some(sid) = &q.session_id {
2180        if c.session_id.as_deref() != Some(sid.as_str()) {
2181            return false;
2182        }
2183    }
2184    // Project root prefix
2185    if let Some(prefix) = &q.project_root_prefix {
2186        match &c.project_root {
2187            Some(root) if root.starts_with(prefix.as_str()) => {}
2188            _ => return false,
2189        }
2190    }
2191    // Min importance
2192    if let Some(min) = q.min_importance {
2193        if c.importance < min {
2194            return false;
2195        }
2196    }
2197    // Caller scope. `Own` restricts to the caller's own chunks.
2198    // `OwnPlusShared` (Phase 4) permits the caller's own chunks *plus* any
2199    // chunk tagged `shareable=true` from another caller. `Global` permits
2200    // everything — granted only to privileged surfaces (Memory tab,
2201    // audit timeline).
2202    match q.caller_scope {
2203        cel_memory::CallerScope::Own => {
2204            if c.caller_id != q.caller_id {
2205                return false;
2206            }
2207        }
2208        cel_memory::CallerScope::OwnPlusShared => {
2209            if c.caller_id != q.caller_id && !c.shareable {
2210                return false;
2211            }
2212        }
2213        cel_memory::CallerScope::Global => {}
2214    }
2215    true
2216}
2217
2218/// Translate a [`MemoryPredicate`] into a parameterized WHERE clause for
2219/// `memory_chunks`. Returns the clause body (without the leading `WHERE`)
2220/// and the parameter vector. The clause is composed of `AND`-joined
2221/// sub-clauses, defaulting to `1=1` if (somehow) every predicate field is
2222/// `None` — but [`MemoryPredicate::is_empty`] should have short-circuited
2223/// before this is called.
2224fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2225    let mut clauses: Vec<String> = vec!["1=1".to_string()];
2226    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2227    if let Some(kinds) = &p.kinds {
2228        let placeholders = vec!["?"; kinds.len()].join(",");
2229        clauses.push(format!("kind IN ({placeholders})"));
2230        for k in kinds {
2231            params.push(Box::new(kind_str(*k).to_string()));
2232        }
2233    }
2234    if let Some(callers) = &p.callers {
2235        let placeholders = vec!["?"; callers.len()].join(",");
2236        clauses.push(format!("caller_id IN ({placeholders})"));
2237        for c in callers {
2238            params.push(Box::new(c.clone()));
2239        }
2240    }
2241    if let Some(sids) = &p.session_ids {
2242        let placeholders = vec!["?"; sids.len()].join(",");
2243        clauses.push(format!("session_id IN ({placeholders})"));
2244        for s in sids {
2245            params.push(Box::new(s.clone()));
2246        }
2247    }
2248    if let Some(prefix) = &p.project_root_prefix {
2249        clauses.push("project_root LIKE ?".to_string());
2250        params.push(Box::new(format!("{prefix}%")));
2251    }
2252    if let Some(before) = p.before {
2253        clauses.push("created_at < ?".to_string());
2254        params.push(Box::new(dt_to_ms(before)));
2255    }
2256    if let Some(after) = p.after {
2257        clauses.push("created_at > ?".to_string());
2258        params.push(Box::new(dt_to_ms(after)));
2259    }
2260    if let Some(pinned) = p.pinned {
2261        clauses.push("pinned = ?".to_string());
2262        params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2263    }
2264    if let Some(below) = p.importance_below {
2265        clauses.push("importance < ?".to_string());
2266        params.push(Box::new(below as f64));
2267    }
2268    if let Some(needle) = &p.content_contains {
2269        clauses.push("LOWER(content) LIKE ?".to_string());
2270        params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2271    }
2272    (clauses.join(" AND "), params)
2273}
2274
2275/// Inverse of [`eviction_reason_str`]. Unknown strings (e.g., from a future
2276/// schema version) fall back to `UserDelete` rather than panicking; the
2277/// caller is expected to log + carry on.
2278fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2279    Ok(match s {
2280        "user_delete" => EvictionReason::UserDelete,
2281        "aging" => EvictionReason::Aging,
2282        "low_importance" => EvictionReason::LowImportance,
2283        "redact_rule" => EvictionReason::RedactRule,
2284        "storage_cap" => EvictionReason::StorageCap,
2285        "purge_all" => EvictionReason::PurgeAll,
2286        other => {
2287            return Err(MemoryError::Storage(format!(
2288                "unknown eviction reason: {other}"
2289            )))
2290        }
2291    })
2292}
2293
2294fn eviction_reason_str(r: EvictionReason) -> &'static str {
2295    match r {
2296        EvictionReason::UserDelete => "user_delete",
2297        EvictionReason::Aging => "aging",
2298        EvictionReason::LowImportance => "low_importance",
2299        EvictionReason::RedactRule => "redact_rule",
2300        EvictionReason::StorageCap => "storage_cap",
2301        EvictionReason::PurgeAll => "purge_all",
2302    }
2303}
2304
2305// Silence unused-import warnings when `record_access` etc. aren't wired.
2306#[allow(dead_code)]
2307fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}