Skip to main content

zeroclaw_api/
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    /// Resolved, human-readable agent alias for this row (the HashMap key
48    /// in `Config::agents`, e.g. `"clamps"`). SQL-backed stores produce
49    /// this via `LEFT JOIN agents ON agents.id = memories.agent_id`;
50    /// Markdown / Qdrant / None backends populate it with the raw column
51    /// value (which is itself the alias for those backends).
52    ///
53    /// Use this field for display / routing. For scope-equality checks
54    /// (e.g. inside `AgentScopedMemory`) use [`MemoryEntry::agent_id`]
55    /// instead since that's stable across backend kinds (UUID for SQL,
56    /// alias for non-SQL).
57    #[serde(default)]
58    pub agent_alias: Option<String>,
59    /// Raw value of the storage layer's agent column. For SQL backends
60    /// this is the `memories.agent_id` UUID FK to `agents.id`; for
61    /// Markdown / Qdrant / None this is the alias string. The scoping
62    /// wrapper compares on this field so backend-kind doesn't matter.
63    #[serde(default, alias = "agent_id")]
64    pub agent_id: Option<String>,
65}
66
67fn default_namespace() -> String {
68    "default".into()
69}
70
71impl std::fmt::Debug for MemoryEntry {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("MemoryEntry")
74            .field("id", &self.id)
75            .field("key", &self.key)
76            .field("content", &self.content)
77            .field("category", &self.category)
78            .field("timestamp", &self.timestamp)
79            .field("score", &self.score)
80            .field("namespace", &self.namespace)
81            .field("importance", &self.importance)
82            .field("agent_alias", &self.agent_alias)
83            .finish_non_exhaustive()
84    }
85}
86
87/// Memory categories for organization
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum MemoryCategory {
90    /// Long-term facts, preferences, decisions
91    Core,
92    /// Daily session logs
93    Daily,
94    /// Conversation context
95    Conversation,
96    /// User-defined custom category
97    Custom(String),
98}
99
100impl serde::Serialize for MemoryCategory {
101    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
102        serializer.serialize_str(&self.to_string())
103    }
104}
105
106impl<'de> serde::Deserialize<'de> for MemoryCategory {
107    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
108        let s = String::deserialize(deserializer)?;
109        Ok(match s.as_str() {
110            "core" => Self::Core,
111            "daily" => Self::Daily,
112            "conversation" => Self::Conversation,
113            _ => Self::Custom(s),
114        })
115    }
116}
117
118impl std::fmt::Display for MemoryCategory {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            Self::Core => write!(f, "core"),
122            Self::Daily => write!(f, "daily"),
123            Self::Conversation => write!(f, "conversation"),
124            Self::Custom(name) => write!(f, "{name}"),
125        }
126    }
127}
128
129/// Returns true when a recall query should be interpreted as recent/time-only recall.
130///
131/// A bare "*" is intentionally equivalent to an omitted query for tool-call
132/// compatibility. Non-bare wildcard terms such as "wild*" remain keyword queries.
133pub fn is_recent_recall_query(query: &str) -> bool {
134    let trimmed = query.trim();
135    trimmed.is_empty() || trimmed == "*"
136}
137
138/// Normalizes recent/time-only recall queries to the backend-neutral empty query.
139pub fn normalize_recent_recall_query(query: &str) -> &str {
140    if is_recent_recall_query(query) {
141        ""
142    } else {
143        query
144    }
145}
146
147/// Core memory trait — implement for any persistence backend
148#[async_trait]
149pub trait Memory: Send + Sync + crate::attribution::Attributable {
150    /// Backend name
151    fn name(&self) -> &str;
152
153    /// Store a memory entry, optionally scoped to a session
154    async fn store(
155        &self,
156        key: &str,
157        content: &str,
158        category: MemoryCategory,
159        session_id: Option<&str>,
160    ) -> anyhow::Result<()>;
161
162    /// Recall memories matching a query (keyword search), optionally scoped to a session
163    /// and time range. Empty, whitespace-only, and bare "*" queries return recent/time-only
164    /// entries. Non-bare wildcard terms such as "wild*" remain keyword queries.
165    /// Time bounds use RFC 3339 / ISO 8601 format
166    /// (e.g. "2025-03-01T00:00:00Z"); inclusive (created_at >= since, created_at <= until).
167    async fn recall(
168        &self,
169        query: &str,
170        limit: usize,
171        session_id: Option<&str>,
172        since: Option<&str>,
173        until: Option<&str>,
174    ) -> anyhow::Result<Vec<MemoryEntry>>;
175
176    /// Get a specific memory by key.
177    ///
178    /// After composite uniqueness landed, multiple rows may share a `key`
179    /// (one per agent). This method returns *some* matching row without an
180    /// agent filter; callers that need an agent-scoped lookup use
181    /// [`get_for_agent`](Self::get_for_agent).
182    async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
183
184    /// Get the memory row matching `(key, agent_id)`. Siblings of the same
185    /// key under other agents are invisible.
186    ///
187    /// The default implementation composes [`get`](Self::get) with an
188    /// `agent_id` filter and is only correct for backends whose storage
189    /// layout cannot hold more than one row per `key` (markdown's
190    /// per-agent dir scheme, the `none` stub). Backends that can hold
191    /// multiple rows per `key` (SQL with composite unique, Qdrant)
192    /// override this with a native composite lookup.
193    async fn get_for_agent(
194        &self,
195        key: &str,
196        agent_id: &str,
197    ) -> anyhow::Result<Option<MemoryEntry>> {
198        let hit = self.get(key).await?;
199        Ok(hit.filter(|e| e.agent_id.as_deref() == Some(agent_id)))
200    }
201
202    /// List all memory keys, optionally filtered by category and/or session
203    async fn list(
204        &self,
205        category: Option<&MemoryCategory>,
206        session_id: Option<&str>,
207    ) -> anyhow::Result<Vec<MemoryEntry>>;
208
209    /// Remove a memory by key. Deletes every row matching `key`, regardless
210    /// of agent attribution. Agent-scoped callers (the `AgentScopedMemory`
211    /// wrapper) use [`forget_for_agent`](Self::forget_for_agent) instead.
212    async fn forget(&self, key: &str) -> anyhow::Result<bool>;
213
214    /// Remove the row matching `(key, agent_id)`. Siblings of the same key
215    /// under other agents are untouched. Returns `true` if a row was
216    /// removed. Required: no safe default exists for backends or wrappers
217    /// that can hold more than one row per `key` — the unscoped `forget`
218    /// would destroy sibling rows.
219    async fn forget_for_agent(&self, key: &str, agent_id: &str) -> anyhow::Result<bool>;
220
221    /// Remove all memories whose `namespace` field equals the given value.
222    /// Returns the number of deleted entries.
223    /// Default: returns unsupported error. Backends that support bulk deletion override this.
224    async fn purge_namespace(&self, _namespace: &str) -> anyhow::Result<usize> {
225        anyhow::bail!("purge_namespace not supported by this memory backend")
226    }
227
228    /// Remove all memories in a session.
229    /// Returns the number of deleted entries.
230    /// Default: returns unsupported error. Backends that support bulk deletion override this.
231    async fn purge_session(&self, _session_id: &str) -> anyhow::Result<usize> {
232        anyhow::bail!("purge_session not supported by this memory backend")
233    }
234
235    /// Remove all memories in a session for one agent.
236    /// Returns the number of deleted entries.
237    /// Default: returns unsupported error. Backends with per-agent storage
238    /// override this; agent-scoped wrappers use it instead of composing a
239    /// session list with key-only deletes.
240    async fn purge_session_for_agent(
241        &self,
242        _session_id: &str,
243        _agent_id: &str,
244    ) -> anyhow::Result<usize> {
245        anyhow::bail!("purge_session_for_agent not supported by this memory backend")
246    }
247
248    /// Remove every memory row attributed to the given agent alias.
249    /// Returns the number of deleted entries. Called when an agent alias is
250    /// removed from `[agents.<alias>]` so the database doesn't accumulate
251    /// rows for retired aliases.
252    /// Default: returns unsupported error. Backends with per-agent storage
253    /// (sqlite, postgres) override this; backends without (markdown, none)
254    /// keep the default and the caller logs a warning.
255    async fn purge_agent(&self, _agent_alias: &str) -> anyhow::Result<usize> {
256        anyhow::bail!("purge_agent not supported by this memory backend")
257    }
258
259    /// Count total memories
260    async fn count(&self) -> anyhow::Result<usize>;
261
262    /// Health check
263    async fn health_check(&self) -> bool;
264
265    /// Rebuild backend indexes: FTS tables and any missing embedding vectors.
266    ///
267    /// Intended as a manual fixup after bulk writes that didn't go through
268    /// the normal `store()` path (e.g. `zeroclaw migrate openclaw`, which
269    /// uses `NoopEmbedding` for speed and leaves `embedding = NULL` behind).
270    /// Returns the number of entries that were re-embedded; backends
271    /// without a vector index or with nothing to fill in return 0.
272    ///
273    /// Default: no-op. Overridden by backends that maintain separate
274    /// derived indexes (e.g. `SqliteMemory`).
275    async fn reindex(&self) -> anyhow::Result<usize> {
276        Ok(0)
277    }
278
279    /// Store a conversation trace as procedural memory.
280    ///
281    /// Backends that support procedural storage override this
282    /// to extract "how to" patterns from tool-calling turns.  The default
283    /// implementation is a no-op.
284    async fn store_procedural(
285        &self,
286        _messages: &[ProceduralMessage],
287        _session_id: Option<&str>,
288    ) -> anyhow::Result<()> {
289        Ok(())
290    }
291
292    /// Recall memories scoped to a specific namespace.
293    ///
294    /// Default implementation delegates to `recall()` and filters by namespace.
295    /// Backends with native namespace support should override for efficiency.
296    async fn recall_namespaced(
297        &self,
298        namespace: &str,
299        query: &str,
300        limit: usize,
301        session_id: Option<&str>,
302        since: Option<&str>,
303        until: Option<&str>,
304    ) -> anyhow::Result<Vec<MemoryEntry>> {
305        let entries = self
306            .recall(query, limit * 2, session_id, since, until)
307            .await?;
308        let filtered: Vec<MemoryEntry> = entries
309            .into_iter()
310            .filter(|e| e.namespace == namespace)
311            .take(limit)
312            .collect();
313        Ok(filtered)
314    }
315
316    /// Bulk-export memories matching the given filter criteria.
317    ///
318    /// Intended for GDPR Art. 20 data portability. Returns entries ordered by
319    /// creation time (ascending). Embeddings are excluded.
320    ///
321    /// Default implementation delegates to `list()` and post-filters on
322    /// namespace and time range. Backends with native query support should
323    /// override for efficiency.
324    async fn export(&self, filter: &ExportFilter) -> anyhow::Result<Vec<MemoryEntry>> {
325        let entries = self
326            .list(filter.category.as_ref(), filter.session_id.as_deref())
327            .await?;
328        let filtered: Vec<MemoryEntry> = entries
329            .into_iter()
330            .filter(|e| {
331                if let Some(ref ns) = filter.namespace
332                    && e.namespace != *ns
333                {
334                    return false;
335                }
336                if let Some(ref since) = filter.since
337                    && e.timestamp.as_str() < since.as_str()
338                {
339                    return false;
340                }
341                if let Some(ref until) = filter.until
342                    && e.timestamp.as_str() > until.as_str()
343                {
344                    return false;
345                }
346                true
347            })
348            .collect();
349        Ok(filtered)
350    }
351
352    /// Store a memory entry with namespace and importance.
353    ///
354    /// Default implementation delegates to `store()`. Backends with native
355    /// namespace/importance support should override.
356    async fn store_with_metadata(
357        &self,
358        key: &str,
359        content: &str,
360        category: MemoryCategory,
361        session_id: Option<&str>,
362        _namespace: Option<&str>,
363        _importance: Option<f64>,
364    ) -> anyhow::Result<()> {
365        self.store(key, content, category, session_id).await
366    }
367
368    /// Store a memory entry attributed to an explicit agent UUID.
369    /// Every backend must implement this explicitly so the agent_id
370    /// is never silently dropped at storage time. Backends with
371    /// native agent_id columns (SqliteMemory, PostgresMemory,
372    /// LucidMemory) persist the attribution in SQL; MarkdownMemory
373    /// attributes via the per-agent directory path; QdrantMemory
374    /// persists in the vector payload; NoneMemory is a no-op stub.
375    /// `AgentScopedMemory` is the canonical caller.
376    async fn store_with_agent(
377        &self,
378        key: &str,
379        content: &str,
380        category: MemoryCategory,
381        session_id: Option<&str>,
382        namespace: Option<&str>,
383        importance: Option<f64>,
384        agent_id: Option<&str>,
385    ) -> anyhow::Result<()>;
386
387    /// Recall memory entries scoped to a specific set of agent UUIDs.
388    /// When `allowed_agent_ids` is non-empty, the backend filters its
389    /// result set to rows whose `agent_id` matches one of the listed
390    /// UUIDs (or is NULL, for legacy rows written before the agent_id
391    /// column existed). Every backend must implement this explicitly
392    /// so the allowlist is never silently dropped at read time.
393    ///
394    /// For SQL-backed stores the filter is `WHERE agent_id IN (...)`.
395    /// For Markdown the implementation walks the allowed agents'
396    /// per-agent directories. For Qdrant it's a payload filter on
397    /// the `agent_id` field. For None it returns an empty list.
398    /// `AgentScopedMemory` is the canonical caller; direct invocation
399    /// is also valid for read-only cross-agent queries that bypass
400    /// the wrapper.
401    ///
402    /// Cross-backend allowlist entries are rejected at config load
403    /// (`agents.<alias>.workspace.read_memory_from` cannot point at a
404    /// sibling on a different memory backend); backends therefore
405    /// never need to handle a cross-backend recall.
406    async fn recall_for_agents(
407        &self,
408        allowed_agent_ids: &[&str],
409        query: &str,
410        limit: usize,
411        session_id: Option<&str>,
412        since: Option<&str>,
413        until: Option<&str>,
414    ) -> anyhow::Result<Vec<MemoryEntry>>;
415
416    /// Look up (or create) the identifier the backend uses to refer
417    /// to the agent named by `alias`.
418    ///
419    /// Backends with an `agents` table (SqliteMemory, PostgresMemory,
420    /// LucidMemory) return the row's UUID, inserting if absent.
421    /// Backends without (MarkdownMemory, QdrantMemory, NoneMemory)
422    /// return the alias verbatim — there is no UUID indirection at
423    /// the storage layer, so the alias serves as the agent_id.
424    /// Default impl returns the alias unchanged; SQL backends
425    /// override to do the real lookup.
426    async fn ensure_agent_uuid(&self, alias: &str) -> anyhow::Result<String> {
427        Ok(alias.to_string())
428    }
429}
430
431/// High-level memory lifecycle policy.
432/// Implemented by strategy objects that wrap one or more `Memory` backends.
433#[async_trait]
434pub trait MemoryStrategy: Send + Sync {
435    /// Load and format relevant memory context for a conversation turn.
436    async fn load_context(&self, query: &str, session_id: Option<&str>) -> anyhow::Result<String>;
437
438    /// Consolidate a conversation turn into long-term memory.
439    async fn consolidate_turn(
440        &self,
441        user_message: &str,
442        assistant_response: &str,
443        provider: &dyn crate::model_provider::ModelProvider,
444        model: &str,
445        temperature: Option<f64>,
446    ) -> anyhow::Result<()>;
447
448    /// Run memory governance (cleanup, archiving, background consolidation).
449    async fn run_governance(&self) -> anyhow::Result<()>;
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn memory_category_display_outputs_expected_values() {
458        assert_eq!(MemoryCategory::Core.to_string(), "core");
459        assert_eq!(MemoryCategory::Daily.to_string(), "daily");
460        assert_eq!(MemoryCategory::Conversation.to_string(), "conversation");
461        assert_eq!(
462            MemoryCategory::Custom("project_notes".into()).to_string(),
463            "project_notes"
464        );
465    }
466
467    #[test]
468    fn memory_category_serde_uses_snake_case() {
469        let core = serde_json::to_string(&MemoryCategory::Core).unwrap();
470        let daily = serde_json::to_string(&MemoryCategory::Daily).unwrap();
471        let conversation = serde_json::to_string(&MemoryCategory::Conversation).unwrap();
472
473        assert_eq!(core, "\"core\"");
474        assert_eq!(daily, "\"daily\"");
475        assert_eq!(conversation, "\"conversation\"");
476    }
477
478    #[test]
479    fn memory_category_custom_roundtrip() {
480        let custom = MemoryCategory::Custom("project_notes".into());
481        let json = serde_json::to_string(&custom).unwrap();
482        assert_eq!(json, "\"project_notes\"");
483        let parsed: MemoryCategory = serde_json::from_str(&json).unwrap();
484        assert_eq!(parsed, custom);
485    }
486
487    #[test]
488    fn memory_entry_roundtrip_preserves_optional_fields() {
489        let entry = MemoryEntry {
490            id: "id-1".into(),
491            key: "favorite_language".into(),
492            content: "Rust".into(),
493            category: MemoryCategory::Core,
494            timestamp: "2026-02-16T00:00:00Z".into(),
495            session_id: Some("session-abc".into()),
496            score: Some(0.98),
497            namespace: "default".into(),
498            importance: Some(0.7),
499            superseded_by: None,
500            agent_alias: None,
501            agent_id: None,
502        };
503
504        let json = serde_json::to_string(&entry).unwrap();
505        let parsed: MemoryEntry = serde_json::from_str(&json).unwrap();
506
507        assert_eq!(parsed.id, "id-1");
508        assert_eq!(parsed.key, "favorite_language");
509        assert_eq!(parsed.content, "Rust");
510        assert_eq!(parsed.category, MemoryCategory::Core);
511        assert_eq!(parsed.session_id.as_deref(), Some("session-abc"));
512        assert_eq!(parsed.score, Some(0.98));
513        assert_eq!(parsed.namespace, "default");
514        assert_eq!(parsed.importance, Some(0.7));
515        assert!(parsed.superseded_by.is_none());
516    }
517}