Skip to main content

construct/memory/
traits.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4/// Filter criteria for bulk memory export (GDPR Art. 20 data portability).
5#[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    /// RFC 3339 lower bound (inclusive) on created_at.
11    pub since: Option<String>,
12    /// RFC 3339 upper bound (inclusive) on created_at.
13    pub until: Option<String>,
14}
15
16/// A single message in a conversation trace for procedural memory.
17///
18/// Used to capture "how to" patterns from tool-calling turns so that
19/// backends that support procedural storage can learn from them.
20#[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/// A single memory entry
29#[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    /// Namespace for isolation between agents/contexts.
39    #[serde(default = "default_namespace")]
40    pub namespace: String,
41    /// Importance score (0.0–1.0) for prioritized retrieval.
42    #[serde(default)]
43    pub importance: Option<f64>,
44    /// If this entry was superseded by a newer conflicting entry.
45    #[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/// Memory categories for organization
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum MemoryCategory {
71    /// Long-term facts, preferences, decisions
72    Core,
73    /// Daily session logs
74    Daily,
75    /// Conversation context
76    Conversation,
77    /// User-defined custom category
78    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/// Core memory trait — implement for any persistence backend
111#[async_trait]
112pub trait Memory: Send + Sync {
113    /// Backend name
114    fn name(&self) -> &str;
115
116    /// Store a memory entry, optionally scoped to a session
117    async fn store(
118        &self,
119        key: &str,
120        content: &str,
121        category: MemoryCategory,
122        session_id: Option<&str>,
123    ) -> anyhow::Result<()>;
124
125    /// Recall memories matching a query (keyword search), optionally scoped to a session
126    /// and time range. Time bounds use RFC 3339 / ISO 8601 format
127    /// (e.g. "2025-03-01T00:00:00Z"); inclusive (created_at >= since, created_at <= until).
128    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    /// Get a specific memory by key
138    async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
139
140    /// List all memory keys, optionally filtered by category and/or session
141    async fn list(
142        &self,
143        category: Option<&MemoryCategory>,
144        session_id: Option<&str>,
145    ) -> anyhow::Result<Vec<MemoryEntry>>;
146
147    /// Remove a memory by key
148    async fn forget(&self, key: &str) -> anyhow::Result<bool>;
149
150    /// Remove all memories in a namespace (category).
151    /// Returns the number of deleted entries.
152    /// Default: returns unsupported error. Backends that support bulk deletion override this.
153    async fn purge_namespace(&self, _namespace: &str) -> anyhow::Result<usize> {
154        anyhow::bail!("purge_namespace not supported by this memory backend")
155    }
156
157    /// Remove all memories in a session.
158    /// Returns the number of deleted entries.
159    /// Default: returns unsupported error. Backends that support bulk deletion override this.
160    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    /// Count total memories
165    async fn count(&self) -> anyhow::Result<usize>;
166
167    /// Health check
168    async fn health_check(&self) -> bool;
169
170    /// Store a conversation trace as procedural memory.
171    ///
172    /// Backends that support procedural storage override this
173    /// to extract "how to" patterns from tool-calling turns.  The default
174    /// implementation is a no-op.
175    async fn store_procedural(
176        &self,
177        _messages: &[ProceduralMessage],
178        _session_id: Option<&str>,
179    ) -> anyhow::Result<()> {
180        Ok(())
181    }
182
183    /// Recall memories scoped to a specific namespace.
184    ///
185    /// Default implementation delegates to `recall()` and filters by namespace.
186    /// Backends with native namespace support should override for efficiency.
187    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    /// Bulk-export memories matching the given filter criteria.
208    ///
209    /// Intended for GDPR Art. 20 data portability. Returns entries ordered by
210    /// creation time (ascending). Embeddings are excluded.
211    ///
212    /// Default implementation delegates to `list()` and post-filters on
213    /// namespace and time range. Backends with native query support should
214    /// override for efficiency.
215    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    /// Store a memory entry with namespace and importance.
244    ///
245    /// Default implementation delegates to `store()`. Backends with native
246    /// namespace/importance support should override.
247    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}