1use 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
18const MAX_BATCH: usize = 490;
23
24fn 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
52fn 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
72fn 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 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
111fn 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 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 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 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 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 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 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 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 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 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 pub async fn update_fidelity_tags(
434 &self,
435 updates: &[(MessageId, u8)],
436 ) -> Result<(), MemoryError> {
437 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 pub async fn get_eviction_candidates(
1023 &self,
1024 ) -> Result<Vec<crate::eviction::EvictionEntry>, crate::error::MemoryError> {
1025 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 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 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 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 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 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 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 #[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 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 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 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 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 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 let mut tx = begin_write(&self.pool).await?;
1369
1370 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 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 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 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 #[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 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 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 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 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 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 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 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 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 pub async fn batch_increment_access_count(&self, ids: &[MessageId]) -> Result<(), MemoryError> {
1748 self.increment_access_counts(ids).await
1749 }
1750
1751 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 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 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 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 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#[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;