Skip to main content

oxicode/store/
mnemopi.rs

1//! Simple JSON-file-based memory store implementing `MemoryBackend`.
2//!
3//! A lightweight alternative to the full Mnemopi SQLite backend.
4//! Stores memories as a JSON array in `~/.oxicode/memory/<project>.json`.
5//! Suitable for small-scale use; the full SQLite backend with FTS5
6//! and embedding search is a future enhancement (see `11-mnemopi-backend.md`).
7
8use oxicode_agent::tools::{MemoryBackend, MemoryItem, ToolError};
9use parking_lot::RwLock;
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::pin::Pin;
13
14/// JSON-file memory store.
15///
16/// Implements `MemoryBackend` for the `memory_*` agent tools.
17/// Each memory is stored with an auto-generated ID, kind, content, and subject.
18/// Search is simple substring matching (no embeddings).
19#[derive(Debug)]
20pub struct MnemopiStore {
21    /// In-memory cache of all memories, keyed by ID.
22    memories: RwLock<HashMap<String, StoredMemory>>,
23    /// File path for persistence.
24    path: PathBuf,
25}
26
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28struct StoredMemory {
29    id: String,
30    kind: String,
31    content: String,
32    subject: String,
33}
34
35impl MnemopiStore {
36    /// Open or create a memory store at the given path.
37    pub fn open(path: PathBuf) -> Self {
38        let memories = if path.exists() {
39            match std::fs::read_to_string(&path) {
40                Ok(text) => {
41                    let entries: Vec<StoredMemory> =
42                        serde_json::from_str(&text).unwrap_or_default();
43                    entries.into_iter().map(|m| (m.id.clone(), m)).collect()
44                }
45                Err(_) => HashMap::new(),
46            }
47        } else {
48            HashMap::new()
49        };
50
51        Self {
52            memories: RwLock::new(memories),
53            path,
54        }
55    }
56
57    /// Create a default store at `~/.oxicode/memory/default.json`.
58    pub fn default_path() -> PathBuf {
59        dirs::home_dir()
60            .unwrap_or_else(|| PathBuf::from("."))
61            .join(".oxicode")
62            .join("memory")
63            .join("default.json")
64    }
65
66    /// Persist the current state to disk.
67    fn save(&self) {
68        let entries: Vec<StoredMemory> = self.memories.read().values().cloned().collect();
69        if let Ok(text) = serde_json::to_string_pretty(&entries) {
70            if let Some(parent) = self.path.parent() {
71                let _ = std::fs::create_dir_all(parent);
72            }
73            let _ = std::fs::write(&self.path, text);
74        }
75    }
76
77    /// Generate a new unique ID.
78    fn next_id(&self) -> String {
79        let count = self.memories.read().len();
80        format!("mem-{}", count + 1)
81    }
82}
83
84impl MemoryBackend for MnemopiStore {
85    fn put<'a>(
86        &'a self,
87        content: &'a str,
88        kind: &'a str,
89        subject: &'a str,
90    ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
91        Box::pin(async move {
92            let id = self.next_id();
93            let entry = StoredMemory {
94                id: id.clone(),
95                kind: kind.to_string(),
96                content: content.to_string(),
97                subject: subject.to_string(),
98            };
99            self.memories.write().insert(id.clone(), entry);
100            self.save();
101            Ok(id)
102        })
103    }
104
105    fn search<'a>(
106        &'a self,
107        query: &'a str,
108        k: usize,
109    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
110        Box::pin(async move {
111            let query_lower = query.to_lowercase();
112            let memories = self.memories.read();
113            let mut results: Vec<MemoryItem> = memories
114                .values()
115                .filter(|m| m.content.to_lowercase().contains(&query_lower))
116                .take(k)
117                .map(|m| MemoryItem {
118                    id: m.id.clone(),
119                    kind: m.kind.clone(),
120                    content: m.content.clone(),
121                    subject: m.subject.clone(),
122                })
123                .collect();
124            // Sort by content length (shorter = more relevant for exact match)
125            results.sort_by_key(|m| m.content.len());
126            Ok(results)
127        })
128    }
129
130    fn list<'a>(
131        &'a self,
132        subject: &'a str,
133    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
134        Box::pin(async move {
135            let memories = self.memories.read();
136            Ok(memories
137                .values()
138                .filter(|m| m.subject == subject)
139                .map(|m| MemoryItem {
140                    id: m.id.clone(),
141                    kind: m.kind.clone(),
142                    content: m.content.clone(),
143                    subject: m.subject.clone(),
144                })
145                .collect())
146        })
147    }
148
149    fn delete<'a>(
150        &'a self,
151        id: &'a str,
152    ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
153        Box::pin(async move {
154            self.memories.write().remove(id);
155            self.save();
156            Ok(())
157        })
158    }
159}
160
161// ── Tests ────────────────────────────────────────────────────────────
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn put_and_search() {
169        let tmp = tempfile::tempdir().unwrap();
170        let store = MnemopiStore::open(tmp.path().join("mem.json"));
171
172        // Can't easily test async MemoryBackend without tokio runtime,
173        // but we can test the internal state.
174        store.memories.write().insert(
175            "1".into(),
176            StoredMemory {
177                id: "1".into(),
178                kind: "fact".into(),
179                content: "The project uses Rust 2024".into(),
180                subject: "default".into(),
181            },
182        );
183
184        let memories = store.memories.read();
185        assert_eq!(memories.len(), 1);
186        assert_eq!(memories.get("1").unwrap().kind, "fact");
187    }
188
189    #[test]
190    fn persistence_roundtrip() {
191        let tmp = tempfile::tempdir().unwrap();
192        let path = tmp.path().join("mem.json");
193
194        {
195            let store = MnemopiStore::open(path.clone());
196            store.memories.write().insert(
197                "1".into(),
198                StoredMemory {
199                    id: "1".into(),
200                    kind: "preference".into(),
201                    content: "Prefers Korean prose".into(),
202                    subject: "project".into(),
203                },
204            );
205            store.save();
206        }
207
208        // Reopen and verify
209        let store2 = MnemopiStore::open(path);
210        let memories = store2.memories.read();
211        assert_eq!(memories.len(), 1);
212        assert_eq!(memories.get("1").unwrap().content, "Prefers Korean prose");
213    }
214
215    #[test]
216    fn default_path_in_home() {
217        let path = MnemopiStore::default_path();
218        assert!(path.to_string_lossy().contains(".oxicode"));
219        assert!(path.to_string_lossy().contains("memory"));
220    }
221}