Skip to main content

zeph_memory/semantic/
cross_session.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_llm::provider::LlmProvider as _;
5
6use crate::error::MemoryError;
7use crate::types::ConversationId;
8use crate::vector_store::{FieldCondition, FieldValue, VectorFilter};
9
10use super::{SESSION_SUMMARIES_COLLECTION, SemanticMemory};
11
12#[derive(Debug, Clone)]
13pub struct SessionSummaryResult {
14    pub summary_text: String,
15    pub score: f32,
16    pub conversation_id: ConversationId,
17}
18
19impl SemanticMemory {
20    /// Check whether a session summary already exists for the given conversation.
21    ///
22    /// Returns `true` if at least one session summary is stored in `SQLite` for this conversation.
23    /// Used as the primary guard in the shutdown summary path to handle cases where hard
24    /// compaction fired but its Qdrant write failed (the `SQLite` record is the authoritative source).
25    ///
26    /// # Errors
27    ///
28    /// Returns an error if the database query fails.
29    pub async fn has_session_summary(
30        &self,
31        conversation_id: ConversationId,
32    ) -> Result<bool, MemoryError> {
33        let summaries = self.sqlite.load_summaries(conversation_id).await?;
34        Ok(!summaries.is_empty())
35    }
36
37    /// Store a shutdown session summary: persists to `SQLite`, embeds into the
38    /// `zeph_session_summaries` Qdrant collection (so cross-session search can find it),
39    /// and stores key facts into the key-facts collection.
40    ///
41    /// Unlike the hard-compaction path, `first_message_id` and `last_message_id` are `None`
42    /// because the shutdown hook does not track exact message boundaries.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if the `SQLite` insert fails. Qdrant errors are logged as warnings
47    /// and do not propagate — the `SQLite` record is the authoritative summary store.
48    pub async fn store_shutdown_summary(
49        &self,
50        conversation_id: ConversationId,
51        summary_text: &str,
52        key_facts: &[String],
53    ) -> Result<(), MemoryError> {
54        let token_estimate =
55            i64::try_from(self.token_counter.count_tokens(summary_text)).unwrap_or(0);
56        // Persist to SQLite first — this is the authoritative record and the source of truth
57        // for has_session_summary(). NULL message range = session-level summary.
58        let summary_id = self
59            .sqlite
60            .save_summary(conversation_id, summary_text, None, None, token_estimate)
61            .await?;
62
63        // Embed into SESSION_SUMMARIES_COLLECTION so search_session_summaries() can find it.
64        if let Err(e) = self
65            .store_session_summary(conversation_id, summary_text)
66            .await
67        {
68            tracing::warn!("shutdown summary: failed to embed into session summaries: {e:#}");
69        }
70
71        if !key_facts.is_empty() {
72            self.store_key_facts(conversation_id, summary_id, key_facts)
73                .await;
74        }
75
76        tracing::debug!(
77            conversation_id = conversation_id.0,
78            summary_id,
79            "stored shutdown session summary"
80        );
81        Ok(())
82    }
83
84    /// Store a session summary into the dedicated `zeph_session_summaries` Qdrant collection.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if embedding or Qdrant storage fails.
89    pub async fn store_session_summary(
90        &self,
91        conversation_id: ConversationId,
92        summary_text: &str,
93    ) -> Result<(), MemoryError> {
94        let Some(qdrant) = &self.qdrant else {
95            return Ok(());
96        };
97        if !self.effective_embed_provider().supports_embeddings() {
98            return Ok(());
99        }
100
101        let vector = match tokio::time::timeout(
102            self.embed_timeout,
103            self.effective_embed_provider().embed(summary_text),
104        )
105        .await
106        {
107            Ok(Ok(v)) => v,
108            Ok(Err(e)) => return Err(e.into()),
109            Err(_) => {
110                tracing::warn!("store_session_summary: embed timed out — skipping store");
111                return Ok(());
112            }
113        };
114        qdrant
115            .ensure_named_collection_for_vector(SESSION_SUMMARIES_COLLECTION, &vector)
116            .await?;
117
118        let point_id = {
119            const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
120            let name = format!("{}:{}", qdrant.db_instance_id(), conversation_id.0);
121            uuid::Uuid::new_v5(&NS, name.as_bytes()).to_string()
122        };
123        let payload = serde_json::json!({
124            "conversation_id": conversation_id.0,
125            "db_instance_id": qdrant.db_instance_id(),
126            "summary_text": summary_text,
127        });
128
129        qdrant
130            .upsert_to_collection(SESSION_SUMMARIES_COLLECTION, &point_id, payload, vector)
131            .await?;
132
133        tracing::debug!(
134            conversation_id = conversation_id.0,
135            "stored session summary"
136        );
137        Ok(())
138    }
139
140    /// Search session summaries from other conversations.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if embedding or Qdrant search fails.
145    #[cfg_attr(
146        feature = "profiling",
147        tracing::instrument(name = "memory.cross_session", skip_all, fields(result_count = tracing::field::Empty))
148    )]
149    pub async fn search_session_summaries(
150        &self,
151        query: &str,
152        limit: usize,
153        exclude_conversation_id: Option<ConversationId>,
154    ) -> Result<Vec<SessionSummaryResult>, MemoryError> {
155        let Some(qdrant) = &self.qdrant else {
156            tracing::debug!("session-summaries: skipped, no vector store");
157            return Ok(Vec::new());
158        };
159        if !self.effective_embed_provider().supports_embeddings() {
160            tracing::debug!("session-summaries: skipped, no embedding support");
161            return Ok(Vec::new());
162        }
163
164        let vector = match tokio::time::timeout(
165            self.embed_timeout,
166            self.effective_embed_provider().embed(query),
167        )
168        .await
169        {
170            Ok(Ok(v)) => v,
171            Ok(Err(e)) => return Err(e.into()),
172            Err(_) => {
173                tracing::warn!(
174                    "search_session_summaries: embed timed out, returning empty results"
175                );
176                return Ok(Vec::new());
177            }
178        };
179        qdrant
180            .ensure_named_collection_for_vector(SESSION_SUMMARIES_COLLECTION, &vector)
181            .await?;
182
183        // Always scope to this database (#5742): without a db_instance_id condition, this
184        // search would otherwise span every conversation in every database sharing this
185        // Qdrant instance whenever exclude_conversation_id is None (the common case).
186        let filter = Some(VectorFilter {
187            must: vec![FieldCondition {
188                field: "db_instance_id".into(),
189                value: FieldValue::Text(qdrant.db_instance_id().to_owned()),
190            }],
191            must_not: exclude_conversation_id
192                .map(|cid| {
193                    vec![FieldCondition {
194                        field: "conversation_id".into(),
195                        value: FieldValue::Integer(cid.0),
196                    }]
197                })
198                .unwrap_or_default(),
199        });
200
201        let points = qdrant
202            .search_collection(SESSION_SUMMARIES_COLLECTION, &vector, limit, filter)
203            .await?;
204
205        tracing::debug!(
206            results = points.len(),
207            limit,
208            exclude_conversation_id = exclude_conversation_id.map(|c| c.0),
209            "session-summaries: search complete"
210        );
211
212        let results = points
213            .into_iter()
214            .filter_map(|point| {
215                let summary_text = point.payload.get("summary_text")?.as_str()?.to_owned();
216                let conversation_id =
217                    ConversationId(point.payload.get("conversation_id")?.as_i64()?);
218                Some(SessionSummaryResult {
219                    summary_text,
220                    score: point.score,
221                    conversation_id,
222                })
223            })
224            .collect();
225
226        Ok(results)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use zeph_llm::any::AnyProvider;
233    use zeph_llm::mock::MockProvider;
234
235    use crate::types::MessageId;
236
237    use super::*;
238
239    async fn make_memory() -> SemanticMemory {
240        SemanticMemory::new(
241            ":memory:",
242            "http://127.0.0.1:1",
243            None,
244            AnyProvider::Mock(MockProvider::default()),
245            "test-model",
246        )
247        .await
248        .unwrap()
249    }
250
251    /// Insert a real message into the conversation and return its `MessageId`.
252    /// Required because the `summaries` table has FK constraints on `messages.id`.
253    async fn insert_message(memory: &SemanticMemory, cid: ConversationId) -> MessageId {
254        memory
255            .sqlite()
256            .save_message(cid, "user", "test message")
257            .await
258            .unwrap()
259    }
260
261    #[tokio::test]
262    async fn has_session_summary_returns_false_when_no_summaries() {
263        let memory = make_memory().await;
264        let cid = memory.sqlite().create_conversation().await.unwrap();
265
266        let result = memory.has_session_summary(cid).await.unwrap();
267        assert!(!result, "new conversation must have no summaries");
268    }
269
270    #[tokio::test]
271    async fn has_session_summary_returns_true_after_summary_stored_via_sqlite() {
272        let memory = make_memory().await;
273        let cid = memory.sqlite().create_conversation().await.unwrap();
274        let msg_id = insert_message(&memory, cid).await;
275
276        // Use sqlite directly to insert a valid summary with real FK references.
277        memory
278            .sqlite()
279            .save_summary(
280                cid,
281                "session about Rust and async",
282                Some(msg_id),
283                Some(msg_id),
284                10,
285            )
286            .await
287            .unwrap();
288
289        let result = memory.has_session_summary(cid).await.unwrap();
290        assert!(result, "must return true after a summary is persisted");
291    }
292
293    #[tokio::test]
294    async fn has_session_summary_is_isolated_per_conversation() {
295        let memory = make_memory().await;
296        let cid_a = memory.sqlite().create_conversation().await.unwrap();
297        let cid_b = memory.sqlite().create_conversation().await.unwrap();
298        let msg_id = insert_message(&memory, cid_a).await;
299
300        memory
301            .sqlite()
302            .save_summary(
303                cid_a,
304                "summary for conversation A",
305                Some(msg_id),
306                Some(msg_id),
307                5,
308            )
309            .await
310            .unwrap();
311
312        assert!(
313            memory.has_session_summary(cid_a).await.unwrap(),
314            "cid_a must have a summary"
315        );
316        assert!(
317            !memory.has_session_summary(cid_b).await.unwrap(),
318            "cid_b must not be affected by cid_a summary"
319        );
320    }
321
322    /// Mirrors the point-id computation in `store_session_summary` so the assertion stays
323    /// meaningful if the production format string changes shape.
324    fn point_id_name(db_instance_id: &str, cid: ConversationId) -> String {
325        format!("{db_instance_id}:{}", cid.0)
326    }
327
328    #[test]
329    fn store_session_summary_point_id_is_deterministic() {
330        // Same (db_instance_id, conversation_id) must always produce the same UUID v5 point
331        // ID, ensuring that repeated compaction calls upsert rather than insert a new point.
332        const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
333        let cid = ConversationId(42);
334        let id1 = uuid::Uuid::new_v5(&NS, point_id_name("", cid).as_bytes()).to_string();
335        let id2 = uuid::Uuid::new_v5(&NS, point_id_name("", cid).as_bytes()).to_string();
336        assert_eq!(
337            id1, id2,
338            "point_id must be deterministic for the same inputs"
339        );
340
341        let cid2 = ConversationId(43);
342        let id3 = uuid::Uuid::new_v5(&NS, point_id_name("", cid2).as_bytes()).to_string();
343        assert_ne!(
344            id1, id3,
345            "different conversation_ids must produce different point_ids"
346        );
347    }
348
349    #[test]
350    fn store_session_summary_point_id_boundary_ids() {
351        // conversation_id = 0 and negative values are valid i64 variants — confirm they produce
352        // valid, distinct, and stable UUIDs.
353        const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
354
355        let id_zero_a = uuid::Uuid::new_v5(&NS, point_id_name("", ConversationId(0)).as_bytes());
356        let id_zero_b = uuid::Uuid::new_v5(&NS, point_id_name("", ConversationId(0)).as_bytes());
357        assert_eq!(id_zero_a, id_zero_b, "zero conversation_id must be stable");
358
359        let id_neg = uuid::Uuid::new_v5(&NS, point_id_name("", ConversationId(-1)).as_bytes());
360        assert_ne!(
361            id_zero_a, id_neg,
362            "zero and -1 conversation_ids must produce different point_ids"
363        );
364
365        // Confirm the UUID version is 5 (deterministic SHA-1 name-based).
366        assert_eq!(
367            id_zero_a.get_version_num(),
368            5,
369            "generated UUID must be version 5"
370        );
371    }
372
373    /// Regression test for the write-time clobber finding in #5742: two databases whose
374    /// `conversation_id` sequences both reach the same value must not collide on the same
375    /// Qdrant point id.
376    #[test]
377    fn store_session_summary_point_id_differs_across_db_instances() {
378        const NS: uuid::Uuid = uuid::Uuid::NAMESPACE_OID;
379        let cid = ConversationId(1);
380        let id_db_a = uuid::Uuid::new_v5(&NS, point_id_name("db-a", cid).as_bytes());
381        let id_db_b = uuid::Uuid::new_v5(&NS, point_id_name("db-b", cid).as_bytes());
382        assert_ne!(
383            id_db_a, id_db_b,
384            "same conversation_id in two different databases must produce different point_ids"
385        );
386    }
387
388    /// Builds a memory whose `EmbeddingStore` is tagged with `db_instance_id` and backed by
389    /// `shared_vector_pool` instead of its own private pool — simulating a second physical
390    /// database sharing one Qdrant instance with whatever other store(s) were also built against
391    /// `shared_vector_pool` (#5742). The memory keeps its own private `SqliteStore`, so two
392    /// memories built this way independently start at `conversation_id = 1`.
393    async fn make_embed_memory_with_db_instance(
394        db_instance_id: &str,
395        shared_vector_pool: zeph_db::DbPool,
396    ) -> SemanticMemory {
397        let mut mock = MockProvider::default();
398        mock.supports_embeddings = true;
399        mock.embedding = vec![1.0, 0.0, 0.0, 0.0];
400        let provider = AnyProvider::Mock(mock);
401
402        let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
403        let pool = sqlite.pool().clone();
404        let store = crate::embedding_store::EmbeddingStore::with_store(
405            Box::new(crate::db_vector_store::DbVectorStore::new(
406                shared_vector_pool,
407            )),
408            pool,
409        )
410        .with_db_instance_id(db_instance_id);
411
412        SemanticMemory::base(
413            sqlite,
414            Some(std::sync::Arc::new(store)),
415            provider,
416            "test-model".into(),
417            0.7,
418            0.3,
419            std::sync::Arc::new(crate::token_counter::TokenCounter::new()),
420        )
421    }
422
423    /// #5742 regression: two independent databases (own `conversation_id` counters, each starting
424    /// at 1) share one backing vector store. `search_session_summaries`'s unscoped branch
425    /// (`exclude_conversation_id = None`, the common cross-session-recall case) must not surface
426    /// a summary written by a different database, even when their `conversation_id` values match.
427    #[tokio::test]
428    async fn search_session_summaries_scoped_excludes_other_database_same_conversation_id() {
429        let shared = crate::store::SqliteStore::new(":memory:").await.unwrap();
430        let shared_pool = shared.pool().clone();
431
432        let memory_a = make_embed_memory_with_db_instance("db-a", shared_pool.clone()).await;
433        let memory_b = make_embed_memory_with_db_instance("db-b", shared_pool.clone()).await;
434
435        let cid_a = memory_a.sqlite().create_conversation().await.unwrap();
436        let cid_b = memory_b.sqlite().create_conversation().await.unwrap();
437        assert_eq!(
438            cid_a, cid_b,
439            "both databases must independently start at conversation_id=1"
440        );
441
442        memory_a
443            .store_session_summary(cid_a, "summary from database A")
444            .await
445            .unwrap();
446        memory_b
447            .store_session_summary(cid_b, "summary from database B")
448            .await
449            .unwrap();
450
451        let results_a = memory_a
452            .search_session_summaries("summary", 10, None)
453            .await
454            .unwrap();
455        assert_eq!(
456            results_a.len(),
457            1,
458            "db-a's unscoped cross-session recall must not see db-b's summary"
459        );
460        assert_eq!(results_a[0].summary_text, "summary from database A");
461
462        let results_b = memory_b
463            .search_session_summaries("summary", 10, None)
464            .await
465            .unwrap();
466        assert_eq!(
467            results_b.len(),
468            1,
469            "db-b's unscoped cross-session recall must not see db-a's summary"
470        );
471        assert_eq!(results_b[0].summary_text, "summary from database B");
472    }
473
474    #[tokio::test]
475    async fn store_shutdown_summary_succeeds_with_null_message_ids() {
476        let memory = make_memory().await;
477        let cid = memory.sqlite().create_conversation().await.unwrap();
478
479        let result = memory
480            .store_shutdown_summary(cid, "summary text", &[])
481            .await;
482
483        assert!(
484            result.is_ok(),
485            "shutdown summary must succeed without messages"
486        );
487        assert!(
488            memory.has_session_summary(cid).await.unwrap(),
489            "SQLite must record the shutdown summary"
490        );
491    }
492}