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::{ActiveDialect, query, query_as, query_scalar, sql};
5
6use super::SqliteStore;
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: i32,
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 SqliteStore {
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 now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
59        let raw = format!(
60            "INSERT INTO persona_memory
61                (category, content, confidence, evidence_count, source_conversation_id,
62                 supersedes_id, updated_at)
63             VALUES
64                (?, ?, ?, 1, ?, ?, {now})
65             ON CONFLICT(category, content) DO UPDATE SET
66                evidence_count = persona_memory.evidence_count + 1,
67                confidence     = excluded.confidence,
68                supersedes_id  = COALESCE(excluded.supersedes_id, persona_memory.supersedes_id),
69                updated_at     = {now}
70             RETURNING id"
71        );
72        let query_sql = zeph_db::rewrite_placeholders(&raw);
73        let (id,): (i64,) = query_as(sqlx::AssertSqlSafe(query_sql))
74            .bind(category)
75            .bind(content)
76            .bind(confidence)
77            .bind(safe_source_id)
78            .bind(supersedes_id)
79            .fetch_one(self.pool())
80            .await?;
81
82        Ok(id)
83    }
84
85    /// Load all persona facts above `min_confidence`, excluding superseded facts.
86    ///
87    /// Results are ordered by confidence DESC so the most reliable facts come first.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the query fails.
92    pub async fn load_persona_facts(
93        &self,
94        min_confidence: f64,
95    ) -> Result<Vec<PersonaFactRow>, MemoryError> {
96        // Facts that appear in any other row's supersedes_id column are excluded:
97        // they have been replaced by a newer, contradicting fact.
98        // `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project
99        // both through `Dialect::select_as_text`, aliased back to their original names so
100        // `#[derive(sqlx::FromRow)]` still binds them into the `String` fields below.
101        // `confidence` is `REAL` (`FLOAT4`) on Postgres but decodes into an `f64` field;
102        // `CAST(... AS DOUBLE PRECISION)` widens it (same idiom used elsewhere in this crate).
103        let created_at_sel =
104            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
105        let updated_at_sel =
106            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
107        let raw = format!(
108            "SELECT id, category, content, CAST(confidence AS DOUBLE PRECISION) AS confidence,
109                    evidence_count, source_conversation_id, supersedes_id,
110                    {created_at_sel} AS created_at, {updated_at_sel} AS updated_at
111             FROM persona_memory
112             WHERE confidence >= ?
113               AND id NOT IN (
114                   SELECT supersedes_id FROM persona_memory
115                   WHERE supersedes_id IS NOT NULL
116               )
117             ORDER BY confidence DESC"
118        );
119        let query_sql = zeph_db::rewrite_placeholders(&raw);
120        let rows: Vec<PersonaFactRow> = query_as(sqlx::AssertSqlSafe(query_sql))
121            .bind(min_confidence)
122            .fetch_all(self.pool())
123            .await?;
124
125        Ok(rows)
126    }
127
128    /// Delete a persona fact by id (for user-initiated corrections).
129    ///
130    /// Returns `true` if a row was deleted, `false` if the id was not found.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the query fails.
135    pub async fn delete_persona_fact(&self, id: i64) -> Result<bool, MemoryError> {
136        let affected = query(sql!("DELETE FROM persona_memory WHERE id = ?"))
137            .bind(id)
138            .execute(self.pool())
139            .await?
140            .rows_affected();
141
142        Ok(affected > 0)
143    }
144
145    /// Count total persona facts (for metrics/TUI).
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the query fails.
150    pub async fn count_persona_facts(&self) -> Result<i64, MemoryError> {
151        let count: i64 = query_scalar(sql!("SELECT COUNT(*) FROM persona_memory"))
152            .fetch_one(self.pool())
153            .await?;
154
155        Ok(count)
156    }
157
158    /// Read the last extracted message id from the `persona_meta` singleton.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the query fails.
163    pub async fn persona_last_extracted_message_id(&self) -> Result<i64, MemoryError> {
164        let id: i64 = query_scalar(sql!(
165            "SELECT last_extracted_message_id FROM persona_meta WHERE id = 1"
166        ))
167        .fetch_one(self.pool())
168        .await?;
169
170        Ok(id)
171    }
172
173    /// Update the last extracted message id in the `persona_meta` singleton.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the query fails.
178    pub async fn set_persona_last_extracted_message_id(
179        &self,
180        message_id: i64,
181    ) -> Result<(), MemoryError> {
182        let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
183        let raw = format!(
184            "UPDATE persona_meta
185             SET last_extracted_message_id = ?, updated_at = {now}
186             WHERE id = 1"
187        );
188        let query_sql = zeph_db::rewrite_placeholders(&raw);
189        query(sqlx::AssertSqlSafe(query_sql))
190            .bind(message_id)
191            .execute(self.pool())
192            .await?;
193
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    async fn make_store() -> SqliteStore {
203        SqliteStore::with_pool_size(":memory:", 1)
204            .await
205            .expect("in-memory store")
206    }
207
208    #[tokio::test]
209    async fn upsert_persona_fact_basic_insert() {
210        let store = make_store().await;
211        let id = store
212            .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
213            .await
214            .expect("upsert");
215        assert!(id > 0);
216        assert_eq!(store.count_persona_facts().await.expect("count"), 1);
217    }
218
219    #[tokio::test]
220    async fn upsert_persona_fact_increments_evidence_count() {
221        let store = make_store().await;
222        let id1 = store
223            .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, None)
224            .await
225            .expect("first upsert");
226        let id2 = store
227            .upsert_persona_fact("preference", "I prefer dark mode", 0.95, None, None)
228            .await
229            .expect("second upsert");
230        // Same row on conflict — same id returned.
231        assert_eq!(id1, id2);
232
233        let facts = store.load_persona_facts(0.0).await.expect("load");
234        assert_eq!(facts.len(), 1);
235        assert_eq!(facts[0].evidence_count, 2);
236        // Confidence updated to the latest value.
237        assert!((facts[0].confidence - 0.95).abs() < 1e-9);
238    }
239
240    #[tokio::test]
241    async fn upsert_persona_fact_supersedes_id_propagated() {
242        let store = make_store().await;
243        let old_id = store
244            .upsert_persona_fact("preference", "I prefer light mode", 0.8, None, None)
245            .await
246            .expect("old fact");
247
248        let _new_id = store
249            .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, Some(old_id))
250            .await
251            .expect("new fact");
252
253        // Old fact should be excluded because it appears in another row's supersedes_id.
254        let facts = store.load_persona_facts(0.0).await.expect("load");
255        assert_eq!(facts.len(), 1);
256        assert_eq!(facts[0].content, "I prefer dark mode");
257    }
258
259    #[tokio::test]
260    async fn load_persona_facts_excludes_superseded() {
261        let store = make_store().await;
262        let old_id = store
263            .upsert_persona_fact("domain_knowledge", "I know Python", 0.7, None, None)
264            .await
265            .expect("old");
266        store
267            .upsert_persona_fact(
268                "domain_knowledge",
269                "I know Python and Rust",
270                0.85,
271                None,
272                Some(old_id),
273            )
274            .await
275            .expect("new");
276
277        let facts = store.load_persona_facts(0.0).await.expect("load");
278        assert_eq!(facts.len(), 1);
279        assert_eq!(facts[0].content, "I know Python and Rust");
280    }
281
282    #[tokio::test]
283    async fn load_persona_facts_min_confidence_filter() {
284        let store = make_store().await;
285        store
286            .upsert_persona_fact("background", "Senior engineer", 0.9, None, None)
287            .await
288            .expect("high confidence");
289        store
290            .upsert_persona_fact("background", "Works remotely", 0.3, None, None)
291            .await
292            .expect("low confidence");
293
294        let facts = store.load_persona_facts(0.5).await.expect("load");
295        assert_eq!(facts.len(), 1);
296        assert_eq!(facts[0].content, "Senior engineer");
297    }
298
299    #[tokio::test]
300    async fn delete_persona_fact_returns_true_when_found() {
301        let store = make_store().await;
302        let id = store
303            .upsert_persona_fact("working_style", "I prefer async comms", 0.8, None, None)
304            .await
305            .expect("upsert");
306        let deleted = store.delete_persona_fact(id).await.expect("delete");
307        assert!(deleted);
308        assert_eq!(store.count_persona_facts().await.expect("count"), 0);
309    }
310
311    #[tokio::test]
312    async fn delete_persona_fact_returns_false_when_not_found() {
313        let store = make_store().await;
314        let deleted = store.delete_persona_fact(9999).await.expect("delete");
315        assert!(!deleted);
316    }
317
318    #[tokio::test]
319    async fn count_persona_facts_is_zero_initially() {
320        let store = make_store().await;
321        assert_eq!(store.count_persona_facts().await.expect("count"), 0);
322    }
323
324    #[tokio::test]
325    async fn persona_meta_singleton_initial_value() {
326        let store = make_store().await;
327        let id = store
328            .persona_last_extracted_message_id()
329            .await
330            .expect("meta");
331        assert_eq!(id, 0);
332    }
333
334    #[tokio::test]
335    async fn set_persona_last_extracted_message_id_round_trip() {
336        let store = make_store().await;
337        store
338            .set_persona_last_extracted_message_id(42)
339            .await
340            .expect("set");
341        let id = store
342            .persona_last_extracted_message_id()
343            .await
344            .expect("get");
345        assert_eq!(id, 42);
346    }
347
348    #[tokio::test]
349    async fn upsert_persona_fact_invalid_source_conversation_id_stored_as_null() {
350        let store = make_store().await;
351        // Conversation ID 9999 does not exist — upsert must succeed and store NULL.
352        let id = store
353            .upsert_persona_fact("preference", "I prefer Vim", 0.8, Some(9999), None)
354            .await
355            .expect("upsert with invalid source_conversation_id");
356        assert!(id > 0);
357
358        let facts = store.load_persona_facts(0.0).await.expect("load");
359        assert_eq!(facts.len(), 1);
360        assert!(
361            facts[0].source_conversation_id.is_none(),
362            "source_conversation_id should be NULL for non-existent conversation"
363        );
364    }
365
366    #[tokio::test]
367    async fn upsert_persona_fact_valid_source_conversation_id_preserved() {
368        let store = make_store().await;
369        // Create a real conversation so the ID is valid.
370        let cid = store
371            .create_conversation()
372            .await
373            .expect("create_conversation")
374            .0;
375        let id = store
376            .upsert_persona_fact("preference", "I prefer Emacs", 0.7, Some(cid), None)
377            .await
378            .expect("upsert with valid source_conversation_id");
379        assert!(id > 0);
380
381        let facts = store.load_persona_facts(0.0).await.expect("load");
382        assert_eq!(facts.len(), 1);
383        assert_eq!(
384            facts[0].source_conversation_id,
385            Some(cid),
386            "valid source_conversation_id must be preserved"
387        );
388    }
389}