1use oxicode_agent::tools::{MemoryBackend, MemoryItem, ToolError};
9use parking_lot::RwLock;
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::pin::Pin;
13
14#[derive(Debug)]
20pub struct MnemopiStore {
21 memories: RwLock<HashMap<String, StoredMemory>>,
23 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 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 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 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 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 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#[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 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 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}