Skip to main content

zeph_memory/store/
persona.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_db::{query, query_as, query_scalar, sql};
5
6use super::DbStore;
7use crate::error::MemoryError;
8
9/// A single persona fact row from the `persona_memory` table.
10#[derive(Debug, Clone, sqlx::FromRow)]
11pub struct PersonaFactRow {
12    pub id: i64,
13    pub category: String,
14    pub content: String,
15    pub confidence: f64,
16    pub evidence_count: i64,
17    pub source_conversation_id: Option<i64>,
18    pub supersedes_id: Option<i64>,
19    pub created_at: String,
20    pub updated_at: String,
21}
22
23impl DbStore {
24    /// Upsert a persona fact.
25    ///
26    /// On exact-content conflict within the same category: increments `evidence_count`
27    /// and updates `confidence` and `updated_at`.
28    ///
29    /// When `supersedes_id` is provided, the referenced older fact is logically
30    /// replaced — it will be excluded from context assembly via the NOT IN filter.
31    ///
32    /// # Errors
33    ///
34    /// Returns an error if the query fails.
35    pub async fn upsert_persona_fact(
36        &self,
37        category: &str,
38        content: &str,
39        confidence: f64,
40        source_conversation_id: Option<i64>,
41        supersedes_id: Option<i64>,
42    ) -> Result<i64, MemoryError> {
43        // Guard against stale/invalid conversation IDs: if the referenced conversation
44        // no longer exists, store NULL instead of failing with a FK constraint error.
45        let safe_source_id = match source_conversation_id {
46            None => None,
47            Some(cid) => {
48                let exists: bool = query_scalar(sql!(
49                    "SELECT EXISTS(SELECT 1 FROM conversations WHERE id = ?)"
50                ))
51                .bind(cid)
52                .fetch_one(self.pool())
53                .await?;
54                if exists { Some(cid) } else { None }
55            }
56        };
57
58        let (id,): (i64,) = query_as(sql!(
59            "INSERT INTO persona_memory
60                (category, content, confidence, evidence_count, source_conversation_id,
61                 supersedes_id, updated_at)
62             VALUES
63                (?, ?, ?, 1, ?, ?, datetime('now'))
64             ON CONFLICT(category, content) DO UPDATE SET
65                evidence_count = evidence_count + 1,
66                confidence     = excluded.confidence,
67                supersedes_id  = COALESCE(excluded.supersedes_id, persona_memory.supersedes_id),
68                updated_at     = datetime('now')
69             RETURNING id"
70        ))
71        .bind(category)
72        .bind(content)
73        .bind(confidence)
74        .bind(safe_source_id)
75        .bind(supersedes_id)
76        .fetch_one(self.pool())
77        .await?;
78
79        Ok(id)
80    }
81
82    /// Load all persona facts above `min_confidence`, excluding superseded facts.
83    ///
84    /// Results are ordered by confidence DESC so the most reliable facts come first.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the query fails.
89    pub async fn load_persona_facts(
90        &self,
91        min_confidence: f64,
92    ) -> Result<Vec<PersonaFactRow>, MemoryError> {
93        // Facts that appear in any other row's supersedes_id column are excluded:
94        // they have been replaced by a newer, contradicting fact.
95        let rows: Vec<PersonaFactRow> = query_as(sql!(
96            "SELECT id, category, content, confidence, evidence_count,
97                    source_conversation_id, supersedes_id, created_at, updated_at
98             FROM persona_memory
99             WHERE confidence >= ?
100               AND id NOT IN (
101                   SELECT supersedes_id FROM persona_memory
102                   WHERE supersedes_id IS NOT NULL
103               )
104             ORDER BY confidence DESC"
105        ))
106        .bind(min_confidence)
107        .fetch_all(self.pool())
108        .await?;
109
110        Ok(rows)
111    }
112
113    /// Delete a persona fact by id (for user-initiated corrections).
114    ///
115    /// Returns `true` if a row was deleted, `false` if the id was not found.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the query fails.
120    pub async fn delete_persona_fact(&self, id: i64) -> Result<bool, MemoryError> {
121        let affected = query(sql!("DELETE FROM persona_memory WHERE id = ?"))
122            .bind(id)
123            .execute(self.pool())
124            .await?
125            .rows_affected();
126
127        Ok(affected > 0)
128    }
129
130    /// Count total persona facts (for metrics/TUI).
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the query fails.
135    pub async fn count_persona_facts(&self) -> Result<i64, MemoryError> {
136        let count: i64 = query_scalar(sql!("SELECT COUNT(*) FROM persona_memory"))
137            .fetch_one(self.pool())
138            .await?;
139
140        Ok(count)
141    }
142
143    /// Read the last extracted message id from the `persona_meta` singleton.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the query fails.
148    pub async fn persona_last_extracted_message_id(&self) -> Result<i64, MemoryError> {
149        let id: i64 = query_scalar(sql!(
150            "SELECT last_extracted_message_id FROM persona_meta WHERE id = 1"
151        ))
152        .fetch_one(self.pool())
153        .await?;
154
155        Ok(id)
156    }
157
158    /// Update the last extracted message id in the `persona_meta` singleton.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the query fails.
163    pub async fn set_persona_last_extracted_message_id(
164        &self,
165        message_id: i64,
166    ) -> Result<(), MemoryError> {
167        query(sql!(
168            "UPDATE persona_meta
169             SET last_extracted_message_id = ?, updated_at = datetime('now')
170             WHERE id = 1"
171        ))
172        .bind(message_id)
173        .execute(self.pool())
174        .await?;
175
176        Ok(())
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    async fn make_store() -> DbStore {
185        DbStore::with_pool_size(":memory:", 1)
186            .await
187            .expect("in-memory store")
188    }
189
190    #[tokio::test]
191    async fn upsert_persona_fact_basic_insert() {
192        let store = make_store().await;
193        let id = store
194            .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
195            .await
196            .expect("upsert");
197        assert!(id > 0);
198        assert_eq!(store.count_persona_facts().await.expect("count"), 1);
199    }
200
201    #[tokio::test]
202    async fn upsert_persona_fact_increments_evidence_count() {
203        let store = make_store().await;
204        let id1 = store
205            .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
206            .await
207            .expect("first upsert");
208        let id2 = store
209            .upsert_persona_fact("preference", "I prefer dark mode", 0.95, None, None)
210            .await
211            .expect("second upsert");
212        // Same row on conflict — same id returned.
213        assert_eq!(id1, id2);
214
215        let facts = store.load_persona_facts(0.0).await.expect("load");
216        assert_eq!(facts.len(), 1);
217        assert_eq!(facts[0].evidence_count, 2);
218        // Confidence updated to the latest value.
219        assert!((facts[0].confidence - 0.95).abs() < 1e-9);
220    }
221
222    #[tokio::test]
223    async fn upsert_persona_fact_supersedes_id_propagated() {
224        let store = make_store().await;
225        let old_id = store
226            .upsert_persona_fact("preference", "I prefer light mode", 0.8, None, None)
227            .await
228            .expect("old fact");
229
230        let _new_id = store
231            .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, Some(old_id))
232            .await
233            .expect("new fact");
234
235        // Old fact should be excluded because it appears in another row's supersedes_id.
236        let facts = store.load_persona_facts(0.0).await.expect("load");
237        assert_eq!(facts.len(), 1);
238        assert_eq!(facts[0].content, "I prefer dark mode");
239    }
240
241    #[tokio::test]
242    async fn load_persona_facts_excludes_superseded() {
243        let store = make_store().await;
244        let old_id = store
245            .upsert_persona_fact("domain_knowledge", "I know Python", 0.7, None, None)
246            .await
247            .expect("old");
248        store
249            .upsert_persona_fact(
250                "domain_knowledge",
251                "I know Python and Rust",
252                0.85,
253                None,
254                Some(old_id),
255            )
256            .await
257            .expect("new");
258
259        let facts = store.load_persona_facts(0.0).await.expect("load");
260        assert_eq!(facts.len(), 1);
261        assert_eq!(facts[0].content, "I know Python and Rust");
262    }
263
264    #[tokio::test]
265    async fn load_persona_facts_min_confidence_filter() {
266        let store = make_store().await;
267        store
268            .upsert_persona_fact("background", "Senior engineer", 0.9, None, None)
269            .await
270            .expect("high confidence");
271        store
272            .upsert_persona_fact("background", "Works remotely", 0.3, None, None)
273            .await
274            .expect("low confidence");
275
276        let facts = store.load_persona_facts(0.5).await.expect("load");
277        assert_eq!(facts.len(), 1);
278        assert_eq!(facts[0].content, "Senior engineer");
279    }
280
281    #[tokio::test]
282    async fn delete_persona_fact_returns_true_when_found() {
283        let store = make_store().await;
284        let id = store
285            .upsert_persona_fact("working_style", "I prefer async comms", 0.8, None, None)
286            .await
287            .expect("upsert");
288        let deleted = store.delete_persona_fact(id).await.expect("delete");
289        assert!(deleted);
290        assert_eq!(store.count_persona_facts().await.expect("count"), 0);
291    }
292
293    #[tokio::test]
294    async fn delete_persona_fact_returns_false_when_not_found() {
295        let store = make_store().await;
296        let deleted = store.delete_persona_fact(9999).await.expect("delete");
297        assert!(!deleted);
298    }
299
300    #[tokio::test]
301    async fn count_persona_facts_is_zero_initially() {
302        let store = make_store().await;
303        assert_eq!(store.count_persona_facts().await.expect("count"), 0);
304    }
305
306    #[tokio::test]
307    async fn persona_meta_singleton_initial_value() {
308        let store = make_store().await;
309        let id = store
310            .persona_last_extracted_message_id()
311            .await
312            .expect("meta");
313        assert_eq!(id, 0);
314    }
315
316    #[tokio::test]
317    async fn set_persona_last_extracted_message_id_round_trip() {
318        let store = make_store().await;
319        store
320            .set_persona_last_extracted_message_id(42)
321            .await
322            .expect("set");
323        let id = store
324            .persona_last_extracted_message_id()
325            .await
326            .expect("get");
327        assert_eq!(id, 42);
328    }
329
330    #[tokio::test]
331    async fn upsert_persona_fact_invalid_source_conversation_id_stored_as_null() {
332        let store = make_store().await;
333        // Conversation ID 9999 does not exist — upsert must succeed and store NULL.
334        let id = store
335            .upsert_persona_fact("preference", "I prefer Vim", 0.8, Some(9999), None)
336            .await
337            .expect("upsert with invalid source_conversation_id");
338        assert!(id > 0);
339
340        let facts = store.load_persona_facts(0.0).await.expect("load");
341        assert_eq!(facts.len(), 1);
342        assert!(
343            facts[0].source_conversation_id.is_none(),
344            "source_conversation_id should be NULL for non-existent conversation"
345        );
346    }
347
348    #[tokio::test]
349    async fn upsert_persona_fact_valid_source_conversation_id_preserved() {
350        let store = make_store().await;
351        // Create a real conversation so the ID is valid.
352        let cid = store
353            .create_conversation()
354            .await
355            .expect("create_conversation")
356            .0;
357        let id = store
358            .upsert_persona_fact("preference", "I prefer Emacs", 0.7, Some(cid), None)
359            .await
360            .expect("upsert with valid source_conversation_id");
361        assert!(id > 0);
362
363        let facts = store.load_persona_facts(0.0).await.expect("load");
364        assert_eq!(facts.len(), 1);
365        assert_eq!(
366            facts[0].source_conversation_id,
367            Some(cid),
368            "valid source_conversation_id must be preserved"
369        );
370    }
371}