Skip to main content

cel_memory_sqlite/
provider.rs

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