1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6pub struct ExportFilter {
7 pub namespace: Option<String>,
8 pub session_id: Option<String>,
9 pub category: Option<MemoryCategory>,
10 pub since: Option<String>,
12 pub until: Option<String>,
14}
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
21pub struct ProceduralMessage {
22 pub role: String,
23 pub content: String,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub name: Option<String>,
26}
27
28#[derive(Clone, Serialize, Deserialize)]
30pub struct MemoryEntry {
31 pub id: String,
32 pub key: String,
33 pub content: String,
34 pub category: MemoryCategory,
35 pub timestamp: String,
36 pub session_id: Option<String>,
37 pub score: Option<f64>,
38 #[serde(default = "default_namespace")]
40 pub namespace: String,
41 #[serde(default)]
43 pub importance: Option<f64>,
44 #[serde(default)]
46 pub superseded_by: Option<String>,
47}
48
49fn default_namespace() -> String {
50 "default".into()
51}
52
53impl std::fmt::Debug for MemoryEntry {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("MemoryEntry")
56 .field("id", &self.id)
57 .field("key", &self.key)
58 .field("content", &self.content)
59 .field("category", &self.category)
60 .field("timestamp", &self.timestamp)
61 .field("score", &self.score)
62 .field("namespace", &self.namespace)
63 .field("importance", &self.importance)
64 .finish_non_exhaustive()
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum MemoryCategory {
71 Core,
73 Daily,
75 Conversation,
77 Custom(String),
79}
80
81impl serde::Serialize for MemoryCategory {
82 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
83 serializer.serialize_str(&self.to_string())
84 }
85}
86
87impl<'de> serde::Deserialize<'de> for MemoryCategory {
88 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
89 let s = String::deserialize(deserializer)?;
90 Ok(match s.as_str() {
91 "core" => Self::Core,
92 "daily" => Self::Daily,
93 "conversation" => Self::Conversation,
94 _ => Self::Custom(s),
95 })
96 }
97}
98
99impl std::fmt::Display for MemoryCategory {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 Self::Core => write!(f, "core"),
103 Self::Daily => write!(f, "daily"),
104 Self::Conversation => write!(f, "conversation"),
105 Self::Custom(name) => write!(f, "{name}"),
106 }
107 }
108}
109
110#[async_trait]
112pub trait Memory: Send + Sync {
113 fn name(&self) -> &str;
115
116 async fn store(
118 &self,
119 key: &str,
120 content: &str,
121 category: MemoryCategory,
122 session_id: Option<&str>,
123 ) -> anyhow::Result<()>;
124
125 async fn recall(
129 &self,
130 query: &str,
131 limit: usize,
132 session_id: Option<&str>,
133 since: Option<&str>,
134 until: Option<&str>,
135 ) -> anyhow::Result<Vec<MemoryEntry>>;
136
137 async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
139
140 async fn list(
142 &self,
143 category: Option<&MemoryCategory>,
144 session_id: Option<&str>,
145 ) -> anyhow::Result<Vec<MemoryEntry>>;
146
147 async fn forget(&self, key: &str) -> anyhow::Result<bool>;
149
150 async fn purge_namespace(&self, _namespace: &str) -> anyhow::Result<usize> {
154 anyhow::bail!("purge_namespace not supported by this memory backend")
155 }
156
157 async fn purge_session(&self, _session_id: &str) -> anyhow::Result<usize> {
161 anyhow::bail!("purge_session not supported by this memory backend")
162 }
163
164 async fn count(&self) -> anyhow::Result<usize>;
166
167 async fn health_check(&self) -> bool;
169
170 async fn store_procedural(
176 &self,
177 _messages: &[ProceduralMessage],
178 _session_id: Option<&str>,
179 ) -> anyhow::Result<()> {
180 Ok(())
181 }
182
183 async fn recall_namespaced(
188 &self,
189 namespace: &str,
190 query: &str,
191 limit: usize,
192 session_id: Option<&str>,
193 since: Option<&str>,
194 until: Option<&str>,
195 ) -> anyhow::Result<Vec<MemoryEntry>> {
196 let entries = self
197 .recall(query, limit * 2, session_id, since, until)
198 .await?;
199 let filtered: Vec<MemoryEntry> = entries
200 .into_iter()
201 .filter(|e| e.namespace == namespace)
202 .take(limit)
203 .collect();
204 Ok(filtered)
205 }
206
207 async fn export(&self, filter: &ExportFilter) -> anyhow::Result<Vec<MemoryEntry>> {
216 let entries = self
217 .list(filter.category.as_ref(), filter.session_id.as_deref())
218 .await?;
219 let filtered: Vec<MemoryEntry> = entries
220 .into_iter()
221 .filter(|e| {
222 if let Some(ref ns) = filter.namespace {
223 if e.namespace != *ns {
224 return false;
225 }
226 }
227 if let Some(ref since) = filter.since {
228 if e.timestamp.as_str() < since.as_str() {
229 return false;
230 }
231 }
232 if let Some(ref until) = filter.until {
233 if e.timestamp.as_str() > until.as_str() {
234 return false;
235 }
236 }
237 true
238 })
239 .collect();
240 Ok(filtered)
241 }
242
243 async fn store_with_metadata(
248 &self,
249 key: &str,
250 content: &str,
251 category: MemoryCategory,
252 session_id: Option<&str>,
253 _namespace: Option<&str>,
254 _importance: Option<f64>,
255 ) -> anyhow::Result<()> {
256 self.store(key, content, category, session_id).await
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn memory_category_display_outputs_expected_values() {
266 assert_eq!(MemoryCategory::Core.to_string(), "core");
267 assert_eq!(MemoryCategory::Daily.to_string(), "daily");
268 assert_eq!(MemoryCategory::Conversation.to_string(), "conversation");
269 assert_eq!(
270 MemoryCategory::Custom("project_notes".into()).to_string(),
271 "project_notes"
272 );
273 }
274
275 #[test]
276 fn memory_category_serde_uses_snake_case() {
277 let core = serde_json::to_string(&MemoryCategory::Core).unwrap();
278 let daily = serde_json::to_string(&MemoryCategory::Daily).unwrap();
279 let conversation = serde_json::to_string(&MemoryCategory::Conversation).unwrap();
280
281 assert_eq!(core, "\"core\"");
282 assert_eq!(daily, "\"daily\"");
283 assert_eq!(conversation, "\"conversation\"");
284 }
285
286 #[test]
287 fn memory_category_custom_roundtrip() {
288 let custom = MemoryCategory::Custom("project_notes".into());
289 let json = serde_json::to_string(&custom).unwrap();
290 assert_eq!(json, "\"project_notes\"");
291 let parsed: MemoryCategory = serde_json::from_str(&json).unwrap();
292 assert_eq!(parsed, custom);
293 }
294
295 #[test]
296 fn memory_entry_roundtrip_preserves_optional_fields() {
297 let entry = MemoryEntry {
298 id: "id-1".into(),
299 key: "favorite_language".into(),
300 content: "Rust".into(),
301 category: MemoryCategory::Core,
302 timestamp: "2026-02-16T00:00:00Z".into(),
303 session_id: Some("session-abc".into()),
304 score: Some(0.98),
305 namespace: "default".into(),
306 importance: Some(0.7),
307 superseded_by: None,
308 };
309
310 let json = serde_json::to_string(&entry).unwrap();
311 let parsed: MemoryEntry = serde_json::from_str(&json).unwrap();
312
313 assert_eq!(parsed.id, "id-1");
314 assert_eq!(parsed.key, "favorite_language");
315 assert_eq!(parsed.content, "Rust");
316 assert_eq!(parsed.category, MemoryCategory::Core);
317 assert_eq!(parsed.session_id.as_deref(), Some("session-abc"));
318 assert_eq!(parsed.score, Some(0.98));
319 assert_eq!(parsed.namespace, "default");
320 assert_eq!(parsed.importance, Some(0.7));
321 assert!(parsed.superseded_by.is_none());
322 }
323}