Skip to main content

zeph_memory/
facade.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Thin trait abstraction over [`crate::semantic::SemanticMemory`].
5//!
6//! `MemoryFacade` is a narrow interface covering the four operations the agent loop
7//! depends on: `remember`, `recall`, `summarize`, and `compact`. It exists for two
8//! reasons:
9//!
10//! 1. **Unit testing** — `InMemoryFacade` implements the trait using in-process
11//!    storage so tests that exercise memory-dependent agent logic do not need `SQLite`
12//!    or `Qdrant`.
13//!
14//! 2. **Future migration path** — the agent can eventually hold
15//!    `Arc<dyn MemoryFacade>` instead of `Arc<SemanticMemory>`. Phase 2 will
16//!    require making the trait object-safe (e.g. via `#[trait_variant::make]`
17//!    or boxed-future signatures). This PR implements Phase 1 only.
18//!
19//! # Design constraints
20//!
21//! `MemoryEntry.parts` uses `Vec<serde_json::Value>` (opaque JSON) rather than
22//! `Vec<zeph_llm::MessagePart>` to keep `zeph-memory` free of `zeph-llm` dependencies.
23
24use std::collections::BTreeMap;
25use std::sync::Mutex;
26
27use crate::error::MemoryError;
28use crate::types::{ConversationId, MessageId};
29
30// ── Supporting types ─────────────────────────────────────────────────────────
31
32/// An entry to persist in memory.
33///
34/// The `parts` field is opaque `serde_json::Value` — callers are responsible for
35/// serializing their content model. This avoids a `zeph-memory` → `zeph-llm`
36/// dependency at the trait boundary.
37///
38/// # Examples
39///
40/// ```
41/// use zeph_memory::facade::MemoryEntry;
42/// use zeph_memory::ConversationId;
43///
44/// let entry = MemoryEntry {
45///     conversation_id: ConversationId(1),
46///     role: "user".into(),
47///     content: "Hello".into(),
48///     parts: vec![],
49///     metadata: None,
50/// };
51/// assert_eq!(entry.role, "user");
52/// ```
53#[derive(Debug, Clone)]
54pub struct MemoryEntry {
55    /// Conversation this entry belongs to.
56    pub conversation_id: ConversationId,
57    /// Role string (`"user"`, `"assistant"`, `"system"`).
58    pub role: String,
59    /// Flat text content of the message.
60    pub content: String,
61    /// Structured content parts as opaque JSON values.
62    pub parts: Vec<serde_json::Value>,
63    /// Optional per-entry metadata as opaque JSON.
64    pub metadata: Option<serde_json::Value>,
65}
66
67/// A matching entry returned by a recall query.
68///
69/// # Examples
70///
71/// ```
72/// use zeph_memory::facade::{MemoryMatch, MemorySource};
73///
74/// let m = MemoryMatch {
75///     content: "Rust is a systems language.".into(),
76///     score: 0.92,
77///     source: MemorySource::Semantic,
78/// };
79/// assert!(m.score > 0.0);
80/// ```
81#[derive(Debug, Clone)]
82pub struct MemoryMatch {
83    /// Matching message content.
84    pub content: String,
85    /// Relevance score in `[0.0, 1.0]`.
86    pub score: f32,
87    /// Backend that produced this match.
88    pub source: MemorySource,
89}
90
91/// Which memory backend produced a [`MemoryMatch`].
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum MemorySource {
94    /// Qdrant vector search.
95    Semantic,
96    /// Timestamp-filtered `SQLite` FTS5.
97    Episodic,
98    /// Graph traversal from extracted entities.
99    Graph,
100    /// `SQLite` FTS5 keyword search.
101    Keyword,
102}
103
104/// Input context for a compaction run.
105///
106/// # Examples
107///
108/// ```
109/// use zeph_memory::facade::CompactionContext;
110/// use zeph_memory::ConversationId;
111///
112/// let ctx = CompactionContext { conversation_id: ConversationId(1), token_budget: 4096 };
113/// assert_eq!(ctx.token_budget, 4096);
114/// ```
115#[derive(Debug, Clone)]
116pub struct CompactionContext {
117    /// Conversation to compact.
118    pub conversation_id: ConversationId,
119    /// Target token budget; the compactor aims to bring context below this limit.
120    pub token_budget: usize,
121}
122
123/// Result of a compaction run.
124#[derive(Debug, Clone)]
125pub struct CompactionResult {
126    /// Summary text replacing the compacted messages.
127    pub summary: String,
128    /// Number of messages that were replaced by the summary.
129    pub messages_compacted: usize,
130}
131
132// ── MemoryFacade trait ────────────────────────────────────────────────────────
133
134/// Narrow read/write interface over a memory backend.
135///
136/// Implement this trait to provide an alternative backend for unit testing
137/// (see [`InMemoryFacade`]) or future agent refactoring.
138///
139/// # Contract
140///
141/// - `remember` stores a message and returns its stable ID.
142/// - `recall` performs a best-effort similarity search; empty results are valid.
143/// - `summarize` returns a textual summary of the conversation so far.
144/// - `compact` reduces context size to within `ctx.token_budget`.
145///
146/// Implementations must be `Send + Sync` to support `Arc<dyn MemoryFacade>` usage.
147// The `Send` bound on returned futures is guaranteed by the `Send + Sync` supertrait
148// requirement on all implementors. `async_fn_in_trait` fires because auto-trait bounds
149// on the implicit future type cannot be declared without #[trait_variant::make], which
150// is deferred to Phase 2 when the trait becomes object-safe.
151#[allow(async_fn_in_trait)]
152pub trait MemoryFacade: Send + Sync {
153    /// Store a memory entry and return its ID.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`MemoryError`] if the backend fails to persist the entry.
158    async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError>;
159
160    /// Retrieve the most relevant entries for `query`, up to `limit` results.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`MemoryError`] if the recall query fails.
165    async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError>;
166
167    /// Produce a textual summary of `conv_id`.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`MemoryError`] if summarization fails.
172    async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError>;
173
174    /// Compact a conversation to fit within the token budget.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`MemoryError`] if compaction fails.
179    async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError>;
180}
181
182// ── InMemoryFacade ────────────────────────────────────────────────────────────
183
184/// In-process test double for [`MemoryFacade`].
185///
186/// Stores entries in a `Vec` and uses substring matching for recall.
187/// No `SQLite` or `Qdrant` required — suitable for unit tests that exercise
188/// memory-dependent agent logic without external infrastructure.
189///
190/// # Examples
191///
192/// ```no_run
193/// # use zeph_memory::facade::{InMemoryFacade, MemoryEntry, MemoryFacade};
194/// # use zeph_memory::ConversationId;
195/// # #[tokio::main] async fn main() {
196/// let facade = InMemoryFacade::new();
197/// let entry = MemoryEntry {
198///     conversation_id: ConversationId(1),
199///     role: "user".into(),
200///     content: "Rust borrow checker".into(),
201///     parts: vec![],
202///     metadata: None,
203/// };
204/// let id = facade.remember(entry).await.unwrap();
205/// let matches = facade.recall("borrow", 10).await.unwrap();
206/// assert!(!matches.is_empty());
207/// # }
208/// ```
209#[derive(Debug, Default)]
210pub struct InMemoryFacade {
211    entries: Mutex<BTreeMap<i64, MemoryEntry>>,
212    next_id: Mutex<i64>,
213}
214
215impl InMemoryFacade {
216    /// Create a new empty facade.
217    #[must_use]
218    pub fn new() -> Self {
219        Self::default()
220    }
221
222    /// Return the number of stored entries.
223    #[must_use]
224    pub fn len(&self) -> usize {
225        self.entries.lock().map_or(0, |g| g.len())
226    }
227
228    /// Return `true` if no entries have been stored.
229    #[must_use]
230    pub fn is_empty(&self) -> bool {
231        self.len() == 0
232    }
233}
234
235impl MemoryFacade for InMemoryFacade {
236    async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError> {
237        let mut id_guard = self
238            .next_id
239            .lock()
240            .map_err(|e| MemoryError::Other(format!("InMemoryFacade lock poisoned: {e}")))?;
241        *id_guard += 1;
242        let id = *id_guard;
243        let mut entries = self
244            .entries
245            .lock()
246            .map_err(|e| MemoryError::Other(format!("InMemoryFacade lock poisoned: {e}")))?;
247        entries.insert(id, entry);
248        Ok(MessageId(id))
249    }
250
251    async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError> {
252        let entries = self
253            .entries
254            .lock()
255            .map_err(|e| MemoryError::Other(format!("InMemoryFacade lock poisoned: {e}")))?;
256        let query_lower = query.to_lowercase();
257        let mut matches: Vec<MemoryMatch> = entries
258            .values()
259            .filter(|e| e.content.to_lowercase().contains(&query_lower))
260            .map(|e| MemoryMatch {
261                content: e.content.clone(),
262                score: 1.0,
263                source: MemorySource::Keyword,
264            })
265            .take(limit)
266            .collect();
267        // Stable order for deterministic tests
268        matches.sort_by(|a, b| a.content.cmp(&b.content));
269        Ok(matches)
270    }
271
272    async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError> {
273        let entries = self
274            .entries
275            .lock()
276            .map_err(|e| MemoryError::Other(format!("InMemoryFacade lock poisoned: {e}")))?;
277        let texts: Vec<&str> = entries
278            .values()
279            .filter(|e| e.conversation_id == conv_id)
280            .map(|e| e.content.as_str())
281            .collect();
282        Ok(texts.join("\n"))
283    }
284
285    async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError> {
286        let mut entries = self
287            .entries
288            .lock()
289            .map_err(|e| MemoryError::Other(format!("InMemoryFacade lock poisoned: {e}")))?;
290        let ids_to_remove: Vec<i64> = entries
291            .iter()
292            .filter(|(_, e)| e.conversation_id == ctx.conversation_id)
293            .map(|(&id, _)| id)
294            .collect();
295        let count = ids_to_remove.len();
296        let summary: Vec<String> = ids_to_remove
297            .iter()
298            .filter_map(|id| entries.get(id).map(|e| e.content.clone()))
299            .collect();
300        for id in &ids_to_remove {
301            entries.remove(id);
302        }
303        Ok(CompactionResult {
304            summary: summary.join("\n"),
305            messages_compacted: count,
306        })
307    }
308}
309
310// ── SemanticMemory impl ───────────────────────────────────────────────────────
311
312impl MemoryFacade for crate::semantic::SemanticMemory {
313    async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError> {
314        let parts_json = serde_json::to_string(&entry.parts)
315            .map_err(|e| MemoryError::Other(format!("parts serialization failed: {e}")))?;
316        let (id_opt, _embedded) = self
317            .remember_with_parts(
318                entry.conversation_id,
319                &entry.role,
320                &entry.content,
321                &parts_json,
322                None,
323            )
324            .await?;
325        id_opt.ok_or_else(|| MemoryError::Other("message rejected by admission control".into()))
326    }
327
328    async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError> {
329        let recalled = self.recall(query, limit, None).await?;
330        Ok(recalled
331            .into_iter()
332            .map(|r| MemoryMatch {
333                content: r.message.content,
334                score: r.score,
335                source: MemorySource::Semantic,
336            })
337            .collect())
338    }
339
340    async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError> {
341        let summaries = self.load_summaries(conv_id).await?;
342        Ok(summaries
343            .into_iter()
344            .map(|s| s.content)
345            .collect::<Vec<_>>()
346            .join("\n"))
347    }
348
349    async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError> {
350        let before = self.message_count(ctx.conversation_id).await?;
351        let messages_compacted = usize::try_from(before).unwrap_or(0);
352        // Trigger a summarization pass to reduce context below the token budget.
353        // The message_count parameter drives how many messages to summarize at once.
354        // Approximate: 4 chars per token; produce a target message count that
355        // keeps the resulting context under the token budget.
356        let target_msgs = ctx.token_budget.checked_div(4).unwrap_or(512);
357        let _ = self.summarize(ctx.conversation_id, target_msgs).await?;
358        let summary = self
359            .load_summaries(ctx.conversation_id)
360            .await?
361            .into_iter()
362            .map(|s| s.content)
363            .collect::<Vec<_>>()
364            .join("\n");
365        Ok(CompactionResult {
366            summary,
367            messages_compacted,
368        })
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[tokio::test]
377    async fn remember_and_recall() {
378        let facade = InMemoryFacade::new();
379        let entry = MemoryEntry {
380            conversation_id: ConversationId(1),
381            role: "user".into(),
382            content: "Rust ownership model".into(),
383            parts: vec![],
384            metadata: None,
385        };
386        let id = facade.remember(entry).await.unwrap();
387        assert_eq!(id, MessageId(1));
388
389        let matches = facade.recall("ownership", 10).await.unwrap();
390        assert_eq!(matches.len(), 1);
391        assert_eq!(matches[0].content, "Rust ownership model");
392        assert_eq!(matches[0].source, MemorySource::Keyword);
393    }
394
395    #[tokio::test]
396    async fn recall_no_match() {
397        let facade = InMemoryFacade::new();
398        let entry = MemoryEntry {
399            conversation_id: ConversationId(1),
400            role: "user".into(),
401            content: "Rust ownership model".into(),
402            parts: vec![],
403            metadata: None,
404        };
405        facade.remember(entry).await.unwrap();
406        let matches = facade.recall("Python", 10).await.unwrap();
407        assert!(matches.is_empty());
408    }
409
410    #[tokio::test]
411    async fn summarize_joins_content() {
412        let facade = InMemoryFacade::new();
413        for content in ["Hello", "World"] {
414            facade
415                .remember(MemoryEntry {
416                    conversation_id: ConversationId(1),
417                    role: "user".into(),
418                    content: content.into(),
419                    parts: vec![],
420                    metadata: None,
421                })
422                .await
423                .unwrap();
424        }
425        let summary = facade.summarize(ConversationId(1)).await.unwrap();
426        assert!(summary.contains("Hello") && summary.contains("World"));
427    }
428
429    #[tokio::test]
430    async fn compact_removes_conversation_entries() {
431        let facade = InMemoryFacade::new();
432        facade
433            .remember(MemoryEntry {
434                conversation_id: ConversationId(1),
435                role: "user".into(),
436                content: "entry 1".into(),
437                parts: vec![],
438                metadata: None,
439            })
440            .await
441            .unwrap();
442        facade
443            .remember(MemoryEntry {
444                conversation_id: ConversationId(2),
445                role: "user".into(),
446                content: "other conv".into(),
447                parts: vec![],
448                metadata: None,
449            })
450            .await
451            .unwrap();
452
453        let result = facade
454            .compact(&CompactionContext {
455                conversation_id: ConversationId(1),
456                token_budget: 100,
457            })
458            .await
459            .unwrap();
460
461        assert_eq!(result.messages_compacted, 1);
462        assert!(result.summary.contains("entry 1"));
463        // Other conversation untouched
464        assert_eq!(facade.len(), 1);
465    }
466
467    #[tokio::test]
468    async fn recall_respects_limit() {
469        let facade = InMemoryFacade::new();
470        for i in 0..5 {
471            facade
472                .remember(MemoryEntry {
473                    conversation_id: ConversationId(1),
474                    role: "user".into(),
475                    content: format!("memory item {i}"),
476                    parts: vec![],
477                    metadata: None,
478                })
479                .await
480                .unwrap();
481        }
482        let matches = facade.recall("memory", 3).await.unwrap();
483        assert_eq!(matches.len(), 3);
484    }
485}