Skip to main content

zeph_memory/store/messages/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use futures::TryStreamExt as _;
5#[allow(unused_imports)]
6use zeph_common;
7use zeph_common::ContextFidelity;
8use zeph_db::ActiveDialect;
9use zeph_db::fts::sanitize_fts_query;
10#[allow(unused_imports)]
11use zeph_db::{begin_write, placeholder_list, sql};
12use zeph_llm::provider::{Message, MessageMetadata, MessagePart, MessageVisibility, Role};
13
14use super::SqliteStore;
15use crate::error::MemoryError;
16use crate::types::{ConversationId, MessageId};
17
18/// Maximum number of `id IN (...)` placeholders (rows) per batched query chunk.
19///
20/// Stays under `SQLite`'s `SQLITE_MAX_VARIABLE_NUMBER = 999` bind-parameter limit even
21/// for the two-bind-per-row queries in this module (`490 * 2 = 980 < 999`).
22const MAX_BATCH: usize = 490;
23
24/// SQL fragment binding a `?` placeholder into `compacted_at` (`TEXT` on `SQLite`,
25/// `TIMESTAMPTZ` on `PostgreSQL`), dialect-selected.
26///
27/// `compacted_at` is written as a Rust-formatted epoch-seconds string, which has no
28/// valid `timestamptz` input syntax on Postgres — see
29/// [`zeph_db::dialect::Dialect::timestamptz_from_epoch`] for why a bare bind (or one
30/// under `TIMESTAMPTZ_CAST`) is insufficient there.
31fn compacted_at_bind_expr() -> String {
32    <ActiveDialect as zeph_db::dialect::Dialect>::timestamptz_from_epoch("?")
33}
34
35fn parse_role(s: &str) -> Role {
36    match s {
37        "assistant" => Role::Assistant,
38        "system" => Role::System,
39        _ => Role::User,
40    }
41}
42
43#[must_use]
44pub fn role_str(role: Role) -> &'static str {
45    match role {
46        Role::System => "system",
47        Role::Assistant => "assistant",
48        Role::User | _ => "user",
49    }
50}
51
52/// Map a legacy externally-tagged variant key to the `kind` value used by the current
53/// internally-tagged schema.
54fn legacy_key_to_kind(key: &str) -> Option<&'static str> {
55    match key {
56        "Text" => Some("text"),
57        "ToolOutput" => Some("tool_output"),
58        "Recall" => Some("recall"),
59        "CodeContext" => Some("code_context"),
60        "Summary" => Some("summary"),
61        "CrossSession" => Some("cross_session"),
62        "ToolUse" => Some("tool_use"),
63        "ToolResult" => Some("tool_result"),
64        "Image" => Some("image"),
65        "ThinkingBlock" => Some("thinking_block"),
66        "RedactedThinkingBlock" => Some("redacted_thinking_block"),
67        "Compaction" => Some("compaction"),
68        _ => None,
69    }
70}
71
72/// Attempt to parse a JSON string written in the pre-v0.17.1 externally-tagged format.
73///
74/// Old format: `[{"Summary":{"text":"..."}}, ...]`
75/// New format: `[{"kind":"summary","text":"..."}, ...]`
76///
77/// Returns `None` if the input does not look like the old format or if any element fails
78/// to deserialize after conversion.
79fn try_parse_legacy_parts(parts_json: &str) -> Option<Vec<MessagePart>> {
80    let array: Vec<serde_json::Value> = serde_json::from_str(parts_json).ok()?;
81    let mut result = Vec::with_capacity(array.len());
82    for element in array {
83        let obj = element.as_object()?;
84        if obj.contains_key("kind") {
85            return None;
86        }
87        if obj.len() != 1 {
88            return None;
89        }
90        let (key, inner) = obj.iter().next()?;
91        let kind = legacy_key_to_kind(key)?;
92        let mut new_obj = match inner {
93            serde_json::Value::Object(m) => m.clone(),
94            // Image variant wraps a single object directly
95            other => {
96                let mut m = serde_json::Map::new();
97                m.insert("data".to_string(), other.clone());
98                m
99            }
100        };
101        new_obj.insert(
102            "kind".to_string(),
103            serde_json::Value::String(kind.to_string()),
104        );
105        let part: MessagePart = serde_json::from_value(serde_json::Value::Object(new_obj)).ok()?;
106        result.push(part);
107    }
108    Some(result)
109}
110
111/// Deserialize message parts from a stored JSON string.
112///
113/// Returns an empty `Vec` and logs a warning if deserialization fails, including the role and
114/// a truncated excerpt of the malformed JSON for diagnostics.
115fn parse_parts_json(role_str: &str, parts_json: &str) -> Vec<MessagePart> {
116    if parts_json == "[]" {
117        return vec![];
118    }
119    match serde_json::from_str(parts_json) {
120        Ok(p) => p,
121        Err(e) => {
122            if let Some(parts) = try_parse_legacy_parts(parts_json) {
123                let truncated = parts_json.chars().take(120).collect::<String>();
124                tracing::warn!(
125                    role = %role_str,
126                    parts_json = %truncated,
127                    "loaded legacy-format message parts via compat path"
128                );
129                return parts;
130            }
131            let truncated = parts_json.chars().take(120).collect::<String>();
132            tracing::warn!(
133                role = %role_str,
134                parts_json = %truncated,
135                error = %e,
136                "failed to deserialize message parts, falling back to empty"
137            );
138            vec![]
139        }
140    }
141}
142
143impl SqliteStore {
144    /// Create a new conversation and return its ID.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the insert fails.
149    pub async fn create_conversation(&self) -> Result<ConversationId, MemoryError> {
150        let row: (ConversationId,) = zeph_db::query_as(sql!(
151            "INSERT INTO conversations DEFAULT VALUES RETURNING id"
152        ))
153        .fetch_one(&self.pool)
154        .await?;
155        Ok(row.0)
156    }
157
158    /// Save a message to the given conversation and return the message ID.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the insert fails.
163    pub async fn save_message(
164        &self,
165        conversation_id: ConversationId,
166        role: &str,
167        content: &str,
168    ) -> Result<MessageId, MemoryError> {
169        self.save_message_with_parts(conversation_id, role, content, "[]")
170            .await
171    }
172
173    /// Save a message with structured parts JSON.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the insert fails.
178    pub async fn save_message_with_parts(
179        &self,
180        conversation_id: ConversationId,
181        role: &str,
182        content: &str,
183        parts_json: &str,
184    ) -> Result<MessageId, MemoryError> {
185        self.save_message_with_metadata(
186            conversation_id,
187            role,
188            content,
189            parts_json,
190            MessageVisibility::Both,
191        )
192        .await
193    }
194
195    /// Save a message with an optional category tag.
196    ///
197    /// The `category` column is NULL when `None` — existing rows are unaffected.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if the insert fails.
202    pub async fn save_message_with_category(
203        &self,
204        conversation_id: ConversationId,
205        role: &str,
206        content: &str,
207        category: Option<&str>,
208    ) -> Result<MessageId, MemoryError> {
209        let importance_score = crate::semantic::importance::compute_importance(content, role);
210        let row: (MessageId,) = zeph_db::query_as(sql!(
211            "INSERT INTO messages \
212                 (conversation_id, role, content, parts, visibility, \
213                  importance_score, category) \
214                 VALUES (?, ?, ?, '[]', 'both', ?, ?) RETURNING id"
215        ))
216        .bind(conversation_id)
217        .bind(role)
218        .bind(content)
219        .bind(importance_score)
220        .bind(category)
221        .fetch_one(&self.pool)
222        .await?;
223        Ok(row.0)
224    }
225
226    /// Save a message with visibility metadata.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if the insert fails.
231    pub async fn save_message_with_metadata(
232        &self,
233        conversation_id: ConversationId,
234        role: &str,
235        content: &str,
236        parts_json: &str,
237        visibility: MessageVisibility,
238    ) -> Result<MessageId, MemoryError> {
239        const MAX_BYTES: usize = 100 * 1024;
240
241        // Truncate plain-text content only. `parts_json` is skipped because a
242        // mid-byte cut produces invalid JSON that breaks downstream deserialization.
243        let content_cow: std::borrow::Cow<'_, str> = if content.len() > MAX_BYTES {
244            let boundary = content.floor_char_boundary(MAX_BYTES);
245            tracing::debug!(
246                original_bytes = content.len(),
247                "save_message: content exceeds 100KB, truncating"
248            );
249            std::borrow::Cow::Owned(format!(
250                "{}... [truncated, {} bytes total]",
251                &content[..boundary],
252                content.len()
253            ))
254        } else {
255            std::borrow::Cow::Borrowed(content)
256        };
257
258        let importance_score = crate::semantic::importance::compute_importance(&content_cow, role);
259        let json_cast = <ActiveDialect as zeph_db::dialect::Dialect>::JSON_CAST;
260        let insert_sql = zeph_db::rewrite_placeholders(&format!(
261            "INSERT INTO messages (conversation_id, role, content, parts, visibility, importance_score) \
262             VALUES (?, ?, ?, ?{json_cast}, ?, ?) RETURNING id"
263        ));
264        let row: (MessageId,) = zeph_db::query_as(sqlx::AssertSqlSafe(insert_sql))
265            .bind(conversation_id)
266            .bind(role)
267            .bind(content_cow.as_ref())
268            .bind(parts_json)
269            .bind(visibility.as_db_str())
270            .bind(importance_score)
271            .fetch_one(&self.pool)
272            .await?;
273        Ok(row.0)
274    }
275
276    /// Load the most recent messages for a conversation, up to `limit`.
277    ///
278    /// Restores persisted `fidelity_tag` into [`MessageMetadata::fidelity_tag`].
279    /// A stored value of `0` maps to `None` (never scored / Full by default) so
280    /// messages written before CAM was enabled do not acquire a spurious floor.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if the query fails.
285    pub async fn load_history(
286        &self,
287        conversation_id: ConversationId,
288        limit: u32,
289    ) -> Result<Vec<Message>, MemoryError> {
290        let parts_select = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("parts");
291        let raw = format!(
292            "SELECT role, content, {parts_select} AS parts, visibility, id, \
293                    CAST(fidelity_tag AS INTEGER) AS fidelity_tag FROM (\
294                SELECT role, content, parts, visibility, id, fidelity_tag FROM messages \
295                WHERE conversation_id = ? AND deleted_at IS NULL \
296                ORDER BY id DESC \
297                LIMIT ?\
298             ) ORDER BY id ASC"
299        );
300        let sql = zeph_db::rewrite_placeholders(&raw);
301        let rows: Vec<(String, String, String, String, i64, i32)> =
302            zeph_db::query_as(sqlx::AssertSqlSafe(sql))
303                .bind(conversation_id)
304                .bind(i64::from(limit))
305                .fetch_all(&self.pool)
306                .await?;
307
308        let messages = rows
309            .into_iter()
310            .map(
311                |(role_str, content, parts_json, visibility_str, row_id, fidelity_raw)| {
312                    let parts = parse_parts_json(&role_str, &parts_json);
313                    Message {
314                        role: parse_role(&role_str),
315                        content,
316                        parts,
317                        metadata: MessageMetadata {
318                            visibility: MessageVisibility::from_db_str(&visibility_str),
319                            compacted_at: None,
320                            deferred_summary: None,
321                            focus_pinned: false,
322                            focus_marker_id: None,
323                            db_id: Some(row_id),
324                            fidelity_tag: if fidelity_raw == 0 {
325                                None
326                            } else {
327                                u8::try_from(fidelity_raw)
328                                    .ok()
329                                    .map(ContextFidelity::from_u8)
330                            },
331                            embedding: None,
332                        },
333                    }
334                },
335            )
336            .collect();
337        Ok(messages)
338    }
339
340    /// Load messages filtered by visibility flags.
341    ///
342    /// Pass `Some(true)` to filter by a flag, `None` to skip filtering.
343    ///
344    /// Restores persisted `fidelity_tag` into [`MessageMetadata::fidelity_tag`].
345    /// A stored value of `0` maps to `None` (never scored / Full by default) so
346    /// messages written before CAM was enabled do not acquire a spurious floor.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the query fails.
351    pub async fn load_history_filtered(
352        &self,
353        conversation_id: ConversationId,
354        limit: u32,
355        agent_visible: Option<bool>,
356        user_visible: Option<bool>,
357    ) -> Result<Vec<Message>, MemoryError> {
358        // Map boolean filters to SQL predicates on the visibility column.
359        // agent_visible=true  → exclude 'user_only'  rows
360        // user_visible=true   → exclude 'agent_only' rows
361        // The two filters are independent; when both are Some(true) the
362        // combined effect is to keep only 'both' rows.
363        let exclude_user_only = agent_visible == Some(true);
364        let exclude_agent_only = user_visible == Some(true);
365
366        let parts_select = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("parts");
367        let raw = format!(
368            "WITH recent AS (\
369                SELECT role, content, parts, visibility, id, fidelity_tag FROM messages \
370                WHERE conversation_id = ? \
371                  AND deleted_at IS NULL \
372                  AND (NOT ? OR visibility != 'user_only') \
373                  AND (NOT ? OR visibility != 'agent_only') \
374                ORDER BY id DESC \
375                LIMIT ?\
376             ) SELECT role, content, {parts_select} AS parts, visibility, id, \
377                      CAST(fidelity_tag AS INTEGER) AS fidelity_tag FROM recent ORDER BY id ASC"
378        );
379        let sql = zeph_db::rewrite_placeholders(&raw);
380        let rows: Vec<(String, String, String, String, i64, i32)> =
381            zeph_db::query_as(sqlx::AssertSqlSafe(sql))
382                .bind(conversation_id)
383                .bind(exclude_user_only)
384                .bind(exclude_agent_only)
385                .bind(i64::from(limit))
386                .fetch_all(&self.pool)
387                .await?;
388
389        let messages = rows
390            .into_iter()
391            .map(
392                |(role_str, content, parts_json, visibility_str, row_id, fidelity_raw)| {
393                    let parts = parse_parts_json(&role_str, &parts_json);
394                    Message {
395                        role: parse_role(&role_str),
396                        content,
397                        parts,
398                        metadata: MessageMetadata {
399                            visibility: MessageVisibility::from_db_str(&visibility_str),
400                            compacted_at: None,
401                            deferred_summary: None,
402                            focus_pinned: false,
403                            focus_marker_id: None,
404                            db_id: Some(row_id),
405                            fidelity_tag: if fidelity_raw == 0 {
406                                None
407                            } else {
408                                u8::try_from(fidelity_raw)
409                                    .ok()
410                                    .map(ContextFidelity::from_u8)
411                            },
412                            embedding: None,
413                        },
414                    }
415                },
416            )
417            .collect();
418        Ok(messages)
419    }
420
421    /// Batch-update fidelity tags for messages by their database IDs.
422    ///
423    /// Called after fidelity scoring to persist the assigned fidelity levels so subsequent
424    /// turns see the persisted floor invariant.
425    ///
426    /// Updates are chunked at 333 rows per statement to stay under `SQLITE_MAX_VARIABLE_NUMBER=999`
427    /// (each row uses 3 bind slots). All chunks execute in a single transaction for atomicity.
428    /// The operation is a no-op when `updates` is empty.
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if the transaction or any UPDATE fails.
433    pub async fn update_fidelity_tags(
434        &self,
435        updates: &[(MessageId, u8)],
436    ) -> Result<(), MemoryError> {
437        // Each row occupies 3 SQLite bind slots (2 for CASE arm + 1 for IN list).
438        // SQLITE_MAX_VARIABLE_NUMBER = 999, so max rows per statement = floor(999/3) = 333.
439        const MAX_FIDELITY_BATCH: usize = 333;
440        if updates.is_empty() {
441            return Ok(());
442        }
443        let mut tx = self.pool.begin().await?;
444        for chunk in updates.chunks(MAX_FIDELITY_BATCH) {
445            let case_arms: String = chunk
446                .iter()
447                .map(|_| "WHEN ? THEN ?")
448                .collect::<Vec<_>>()
449                .join(" ");
450            let in_list: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
451            let sql = zeph_db::rewrite_placeholders(&format!(
452                "UPDATE messages SET fidelity_tag = CASE id {case_arms} END WHERE id IN ({in_list})"
453            ));
454            let mut q = zeph_db::query(sqlx::AssertSqlSafe(sql));
455            for &(id, tag) in chunk {
456                q = q.bind(id.0).bind(i32::from(tag));
457            }
458            for &(id, _) in chunk {
459                q = q.bind(id.0);
460            }
461            q.execute(&mut *tx).await?;
462        }
463        tx.commit().await?;
464        Ok(())
465    }
466
467    /// Atomically mark a range of messages as user-only and insert a summary as agent-only.
468    ///
469    /// Within a single transaction:
470    /// 1. Updates `visibility=user_only, compacted_at=now` for messages in `compacted_range`.
471    /// 2. Inserts `summary_content` with `visibility=agent_only`.
472    ///
473    /// Returns the `MessageId` of the inserted summary.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if the transaction fails.
478    pub async fn replace_conversation(
479        &self,
480        conversation_id: ConversationId,
481        compacted_range: std::ops::RangeInclusive<MessageId>,
482        summary_role: &str,
483        summary_content: &str,
484    ) -> Result<MessageId, MemoryError> {
485        let now = {
486            let secs = std::time::SystemTime::now()
487                .duration_since(std::time::UNIX_EPOCH)
488                .unwrap_or_default()
489                .as_secs();
490            format!("{secs}")
491        };
492        let start_id = compacted_range.start().0;
493        let end_id = compacted_range.end().0;
494
495        let mut tx = self.pool.begin().await?;
496
497        let compacted_at_expr = compacted_at_bind_expr();
498        let update_sql = zeph_db::rewrite_placeholders(&format!(
499            "UPDATE messages SET visibility = 'user_only', compacted_at = {compacted_at_expr} \
500             WHERE conversation_id = ? AND id >= ? AND id <= ?"
501        ));
502        zeph_db::query(sqlx::AssertSqlSafe(update_sql))
503            .bind(&now)
504            .bind(conversation_id)
505            .bind(start_id)
506            .bind(end_id)
507            .execute(&mut *tx)
508            .await?;
509
510        // importance_score uses schema DEFAULT 0.5 (neutral); compaction summaries are not scored at write time.
511        let row: (MessageId,) = zeph_db::query_as(sql!(
512            "INSERT INTO messages \
513             (conversation_id, role, content, parts, visibility) \
514             VALUES (?, ?, ?, '[]', 'agent_only') RETURNING id"
515        ))
516        .bind(conversation_id)
517        .bind(summary_role)
518        .bind(summary_content)
519        .fetch_one(&mut *tx)
520        .await?;
521
522        tx.commit().await?;
523
524        Ok(row.0)
525    }
526
527    /// Atomically hide `tool_use/tool_result` message pairs and insert summary messages.
528    ///
529    /// Within a single transaction:
530    /// 1. Sets `visibility=user_only, compacted_at=<now>` for each ID in `hide_ids`.
531    /// 2. Inserts each text in `summaries` as a new agent-only assistant message.
532    ///
533    /// # Errors
534    ///
535    /// Returns an error if the transaction fails.
536    pub async fn apply_tool_pair_summaries(
537        &self,
538        conversation_id: ConversationId,
539        hide_ids: &[i64],
540        summaries: &[String],
541    ) -> Result<(), MemoryError> {
542        if hide_ids.is_empty() && summaries.is_empty() {
543            return Ok(());
544        }
545
546        let now = std::time::SystemTime::now()
547            .duration_since(std::time::UNIX_EPOCH)
548            .unwrap_or_default()
549            .as_secs()
550            .to_string();
551
552        let mut tx = self.pool.begin().await?;
553
554        let compacted_at_expr = compacted_at_bind_expr();
555        for chunk in hide_ids.chunks(MAX_BATCH) {
556            let in_list: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
557            let update_sql = zeph_db::rewrite_placeholders(&format!(
558                "UPDATE messages SET visibility = 'user_only', compacted_at = {compacted_at_expr} \
559                 WHERE id IN ({in_list})"
560            ));
561            let mut q = zeph_db::query(sqlx::AssertSqlSafe(update_sql)).bind(&now);
562            for &id in chunk {
563                q = q.bind(id);
564            }
565            q.execute(&mut *tx).await?;
566        }
567
568        for summary in summaries {
569            let content = format!("[tool summary] {summary}");
570            let parts = serde_json::to_string(&[MessagePart::Summary {
571                text: summary.clone(),
572            }])
573            .unwrap_or_else(|_| "[]".to_string());
574            let json_cast = <ActiveDialect as zeph_db::dialect::Dialect>::JSON_CAST;
575            let insert_sql = zeph_db::rewrite_placeholders(&format!(
576                "INSERT INTO messages \
577                 (conversation_id, role, content, parts, visibility) \
578                 VALUES (?, 'assistant', ?, ?{json_cast}, 'agent_only')"
579            ));
580            zeph_db::query(sqlx::AssertSqlSafe(insert_sql))
581                .bind(conversation_id)
582                .bind(&content)
583                .bind(&parts)
584                .execute(&mut *tx)
585                .await?;
586        }
587
588        tx.commit().await?;
589        Ok(())
590    }
591
592    /// Return the IDs of the N oldest messages in a conversation (ascending order).
593    ///
594    /// # Errors
595    ///
596    /// Returns an error if the query fails.
597    pub async fn oldest_message_ids(
598        &self,
599        conversation_id: ConversationId,
600        n: u32,
601    ) -> Result<Vec<MessageId>, MemoryError> {
602        let rows: Vec<(MessageId,)> = zeph_db::query_as(
603            sql!("SELECT id FROM messages WHERE conversation_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?"),
604        )
605        .bind(conversation_id)
606        .bind(i64::from(n))
607        .fetch_all(&self.pool)
608        .await?;
609        Ok(rows.into_iter().map(|r| r.0).collect())
610    }
611
612    /// Return the ID of the most recent conversation, if any.
613    ///
614    /// # Errors
615    ///
616    /// Returns an error if the query fails.
617    pub async fn latest_conversation_id(&self) -> Result<Option<ConversationId>, MemoryError> {
618        let row: Option<(ConversationId,)> = zeph_db::query_as(sql!(
619            "SELECT id FROM conversations ORDER BY id DESC LIMIT 1"
620        ))
621        .fetch_optional(&self.pool)
622        .await?;
623        Ok(row.map(|r| r.0))
624    }
625
626    /// Fetch a single message by its ID.
627    ///
628    /// Restores persisted `fidelity_tag` into [`MessageMetadata::fidelity_tag`].
629    /// A stored value of `0` maps to `None` (never scored / Full by default) so
630    /// messages written before CAM was enabled do not acquire a spurious floor.
631    ///
632    /// # Errors
633    ///
634    /// Returns an error if the query fails.
635    pub async fn message_by_id(
636        &self,
637        message_id: MessageId,
638    ) -> Result<Option<Message>, MemoryError> {
639        let parts_select = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("parts");
640        let sql = zeph_db::rewrite_placeholders(&format!(
641            "SELECT role, content, {parts_select} AS parts, visibility, \
642                    CAST(fidelity_tag AS INTEGER) AS fidelity_tag FROM messages \
643             WHERE id = ? AND deleted_at IS NULL"
644        ));
645        let row: Option<(String, String, String, String, i32)> =
646            zeph_db::query_as(sqlx::AssertSqlSafe(sql))
647                .bind(message_id)
648                .fetch_optional(&self.pool)
649                .await?;
650
651        Ok(row.map(
652            |(role_str, content, parts_json, visibility_str, fidelity_raw)| {
653                let parts = parse_parts_json(&role_str, &parts_json);
654                Message {
655                    role: parse_role(&role_str),
656                    content,
657                    parts,
658                    metadata: MessageMetadata {
659                        visibility: MessageVisibility::from_db_str(&visibility_str),
660                        compacted_at: None,
661                        deferred_summary: None,
662                        focus_pinned: false,
663                        focus_marker_id: None,
664                        db_id: Some(message_id.0),
665                        fidelity_tag: if fidelity_raw == 0 {
666                            None
667                        } else {
668                            u8::try_from(fidelity_raw)
669                                .ok()
670                                .map(ContextFidelity::from_u8)
671                        },
672                        embedding: None,
673                    },
674                }
675            },
676        ))
677    }
678
679    /// Fetch messages by a list of IDs in a single query.
680    ///
681    /// # Errors
682    ///
683    /// Returns an error if the query fails.
684    pub async fn messages_by_ids(
685        &self,
686        ids: &[MessageId],
687    ) -> Result<Vec<(MessageId, Message)>, MemoryError> {
688        if ids.is_empty() {
689            return Ok(Vec::new());
690        }
691
692        let placeholders = zeph_db::placeholder_list(1, ids.len());
693        let parts_select = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("parts");
694
695        let query = format!(
696            "SELECT id, role, content, {parts_select} AS parts FROM messages \
697             WHERE id IN ({placeholders}) AND visibility != 'user_only' AND deleted_at IS NULL"
698        );
699        let mut q =
700            zeph_db::query_as::<_, (MessageId, String, String, String)>(sqlx::AssertSqlSafe(query));
701        for &id in ids {
702            q = q.bind(id);
703        }
704
705        let rows = q.fetch_all(&self.pool).await?;
706
707        Ok(rows
708            .into_iter()
709            .map(|(id, role_str, content, parts_json)| {
710                let parts = parse_parts_json(&role_str, &parts_json);
711                (
712                    id,
713                    Message {
714                        role: parse_role(&role_str),
715                        content,
716                        parts,
717                        metadata: MessageMetadata {
718                            db_id: Some(id.0),
719                            ..MessageMetadata::default()
720                        },
721                    },
722                )
723            })
724            .collect())
725    }
726
727    /// Return message IDs and content for messages without embeddings.
728    ///
729    /// # Errors
730    ///
731    /// Returns an error if the query fails.
732    pub async fn unembedded_message_ids(
733        &self,
734        limit: Option<usize>,
735    ) -> Result<Vec<(MessageId, ConversationId, String, String)>, MemoryError> {
736        let effective_limit = limit.map_or(i64::MAX, |l| i64::try_from(l).unwrap_or(i64::MAX));
737
738        let rows: Vec<(MessageId, ConversationId, String, String)> = zeph_db::query_as(sql!(
739            "SELECT m.id, m.conversation_id, m.role, m.content \
740             FROM messages m \
741             LEFT JOIN embeddings_metadata em ON m.id = em.message_id \
742             WHERE em.id IS NULL AND m.deleted_at IS NULL \
743             ORDER BY m.id ASC \
744             LIMIT ?"
745        ))
746        .bind(effective_limit)
747        .fetch_all(&self.pool)
748        .await?;
749
750        Ok(rows)
751    }
752
753    /// Count messages that have no embedding yet.
754    ///
755    /// # Errors
756    ///
757    /// Returns an error if the query fails.
758    pub async fn count_unembedded_messages(&self) -> Result<usize, MemoryError> {
759        let row: (i64,) = zeph_db::query_as(sql!(
760            "SELECT COUNT(*) FROM messages m \
761             LEFT JOIN embeddings_metadata em ON m.id = em.message_id \
762             WHERE em.id IS NULL AND m.deleted_at IS NULL"
763        ))
764        .fetch_one(&self.pool)
765        .await?;
766        Ok(usize::try_from(row.0).unwrap_or(usize::MAX))
767    }
768
769    /// Stream message IDs and content for messages without embeddings, one row at a time.
770    ///
771    /// Executes the same query as [`Self::unembedded_message_ids`] but returns a streaming
772    /// cursor instead of loading all rows into a `Vec`. The `SQLite` read transaction is held
773    /// for the duration of iteration; callers must not write to `embeddings_metadata` while
774    /// the stream is live (use a separate async task for writes).
775    ///
776    /// # Errors
777    ///
778    /// Yields a [`MemoryError`] if the query or row decoding fails.
779    pub fn stream_unembedded_messages(
780        &self,
781        limit: i64,
782    ) -> impl futures::Stream<Item = Result<(MessageId, ConversationId, String, String), MemoryError>> + '_
783    {
784        zeph_db::query_as(sql!(
785            "SELECT m.id, m.conversation_id, m.role, m.content \
786             FROM messages m \
787             LEFT JOIN embeddings_metadata em ON m.id = em.message_id \
788             WHERE em.id IS NULL AND m.deleted_at IS NULL \
789             ORDER BY m.id ASC \
790             LIMIT ?"
791        ))
792        .bind(limit)
793        .fetch(&self.pool)
794        .map_err(MemoryError::from)
795    }
796
797    /// Count the number of messages in a conversation.
798    ///
799    /// # Errors
800    ///
801    /// Returns an error if the query fails.
802    pub async fn count_messages(
803        &self,
804        conversation_id: ConversationId,
805    ) -> Result<i64, MemoryError> {
806        let row: (i64,) = zeph_db::query_as(sql!(
807            "SELECT COUNT(*) FROM messages WHERE conversation_id = ? AND deleted_at IS NULL"
808        ))
809        .bind(conversation_id)
810        .fetch_one(&self.pool)
811        .await?;
812        Ok(row.0)
813    }
814
815    /// Count messages in a conversation with id greater than `after_id`.
816    ///
817    /// # Errors
818    ///
819    /// Returns an error if the query fails.
820    pub async fn count_messages_after(
821        &self,
822        conversation_id: ConversationId,
823        after_id: MessageId,
824    ) -> Result<i64, MemoryError> {
825        let row: (i64,) =
826            zeph_db::query_as(
827                sql!("SELECT COUNT(*) FROM messages WHERE conversation_id = ? AND id > ? AND deleted_at IS NULL"),
828            )
829            .bind(conversation_id)
830            .bind(after_id)
831            .fetch_one(&self.pool)
832            .await?;
833        Ok(row.0)
834    }
835
836    /// Full-text keyword search over messages using FTS5.
837    ///
838    /// Returns message IDs with BM25 relevance scores (lower = more relevant,
839    /// negated to positive for consistency with vector scores).
840    ///
841    /// # Errors
842    ///
843    /// Returns an error if the query fails.
844    pub async fn keyword_search(
845        &self,
846        query: &str,
847        limit: usize,
848        conversation_id: Option<ConversationId>,
849    ) -> Result<Vec<(MessageId, f64)>, MemoryError> {
850        let effective_limit = i64::try_from(limit).unwrap_or(i64::MAX);
851        let safe_query = sanitize_fts_query(query);
852        if safe_query.is_empty() {
853            return Ok(Vec::new());
854        }
855
856        let rows: Vec<(MessageId, f64)> = if let Some(cid) = conversation_id {
857            zeph_db::query_as(
858                sql!("SELECT m.id, -rank AS score \
859                 FROM messages_fts f \
860                 JOIN messages m ON m.id = f.rowid \
861                 WHERE messages_fts MATCH ? AND m.conversation_id = ? AND m.visibility != 'user_only' AND m.deleted_at IS NULL \
862                 ORDER BY rank \
863                 LIMIT ?"),
864            )
865            .bind(&safe_query)
866            .bind(cid)
867            .bind(effective_limit)
868            .fetch_all(&self.pool)
869            .await?
870        } else {
871            zeph_db::query_as(sql!(
872                "SELECT m.id, -rank AS score \
873                 FROM messages_fts f \
874                 JOIN messages m ON m.id = f.rowid \
875                 WHERE messages_fts MATCH ? AND m.visibility != 'user_only' AND m.deleted_at IS NULL \
876                 ORDER BY rank \
877                 LIMIT ?"
878            ))
879            .bind(&safe_query)
880            .bind(effective_limit)
881            .fetch_all(&self.pool)
882            .await?
883        };
884
885        Ok(rows)
886    }
887
888    /// Full-text keyword search over messages using FTS5, filtered by a `created_at` time range.
889    ///
890    /// Used by the `Episodic` recall path to combine keyword matching with temporal filtering.
891    /// Temporal keywords are stripped from `query` by the caller before this method is invoked
892    /// (see `strip_temporal_keywords`) to prevent BM25 score distortion.
893    ///
894    /// `after` and `before` are `SQLite` datetime strings in `YYYY-MM-DD HH:MM:SS` format (UTC).
895    /// `None` means "no bound" on that side.
896    ///
897    /// # Errors
898    ///
899    /// Returns an error if the query fails.
900    pub async fn keyword_search_with_time_range(
901        &self,
902        query: &str,
903        limit: usize,
904        conversation_id: Option<ConversationId>,
905        after: Option<&str>,
906        before: Option<&str>,
907    ) -> Result<Vec<(MessageId, f64)>, MemoryError> {
908        let effective_limit = i64::try_from(limit).unwrap_or(i64::MAX);
909        let safe_query = sanitize_fts_query(query);
910        if safe_query.is_empty() {
911            return Ok(Vec::new());
912        }
913
914        // Build time-range clauses dynamically. Both bounds are optional.
915        let after_clause = if after.is_some() {
916            " AND m.created_at > ?"
917        } else {
918            ""
919        };
920        let before_clause = if before.is_some() {
921            " AND m.created_at < ?"
922        } else {
923            ""
924        };
925        let conv_clause = if conversation_id.is_some() {
926            " AND m.conversation_id = ?"
927        } else {
928            ""
929        };
930
931        let sql = zeph_db::rewrite_placeholders(&format!(
932            "SELECT m.id, -rank AS score \
933             FROM messages_fts f \
934             JOIN messages m ON m.id = f.rowid \
935             WHERE messages_fts MATCH ? AND m.visibility != 'user_only' AND m.deleted_at IS NULL\
936             {after_clause}{before_clause}{conv_clause} \
937             ORDER BY rank \
938             LIMIT ?"
939        ));
940
941        let mut q =
942            zeph_db::query_as::<_, (MessageId, f64)>(sqlx::AssertSqlSafe(sql)).bind(&safe_query);
943        if let Some(a) = after {
944            q = q.bind(a);
945        }
946        if let Some(b) = before {
947            q = q.bind(b);
948        }
949        if let Some(cid) = conversation_id {
950            q = q.bind(cid);
951        }
952        q = q.bind(effective_limit);
953
954        Ok(q.fetch_all(&self.pool).await?)
955    }
956
957    /// Fetch creation timestamps (Unix epoch seconds) for the given message IDs.
958    ///
959    /// Messages without a `created_at` column fall back to 0.
960    ///
961    /// # Errors
962    ///
963    /// Returns an error if the query fails.
964    #[tracing::instrument(name = "memory.store.message_timestamps", skip(self, ids), fields(count = ids.len()))]
965    pub async fn message_timestamps(
966        &self,
967        ids: &[MessageId],
968    ) -> Result<std::collections::HashMap<MessageId, i64>, MemoryError> {
969        if ids.is_empty() {
970            return Ok(std::collections::HashMap::new());
971        }
972
973        let placeholders: String =
974            zeph_db::rewrite_placeholders(&ids.iter().map(|_| "?").collect::<Vec<_>>().join(","));
975        let epoch_expr = <ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("created_at");
976        let query = format!(
977            "SELECT id, {epoch_expr} FROM messages WHERE id IN ({placeholders}) AND deleted_at IS NULL"
978        );
979        let mut q = zeph_db::query_as::<_, (MessageId, i64)>(sqlx::AssertSqlSafe(query));
980        for &id in ids {
981            q = q.bind(id);
982        }
983
984        let rows = q.fetch_all(&self.pool).await?;
985        Ok(rows.into_iter().collect())
986    }
987
988    /// Load a range of messages after a given message ID.
989    ///
990    /// # Errors
991    ///
992    /// Returns an error if the query fails.
993    pub async fn load_messages_range(
994        &self,
995        conversation_id: ConversationId,
996        after_message_id: MessageId,
997        limit: usize,
998    ) -> Result<Vec<(MessageId, String, String)>, MemoryError> {
999        let effective_limit = i64::try_from(limit).unwrap_or(i64::MAX);
1000
1001        let rows: Vec<(MessageId, String, String)> = zeph_db::query_as(sql!(
1002            "SELECT id, role, content FROM messages \
1003             WHERE conversation_id = ? AND id > ? AND deleted_at IS NULL \
1004             ORDER BY id ASC LIMIT ?"
1005        ))
1006        .bind(conversation_id)
1007        .bind(after_message_id)
1008        .bind(effective_limit)
1009        .fetch_all(&self.pool)
1010        .await?;
1011
1012        Ok(rows)
1013    }
1014
1015    // ── Eviction helpers ──────────────────────────────────────────────────────
1016
1017    /// Return all non-deleted message IDs with their eviction metadata.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Returns an error if the query fails.
1022    pub async fn get_eviction_candidates(
1023        &self,
1024    ) -> Result<Vec<crate::eviction::EvictionEntry>, crate::error::MemoryError> {
1025        // `created_at`/`last_accessed` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project
1026        // both through `Dialect::select_as_text` so they decode into the `String`/`Option<String>`
1027        // fields below. `access_count` is `INTEGER` (`INT4`) on Postgres, so it decodes as `i32`,
1028        // not `i64`.
1029        let created_at_sel =
1030            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
1031        let last_accessed_sel =
1032            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("last_accessed");
1033        let raw = format!(
1034            "SELECT id, {created_at_sel}, {last_accessed_sel}, access_count \
1035             FROM messages WHERE deleted_at IS NULL"
1036        );
1037        let query_sql = zeph_db::rewrite_placeholders(&raw);
1038        let rows: Vec<(MessageId, String, Option<String>, i32)> =
1039            zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
1040                .fetch_all(&self.pool)
1041                .await?;
1042
1043        Ok(rows
1044            .into_iter()
1045            .map(
1046                |(id, created_at, last_accessed, access_count)| crate::eviction::EvictionEntry {
1047                    id,
1048                    created_at,
1049                    last_accessed,
1050                    access_count: access_count.try_into().unwrap_or(0),
1051                },
1052            )
1053            .collect())
1054    }
1055
1056    /// Soft-delete a set of messages by marking `deleted_at`.
1057    ///
1058    /// Soft-deleted messages are excluded from all history queries.
1059    ///
1060    /// # Errors
1061    ///
1062    /// Returns an error if the update fails.
1063    pub async fn soft_delete_messages(
1064        &self,
1065        ids: &[MessageId],
1066    ) -> Result<(), crate::error::MemoryError> {
1067        if ids.is_empty() {
1068            return Ok(());
1069        }
1070        for chunk in ids.chunks(MAX_BATCH) {
1071            let placeholders = placeholder_list(1, chunk.len());
1072            let sql = format!(
1073                "UPDATE messages SET deleted_at = CURRENT_TIMESTAMP \
1074                 WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1075            );
1076            let mut q = zeph_db::query(sqlx::AssertSqlSafe(sql));
1077            for &id in chunk {
1078                q = q.bind(id);
1079            }
1080            q.execute(&self.pool).await?;
1081        }
1082        Ok(())
1083    }
1084
1085    /// Return IDs of soft-deleted messages that have not yet been cleaned from Qdrant.
1086    ///
1087    /// # Errors
1088    ///
1089    /// Returns an error if the query fails.
1090    pub async fn get_soft_deleted_message_ids(
1091        &self,
1092    ) -> Result<Vec<MessageId>, crate::error::MemoryError> {
1093        let rows: Vec<(MessageId,)> = zeph_db::query_as(sql!(
1094            "SELECT id FROM messages WHERE deleted_at IS NOT NULL AND qdrant_cleaned = FALSE"
1095        ))
1096        .fetch_all(&self.pool)
1097        .await?;
1098        Ok(rows.into_iter().map(|(id,)| id).collect())
1099    }
1100
1101    /// Filter `candidate_ids` to retain only IDs that are NOT covered by any
1102    /// summary's `[first_message_id, last_message_id]` range (MM-F4, #3341).
1103    ///
1104    /// Returns the **safe-to-delete** subset: candidate IDs that do not fall inside
1105    /// any summary range. This enforces the data-integrity invariant that raw episodes
1106    /// referenced by a summary can never be soft-deleted by the eviction sweep.
1107    ///
1108    /// Batched at `MAX_BATCH = 490` placeholders per chunk to stay under the `SQLite`
1109    /// 999-parameter limit. The `idx_summaries_message_range` partial index (migration 077)
1110    /// makes the inner `NOT EXISTS` range probe efficient.
1111    ///
1112    /// # Errors
1113    ///
1114    /// Returns [`MemoryError`] if the underlying query fails.
1115    pub async fn filter_out_preserved_episode_ids(
1116        &self,
1117        candidate_ids: &[MessageId],
1118    ) -> Result<Vec<MessageId>, crate::error::MemoryError> {
1119        if candidate_ids.is_empty() {
1120            return Ok(Vec::new());
1121        }
1122        let mut safe_to_delete: Vec<MessageId> = Vec::with_capacity(candidate_ids.len());
1123        for chunk in candidate_ids.chunks(MAX_BATCH) {
1124            let placeholders = placeholder_list(1, chunk.len());
1125            let sql = format!(
1126                "SELECT m.id \
1127                   FROM messages m \
1128                  WHERE m.id IN ({placeholders}) \
1129                    AND NOT EXISTS ( \
1130                          SELECT 1 \
1131                            FROM summaries s \
1132                           WHERE s.first_message_id IS NOT NULL \
1133                             AND s.last_message_id IS NOT NULL \
1134                             AND m.id >= s.first_message_id \
1135                             AND m.id <= s.last_message_id \
1136                        )"
1137            );
1138            let mut q = zeph_db::query_as::<_, (MessageId,)>(sqlx::AssertSqlSafe(sql));
1139            for &id in chunk {
1140                q = q.bind(id);
1141            }
1142            let rows: Vec<(MessageId,)> = q.fetch_all(&self.pool).await?;
1143            safe_to_delete.extend(rows.into_iter().map(|(id,)| id));
1144        }
1145        Ok(safe_to_delete)
1146    }
1147
1148    /// Mark a set of soft-deleted messages as Qdrant-cleaned.
1149    ///
1150    /// # Errors
1151    ///
1152    /// Returns an error if the update fails.
1153    pub async fn mark_qdrant_cleaned(
1154        &self,
1155        ids: &[MessageId],
1156    ) -> Result<(), crate::error::MemoryError> {
1157        if ids.is_empty() {
1158            return Ok(());
1159        }
1160        for chunk in ids.chunks(MAX_BATCH) {
1161            let placeholders = placeholder_list(1, chunk.len());
1162            let sql =
1163                format!("UPDATE messages SET qdrant_cleaned = TRUE WHERE id IN ({placeholders})");
1164            let mut q = zeph_db::query(sqlx::AssertSqlSafe(sql));
1165            for &id in chunk {
1166                q = q.bind(id);
1167            }
1168            q.execute(&self.pool).await?;
1169        }
1170        Ok(())
1171    }
1172
1173    /// Fetch `importance_score` values for the given message IDs.
1174    ///
1175    /// Messages missing from the table fall back to 0.5 (neutral) and are omitted from the map.
1176    ///
1177    /// # Errors
1178    ///
1179    /// Returns an error if the query fails.
1180    pub async fn fetch_importance_scores(
1181        &self,
1182        ids: &[MessageId],
1183    ) -> Result<std::collections::HashMap<MessageId, f64>, MemoryError> {
1184        if ids.is_empty() {
1185            return Ok(std::collections::HashMap::new());
1186        }
1187        let placeholders = zeph_db::placeholder_list(1, ids.len());
1188        let query = format!(
1189            "SELECT id, importance_score FROM messages WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1190        );
1191        let mut q = zeph_db::query_as::<_, (MessageId, f64)>(sqlx::AssertSqlSafe(query));
1192        for &id in ids {
1193            q = q.bind(id);
1194        }
1195        let rows = q.fetch_all(&self.pool).await?;
1196        Ok(rows.into_iter().collect())
1197    }
1198
1199    /// Increment `access_count` and set `last_accessed = CURRENT_TIMESTAMP` for the given IDs.
1200    ///
1201    /// Skips the update when `ids` is empty.
1202    ///
1203    /// # Errors
1204    ///
1205    /// Returns an error if the update fails.
1206    pub async fn increment_access_counts(&self, ids: &[MessageId]) -> Result<(), MemoryError> {
1207        if ids.is_empty() {
1208            return Ok(());
1209        }
1210        let placeholders = zeph_db::placeholder_list(1, ids.len());
1211        let query = format!(
1212            "UPDATE messages SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP \
1213             WHERE id IN ({placeholders})"
1214        );
1215        let mut q = zeph_db::query(sqlx::AssertSqlSafe(query));
1216        for &id in ids {
1217            q = q.bind(id);
1218        }
1219        q.execute(&self.pool).await?;
1220        Ok(())
1221    }
1222
1223    /// Fetch `access_count` for the given message IDs.
1224    ///
1225    /// Messages not found or already deleted are omitted from the result.
1226    ///
1227    /// Used by the MEMTIER cognitive signal to approximate message importance
1228    /// based on access frequency.
1229    ///
1230    /// # Errors
1231    ///
1232    /// Returns an error if the query fails.
1233    #[tracing::instrument(name = "memory.store.message_access_counts", skip(self, ids), fields(count = ids.len()))]
1234    pub async fn message_access_counts(
1235        &self,
1236        ids: &[MessageId],
1237    ) -> Result<std::collections::HashMap<MessageId, i64>, MemoryError> {
1238        if ids.is_empty() {
1239            return Ok(std::collections::HashMap::new());
1240        }
1241        let placeholders = zeph_db::placeholder_list(1, ids.len());
1242        let query = format!(
1243            "SELECT id, CAST(access_count AS BIGINT) AS access_count FROM messages \
1244             WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1245        );
1246        let mut q = zeph_db::query_as::<_, (MessageId, i64)>(sqlx::AssertSqlSafe(query));
1247        for &id in ids {
1248            q = q.bind(id);
1249        }
1250        let rows = q.fetch_all(&self.pool).await?;
1251        Ok(rows.into_iter().collect())
1252    }
1253
1254    // ── Tier promotion helpers ─────────────────────────────────────────────────
1255
1256    /// Return episodic messages with `session_count >= min_sessions`, ordered by
1257    /// session count descending then importance score descending.
1258    ///
1259    /// # Errors
1260    ///
1261    /// Returns an error if the query fails.
1262    pub async fn find_promotion_candidates(
1263        &self,
1264        min_sessions: u32,
1265        batch_size: usize,
1266    ) -> Result<Vec<PromotionCandidate>, MemoryError> {
1267        let limit = i64::try_from(batch_size).unwrap_or(i64::MAX);
1268        let min = i64::from(min_sessions);
1269        // `session_count` is `INTEGER` (`INT4`) on Postgres, so it must be cast to `BIGINT`
1270        // to decode into the `i64` tuple field below (mirrors `message_access_counts`).
1271        let rows: Vec<(MessageId, ConversationId, String, i64, f64)> = zeph_db::query_as(sql!(
1272            "SELECT id, conversation_id, content, \
1273                    CAST(session_count AS BIGINT) AS session_count, importance_score \
1274             FROM messages \
1275             WHERE tier = 'episodic' AND session_count >= ? AND deleted_at IS NULL \
1276             ORDER BY session_count DESC, importance_score DESC \
1277             LIMIT ?"
1278        ))
1279        .bind(min)
1280        .bind(limit)
1281        .fetch_all(&self.pool)
1282        .await?;
1283
1284        Ok(rows
1285            .into_iter()
1286            .map(
1287                |(id, conversation_id, content, session_count, importance_score)| {
1288                    PromotionCandidate {
1289                        id,
1290                        conversation_id,
1291                        content,
1292                        session_count: session_count.try_into().unwrap_or(0),
1293                        importance_score,
1294                    }
1295                },
1296            )
1297            .collect())
1298    }
1299
1300    /// Count messages per tier (episodic, semantic) that are not deleted.
1301    ///
1302    /// Returns `(episodic_count, semantic_count)`.
1303    ///
1304    /// # Errors
1305    ///
1306    /// Returns an error if the query fails.
1307    pub async fn count_messages_by_tier(&self) -> Result<(i64, i64), MemoryError> {
1308        let rows: Vec<(String, i64)> = zeph_db::query_as(sql!(
1309            "SELECT tier, COUNT(*) FROM messages \
1310             WHERE deleted_at IS NULL AND tier IN ('episodic', 'semantic') \
1311             GROUP BY tier"
1312        ))
1313        .fetch_all(&self.pool)
1314        .await?;
1315
1316        let mut episodic = 0i64;
1317        let mut semantic = 0i64;
1318        for (tier, count) in rows {
1319            match tier.as_str() {
1320                "episodic" => episodic = count,
1321                "semantic" => semantic = count,
1322                _ => {}
1323            }
1324        }
1325        Ok((episodic, semantic))
1326    }
1327
1328    /// Count semantic facts (tier='semantic', not deleted).
1329    ///
1330    /// # Errors
1331    ///
1332    /// Returns an error if the query fails.
1333    pub async fn count_semantic_facts(&self) -> Result<i64, MemoryError> {
1334        let row: (i64,) = zeph_db::query_as(sql!(
1335            "SELECT COUNT(*) FROM messages WHERE tier = 'semantic' AND deleted_at IS NULL"
1336        ))
1337        .fetch_one(&self.pool)
1338        .await?;
1339        Ok(row.0)
1340    }
1341
1342    /// Promote a set of episodic messages to semantic tier in a single transaction.
1343    ///
1344    /// Within one transaction:
1345    /// 1. Inserts a new message with `tier='semantic'` and `promotion_timestamp=unixepoch()`.
1346    /// 2. Soft-deletes the original episodic messages and marks them `qdrant_cleaned=0`
1347    ///    so the eviction sweep picks up their Qdrant vectors.
1348    ///
1349    /// Returns the `MessageId` of the new semantic message.
1350    ///
1351    /// # Errors
1352    ///
1353    /// Returns an error if the transaction fails.
1354    pub async fn promote_to_semantic(
1355        &self,
1356        conversation_id: ConversationId,
1357        merged_content: &str,
1358        original_ids: &[MessageId],
1359    ) -> Result<MessageId, MemoryError> {
1360        if original_ids.is_empty() {
1361            return Err(MemoryError::InvalidInput(
1362                "promote_to_semantic: original_ids must not be empty".into(),
1363            ));
1364        }
1365
1366        // Acquire the write lock immediately (BEGIN IMMEDIATE) to avoid the DEFERRED read->write
1367        // upgrade race that causes SQLITE_BUSY when a concurrent writer holds the lock.
1368        let mut tx = begin_write(&self.pool).await?;
1369
1370        // Insert the new semantic fact.
1371        let epoch_now = <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1372        let promote_insert_raw = format!(
1373            "INSERT INTO messages \
1374             (conversation_id, role, content, parts, visibility, \
1375              tier, promotion_timestamp) \
1376             VALUES (?, 'assistant', ?, '[]', 'agent_only', 'semantic', {epoch_now}) \
1377             RETURNING id"
1378        );
1379        let promote_insert_sql = zeph_db::rewrite_placeholders(&promote_insert_raw);
1380        let row: (MessageId,) = zeph_db::query_as(sqlx::AssertSqlSafe(promote_insert_sql))
1381            .bind(conversation_id)
1382            .bind(merged_content)
1383            .fetch_one(&mut *tx)
1384            .await?;
1385
1386        let new_id = row.0;
1387
1388        // Soft-delete originals and reset qdrant_cleaned so eviction sweep removes vectors.
1389        for &id in original_ids {
1390            zeph_db::query(sql!(
1391                "UPDATE messages \
1392                 SET deleted_at = CURRENT_TIMESTAMP, qdrant_cleaned = FALSE \
1393                 WHERE id = ? AND deleted_at IS NULL"
1394            ))
1395            .bind(id)
1396            .execute(&mut *tx)
1397            .await?;
1398        }
1399
1400        tx.commit().await?;
1401        Ok(new_id)
1402    }
1403
1404    /// Manually promote a set of messages to semantic tier without merging.
1405    ///
1406    /// Sets `tier='semantic'` and `promotion_timestamp=unixepoch()` for the given IDs.
1407    /// Does NOT soft-delete the originals — use this for direct user-requested promotion.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an error if the update fails.
1412    pub async fn manual_promote(&self, ids: &[MessageId]) -> Result<usize, MemoryError> {
1413        if ids.is_empty() {
1414            return Ok(0);
1415        }
1416        let epoch_now = <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1417        let mut count = 0usize;
1418        for chunk in ids.chunks(MAX_BATCH) {
1419            let placeholders = placeholder_list(1, chunk.len());
1420            let manual_promote_sql = format!(
1421                "UPDATE messages \
1422                 SET tier = 'semantic', promotion_timestamp = {epoch_now} \
1423                 WHERE id IN ({placeholders}) AND deleted_at IS NULL AND tier = 'episodic'"
1424            );
1425            let mut q = zeph_db::query(sqlx::AssertSqlSafe(manual_promote_sql));
1426            for &id in chunk {
1427                q = q.bind(id);
1428            }
1429            let result = q.execute(&self.pool).await?;
1430            count += usize::try_from(result.rows_affected()).unwrap_or(0);
1431        }
1432        Ok(count)
1433    }
1434
1435    /// Increment `session_count` for all episodic messages in a conversation.
1436    ///
1437    /// Called when a session restores an existing conversation to mark that messages
1438    /// were accessed in a new session. Only episodic (non-deleted) messages are updated.
1439    ///
1440    /// # Errors
1441    ///
1442    /// Returns an error if the database update fails.
1443    pub async fn increment_session_counts_for_conversation(
1444        &self,
1445        conversation_id: ConversationId,
1446    ) -> Result<(), MemoryError> {
1447        zeph_db::query(sql!(
1448            "UPDATE messages SET session_count = session_count + 1 \
1449             WHERE conversation_id = ? AND tier = 'episodic' AND deleted_at IS NULL"
1450        ))
1451        .bind(conversation_id)
1452        .execute(&self.pool)
1453        .await?;
1454        Ok(())
1455    }
1456
1457    /// Fetch the tier string for each of the given message IDs.
1458    ///
1459    /// Messages not found or already deleted are omitted from the result.
1460    ///
1461    /// # Errors
1462    ///
1463    /// Returns an error if the query fails.
1464    #[tracing::instrument(name = "memory.store.fetch_tiers", skip(self, ids), fields(count = ids.len()))]
1465    pub async fn fetch_tiers(
1466        &self,
1467        ids: &[MessageId],
1468    ) -> Result<std::collections::HashMap<MessageId, String>, MemoryError> {
1469        if ids.is_empty() {
1470            return Ok(std::collections::HashMap::new());
1471        }
1472        let placeholders = zeph_db::placeholder_list(1, ids.len());
1473        let query = format!(
1474            "SELECT id, tier FROM messages WHERE id IN ({placeholders}) AND deleted_at IS NULL"
1475        );
1476        let mut q = zeph_db::query_as::<_, (MessageId, String)>(sqlx::AssertSqlSafe(query));
1477        for &id in ids {
1478            q = q.bind(id);
1479        }
1480        let rows = q.fetch_all(&self.pool).await?;
1481        Ok(rows.into_iter().collect())
1482    }
1483
1484    /// Return all conversation IDs that have at least one non-consolidated original message.
1485    ///
1486    /// Used by the consolidation sweep to find conversations that need processing.
1487    ///
1488    /// # Errors
1489    ///
1490    /// Returns an error if the database query fails.
1491    pub async fn conversations_with_unconsolidated_messages(
1492        &self,
1493    ) -> Result<Vec<ConversationId>, MemoryError> {
1494        let rows: Vec<(ConversationId,)> = zeph_db::query_as(sql!(
1495            "SELECT DISTINCT conversation_id FROM messages \
1496             WHERE consolidated = FALSE AND deleted_at IS NULL"
1497        ))
1498        .fetch_all(&self.pool)
1499        .await?;
1500        Ok(rows.into_iter().map(|(id,)| id).collect())
1501    }
1502
1503    /// Fetch a batch of non-consolidated original messages for the consolidation sweep.
1504    ///
1505    /// Returns `(id, content)` pairs for messages that have not yet been processed by the
1506    /// consolidation loop (`consolidated = 0`) and are not soft-deleted.
1507    ///
1508    /// # Errors
1509    ///
1510    /// Returns an error if the database query fails.
1511    pub async fn find_unconsolidated_messages(
1512        &self,
1513        conversation_id: ConversationId,
1514        limit: usize,
1515    ) -> Result<Vec<(MessageId, String)>, MemoryError> {
1516        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
1517        let rows: Vec<(MessageId, String)> = zeph_db::query_as(sql!(
1518            "SELECT id, content FROM messages \
1519             WHERE conversation_id = ? \
1520               AND consolidated = FALSE \
1521               AND deleted_at IS NULL \
1522             ORDER BY id ASC \
1523             LIMIT ?"
1524        ))
1525        .bind(conversation_id)
1526        .bind(limit)
1527        .fetch_all(&self.pool)
1528        .await?;
1529        Ok(rows)
1530    }
1531
1532    /// Look up which consolidated entry (if any) covers the given original message.
1533    ///
1534    /// Returns the `consolidated_id` of the first consolidation product that lists `source_id`
1535    /// in its sources, or `None` if no consolidated entry covers it.
1536    ///
1537    /// # Errors
1538    ///
1539    /// Returns an error if the database query fails.
1540    pub async fn find_consolidated_for_source(
1541        &self,
1542        source_id: MessageId,
1543    ) -> Result<Option<MessageId>, MemoryError> {
1544        let row: Option<(MessageId,)> = zeph_db::query_as(sql!(
1545            "SELECT consolidated_id FROM memory_consolidation_sources \
1546             WHERE source_id = ? \
1547             LIMIT 1"
1548        ))
1549        .bind(source_id)
1550        .fetch_optional(&self.pool)
1551        .await?;
1552        Ok(row.map(|(id,)| id))
1553    }
1554
1555    /// Insert a consolidated message and record its source linkage in a single transaction.
1556    ///
1557    /// Atomically:
1558    /// 1. Inserts the consolidated message with `consolidated = 1` and the given confidence.
1559    /// 2. Inserts rows into `memory_consolidation_sources` for each source ID.
1560    /// 3. Marks each source message's `consolidated = 1` so future sweeps skip them.
1561    ///
1562    /// If `confidence < confidence_threshold` the operation is skipped and `false` is returned.
1563    ///
1564    /// # Errors
1565    ///
1566    /// Returns an error if any database operation fails. The transaction is rolled back automatically
1567    /// on failure so no partial state is written.
1568    pub async fn apply_consolidation_merge(
1569        &self,
1570        conversation_id: ConversationId,
1571        role: &str,
1572        merged_content: &str,
1573        source_ids: &[MessageId],
1574        confidence: f32,
1575        confidence_threshold: f32,
1576    ) -> Result<bool, MemoryError> {
1577        if confidence < confidence_threshold {
1578            return Ok(false);
1579        }
1580        if source_ids.is_empty() {
1581            return Ok(false);
1582        }
1583
1584        let mut tx = self.pool.begin().await?;
1585
1586        let importance = crate::semantic::importance::compute_importance(merged_content, role);
1587        let row: (MessageId,) = zeph_db::query_as(sql!(
1588            "INSERT INTO messages \
1589               (conversation_id, role, content, parts, visibility, \
1590                importance_score, consolidated, consolidation_confidence) \
1591             VALUES (?, ?, ?, '[]', 'both', ?, TRUE, ?) \
1592             RETURNING id"
1593        ))
1594        .bind(conversation_id)
1595        .bind(role)
1596        .bind(merged_content)
1597        .bind(importance)
1598        .bind(confidence)
1599        .fetch_one(&mut *tx)
1600        .await?;
1601        let consolidated_id = row.0;
1602
1603        for chunk in source_ids.chunks(MAX_BATCH) {
1604            let values_list: String = chunk
1605                .iter()
1606                .map(|_| "(?, ?)")
1607                .collect::<Vec<_>>()
1608                .join(", ");
1609            let consol_sql = zeph_db::rewrite_placeholders(&format!(
1610                "{} INTO memory_consolidation_sources (consolidated_id, source_id) VALUES {values_list}{}",
1611                <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
1612                <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
1613            ));
1614            let mut insert_q = zeph_db::query(sqlx::AssertSqlSafe(consol_sql));
1615            for &source_id in chunk {
1616                insert_q = insert_q.bind(consolidated_id).bind(source_id);
1617            }
1618            insert_q.execute(&mut *tx).await?;
1619
1620            // Mark originals as consolidated so future sweeps skip them.
1621            let in_list: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1622            let update_sql = zeph_db::rewrite_placeholders(&format!(
1623                "UPDATE messages SET consolidated = TRUE WHERE id IN ({in_list})"
1624            ));
1625            let mut update_q = zeph_db::query(sqlx::AssertSqlSafe(update_sql));
1626            for &source_id in chunk {
1627                update_q = update_q.bind(source_id);
1628            }
1629            update_q.execute(&mut *tx).await?;
1630        }
1631
1632        tx.commit().await?;
1633        Ok(true)
1634    }
1635
1636    /// Update an existing consolidated message in-place with new content.
1637    ///
1638    /// Atomically:
1639    /// 1. Updates `content` and `consolidation_confidence` on `target_id`.
1640    /// 2. Inserts rows into `memory_consolidation_sources` linking `target_id` → each source.
1641    /// 3. Marks each source message's `consolidated = 1`.
1642    ///
1643    /// If `confidence < confidence_threshold` the operation is skipped and `false` is returned.
1644    ///
1645    /// # Errors
1646    ///
1647    /// Returns an error if any database operation fails.
1648    pub async fn apply_consolidation_update(
1649        &self,
1650        target_id: MessageId,
1651        new_content: &str,
1652        additional_source_ids: &[MessageId],
1653        confidence: f32,
1654        confidence_threshold: f32,
1655    ) -> Result<bool, MemoryError> {
1656        if confidence < confidence_threshold {
1657            return Ok(false);
1658        }
1659
1660        let mut tx = self.pool.begin().await?;
1661
1662        zeph_db::query(sql!(
1663            "UPDATE messages SET content = ?, consolidation_confidence = ?, consolidated = TRUE WHERE id = ?"
1664        ))
1665        .bind(new_content)
1666        .bind(confidence)
1667        .bind(target_id)
1668        .execute(&mut *tx)
1669        .await?;
1670
1671        for chunk in additional_source_ids.chunks(MAX_BATCH) {
1672            let values_list: String = chunk
1673                .iter()
1674                .map(|_| "(?, ?)")
1675                .collect::<Vec<_>>()
1676                .join(", ");
1677            let consol_sql = zeph_db::rewrite_placeholders(&format!(
1678                "{} INTO memory_consolidation_sources (consolidated_id, source_id) VALUES {values_list}{}",
1679                <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
1680                <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
1681            ));
1682            let mut insert_q = zeph_db::query(sqlx::AssertSqlSafe(consol_sql));
1683            for &source_id in chunk {
1684                insert_q = insert_q.bind(target_id).bind(source_id);
1685            }
1686            insert_q.execute(&mut *tx).await?;
1687
1688            let in_list: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1689            let update_sql = zeph_db::rewrite_placeholders(&format!(
1690                "UPDATE messages SET consolidated = TRUE WHERE id IN ({in_list})"
1691            ));
1692            let mut update_q = zeph_db::query(sqlx::AssertSqlSafe(update_sql));
1693            for &source_id in chunk {
1694                update_q = update_q.bind(source_id);
1695            }
1696            update_q.execute(&mut *tx).await?;
1697        }
1698
1699        tx.commit().await?;
1700        Ok(true)
1701    }
1702
1703    // ── Forgetting sweep helpers ───────────────────────────────────────────────
1704
1705    /// Set `importance_score` for a single message by ID.
1706    ///
1707    /// Used in tests and by forgetting sweep helpers.
1708    ///
1709    /// # Errors
1710    ///
1711    /// Returns an error if the update fails.
1712    pub async fn set_importance_score(&self, id: MessageId, score: f64) -> Result<(), MemoryError> {
1713        zeph_db::query(sql!(
1714            "UPDATE messages SET importance_score = ? WHERE id = ? AND deleted_at IS NULL"
1715        ))
1716        .bind(score)
1717        .bind(id)
1718        .execute(&self.pool)
1719        .await?;
1720        Ok(())
1721    }
1722
1723    /// Get `importance_score` for a single message by ID.
1724    ///
1725    /// Returns `None` if the message does not exist or is deleted.
1726    ///
1727    /// # Errors
1728    ///
1729    /// Returns an error if the query fails.
1730    pub async fn get_importance_score(&self, id: MessageId) -> Result<Option<f64>, MemoryError> {
1731        let row: Option<(f64,)> = zeph_db::query_as(sql!(
1732            "SELECT importance_score FROM messages WHERE id = ? AND deleted_at IS NULL"
1733        ))
1734        .bind(id)
1735        .fetch_optional(&self.pool)
1736        .await?;
1737        Ok(row.map(|(s,)| s))
1738    }
1739
1740    /// Increment `access_count` and update `last_accessed` for a batch of messages.
1741    ///
1742    /// Alias used in forgetting tests; forwards to `increment_access_counts`.
1743    ///
1744    /// # Errors
1745    ///
1746    /// Returns an error if the update fails.
1747    pub async fn batch_increment_access_count(&self, ids: &[MessageId]) -> Result<(), MemoryError> {
1748        self.increment_access_counts(ids).await
1749    }
1750
1751    /// Mark a set of messages as consolidated (`consolidated = 1`).
1752    ///
1753    /// Used in tests to simulate the state after consolidation.
1754    ///
1755    /// # Errors
1756    ///
1757    /// Returns an error if the update fails.
1758    pub async fn mark_messages_consolidated(&self, ids: &[i64]) -> Result<(), MemoryError> {
1759        if ids.is_empty() {
1760            return Ok(());
1761        }
1762        for chunk in ids.chunks(MAX_BATCH) {
1763            let placeholders = placeholder_list(1, chunk.len());
1764            let sql =
1765                format!("UPDATE messages SET consolidated = TRUE WHERE id IN ({placeholders})");
1766            let mut q = zeph_db::query(sqlx::AssertSqlSafe(sql));
1767            for &id in chunk {
1768                q = q.bind(id);
1769            }
1770            q.execute(&self.pool).await?;
1771        }
1772        Ok(())
1773    }
1774
1775    /// Execute the three-phase forgetting sweep (`SleepGate`) inside a single transaction.
1776    ///
1777    /// **Phase 1** — Downscale all active non-consolidated importance scores by `decay_rate`.
1778    /// **Phase 2** — Undo the decay for messages accessed within `replay_window_hours` or
1779    ///   with `access_count >= replay_min_access_count` (undo current sweep's decay only).
1780    /// **Phase 3** — Soft-delete messages below `forgetting_floor` that are not protected
1781    ///   by recent access (`protect_recent_hours`) or high access count
1782    ///   (`protect_min_access_count`). Uses `batch_size` as a row-count cap.
1783    ///
1784    /// All phases commit atomically: concurrent readers see either the pre-sweep or
1785    /// post-sweep state, never an intermediate.
1786    ///
1787    /// # Errors
1788    ///
1789    /// Returns an error if any database operation fails.
1790    pub async fn run_forgetting_sweep_tx(
1791        &self,
1792        config: &zeph_common::config::memory::ForgettingConfig,
1793    ) -> Result<crate::forgetting::ForgettingResult, MemoryError> {
1794        let mut tx = self.pool.begin().await?;
1795
1796        let decay = f64::from(config.decay_rate);
1797        let floor = f64::from(config.forgetting_floor);
1798        let batch = i64::try_from(config.sweep_batch_size).unwrap_or(i64::MAX);
1799        let replay_window_secs = i64::from(config.replay_window_hours) * 3600;
1800        let replay_min_access = i64::from(config.replay_min_access_count);
1801        let protect_window_secs = i64::from(config.protect_recent_hours) * 3600;
1802        let protect_min_access = i64::from(config.protect_min_access_count);
1803
1804        // Phase 1: downscale all active, non-consolidated messages (limited to batch_size).
1805        // We target a specific set of IDs to respect sweep_batch_size.
1806        let candidate_ids: Vec<(MessageId,)> = zeph_db::query_as(sql!(
1807            "SELECT id FROM messages \
1808             WHERE deleted_at IS NULL AND consolidated = FALSE \
1809             ORDER BY importance_score ASC \
1810             LIMIT ?"
1811        ))
1812        .bind(batch)
1813        .fetch_all(&mut *tx)
1814        .await?;
1815
1816        #[allow(clippy::cast_possible_truncation)]
1817        let downscaled = candidate_ids.len() as u32;
1818
1819        if downscaled > 0 {
1820            let placeholders = zeph_db::placeholder_list(1, candidate_ids.len());
1821            let downscale_sql = format!(
1822                "UPDATE messages SET importance_score = importance_score * (1.0 - {decay}) \
1823                 WHERE id IN ({placeholders})"
1824            );
1825            let mut q = zeph_db::query(sqlx::AssertSqlSafe(downscale_sql));
1826            for &(id,) in &candidate_ids {
1827                q = q.bind(id);
1828            }
1829            q.execute(&mut *tx).await?;
1830        }
1831
1832        // Phase 2: selective replay — undo decay for recently-accessed messages.
1833        // Formula: score / (1 - decay_rate) restores the current sweep's downscaling.
1834        // Cap at 1.0 to avoid exceeding the maximum importance score.
1835        // Scoped to the Phase 1 batch only: messages not decayed this sweep must not
1836        // have their scores inflated by the inverse formula.
1837        let replayed = if downscaled > 0 {
1838            let replay_placeholders: String = candidate_ids
1839                .iter()
1840                .map(|_| "?")
1841                .collect::<Vec<_>>()
1842                .join(",");
1843            let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1844            let last_accessed_epoch =
1845                <ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("last_accessed");
1846            let least_fn = <ActiveDialect as zeph_db::dialect::Dialect>::LEAST_FN;
1847            let replay_sql = zeph_db::rewrite_placeholders(&format!(
1848                "UPDATE messages \
1849                 SET importance_score = {least_fn}(1.0, importance_score / (1.0 - {decay})) \
1850                 WHERE id IN ({replay_placeholders}) \
1851                 AND (\
1852                     (last_accessed IS NOT NULL \
1853                      AND {last_accessed_epoch} >= {epoch_now} - ?) \
1854                     OR access_count >= ?\
1855                 )"
1856            ));
1857            let mut rq = zeph_db::query(sqlx::AssertSqlSafe(replay_sql));
1858            for &(id,) in &candidate_ids {
1859                rq = rq.bind(id);
1860            }
1861            let replay_result = rq
1862                .bind(replay_window_secs)
1863                .bind(replay_min_access)
1864                .execute(&mut *tx)
1865                .await?;
1866            #[allow(clippy::cast_possible_truncation)]
1867            let n = replay_result.rows_affected() as u32;
1868            n
1869        } else {
1870            0
1871        };
1872
1873        // Phase 3: targeted forgetting — soft-delete low-score unprotected messages.
1874        let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1875        let last_accessed_epoch =
1876            <ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("last_accessed");
1877        let prune_sql = zeph_db::rewrite_placeholders(&format!(
1878            "UPDATE messages \
1879             SET deleted_at = CURRENT_TIMESTAMP \
1880             WHERE deleted_at IS NULL AND consolidated = FALSE \
1881             AND importance_score < {floor} \
1882             AND (\
1883                 last_accessed IS NULL \
1884                 OR {last_accessed_epoch} < {epoch_now} - ?\
1885             ) \
1886             AND access_count < ?"
1887        ));
1888        let prune_result = zeph_db::query(sqlx::AssertSqlSafe(prune_sql))
1889            .bind(protect_window_secs)
1890            .bind(protect_min_access)
1891            .execute(&mut *tx)
1892            .await?;
1893        #[allow(clippy::cast_possible_truncation)]
1894        let pruned = prune_result.rows_affected() as u32;
1895
1896        tx.commit().await?;
1897
1898        Ok(crate::forgetting::ForgettingResult {
1899            downscaled,
1900            replayed,
1901            pruned,
1902        })
1903    }
1904}
1905
1906/// A candidate message for tier promotion, returned by [`SqliteStore::find_promotion_candidates`].
1907#[derive(Debug, Clone)]
1908pub struct PromotionCandidate {
1909    pub id: MessageId,
1910    pub conversation_id: ConversationId,
1911    pub content: String,
1912    pub session_count: u32,
1913    pub importance_score: f64,
1914}
1915
1916#[cfg(test)]
1917mod tests;