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}