Skip to main content

oxicode/store/
memory_mnemopi.rs

1//! Mnemopi-backed memory store implementing [`MemoryBackend`].
2//!
3//! Bridges the `oxicode_mnemopi` engine to the agent tool contract. This is the
4//! production memory backend with FTS5 full-text search — replaces the simpler
5//! `SqliteMemoryStore` (LIKE search) and `MnemopiStore` (JSON file) when
6//! `mnemopi_engine` is enabled in settings.
7
8use oxicode_agent::tools::{MemoryBackend, MemoryItem, ToolError};
9use oxicode_mnemopi::{EmbeddingProvider, Mnemopi, MnemopiConfig, RecallOptions, RememberOptions};
10use std::path::Path;
11use std::pin::Pin;
12use std::sync::Arc;
13
14///
15/// Wraps an [`oxicode_mnemopi::Mnemopi`] engine and exposes it through the
16/// [`MemoryBackend`] trait used by the `memory_*` agent tools.
17#[derive(Debug)]
18pub struct MnemopiMemoryBackend {
19    engine: Mnemopi,
20}
21
22impl MnemopiMemoryBackend {
23    /// Open or create a Mnemopi-backed memory store at `path`.
24    ///
25    /// `embedding_provider` is the optional dense-vector model. When
26    /// `Some`, `Mnemopi::remember`/`recall` will auto-embed every stored
27    /// fact and recall query, activating the dense cosine-similarity
28    /// signal of the hybrid scoring formula. When `None`, recall runs in
29    /// FTS5-only mode.
30    ///
31    /// `embedding_model_name` is the logical model identifier recorded
32    /// alongside each stored embedding (used for cache keying and
33    /// diagnostics). Pass an empty string when no provider is wired.
34    pub fn open(
35        path: &Path,
36        session_id: &str,
37        embedding_provider: Option<Arc<dyn EmbeddingProvider>>,
38        embedding_model_name: &str,
39    ) -> Result<Self, String> {
40        let mut config = MnemopiConfig {
41            session_id: session_id.to_string(),
42            ..Default::default()
43        };
44        config.embedding_provider = embedding_provider;
45        if !embedding_model_name.is_empty() {
46            config.embedding_model = Some(embedding_model_name.to_string());
47        }
48        let engine = Mnemopi::open(path, config).map_err(|e| format!("mnemopi open: {e}"))?;
49        Ok(Self { engine })
50    }
51}
52
53impl MnemopiMemoryBackend {
54    /// Get a reference to the underlying engine.
55    pub fn engine(&self) -> &Mnemopi {
56        &self.engine
57    }
58
59    /// Run sleep consolidation.
60    pub async fn sleep(&self, ttl_hours: i64, dry_run: bool) -> Result<(), String> {
61        self.engine
62            .sleep(ttl_hours, dry_run)
63            .await
64            .map(|_| ())
65            .map_err(|e| format!("mnemopi sleep: {e}"))
66    }
67
68    /// Run SHMR harmonization.
69    pub async fn harmonize(&self) -> Result<String, String> {
70        self.engine
71            .harmonize()
72            .await
73            .map(|stats| {
74                format!(
75                    "clusters={}, beliefs={}, contradictions={}, harmony={:.4}, status={}",
76                    stats.clusters_found,
77                    stats.beliefs_generated,
78                    stats.contradictions_resolved,
79                    stats.harmony_score_avg,
80                    stats.status
81                )
82            })
83            .map_err(|e| format!("mnemopi harmonize: {e}"))
84    }
85
86    /// Get session stats (synchronous).
87    pub fn stats(&self) -> oxicode_mnemopi::session::SessionStats {
88        self.engine.blocking_session_stats()
89    }
90
91    /// Check if auto-sleep should trigger.
92    pub fn should_auto_sleep(&self, threshold: usize) -> bool {
93        self.engine.blocking_should_auto_sleep(threshold)
94    }
95
96    /// Run auto-sleep if threshold is exceeded.
97    pub async fn maybe_auto_sleep(&self, threshold: usize) -> Result<bool, String> {
98        if self.should_auto_sleep(threshold) {
99            self.sleep(24, false).await?;
100            Ok(true)
101        } else {
102            Ok(false)
103        }
104    }
105}
106
107impl MemoryBackend for MnemopiMemoryBackend {
108    fn put<'a>(
109        &'a self,
110        content: &'a str,
111        kind: &'a str,
112        subject: &'a str,
113    ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
114        Box::pin(async move {
115            // Sanitize content (strip data URIs, high-entropy blobs)
116            let (sanitized, _blob_meta) =
117                oxicode_mnemopi::content_sanitizer::sanitize_content(content);
118
119            let options = RememberOptions {
120                source: Some(subject.to_string()),
121                memory_type: Some(kind.to_string()),
122                ..Default::default()
123            };
124            let id = self
125                .engine
126                .remember(&sanitized, options)
127                .await
128                .map_err(|e| format!("mnemopi put: {e}"))?;
129
130            // block_in_place allows blocking_lock inside Mnemopi
131            // from an async context on multi-thread tokio runtimes.
132            if tokio::runtime::Handle::try_current().is_ok()
133                && tokio::task::block_in_place(|| self.engine.blocking_should_auto_sleep(200))
134                && let Err(e) = self.engine.sleep(24, false).await
135            {
136                tracing::debug!("mnemopi auto-sleep failed: {e}");
137            }
138
139            Ok(id)
140        })
141    }
142
143    fn search<'a>(
144        &'a self,
145        query: &'a str,
146        k: usize,
147    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
148        Box::pin(async move {
149            let results = self
150                .engine
151                .recall(
152                    query,
153                    RecallOptions {
154                        limit: Some(k),
155                        ..Default::default()
156                    },
157                )
158                .await
159                .map_err(|e| format!("mnemopi search: {e}"))?;
160
161            Ok(results
162                .into_iter()
163                .map(|r| MemoryItem {
164                    id: r.id,
165                    kind: "fact".to_string(),
166                    content: r.content,
167                    subject: r.source.unwrap_or_default(),
168                })
169                .collect())
170        })
171    }
172
173    fn list<'a>(
174        &'a self,
175        subject: &'a str,
176    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
177        Box::pin(async move {
178            let rows = self
179                .engine
180                .list_by_source(subject, 100)
181                .await
182                .map_err(|e| format!("mnemopi list: {e}"))?;
183
184            Ok(rows
185                .into_iter()
186                .map(|r| MemoryItem {
187                    id: r.id,
188                    kind: r.memory_type.unwrap_or_else(|| "fact".to_string()),
189                    content: r.content,
190                    subject: r.source.unwrap_or_default(),
191                })
192                .collect())
193        })
194    }
195
196    fn delete<'a>(
197        &'a self,
198        id: &'a str,
199    ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
200        Box::pin(async move {
201            self.engine
202                .forget(id)
203                .await
204                .map_err(|e| format!("mnemopi delete: {e}"))?;
205            Ok(())
206        })
207    }
208
209    /// Bulk erase via the underlying SQLite connection: working_memory,
210    /// episodic_memory, and memory_embeddings. The FTS5 indices are
211    /// rebuilt by their existing triggers when rows are removed.
212    /// Uses `Mnemopi::spawn_blocking` so the call is safe from any
213    /// async context (the closure runs on the blocking pool; the
214    fn clear_all<'a>(
215        &'a self,
216    ) -> Pin<Box<dyn Future<Output = Result<usize, ToolError>> + Send + 'a>> {
217        Box::pin(async move {
218            self.engine
219                .spawn_blocking(|conn| {
220                    let wm = conn
221                        .execute("DELETE FROM working_memory", [])
222                        .map_err(|e| {
223                            oxicode_mnemopi::MnemopiError::Other(format!(
224                                "clear working_memory: {e}"
225                            ))
226                        })?;
227                    let em = conn
228                        .execute("DELETE FROM episodic_memory", [])
229                        .map_err(|e| {
230                            oxicode_mnemopi::MnemopiError::Other(format!(
231                                "clear episodic_memory: {e}"
232                            ))
233                        })?;
234                    let me = conn
235                        .execute("DELETE FROM memory_embeddings", [])
236                        .map_err(|e| {
237                            oxicode_mnemopi::MnemopiError::Other(format!(
238                                "clear memory_embeddings: {e}"
239                            ))
240                        })?;
241                    Ok::<usize, oxicode_mnemopi::MnemopiError>(wm + em + me)
242                })
243                .await
244                .map_err(|e| format!("mnemopi clear_all: {e}"))
245        })
246    }
247
248    /// Real consolidation trigger: run a synchronous sleep pass through
249    /// the engine. The Mnemopi facade already owns the SQLite handle,
250    /// so this is the correct level for the operation — no separate
251    /// pipeline DB is needed for an immediate consolidation pass.
252    fn enqueue_consolidation<'a>(
253        &'a self,
254    ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
255        Box::pin(async move {
256            self.engine
257                .sleep(24, false)
258                .await
259                .map(|result| {
260                    format!(
261                        "consolidated {} → {} summaries (tier1→2={}, tier2→3={})",
262                        result.items_consolidated,
263                        result.summaries_created,
264                        result.degradation.tier1_to_tier2,
265                        result.degradation.tier2_to_tier3,
266                    )
267                })
268                .map_err(|e| format!("mnemopi enqueue_consolidation: {e}"))
269        })
270    }
271
272    fn memory_info(&self) -> Option<String> {
273        let stats = self.engine.blocking_session_stats();
274        let db = self
275            .engine
276            .db_path()
277            .map(|p| p.display().to_string())
278            .unwrap_or_else(|| "in-memory".to_string());
279        Some(format!(
280            "Memory Engine (Mnemopi)\n\
281             ├─ Working:       {}\n\
282             ├─ Episodic:      {}\n\
283             ├─ Unconsolidated: {}\n\
284             ├─ Oldest pending: {}\n\
285             ├─ Last consolidation: {}\n\
286             └─ DB: {}",
287            stats.working_count,
288            stats.episodic_count,
289            stats.unconsolidated_count,
290            stats.oldest_unconsolidated.as_deref().unwrap_or("—"),
291            stats.last_consolidation.as_deref().unwrap_or("never"),
292            db,
293        ))
294    }
295
296    fn trigger_consolidation(&self) -> Option<String> {
297        let result = if tokio::runtime::Handle::try_current().is_ok() {
298            tokio::task::block_in_place(|| self.engine.blocking_sleep(24, false))
299        } else {
300            self.engine.blocking_sleep(24, false)
301        };
302        if result.summaries_created > 0 || result.status == "consolidated" {
303            Some(format!(
304                "✓ Consolidated {} memories → {} summaries. Degraded: tier1→2={}, tier2→3={}",
305                result.items_consolidated,
306                result.summaries_created,
307                result.degradation.tier1_to_tier2,
308                result.degradation.tier2_to_tier3,
309            ))
310        } else {
311            Some(format!("No memories to consolidate ({})", result.status))
312        }
313    }
314
315    fn trigger_harmonize(&self) -> Option<String> {
316        let stats = if tokio::runtime::Handle::try_current().is_ok() {
317            tokio::task::block_in_place(|| self.engine.blocking_harmonize())
318        } else {
319            self.engine.blocking_harmonize()
320        };
321        Some(format!(
322            "✓ Harmonized: clusters={}, beliefs={}, contradictions={}, harmony={:.4}, status={}",
323            stats.clusters_found,
324            stats.beliefs_generated,
325            stats.contradictions_resolved,
326            stats.harmony_score_avg,
327            stats.status,
328        ))
329    }
330}
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn tmp_backend() -> (MnemopiMemoryBackend, tempfile::TempDir) {
336        let dir = tempfile::tempdir().expect("tempdir");
337        let backend =
338            MnemopiMemoryBackend::open(&dir.path().join("mnemopi.db"), "default", None, "")
339                .unwrap();
340        (backend, dir)
341    }
342
343    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
344    async fn put_list_search_delete_roundtrip() {
345        let (backend, _dir) = tmp_backend();
346        let id = backend
347            .put("alice prefers rust ownership", "fact", "alice")
348            .await
349            .unwrap();
350        assert!(!id.is_empty());
351
352        let items = backend.list("alice").await.unwrap();
353        assert_eq!(items.len(), 1);
354        assert_eq!(items[0].content, "alice prefers rust ownership");
355        assert_eq!(items[0].subject, "alice");
356        assert_eq!(items[0].kind, "fact");
357
358        let results = backend.search("rust", 5).await.unwrap();
359        assert!(
360            !results.is_empty(),
361            "FTS5 recall must surface the just-stored memory"
362        );
363
364        backend.delete(&id).await.unwrap();
365        assert!(backend.list("alice").await.unwrap().is_empty());
366    }
367
368    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
369    async fn list_scopes_per_subject() {
370        let (backend, _dir) = tmp_backend();
371        let a = backend.put("a1", "fact", "alice").await.unwrap();
372        let b = backend.put("b1", "fact", "bob").await.unwrap();
373
374        assert_eq!(backend.list("alice").await.unwrap().len(), 1);
375        assert_eq!(backend.list("bob").await.unwrap().len(), 1);
376        assert!(backend.list("nobody").await.unwrap().is_empty());
377
378        backend.delete(&a).await.unwrap();
379        backend.delete(&b).await.unwrap();
380    }
381
382    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
383    async fn memory_info_describes_engine_state() {
384        let (backend, _dir) = tmp_backend();
385        // Sync call enters `parking_lot::Mutex::blocking_lock`; hop
386        // to the blocking pool so it doesn't panic inside the runtime.
387        let info = tokio::task::spawn_blocking(move || backend.memory_info())
388            .await
389            .unwrap()
390            .expect("Mnemopi backend reports info");
391        assert!(
392            info.contains("Mnemopi"),
393            "memory_info should advertise Mnemopi: {info}"
394        );
395        assert!(info.contains("Working:"));
396        assert!(info.contains("Episodic:"));
397        assert!(info.contains("DB:"));
398    }
399
400    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
401    async fn clear_all_wipes_working_memory_and_embeddings() {
402        let (backend, _dir) = tmp_backend();
403        backend.put("a1", "fact", "alice").await.unwrap();
404        backend.put("a2", "fact", "alice").await.unwrap();
405        backend.put("b1", "fact", "bob").await.unwrap();
406
407        let removed = backend.clear_all().await.unwrap();
408        assert!(removed >= 3, "expected >=3 rows cleared, got {removed}");
409        assert!(backend.list("alice").await.unwrap().is_empty());
410        assert!(backend.list("bob").await.unwrap().is_empty());
411    }
412
413    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
414    async fn clear_all_is_idempotent_on_empty_backend() {
415        let (backend, _dir) = tmp_backend();
416        let removed = backend.clear_all().await.unwrap();
417        assert_eq!(removed, 0);
418    }
419
420    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
421    async fn enqueue_consolidation_runs_real_sleep_pass() {
422        let (backend, _dir) = tmp_backend();
423        // Pre-populate so sleep has something to do.
424        for i in 0..4 {
425            backend
426                .put(&format!("note-{i}"), "fact", "alice")
427                .await
428                .unwrap();
429        }
430        let msg = backend
431            .enqueue_consolidation()
432            .await
433            .expect("enqueue_consolidation should succeed");
434        assert!(!msg.is_empty());
435        assert!(
436            msg.contains("consolidated") || msg.contains("summaries"),
437            "expected a sleep result message, got: {msg}"
438        );
439    }
440
441    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
442    async fn trigger_consolidation_returns_status_string() {
443        let (backend, _dir) = tmp_backend();
444        backend.put("seed", "fact", "alice").await.unwrap();
445        let msg = backend
446            .trigger_consolidation()
447            .expect("Mnemopi backend exposes trigger_consolidation");
448        assert!(!msg.is_empty());
449    }
450
451    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
452    async fn trigger_harmonize_returns_status_string() {
453        let (backend, _dir) = tmp_backend();
454        backend.put("seed", "fact", "alice").await.unwrap();
455        let msg = backend
456            .trigger_harmonize()
457            .expect("Mnemopi backend exposes trigger_harmonize");
458        assert!(
459            msg.contains("Harmonized"),
460            "trigger_harmonize should report the SHMR outcome: {msg}"
461        );
462    }
463}