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::embedder::Embedder;
37use crate::error::SqliteMemoryError;
38use crate::migrations;
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
741            .embedder
742            .embed(&query.text)
743            .await
744            .map_err(|e| MemoryError::Storage(e.to_string()))?;
745        let q_text = query.text.clone();
746        let conn = Arc::clone(&self.conn);
747
748        let compute =
749            tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
750                let guard = conn.blocking_lock();
751
752                // Sub-retrieval 1: vector k-NN. sqlite-vec accepts the embedding
753                // as a JSON-array string. We use `vec_distance_l2` ordering
754                // (default for the `MATCH` operator). The WHERE filter is
755                // applied via a join because vec0 virtual tables don't honor
756                // arbitrary predicates on adjoining columns at MATCH time.
757                let v_json = serde_json::to_string(&q_embedding)
758                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
759                let vec_ids: Vec<String> = {
760                    let sql = "SELECT v.chunk_id FROM memory_vec v
761                           WHERE v.embedding MATCH ?
762                             AND k = ?
763                           ORDER BY distance";
764                    let mut stmt = guard
765                        .prepare(sql)
766                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
767                    let rows = stmt
768                        .query_map(params![v_json, candidate_k as i64], |r| {
769                            r.get::<_, String>(0)
770                        })
771                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
772                    let mut out = Vec::new();
773                    for r in rows {
774                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
775                    }
776                    out
777                };
778
779                // Sub-retrieval 2: FTS5 BM25 over the same query text. FTS5
780                // ranks lower-distance as higher relevance, hence ASC by rank.
781                let fts_ids: Vec<String> = {
782                    let sql = "SELECT chunk_id FROM memory_fts
783                           WHERE memory_fts MATCH ?
784                           ORDER BY rank
785                           LIMIT ?";
786                    let mut stmt = guard
787                        .prepare(sql)
788                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
789                    let rows = stmt
790                        .query_map(
791                            params![fts_query_escape(&q_text), candidate_k as i64],
792                            |r| r.get::<_, String>(0),
793                        )
794                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
795                    let mut out = Vec::new();
796                    for r in rows {
797                        // FTS may return no-match if the tokenizer drops the
798                        // whole query (e.g., punctuation-only). Surface as
799                        // empty list rather than panic.
800                        match r {
801                            Ok(id) => out.push(id),
802                            Err(_) => break,
803                        }
804                    }
805                    out
806                };
807
808                // Sub-retrieval 3: recency. Newest-first within the filter
809                // window. This is the same candidate set we'll join the
810                // chunk rows from.
811                let recency_ids: Vec<String> = {
812                    let sql = "SELECT id FROM memory_chunks
813                           ORDER BY created_at DESC LIMIT ?";
814                    let mut stmt = guard
815                        .prepare(sql)
816                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
817                    let rows = stmt
818                        .query_map(params![candidate_k as i64], |r| r.get::<_, String>(0))
819                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
820                    let mut out = Vec::new();
821                    for r in rows {
822                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
823                    }
824                    out
825                };
826
827                // RRF fusion. The constant 60 is the standard RRF prior.
828                const RRF_K: f32 = 60.0;
829                let mut scores: std::collections::HashMap<String, f32> =
830                    std::collections::HashMap::new();
831                for (rank, id) in vec_ids.iter().enumerate() {
832                    *scores.entry(id.clone()).or_insert(0.0) += w_vec / (RRF_K + (rank + 1) as f32);
833                }
834                for (rank, id) in fts_ids.iter().enumerate() {
835                    *scores.entry(id.clone()).or_insert(0.0) += w_fts / (RRF_K + (rank + 1) as f32);
836                }
837                // Recency is keyed off the chunk's `created_at` directly.
838                // Skip the score contribution for chunks we don't have a row
839                // for yet (rare race: scored vector for a deleted chunk).
840                let now_ms_val = now_ms();
841                let recency_rows: std::collections::HashMap<String, i64> = {
842                    if recency_ids.is_empty() {
843                        Default::default()
844                    } else {
845                        let placeholders = vec!["?"; recency_ids.len()].join(",");
846                        let sql = format!(
847                            "SELECT id, created_at FROM memory_chunks WHERE id IN ({placeholders})"
848                        );
849                        let p: Vec<&dyn rusqlite::ToSql> = recency_ids
850                            .iter()
851                            .map(|s| s as &dyn rusqlite::ToSql)
852                            .collect();
853                        let mut stmt = guard
854                            .prepare(&sql)
855                            .map_err(|e| MemoryError::Storage(e.to_string()))?;
856                        let rows = stmt
857                            .query_map(p.as_slice(), |r| {
858                                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
859                            })
860                            .map_err(|e| MemoryError::Storage(e.to_string()))?;
861                        let mut map = std::collections::HashMap::new();
862                        for r in rows {
863                            let (id, t) = r.map_err(|e| MemoryError::Storage(e.to_string()))?;
864                            map.insert(id, t);
865                        }
866                        map
867                    }
868                };
869                for (id, created_ms) in &recency_rows {
870                    let dt_secs = ((now_ms_val - created_ms).max(0) / 1000) as f32;
871                    let recency = (-dt_secs / half_life_secs).exp();
872                    *scores.entry(id.clone()).or_insert(0.0) += w_rec * recency;
873                }
874
875                // Materialise candidate chunks from the union of ID sets.
876                let mut id_set: std::collections::HashSet<String> =
877                    std::collections::HashSet::new();
878                id_set.extend(vec_ids);
879                id_set.extend(fts_ids);
880                id_set.extend(recency_ids);
881                if id_set.is_empty() {
882                    return Ok(Vec::new());
883                }
884                let placeholders = vec!["?"; id_set.len()].join(",");
885                let select_sql = format!(
886                    "SELECT id, created_at, kind, tier, source, session_id,
887                        project_root, caller_id, content, metadata,
888                        importance, pinned, shareable, superseded_by,
889                        embedding_model, embedding_dim
890                 FROM memory_chunks WHERE id IN ({placeholders})"
891                );
892                let ids_vec: Vec<String> = id_set.into_iter().collect();
893                let candidates: Vec<MemoryChunk> = {
894                    let p: Vec<&dyn rusqlite::ToSql> =
895                        ids_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
896                    let mut stmt = guard
897                        .prepare(&select_sql)
898                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
899                    let rows = stmt
900                        .query_map(p.as_slice(), row_to_chunk)
901                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
902                    let mut out = Vec::new();
903                    for r in rows {
904                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
905                    }
906                    out
907                };
908
909                // Apply non-ranking filters now (kind, session, scope, time,
910                // project_root, min_importance, include_rollups).
911                let mut filtered: Vec<MemoryChunk> = candidates
912                    .into_iter()
913                    .filter(|c| chunk_matches_query(c, &query))
914                    .collect();
915
916                // Sort by fused score descending; truncate to k.
917                filtered.sort_by(|a, b| {
918                    let sa = scores.get(&a.id).copied().unwrap_or(0.0);
919                    let sb = scores.get(&b.id).copied().unwrap_or(0.0);
920                    sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
921                });
922                filtered.truncate(k);
923                Ok(filtered)
924            });
925        let result = compute
926            .await
927            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
928
929        // Populate the cache after a successful computation. Errors are
930        // never cached — they're cheap to recompute and stale errors would
931        // mask transient lock contention.
932        if let Some(key) = cache_key {
933            self.retrieve_cache.insert(key, result.clone());
934        }
935        Ok(result)
936    }
937
938    async fn get(&self, chunk_id: &str) -> MemoryResult<Option<MemoryChunk>> {
939        let conn = Arc::clone(&self.conn);
940        let chunk_id = chunk_id.to_string();
941        let res: Result<Option<MemoryChunk>, MemoryError> =
942            tokio::task::spawn_blocking(move || -> Result<Option<MemoryChunk>, MemoryError> {
943                let guard = conn.blocking_lock();
944                let mut stmt = guard
945                    .prepare(
946                        "SELECT id, created_at, kind, tier, source, session_id,
947                                project_root, caller_id, content, metadata,
948                                importance, pinned, shareable, superseded_by,
949                                embedding_model, embedding_dim
950                         FROM memory_chunks WHERE id = ?",
951                    )
952                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
953                let row = stmt
954                    .query_row(params![chunk_id], row_to_chunk)
955                    .optional()
956                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
957                Ok(row)
958            })
959            .await
960            .map_err(|e| MemoryError::Internal(format!("join: {e}")))?;
961        res
962    }
963
964    async fn get_session(&self, session_id: &str) -> MemoryResult<Option<MemorySession>> {
965        let conn = Arc::clone(&self.conn);
966        let session_id = session_id.to_string();
967        tokio::task::spawn_blocking(move || -> Result<Option<MemorySession>, MemoryError> {
968            let guard = conn.blocking_lock();
969            let mut stmt = guard
970                .prepare(
971                    "SELECT id, started_at, ended_at, caller_id, title, summary,
972                            outcome, metadata
973                     FROM memory_sessions WHERE id = ?",
974                )
975                .map_err(|e| MemoryError::Storage(e.to_string()))?;
976            let row = stmt
977                .query_row(params![session_id], |r| {
978                    let metadata_str: String = r.get("metadata")?;
979                    let metadata: serde_json::Value =
980                        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
981                    Ok(MemorySession {
982                        id: r.get("id")?,
983                        started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
984                        ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
985                        caller_id: r.get("caller_id")?,
986                        title: r.get("title")?,
987                        summary: r.get("summary")?,
988                        outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
989                            .unwrap_or(SessionOutcome::Aborted),
990                        metadata,
991                    })
992                })
993                .optional()
994                .map_err(|e| MemoryError::Storage(e.to_string()))?;
995            Ok(row)
996        })
997        .await
998        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
999    }
1000
1001    async fn list_sessions(&self, filter: SessionFilter) -> MemoryResult<Vec<MemorySession>> {
1002        let conn = Arc::clone(&self.conn);
1003        tokio::task::spawn_blocking(move || -> Result<Vec<MemorySession>, MemoryError> {
1004            let guard = conn.blocking_lock();
1005            // Build a parameterized query. Each Option<...> on the filter
1006            // adds a single `AND col op ?` clause. The matcher takes
1007            // owned params so we don't fight rusqlite's lifetime rules.
1008            let mut sql = String::from(
1009                "SELECT id, started_at, ended_at, caller_id, title, summary,
1010                        outcome, metadata
1011                 FROM memory_sessions WHERE 1=1",
1012            );
1013            let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1014            if let Some(c) = &filter.caller_id {
1015                sql.push_str(" AND caller_id = ?");
1016                params_vec.push(Box::new(c.clone()));
1017            }
1018            if let Some(o) = filter.outcome {
1019                sql.push_str(" AND outcome = ?");
1020                params_vec.push(Box::new(outcome_str(o).to_string()));
1021            } else if filter.open_only {
1022                sql.push_str(" AND outcome = ?");
1023                params_vec.push(Box::new("open".to_string()));
1024            }
1025            if let Some(since) = filter.since {
1026                sql.push_str(" AND started_at >= ?");
1027                params_vec.push(Box::new(dt_to_ms(since)));
1028            }
1029            if let Some(until) = filter.until {
1030                sql.push_str(" AND started_at <= ?");
1031                params_vec.push(Box::new(dt_to_ms(until)));
1032            }
1033            sql.push_str(" ORDER BY started_at DESC");
1034            if let Some(n) = filter.limit {
1035                sql.push_str(" LIMIT ?");
1036                params_vec.push(Box::new(n as i64));
1037            }
1038
1039            let mut stmt = guard
1040                .prepare(&sql)
1041                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1042            let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1043            let rows = stmt
1044                .query_map(p.as_slice(), |r| {
1045                    let metadata_str: String = r.get("metadata")?;
1046                    let metadata: serde_json::Value =
1047                        serde_json::from_str(&metadata_str).unwrap_or(serde_json::Value::Null);
1048                    Ok(MemorySession {
1049                        id: r.get("id")?,
1050                        started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
1051                        ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
1052                        caller_id: r.get("caller_id")?,
1053                        title: r.get("title")?,
1054                        summary: r.get("summary")?,
1055                        outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
1056                            .unwrap_or(SessionOutcome::Aborted),
1057                        metadata,
1058                    })
1059                })
1060                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1061            let mut out = Vec::new();
1062            for row in rows {
1063                out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1064            }
1065            Ok(out)
1066        })
1067        .await
1068        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1069    }
1070
1071    // ───────────── Writes ─────────────
1072
1073    async fn write(&self, new_chunk: NewMemoryChunk) -> MemoryResult<MemoryChunk> {
1074        if new_chunk.content.trim().is_empty() {
1075            return Err(MemoryError::InvalidArgument(
1076                "content must not be empty".into(),
1077            ));
1078        }
1079        // Eager invalidation. Combined with the conn-mutex serialising
1080        // every read against this write, any retrieve started after this
1081        // point observes the post-write state. See `cache.rs` docs.
1082        self.invalidate_retrieve_cache();
1083
1084        // Pre-write hook: rule-matcher seam. On Redact, return a synthetic
1085        // chunk without persisting (and without embedding — that's the
1086        // expensive part). The caller sees an Ok-with-redacted-marker
1087        // chunk; the SQL store stays untouched.
1088        if let Some(hook) = &self.write_hook {
1089            match hook.before_write(&new_chunk).await? {
1090                cel_memory::WriteDecision::Allow => {}
1091                cel_memory::WriteDecision::Redact { reason } => {
1092                    return Ok(MemoryChunk {
1093                        id: Uuid::now_v7().to_string(),
1094                        created_at: Utc::now(),
1095                        kind: new_chunk.kind,
1096                        tier: MemoryTier::Session,
1097                        source: new_chunk.source,
1098                        session_id: new_chunk.session_id,
1099                        project_root: new_chunk.project_root,
1100                        caller_id: new_chunk.caller_id,
1101                        content: format!("<redacted: {reason}>"),
1102                        metadata: serde_json::json!({"redacted": true, "reason": reason}),
1103                        importance: 0.0,
1104                        pinned: false,
1105                        shareable: false,
1106                        superseded_by: None,
1107                        embedding_model: "none".into(),
1108                        embedding_dim: 0,
1109                    });
1110                }
1111            }
1112        }
1113
1114        let id = Uuid::now_v7().to_string();
1115        let created_at_ms = now_ms();
1116        // Score via the shared heuristic (cel_memory::importance::score).
1117        // If the caller supplied an explicit value, it's honored after clamp;
1118        // otherwise the kind + metadata drive the score.
1119        let importance = cel_memory::importance::score(&new_chunk);
1120        let embedder_dim = self.embedder.dim();
1121        let embedder_name = self.embedder.model_name().to_string();
1122        // Embed the content. If this fails we don't store the chunk —
1123        // chunks without vectors don't participate in retrieval.
1124        let embedding = self
1125            .embedder
1126            .embed(&new_chunk.content)
1127            .await
1128            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1129        if embedding.len() != embedder_dim {
1130            return Err(MemoryError::Internal(format!(
1131                "embedder produced dim {}, declared {}",
1132                embedding.len(),
1133                embedder_dim
1134            )));
1135        }
1136
1137        let chunk = MemoryChunk {
1138            id: id.clone(),
1139            created_at: ms_to_dt(created_at_ms),
1140            kind: new_chunk.kind,
1141            tier: MemoryTier::Session,
1142            source: new_chunk.source,
1143            session_id: new_chunk.session_id.clone(),
1144            project_root: new_chunk.project_root.clone(),
1145            caller_id: new_chunk.caller_id.clone(),
1146            content: new_chunk.content.clone(),
1147            metadata: new_chunk.metadata.clone(),
1148            importance,
1149            pinned: new_chunk.pinned,
1150            shareable: new_chunk.shareable,
1151            superseded_by: None,
1152            embedding_model: embedder_name.clone(),
1153            embedding_dim: embedder_dim as u32,
1154        };
1155
1156        let conn = Arc::clone(&self.conn);
1157        let chunk_for_blocking = chunk.clone();
1158        let embedding_clone = embedding.clone();
1159        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1160            let mut guard = conn.blocking_lock();
1161            let tx = guard
1162                .transaction()
1163                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1164            tx.execute(
1165                "INSERT INTO memory_chunks(
1166                    id, created_at, kind, tier, source, session_id, project_root,
1167                    caller_id, content, metadata, importance, pinned, shareable,
1168                    superseded_by, embedding_model, embedding_dim
1169                ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1170                params![
1171                    chunk_for_blocking.id,
1172                    created_at_ms,
1173                    kind_str(chunk_for_blocking.kind),
1174                    tier_str(chunk_for_blocking.tier),
1175                    source_str(chunk_for_blocking.source),
1176                    chunk_for_blocking.session_id,
1177                    chunk_for_blocking.project_root,
1178                    chunk_for_blocking.caller_id,
1179                    chunk_for_blocking.content,
1180                    serde_json::to_string(&chunk_for_blocking.metadata)
1181                        .unwrap_or_else(|_| "null".into()),
1182                    chunk_for_blocking.importance as f64,
1183                    if chunk_for_blocking.pinned { 1 } else { 0 },
1184                    if new_chunk.shareable { 1 } else { 0 },
1185                    Option::<String>::None,
1186                    chunk_for_blocking.embedding_model,
1187                    chunk_for_blocking.embedding_dim as i64,
1188                ],
1189            )
1190            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1191            // memory_vec insert. sqlite-vec accepts vectors as JSON-array
1192            // text or as a packed BLOB; JSON is simpler and the conversion
1193            // cost is irrelevant for v1.
1194            let v_json = serde_json::to_string(&embedding_clone)
1195                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1196            tx.execute(
1197                "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1198                params![chunk_for_blocking.id, v_json],
1199            )
1200            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1201            tx.commit()
1202                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1203            Ok(())
1204        })
1205        .await
1206        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1207
1208        Ok(chunk)
1209    }
1210
1211    async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> MemoryResult<Vec<MemoryChunk>> {
1212        // Batched path: one embedding call, one transaction, one Mutex hold.
1213        // Single-chunk path delegates to `write` (which already runs the
1214        // single-call optimisation through the embedder; no point batching
1215        // a batch of one).
1216        if chunks.is_empty() {
1217            return Ok(Vec::new());
1218        }
1219        if chunks.len() == 1 {
1220            let nc = chunks.into_iter().next().expect("len == 1");
1221            return Ok(vec![self.write(nc).await?]);
1222        }
1223        self.invalidate_retrieve_cache();
1224
1225        // Hook-present path: per-chunk evaluation is required so redacted
1226        // chunks don't get embedded. Fall back to sequential writes to
1227        // preserve input → output ordering without adding per-chunk
1228        // bookkeeping to the batched insert.
1229        if self.write_hook.is_some() {
1230            let mut out = Vec::with_capacity(chunks.len());
1231            for nc in chunks {
1232                out.push(self.write(nc).await?);
1233            }
1234            return Ok(out);
1235        }
1236
1237        // Validate content non-empty up front so we don't half-embed a
1238        // batch and then fail mid-transaction.
1239        for (i, nc) in chunks.iter().enumerate() {
1240            if nc.content.trim().is_empty() {
1241                return Err(MemoryError::InvalidArgument(format!(
1242                    "chunks[{i}].content must not be empty"
1243                )));
1244            }
1245        }
1246
1247        let embedder_dim = self.embedder.dim();
1248        let embedder_name = self.embedder.model_name().to_string();
1249
1250        // One batched embedding call. The injected `Embedder` implementation
1251        // (fastembed, OpenAI, Voyage) provides the real batching;
1252        // `MockEmbedder` falls back to the trait's default impl which is
1253        // sequential but at least keeps the contract honest.
1254        let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
1255        let embeddings = self
1256            .embedder
1257            .embed_batch(&texts)
1258            .await
1259            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1260        if embeddings.len() != chunks.len() {
1261            return Err(MemoryError::Internal(format!(
1262                "embedder returned {} vectors for {} inputs",
1263                embeddings.len(),
1264                chunks.len()
1265            )));
1266        }
1267        for (i, v) in embeddings.iter().enumerate() {
1268            if v.len() != embedder_dim {
1269                return Err(MemoryError::Internal(format!(
1270                    "embedder produced dim {} for chunk {i}, declared {}",
1271                    v.len(),
1272                    embedder_dim
1273                )));
1274            }
1275        }
1276
1277        // Build all MemoryChunks now so the spawn_blocking payload owns
1278        // everything it needs.
1279        let now_ms_val = now_ms();
1280        let mut owned: Vec<(MemoryChunk, Vec<f32>, bool, String)> =
1281            Vec::with_capacity(chunks.len());
1282        for (nc, embedding) in chunks.into_iter().zip(embeddings) {
1283            let id = Uuid::now_v7().to_string();
1284            let importance = cel_memory::importance::score(&nc);
1285            let metadata_json =
1286                serde_json::to_string(&nc.metadata).unwrap_or_else(|_| "null".into());
1287            let mc = MemoryChunk {
1288                id,
1289                created_at: ms_to_dt(now_ms_val),
1290                kind: nc.kind,
1291                tier: MemoryTier::Session,
1292                source: nc.source,
1293                session_id: nc.session_id,
1294                project_root: nc.project_root,
1295                caller_id: nc.caller_id,
1296                content: nc.content,
1297                metadata: nc.metadata,
1298                importance,
1299                pinned: nc.pinned,
1300                shareable: nc.shareable,
1301                superseded_by: None,
1302                embedding_model: embedder_name.clone(),
1303                embedding_dim: embedder_dim as u32,
1304            };
1305            owned.push((mc, embedding, nc.shareable, metadata_json));
1306        }
1307
1308        let conn = Arc::clone(&self.conn);
1309        let inserted =
1310            tokio::task::spawn_blocking(move || -> Result<Vec<MemoryChunk>, MemoryError> {
1311                let mut guard = conn.blocking_lock();
1312                let tx = guard
1313                    .transaction()
1314                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1315                let mut out = Vec::with_capacity(owned.len());
1316                for (mc, embedding, shareable, metadata_json) in &owned {
1317                    tx.execute(
1318                        "INSERT INTO memory_chunks(
1319                        id, created_at, kind, tier, source, session_id, project_root,
1320                        caller_id, content, metadata, importance, pinned, shareable,
1321                        superseded_by, embedding_model, embedding_dim
1322                    ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1323                        params![
1324                            mc.id,
1325                            now_ms_val,
1326                            kind_str(mc.kind),
1327                            tier_str(mc.tier),
1328                            source_str(mc.source),
1329                            mc.session_id,
1330                            mc.project_root,
1331                            mc.caller_id,
1332                            mc.content,
1333                            metadata_json,
1334                            mc.importance as f64,
1335                            if mc.pinned { 1 } else { 0 },
1336                            if *shareable { 1 } else { 0 },
1337                            Option::<String>::None,
1338                            mc.embedding_model,
1339                            mc.embedding_dim as i64,
1340                        ],
1341                    )
1342                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1343                    let v_json = serde_json::to_string(embedding)
1344                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1345                    tx.execute(
1346                        "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
1347                        params![mc.id, v_json],
1348                    )
1349                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1350                    out.push(mc.clone());
1351                }
1352                tx.commit()
1353                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1354                Ok(out)
1355            })
1356            .await
1357            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1358
1359        Ok(inserted)
1360    }
1361
1362    async fn open_session(&self, init: NewMemorySession) -> MemoryResult<MemorySession> {
1363        let session = MemorySession {
1364            id: Uuid::now_v7().to_string(),
1365            started_at: Utc::now(),
1366            ended_at: None,
1367            caller_id: init.caller_id.clone(),
1368            title: init.title.clone(),
1369            summary: None,
1370            outcome: SessionOutcome::Open,
1371            metadata: init.metadata.clone(),
1372        };
1373        let conn = Arc::clone(&self.conn);
1374        let s = session.clone();
1375        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1376            let guard = conn.blocking_lock();
1377            guard
1378                .execute(
1379                    "INSERT INTO memory_sessions(
1380                        id, started_at, ended_at, caller_id, title, summary,
1381                        outcome, metadata
1382                    ) VALUES (?,?,?,?,?,?,?,?)",
1383                    params![
1384                        s.id,
1385                        dt_to_ms(s.started_at),
1386                        Option::<i64>::None,
1387                        s.caller_id,
1388                        s.title,
1389                        Option::<String>::None,
1390                        outcome_str(s.outcome),
1391                        serde_json::to_string(&s.metadata).unwrap_or_else(|_| "{}".into()),
1392                    ],
1393                )
1394                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1395            Ok(())
1396        })
1397        .await
1398        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1399        Ok(session)
1400    }
1401
1402    async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> MemoryResult<()> {
1403        let resolved = match outcome {
1404            SessionOutcome::Open => SessionOutcome::Aborted,
1405            other => other,
1406        };
1407        let conn = Arc::clone(&self.conn);
1408        let sid = session_id.to_string();
1409        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1410            let guard = conn.blocking_lock();
1411            let n = guard
1412                .execute(
1413                    "UPDATE memory_sessions SET ended_at = ?, outcome = ? WHERE id = ?",
1414                    params![now_ms(), outcome_str(resolved), sid],
1415                )
1416                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1417            if n == 0 {
1418                return Err(MemoryError::NotFound(format!("session {sid}")));
1419            }
1420            Ok(())
1421        })
1422        .await
1423        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1424    }
1425
1426    async fn rename_session(&self, session_id: &str, title: &str) -> MemoryResult<()> {
1427        let conn = Arc::clone(&self.conn);
1428        let sid = session_id.to_string();
1429        let new_title = title.to_string();
1430        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1431            let guard = conn.blocking_lock();
1432            let n = guard
1433                .execute(
1434                    "UPDATE memory_sessions SET title = ? WHERE id = ?",
1435                    params![new_title, sid],
1436                )
1437                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1438            if n == 0 {
1439                return Err(MemoryError::NotFound(format!("session {sid}")));
1440            }
1441            Ok(())
1442        })
1443        .await
1444        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1445    }
1446
1447    // ───────────── Updates ─────────────
1448
1449    async fn pin(&self, chunk_id: &str, pinned: bool) -> MemoryResult<()> {
1450        self.invalidate_retrieve_cache();
1451        let conn = Arc::clone(&self.conn);
1452        let id = chunk_id.to_string();
1453        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1454            let guard = conn.blocking_lock();
1455            let n = guard
1456                .execute(
1457                    "UPDATE memory_chunks SET pinned = ? WHERE id = ?",
1458                    params![if pinned { 1 } else { 0 }, id],
1459                )
1460                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1461            if n == 0 {
1462                return Err(MemoryError::NotFound(format!("chunk {id}")));
1463            }
1464            Ok(())
1465        })
1466        .await
1467        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1468    }
1469
1470    async fn update_importance(&self, chunk_id: &str, importance: f32) -> MemoryResult<()> {
1471        self.invalidate_retrieve_cache();
1472        let conn = Arc::clone(&self.conn);
1473        let id = chunk_id.to_string();
1474        let clamped = importance.clamp(0.0, 1.0) as f64;
1475        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1476            let guard = conn.blocking_lock();
1477            let n = guard
1478                .execute(
1479                    "UPDATE memory_chunks SET importance = ? WHERE id = ?",
1480                    params![clamped, id],
1481                )
1482                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1483            if n == 0 {
1484                return Err(MemoryError::NotFound(format!("chunk {id}")));
1485            }
1486            Ok(())
1487        })
1488        .await
1489        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1490    }
1491
1492    async fn supersede(&self, old_id: &str, new_id: &str) -> MemoryResult<()> {
1493        self.invalidate_retrieve_cache();
1494        let conn = Arc::clone(&self.conn);
1495        let old = old_id.to_string();
1496        let new = new_id.to_string();
1497        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1498            let guard = conn.blocking_lock();
1499            // Both chunks must exist. We don't enforce a foreign-key relation
1500            // at the SQL layer (memory_chunks.superseded_by is plain TEXT) so
1501            // verify by hand to keep the contract clean.
1502            let new_exists: i64 = guard
1503                .query_row(
1504                    "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1505                    params![new],
1506                    |r| r.get(0),
1507                )
1508                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1509            if new_exists == 0 {
1510                return Err(MemoryError::NotFound(format!("new chunk {new}")));
1511            }
1512            let n = guard
1513                .execute(
1514                    "UPDATE memory_chunks SET superseded_by = ? WHERE id = ?",
1515                    params![new, old],
1516                )
1517                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1518            if n == 0 {
1519                return Err(MemoryError::NotFound(format!("old chunk {old}")));
1520            }
1521            Ok(())
1522        })
1523        .await
1524        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1525    }
1526
1527    async fn record_access(
1528        &self,
1529        chunk_id: &str,
1530        retrieved_by: &str,
1531        used: bool,
1532    ) -> MemoryResult<()> {
1533        let conn = Arc::clone(&self.conn);
1534        let id = chunk_id.to_string();
1535        let by = retrieved_by.to_string();
1536        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1537            let guard = conn.blocking_lock();
1538            let exists: i64 = guard
1539                .query_row(
1540                    "SELECT COUNT(*) FROM memory_chunks WHERE id = ?",
1541                    params![id],
1542                    |r| r.get(0),
1543                )
1544                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1545            if exists == 0 {
1546                return Err(MemoryError::NotFound(format!("chunk {id}")));
1547            }
1548            guard
1549                .execute(
1550                    "INSERT INTO memory_access_log(ts, chunk_id, retrieved_by, query_hash, rank, used)
1551                     VALUES (?, ?, ?, '', 0, ?)",
1552                    params![now_ms(), id, by, if used { 1 } else { 0 }],
1553                )
1554                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1555            Ok(())
1556        })
1557        .await
1558        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1559    }
1560
1561    // ───────────── Deletes ─────────────
1562
1563    async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> MemoryResult<()> {
1564        self.invalidate_retrieve_cache();
1565        let conn = Arc::clone(&self.conn);
1566        let id = chunk_id.to_string();
1567        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1568            let mut guard = conn.blocking_lock();
1569            let tx = guard
1570                .transaction()
1571                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1572            let n = tx
1573                .execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1574                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1575            if n == 0 {
1576                return Err(MemoryError::NotFound(format!("chunk {id}")));
1577            }
1578            tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1579                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1580            tx.execute(
1581                "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1582                 VALUES (?,?,?, '{}')",
1583                params![now_ms(), id, eviction_reason_str(reason)],
1584            )
1585            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1586            tx.commit()
1587                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1588            Ok(())
1589        })
1590        .await
1591        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1592    }
1593
1594    async fn delete_matching(
1595        &self,
1596        predicate: MemoryPredicate,
1597        reason: EvictionReason,
1598    ) -> MemoryResult<usize> {
1599        // Footgun guard: an empty predicate would match every chunk; callers
1600        // who want "delete everything" must use `purge_all`. Mirrors
1601        // BasicMemoryProvider's behavior so the locked trait surface
1602        // is consistent across providers.
1603        if predicate.is_empty() {
1604            return Ok(0);
1605        }
1606        self.invalidate_retrieve_cache();
1607        let conn = Arc::clone(&self.conn);
1608        tokio::task::spawn_blocking(move || -> Result<usize, MemoryError> {
1609            let (where_clause, params_vec) = build_chunk_predicate(&predicate);
1610            let select_sql = format!("SELECT id FROM memory_chunks WHERE {}", where_clause);
1611            let mut guard = conn.blocking_lock();
1612            let tx = guard
1613                .transaction()
1614                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1615            let ids: Vec<String> = {
1616                let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1617                let mut stmt = tx
1618                    .prepare(&select_sql)
1619                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1620                let rows = stmt
1621                    .query_map(p.as_slice(), |r| r.get::<_, String>(0))
1622                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1623                let mut out = Vec::new();
1624                for r in rows {
1625                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1626                }
1627                out
1628            };
1629            let now = now_ms();
1630            let reason_s = eviction_reason_str(reason);
1631            for id in &ids {
1632                tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1633                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1634                tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1635                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1636                tx.execute(
1637                    "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1638                     VALUES (?, ?, ?, '{}')",
1639                    params![now, id, reason_s],
1640                )
1641                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1642            }
1643            tx.commit()
1644                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1645            Ok(ids.len())
1646        })
1647        .await
1648        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1649    }
1650
1651    async fn purge_all(&self) -> MemoryResult<PurgeReport> {
1652        self.invalidate_retrieve_cache();
1653        let conn = Arc::clone(&self.conn);
1654        tokio::task::spawn_blocking(move || -> Result<PurgeReport, MemoryError> {
1655            let mut guard = conn.blocking_lock();
1656            let tx = guard
1657                .transaction()
1658                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1659            let chunks: i64 = tx
1660                .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
1661                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1662            let sessions: i64 = tx
1663                .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
1664                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1665            let access: i64 = tx
1666                .query_row("SELECT COUNT(*) FROM memory_access_log", [], |r| r.get(0))
1667                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1668            let evictions: i64 = tx
1669                .query_row("SELECT COUNT(*) FROM memory_eviction_log", [], |r| r.get(0))
1670                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1671
1672            tx.execute("DELETE FROM memory_chunks", [])
1673                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1674            tx.execute("DELETE FROM memory_vec", [])
1675                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1676            tx.execute("DELETE FROM memory_sessions", [])
1677                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1678            tx.execute("DELETE FROM memory_access_log", [])
1679                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1680            tx.execute("DELETE FROM memory_eviction_log", [])
1681                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1682            tx.execute("DELETE FROM memory_summary_members", [])
1683                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1684            tx.commit()
1685                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1686
1687            Ok(PurgeReport {
1688                chunks_deleted: chunks as usize,
1689                sessions_deleted: sessions as usize,
1690                access_log_deleted: access as usize,
1691                eviction_log_deleted: evictions as usize,
1692            })
1693        })
1694        .await
1695        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1696    }
1697
1698    // ───────────── Summarization ─────────────
1699
1700    async fn summarize_session(&self, session_id: &str) -> MemoryResult<MemoryChunk> {
1701        // Honest signal when no summarizer is wired: callers that don't opt
1702        // into summarization keep their `NotImplemented` branch.
1703        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
1704            "SqliteMemoryProvider::summarize_session — no summarizer attached \
1705             (call `with_summarizer` first)",
1706        ))?;
1707
1708        // 1. Confirm the session exists. Returns NotFound with a clear
1709        //    message so callers can distinguish "unknown id" from
1710        //    "summarizer failed".
1711        let session = self
1712            .get_session(session_id)
1713            .await?
1714            .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
1715
1716        // 2. Pull the constituent chunks in chronological order —
1717        //    oldest-first feed so the model can read the conversation
1718        //    as it unfolded.
1719        let members = self.fetch_session_chunks(session_id).await?;
1720        if members.is_empty() {
1721            // No chunks to summarize — the model would hallucinate, and
1722            // the summary_members link table would be empty. Surface as
1723            // InvalidArgument so callers can decide whether to silently
1724            // skip or surface to the user.
1725            return Err(MemoryError::InvalidArgument(format!(
1726                "session {session_id} has no chunks to summarize"
1727            )));
1728        }
1729        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1730
1731        // 3. Call the summarizer. NoInput should be impossible here
1732        //    given the check above, but we still translate honestly.
1733        let ctx = cel_memory::SummaryContext {
1734            kind_label: Some("session".into()),
1735            note: session
1736                .title
1737                .as_ref()
1738                .map(|t| format!("session title: {t}")),
1739            max_words: None,
1740        };
1741        let summary_text = summarizer
1742            .summarize(&members, &ctx)
1743            .await
1744            .map_err(|e| match e {
1745                cel_memory::SummarizerError::NoInput => MemoryError::InvalidArgument(
1746                    "summarizer received no input despite session having chunks".into(),
1747                ),
1748                other => MemoryError::Provider(format!(
1749                    "summarizer {} failed: {other}",
1750                    summarizer.name()
1751                )),
1752            })?;
1753
1754        // 4. Persist the summary as a fresh JobSummary chunk via the
1755        //    public write path so importance scoring and the
1756        //    embedding pipeline both stay consistent with every other
1757        //    write. We use the session's caller_id so the summary
1758        //    inherits scope, and stamp metadata with the member ids
1759        //    and session id for cross-reference.
1760        let new_chunk = NewMemoryChunk {
1761            kind: ChunkKind::JobSummary,
1762            source: ChunkSource::Embedded,
1763            session_id: Some(session_id.to_string()),
1764            project_root: members.iter().find_map(|c| c.project_root.clone()),
1765            caller_id: session.caller_id.clone(),
1766            content: summary_text,
1767            metadata: serde_json::json!({
1768                "session_id": session_id,
1769                "member_count": member_ids.len(),
1770                "summarizer": summarizer.name(),
1771            }),
1772            // Honor the importance default (+0.2 for JobSummary off baseline)
1773            // by leaving `importance: None` — the importance scorer
1774            // applied inside `write` will land on 0.7.
1775            importance: None,
1776            shareable: false,
1777            pinned: false,
1778        };
1779        let written = self.write(new_chunk).await?;
1780
1781        // 5. Link summary → members in memory_summary_members. One row
1782        //    per member. We use INSERT OR IGNORE so repeated calls (or
1783        //    a flaky retry) don't blow up on the composite primary
1784        //    key.
1785        let conn = Arc::clone(&self.conn);
1786        let summary_id = written.id.clone();
1787        let member_ids_for_insert = member_ids.clone();
1788        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1789            let mut guard = conn.blocking_lock();
1790            let tx = guard
1791                .transaction()
1792                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1793            for mid in &member_ids_for_insert {
1794                tx.execute(
1795                    "INSERT OR IGNORE INTO memory_summary_members(rollup_id, member_id)
1796                         VALUES (?, ?)",
1797                    params![summary_id, mid],
1798                )
1799                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1800            }
1801            tx.commit()
1802                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1803            Ok(())
1804        })
1805        .await
1806        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1807
1808        // 6. Backfill the session's `summary` column so the
1809        //    `MemorySession` record exposes the latest synthesis
1810        //    without a join.
1811        let conn = Arc::clone(&self.conn);
1812        let sid = session_id.to_string();
1813        let stored_summary = written.content.clone();
1814        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
1815            let guard = conn.blocking_lock();
1816            guard
1817                .execute(
1818                    "UPDATE memory_sessions SET summary = ? WHERE id = ?",
1819                    params![stored_summary, sid],
1820                )
1821                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1822            Ok(())
1823        })
1824        .await
1825        .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1826
1827        Ok(written)
1828    }
1829
1830    async fn rollup_day(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1831        self.rollup_day_inner(date, false).await
1832    }
1833
1834    async fn rollup_day_forced(&self, date: NaiveDate) -> MemoryResult<Vec<MemoryChunk>> {
1835        self.rollup_day_inner(date, true).await
1836    }
1837
1838    async fn rollup_rule_week(
1839        &self,
1840        rule_id: &str,
1841        week_start: NaiveDate,
1842    ) -> MemoryResult<MemoryChunk> {
1843        self.rollup_rule_week_inner(rule_id, week_start, false)
1844            .await
1845    }
1846
1847    async fn rollup_rule_week_forced(
1848        &self,
1849        rule_id: &str,
1850        week_start: NaiveDate,
1851    ) -> MemoryResult<MemoryChunk> {
1852        self.rollup_rule_week_inner(rule_id, week_start, true).await
1853    }
1854
1855    // ───────────── Maintenance ─────────────
1856
1857    async fn run_aging_sweep(&self) -> MemoryResult<AgingReport> {
1858        // v1 sweep: delete non-pinned non-correction chunks older than
1859        // the retention horizon. Matches BasicMemoryProvider's heuristic
1860        // (30 days) so the trait surface stays consistent across providers.
1861        // A future revision can replace this with an importance-aware policy.
1862        const RETENTION_DAYS: i64 = 30;
1863        let cutoff_ms =
1864            (chrono::Utc::now() - chrono::Duration::days(RETENTION_DAYS)).timestamp_millis();
1865        self.invalidate_retrieve_cache();
1866        let conn = Arc::clone(&self.conn);
1867        tokio::task::spawn_blocking(move || -> Result<AgingReport, MemoryError> {
1868            let mut guard = conn.blocking_lock();
1869            let tx = guard
1870                .transaction()
1871                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1872            let ids: Vec<String> = {
1873                let mut stmt = tx
1874                    .prepare(
1875                        "SELECT id FROM memory_chunks
1876                         WHERE pinned = 0
1877                           AND kind != 'correction'
1878                           AND created_at < ?",
1879                    )
1880                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1881                let rows = stmt
1882                    .query_map(params![cutoff_ms], |r| r.get::<_, String>(0))
1883                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1884                let mut out = Vec::new();
1885                for r in rows {
1886                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
1887                }
1888                out
1889            };
1890            let now = now_ms();
1891            let reason_s = eviction_reason_str(EvictionReason::Aging);
1892            for id in &ids {
1893                tx.execute("DELETE FROM memory_chunks WHERE id = ?", params![id])
1894                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1895                tx.execute("DELETE FROM memory_vec WHERE chunk_id = ?", params![id])
1896                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1897                tx.execute(
1898                    "INSERT INTO memory_eviction_log(ts, chunk_id, reason, metadata)
1899                     VALUES (?, ?, ?, '{}')",
1900                    params![now, id, reason_s],
1901                )
1902                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1903            }
1904            tx.commit()
1905                .map_err(|e| MemoryError::Storage(e.to_string()))?;
1906            let deleted = ids.len();
1907            Ok(AgingReport {
1908                tier_promoted: 0, // session→long_term transition is Phase 3 work
1909                deleted,
1910                bytes_reclaimed: 0,
1911                deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
1912            })
1913        })
1914        .await
1915        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
1916    }
1917    async fn re_embed_all(&self, target_model: &str) -> MemoryResult<ReEmbedReport> {
1918        let started = std::time::Instant::now();
1919        if self.embedder.model_name() != target_model {
1920            return Err(MemoryError::InvalidArgument(format!(
1921                "configured embedder is '{}', re_embed_all target_model is '{}'",
1922                self.embedder.model_name(),
1923                target_model
1924            )));
1925        }
1926        self.invalidate_retrieve_cache();
1927
1928        let embedder = Arc::clone(&self.embedder);
1929        let embedder_dim = embedder.dim();
1930        let embedder_name = embedder.model_name().to_string();
1931        let conn = Arc::clone(&self.conn);
1932
1933        let rows: Vec<(String, String)> =
1934            tokio::task::spawn_blocking(move || -> Result<Vec<(String, String)>, MemoryError> {
1935                let guard = conn.blocking_lock();
1936                let mut stmt = guard
1937                    .prepare(
1938                        "SELECT id, content FROM memory_chunks
1939                     WHERE embedding_model != 'none'
1940                     ORDER BY created_at ASC",
1941                    )
1942                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1943                let mapped = stmt
1944                    .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
1945                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1946                let mut out = Vec::new();
1947                for row in mapped {
1948                    out.push(row.map_err(|e| MemoryError::Storage(e.to_string()))?);
1949                }
1950                Ok(out)
1951            })
1952            .await
1953            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
1954
1955        let total = rows.len();
1956        if total == 0 {
1957            return Ok(ReEmbedReport {
1958                total: 0,
1959                succeeded: 0,
1960                failed: 0,
1961                elapsed_ms: started.elapsed().as_millis() as u64,
1962            });
1963        }
1964
1965        let texts: Vec<String> = rows.iter().map(|(_, content)| content.clone()).collect();
1966        let embeddings = embedder
1967            .embed_batch(&texts)
1968            .await
1969            .map_err(|e| MemoryError::Storage(e.to_string()))?;
1970        if embeddings.len() != total {
1971            return Err(MemoryError::Internal(format!(
1972                "embedder returned {} vectors for {} inputs",
1973                embeddings.len(),
1974                total
1975            )));
1976        }
1977
1978        let conn = Arc::clone(&self.conn);
1979        let (succeeded, failed) =
1980            tokio::task::spawn_blocking(move || -> Result<(usize, usize), MemoryError> {
1981                let mut guard = conn.blocking_lock();
1982                let tx = guard
1983                    .transaction()
1984                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
1985                let mut succeeded = 0usize;
1986                let mut failed = 0usize;
1987                for (i, (id, _)) in rows.iter().enumerate() {
1988                    let embedding = &embeddings[i];
1989                    if embedding.len() != embedder_dim {
1990                        failed += 1;
1991                        continue;
1992                    }
1993                    let v_json = serde_json::to_string(embedding)
1994                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
1995                    tx.execute(
1996                    "UPDATE memory_chunks SET embedding_model = ?, embedding_dim = ? WHERE id = ?",
1997                    params![embedder_name, embedder_dim as i64, id],
1998                )
1999                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2000                    tx.execute(
2001                        "UPDATE memory_vec SET embedding = ? WHERE chunk_id = ?",
2002                        params![v_json, id],
2003                    )
2004                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2005                    succeeded += 1;
2006                }
2007                tx.commit()
2008                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2009                Ok((succeeded, failed))
2010            })
2011            .await
2012            .map_err(|e| MemoryError::Internal(format!("join: {e}")))??;
2013
2014        Ok(ReEmbedReport {
2015            total,
2016            succeeded,
2017            failed,
2018            elapsed_ms: started.elapsed().as_millis() as u64,
2019        })
2020    }
2021    async fn export(&self, filter: ExportFilter) -> MemoryResult<ExportBundle> {
2022        let conn = Arc::clone(&self.conn);
2023        tokio::task::spawn_blocking(move || -> Result<ExportBundle, MemoryError> {
2024            let guard = conn.blocking_lock();
2025
2026            // Chunks first; honor the optional predicate.
2027            let (where_clause, params_vec) = match &filter.predicate {
2028                Some(p) if !p.is_empty() => build_chunk_predicate(p),
2029                _ => ("1=1".to_string(), Vec::new()),
2030            };
2031            let select_sql = format!(
2032                "SELECT id, created_at, kind, tier, source, session_id,
2033                        project_root, caller_id, content, metadata,
2034                        importance, pinned, shareable, superseded_by,
2035                        embedding_model, embedding_dim
2036                 FROM memory_chunks WHERE {} ORDER BY created_at DESC",
2037                where_clause
2038            );
2039            let chunks: Vec<MemoryChunk> = {
2040                let p: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
2041                let mut stmt = guard
2042                    .prepare(&select_sql)
2043                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2044                let rows = stmt
2045                    .query_map(p.as_slice(), row_to_chunk)
2046                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2047                let mut out = Vec::new();
2048                for r in rows {
2049                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2050                }
2051                out
2052            };
2053
2054            // Sessions: only those referenced by the included chunks, and
2055            // only if include_sessions is set.
2056            let sessions = if filter.include_sessions {
2057                let session_ids: std::collections::HashSet<String> =
2058                    chunks.iter().filter_map(|c| c.session_id.clone()).collect();
2059                if session_ids.is_empty() {
2060                    Vec::new()
2061                } else {
2062                    let placeholders = vec!["?"; session_ids.len()].join(",");
2063                    let sql = format!(
2064                        "SELECT id, started_at, ended_at, caller_id, title, summary,
2065                                outcome, metadata
2066                         FROM memory_sessions WHERE id IN ({placeholders})"
2067                    );
2068                    let ids: Vec<String> = session_ids.into_iter().collect();
2069                    let p: Vec<&dyn rusqlite::ToSql> =
2070                        ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
2071                    let mut stmt = guard
2072                        .prepare(&sql)
2073                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
2074                    let rows = stmt
2075                        .query_map(p.as_slice(), |r| {
2076                            let metadata_str: String = r.get("metadata")?;
2077                            let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
2078                                .unwrap_or(serde_json::Value::Null);
2079                            Ok(MemorySession {
2080                                id: r.get("id")?,
2081                                started_at: ms_to_dt(r.get::<_, i64>("started_at")?),
2082                                ended_at: r.get::<_, Option<i64>>("ended_at")?.map(ms_to_dt),
2083                                caller_id: r.get("caller_id")?,
2084                                title: r.get("title")?,
2085                                summary: r.get("summary")?,
2086                                outcome: str_to_outcome(&r.get::<_, String>("outcome")?)
2087                                    .unwrap_or(SessionOutcome::Aborted),
2088                                metadata,
2089                            })
2090                        })
2091                        .map_err(|e| MemoryError::Storage(e.to_string()))?;
2092                    let mut out = Vec::new();
2093                    for r in rows {
2094                        out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2095                    }
2096                    out
2097                }
2098            } else {
2099                Vec::new()
2100            };
2101
2102            let evictions = if filter.include_eviction_log {
2103                let mut stmt = guard
2104                    .prepare(
2105                        "SELECT ts, chunk_id, reason, metadata
2106                         FROM memory_eviction_log ORDER BY ts DESC",
2107                    )
2108                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2109                let rows = stmt
2110                    .query_map([], |r| {
2111                        let reason_s: String = r.get("reason")?;
2112                        let metadata_s: String = r.get("metadata")?;
2113                        let metadata: serde_json::Value =
2114                            serde_json::from_str(&metadata_s).unwrap_or(serde_json::Value::Null);
2115                        Ok(EvictionEntry {
2116                            ts: ms_to_dt(r.get::<_, i64>("ts")?),
2117                            chunk_id: r.get("chunk_id")?,
2118                            reason: str_to_eviction_reason(&reason_s)
2119                                .unwrap_or(EvictionReason::UserDelete),
2120                            metadata,
2121                        })
2122                    })
2123                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2124                let mut out = Vec::new();
2125                for r in rows {
2126                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2127                }
2128                out
2129            } else {
2130                Vec::new()
2131            };
2132
2133            let accesses = if filter.include_access_log {
2134                let mut stmt = guard
2135                    .prepare(
2136                        "SELECT ts, chunk_id, retrieved_by, query_hash, rank, used
2137                         FROM memory_access_log ORDER BY ts DESC",
2138                    )
2139                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2140                let rows = stmt
2141                    .query_map([], |r| {
2142                        Ok(AccessEntry {
2143                            ts: ms_to_dt(r.get::<_, i64>("ts")?),
2144                            chunk_id: r.get("chunk_id")?,
2145                            retrieved_by: r.get("retrieved_by")?,
2146                            query_hash: r.get("query_hash")?,
2147                            rank: r.get::<_, i64>("rank")? as usize,
2148                            used: r.get::<_, i64>("used")? != 0,
2149                        })
2150                    })
2151                    .map_err(|e| MemoryError::Storage(e.to_string()))?;
2152                let mut out = Vec::new();
2153                for r in rows {
2154                    out.push(r.map_err(|e| MemoryError::Storage(e.to_string()))?);
2155                }
2156                out
2157            } else {
2158                Vec::new()
2159            };
2160
2161            Ok(ExportBundle {
2162                chunks,
2163                sessions,
2164                evictions,
2165                accesses,
2166            })
2167        })
2168        .await
2169        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2170    }
2171
2172    async fn stats(&self) -> MemoryResult<MemoryStats> {
2173        let conn = Arc::clone(&self.conn);
2174        let model = self.embedder.model_name().to_string();
2175        tokio::task::spawn_blocking(move || -> Result<MemoryStats, MemoryError> {
2176            let guard = conn.blocking_lock();
2177            let total: i64 = guard
2178                .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
2179                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2180            let session_tier: i64 = guard
2181                .query_row(
2182                    "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'session'",
2183                    [],
2184                    |r| r.get(0),
2185                )
2186                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2187            let lt_tier: i64 = guard
2188                .query_row(
2189                    "SELECT COUNT(*) FROM memory_chunks WHERE tier = 'long_term'",
2190                    [],
2191                    |r| r.get(0),
2192                )
2193                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2194            let total_sessions: i64 = guard
2195                .query_row("SELECT COUNT(*) FROM memory_sessions", [], |r| r.get(0))
2196                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2197            let open: i64 = guard
2198                .query_row(
2199                    "SELECT COUNT(*) FROM memory_sessions WHERE outcome = 'open'",
2200                    [],
2201                    |r| r.get(0),
2202                )
2203                .map_err(|e| MemoryError::Storage(e.to_string()))?;
2204            Ok(MemoryStats {
2205                total_chunks: total as usize,
2206                session_chunks: session_tier as usize,
2207                long_term_chunks: lt_tier as usize,
2208                total_sessions: total_sessions as usize,
2209                open_sessions: open as usize,
2210                db_bytes: 0, // caller can compute file size separately
2211                embedding_model: Some(model),
2212            })
2213        })
2214        .await
2215        .map_err(|e| MemoryError::Internal(format!("join: {e}")))?
2216    }
2217}
2218
2219/// Per-profile retrieval weights. Tuned defaults, not exposed via the trait
2220/// yet, so callers configure profiles via the `RetrievalProfile` enum only.
2221/// Returns `(w_vec, w_fts, w_rec, half_life_seconds)`.
2222fn retrieval_weights(profile: cel_memory::RetrievalProfile) -> (f32, f32, f32, f32) {
2223    use cel_memory::RetrievalProfile::*;
2224    match profile {
2225        // Embedded agent's per-turn retrieval — semantic-heavy, short
2226        // recency half-life (7 days).
2227        AgentChatTurn => (0.55, 0.30, 0.15, 7.0 * 86400.0),
2228        // Long-horizon job context — heavier weight on long_term + summaries.
2229        AgentDelegatedJob => (0.55, 0.30, 0.15, 30.0 * 86400.0),
2230        // NL compiler: similar prior rules. Keyword match dominates because
2231        // users are looking for "the rule that mentions ~/Workspace".
2232        NLCompilerSimilarRules => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2233        // NL compiler: similar prior fires. Same balance, slightly longer
2234        // half-life because rule history compounds.
2235        NLCompilerSimilarFires => (0.40, 0.40, 0.20, 30.0 * 86400.0),
2236        // Audit / Activity tab — keyword-dominant, wide window.
2237        AuditTimeline => (0.30, 0.50, 0.20, 90.0 * 86400.0),
2238        // User free-text search — like audit but with a longer half-life.
2239        UserSearch => (0.40, 0.50, 0.10, 365.0 * 86400.0),
2240    }
2241}
2242
2243/// Sanitise the query string for FTS5. FTS5's query language treats
2244/// punctuation and special characters as operators; the simplest safe
2245/// approach is to quote the entire query as a phrase and escape any
2246/// internal double-quote.
2247fn fts_query_escape(raw: &str) -> String {
2248    let escaped = raw.replace('"', "\"\"");
2249    format!("\"{escaped}\"")
2250}
2251
2252/// Apply the non-ranking filters from a `MemoryQuery` to a candidate
2253/// chunk. Returns true if the chunk should be included in the result.
2254fn chunk_matches_query(c: &MemoryChunk, q: &MemoryQuery) -> bool {
2255    // Kind filter
2256    if let Some(kinds) = &q.kinds {
2257        if !kinds.contains(&c.kind) {
2258            return false;
2259        }
2260    }
2261    // Rollups gate
2262    if !q.include_rollups && c.kind == cel_memory::ChunkKind::Rollup {
2263        return false;
2264    }
2265    // Time bounds
2266    if let Some(since) = q.since {
2267        if c.created_at < since {
2268            return false;
2269        }
2270    }
2271    if let Some(until) = q.until {
2272        if c.created_at > until {
2273            return false;
2274        }
2275    }
2276    // Session
2277    if let Some(sid) = &q.session_id {
2278        if c.session_id.as_deref() != Some(sid.as_str()) {
2279            return false;
2280        }
2281    }
2282    // Project root prefix
2283    if let Some(prefix) = &q.project_root_prefix {
2284        match &c.project_root {
2285            Some(root) if root.starts_with(prefix.as_str()) => {}
2286            _ => return false,
2287        }
2288    }
2289    // Min importance
2290    if let Some(min) = q.min_importance {
2291        if c.importance < min {
2292            return false;
2293        }
2294    }
2295    // Caller scope. `Own` restricts to the caller's own chunks.
2296    // `OwnPlusShared` (Phase 4) permits the caller's own chunks *plus* any
2297    // chunk tagged `shareable=true` from another caller. `Global` permits
2298    // everything — granted only to privileged surfaces (Memory tab,
2299    // audit timeline).
2300    match q.caller_scope {
2301        cel_memory::CallerScope::Own => {
2302            if c.caller_id != q.caller_id {
2303                return false;
2304            }
2305        }
2306        cel_memory::CallerScope::OwnPlusShared => {
2307            if c.caller_id != q.caller_id && !c.shareable {
2308                return false;
2309            }
2310        }
2311        cel_memory::CallerScope::Global => {}
2312    }
2313    true
2314}
2315
2316/// Translate a [`MemoryPredicate`] into a parameterized WHERE clause for
2317/// `memory_chunks`. Returns the clause body (without the leading `WHERE`)
2318/// and the parameter vector. The clause is composed of `AND`-joined
2319/// sub-clauses, defaulting to `1=1` if (somehow) every predicate field is
2320/// `None` — but [`MemoryPredicate::is_empty`] should have short-circuited
2321/// before this is called.
2322fn build_chunk_predicate(p: &MemoryPredicate) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
2323    let mut clauses: Vec<String> = vec!["1=1".to_string()];
2324    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2325    if let Some(kinds) = &p.kinds {
2326        let placeholders = vec!["?"; kinds.len()].join(",");
2327        clauses.push(format!("kind IN ({placeholders})"));
2328        for k in kinds {
2329            params.push(Box::new(kind_str(*k).to_string()));
2330        }
2331    }
2332    if let Some(callers) = &p.callers {
2333        let placeholders = vec!["?"; callers.len()].join(",");
2334        clauses.push(format!("caller_id IN ({placeholders})"));
2335        for c in callers {
2336            params.push(Box::new(c.clone()));
2337        }
2338    }
2339    if let Some(sids) = &p.session_ids {
2340        let placeholders = vec!["?"; sids.len()].join(",");
2341        clauses.push(format!("session_id IN ({placeholders})"));
2342        for s in sids {
2343            params.push(Box::new(s.clone()));
2344        }
2345    }
2346    if let Some(prefix) = &p.project_root_prefix {
2347        clauses.push("project_root LIKE ?".to_string());
2348        params.push(Box::new(format!("{prefix}%")));
2349    }
2350    if let Some(before) = p.before {
2351        clauses.push("created_at < ?".to_string());
2352        params.push(Box::new(dt_to_ms(before)));
2353    }
2354    if let Some(after) = p.after {
2355        clauses.push("created_at > ?".to_string());
2356        params.push(Box::new(dt_to_ms(after)));
2357    }
2358    if let Some(pinned) = p.pinned {
2359        clauses.push("pinned = ?".to_string());
2360        params.push(Box::new(if pinned { 1_i64 } else { 0 }));
2361    }
2362    if let Some(below) = p.importance_below {
2363        clauses.push("importance < ?".to_string());
2364        params.push(Box::new(below as f64));
2365    }
2366    if let Some(needle) = &p.content_contains {
2367        clauses.push("LOWER(content) LIKE ?".to_string());
2368        params.push(Box::new(format!("%{}%", needle.to_lowercase())));
2369    }
2370    (clauses.join(" AND "), params)
2371}
2372
2373/// Inverse of [`eviction_reason_str`]. Unknown strings (e.g., from a future
2374/// schema version) fall back to `UserDelete` rather than panicking; the
2375/// caller is expected to log + carry on.
2376fn str_to_eviction_reason(s: &str) -> Result<EvictionReason, MemoryError> {
2377    Ok(match s {
2378        "user_delete" => EvictionReason::UserDelete,
2379        "aging" => EvictionReason::Aging,
2380        "low_importance" => EvictionReason::LowImportance,
2381        "redact_rule" => EvictionReason::RedactRule,
2382        "storage_cap" => EvictionReason::StorageCap,
2383        "purge_all" => EvictionReason::PurgeAll,
2384        other => {
2385            return Err(MemoryError::Storage(format!(
2386                "unknown eviction reason: {other}"
2387            )))
2388        }
2389    })
2390}
2391
2392fn eviction_reason_str(r: EvictionReason) -> &'static str {
2393    match r {
2394        EvictionReason::UserDelete => "user_delete",
2395        EvictionReason::Aging => "aging",
2396        EvictionReason::LowImportance => "low_importance",
2397        EvictionReason::RedactRule => "redact_rule",
2398        EvictionReason::StorageCap => "storage_cap",
2399        EvictionReason::PurgeAll => "purge_all",
2400    }
2401}
2402
2403// Silence unused-import warnings when `record_access` etc. aren't wired.
2404#[allow(dead_code)]
2405fn _unused_imports_anchor(_: AccessEntry, _: EvictionEntry, _: CallerScope) {}