1use 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#[derive(Debug)]
18pub struct MnemopiMemoryBackend {
19 engine: Mnemopi,
20}
21
22impl MnemopiMemoryBackend {
23 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 pub fn engine(&self) -> &Mnemopi {
56 &self.engine
57 }
58
59 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 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 pub fn stats(&self) -> oxicode_mnemopi::session::SessionStats {
88 self.engine.blocking_session_stats()
89 }
90
91 pub fn should_auto_sleep(&self, threshold: usize) -> bool {
93 self.engine.blocking_should_auto_sleep(threshold)
94 }
95
96 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 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 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 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 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 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 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}