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)]
93#[non_exhaustive]
94pub enum MemorySource {
95    /// Qdrant vector search.
96    Semantic,
97    /// Timestamp-filtered `SQLite` FTS5.
98    Episodic,
99    /// Graph traversal from extracted entities.
100    Graph,
101    /// `SQLite` FTS5 keyword search.
102    Keyword,
103}
104
105/// Input context for a compaction run.
106///
107/// # Examples
108///
109/// ```
110/// use zeph_memory::facade::CompactionContext;
111/// use zeph_memory::ConversationId;
112///
113/// let ctx = CompactionContext { conversation_id: ConversationId(1), token_budget: 4096 };
114/// assert_eq!(ctx.token_budget, 4096);
115/// ```
116#[derive(Debug, Clone)]
117pub struct CompactionContext {
118    /// Conversation to compact.
119    pub conversation_id: ConversationId,
120    /// Target token budget; the compactor aims to bring context below this limit.
121    pub token_budget: usize,
122}
123
124/// Result of a compaction run.
125#[derive(Debug, Clone)]
126pub struct CompactionResult {
127    /// Summary text replacing the compacted messages.
128    pub summary: String,
129    /// Number of messages that were replaced by the summary.
130    pub messages_compacted: usize,
131}
132
133// ── MemoryFacade trait ────────────────────────────────────────────────────────
134
135/// Narrow read/write interface over a memory backend.
136///
137/// Implement this trait to provide an alternative backend for unit testing
138/// (see [`InMemoryFacade`]) or future agent refactoring.
139///
140/// # Contract
141///
142/// - `remember` stores a message and returns its stable ID.
143/// - `recall` performs a best-effort similarity search; empty results are valid.
144/// - `summarize` returns a textual summary of the conversation so far.
145/// - `compact` reduces context size to within `ctx.token_budget`.
146///
147/// Implementations must be `Send + Sync` to support `Arc<dyn MemoryFacade>` usage.
148// The `Send` bound on returned futures is guaranteed by the `Send + Sync` supertrait
149// requirement on all implementors. `async_fn_in_trait` fires because auto-trait bounds
150// on the implicit future type cannot be declared without #[trait_variant::make], which
151// is deferred to Phase 2 when the trait becomes object-safe.
152#[allow(async_fn_in_trait)]
153pub trait MemoryFacade: Send + Sync {
154    /// Store a memory entry and return its ID.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`MemoryError`] if the backend fails to persist the entry.
159    async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError>;
160
161    /// Retrieve the most relevant entries for `query`, up to `limit` results.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`MemoryError`] if the recall query fails.
166    async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError>;
167
168    /// Produce a textual summary of `conv_id`.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`MemoryError`] if summarization fails.
173    async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError>;
174
175    /// Compact a conversation to fit within the token budget.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`MemoryError`] if compaction fails.
180    async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError>;
181}
182
183// ── InMemoryFacade ────────────────────────────────────────────────────────────
184
185/// In-process test double for [`MemoryFacade`].
186///
187/// Stores entries in a `Vec` and uses substring matching for recall.
188/// No `SQLite` or `Qdrant` required — suitable for unit tests that exercise
189/// memory-dependent agent logic without external infrastructure.
190///
191/// # Examples
192///
193/// ```no_run
194/// # use zeph_memory::facade::{InMemoryFacade, MemoryEntry, MemoryFacade};
195/// # use zeph_memory::ConversationId;
196/// # #[tokio::main] async fn main() {
197/// let facade = InMemoryFacade::new();
198/// let entry = MemoryEntry {
199///     conversation_id: ConversationId(1),
200///     role: "user".into(),
201///     content: "Rust borrow checker".into(),
202///     parts: vec![],
203///     metadata: None,
204/// };
205/// let id = facade.remember(entry).await.unwrap();
206/// let matches = facade.recall("borrow", 10).await.unwrap();
207/// assert!(!matches.is_empty());
208/// # }
209/// ```
210#[derive(Debug, Default)]
211pub struct InMemoryFacade {
212    entries: Mutex<BTreeMap<i64, MemoryEntry>>,
213    next_id: Mutex<i64>,
214}
215
216impl InMemoryFacade {
217    /// Create a new empty facade.
218    #[must_use]
219    pub fn new() -> Self {
220        Self::default()
221    }
222
223    /// Return the number of stored entries.
224    #[must_use]
225    pub fn len(&self) -> usize {
226        self.entries.lock().map_or(0, |g| g.len())
227    }
228
229    /// Return `true` if no entries have been stored.
230    #[must_use]
231    pub fn is_empty(&self) -> bool {
232        self.len() == 0
233    }
234
235    fn lock_entries(
236        &self,
237    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<i64, MemoryEntry>>, MemoryError> {
238        self.entries
239            .lock()
240            .map_err(|e| MemoryError::LockPoisoned(format!("InMemoryFacade lock poisoned: {e}")))
241    }
242
243    fn lock_next_id(&self) -> Result<std::sync::MutexGuard<'_, i64>, MemoryError> {
244        self.next_id
245            .lock()
246            .map_err(|e| MemoryError::LockPoisoned(format!("InMemoryFacade lock poisoned: {e}")))
247    }
248}
249
250impl MemoryFacade for InMemoryFacade {
251    #[tracing::instrument(name = "memory.facade.in_memory.remember", skip_all)]
252    async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError> {
253        let mut id_guard = self.lock_next_id()?;
254        *id_guard += 1;
255        let id = *id_guard;
256        let mut entries = self.lock_entries()?;
257        entries.insert(id, entry);
258        Ok(MessageId(id))
259    }
260
261    #[tracing::instrument(name = "memory.facade.in_memory.recall", skip_all)]
262    async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError> {
263        let entries = self.lock_entries()?;
264        let query_lower = query.to_lowercase();
265        let mut matches: Vec<MemoryMatch> = entries
266            .values()
267            .filter(|e| e.content.to_lowercase().contains(&query_lower))
268            .map(|e| MemoryMatch {
269                content: e.content.clone(),
270                score: 1.0,
271                source: MemorySource::Keyword,
272            })
273            .take(limit)
274            .collect();
275        // Stable order for deterministic tests
276        matches.sort_by(|a, b| a.content.cmp(&b.content));
277        Ok(matches)
278    }
279
280    #[tracing::instrument(name = "memory.facade.in_memory.summarize", skip_all)]
281    async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError> {
282        let entries = self.lock_entries()?;
283        let texts: Vec<&str> = entries
284            .values()
285            .filter(|e| e.conversation_id == conv_id)
286            .map(|e| e.content.as_str())
287            .collect();
288        Ok(texts.join("\n"))
289    }
290
291    #[tracing::instrument(name = "memory.facade.in_memory.compact", skip_all)]
292    async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError> {
293        let mut entries = self.lock_entries()?;
294        let ids_to_remove: Vec<i64> = entries
295            .iter()
296            .filter(|(_, e)| e.conversation_id == ctx.conversation_id)
297            .map(|(&id, _)| id)
298            .collect();
299        let count = ids_to_remove.len();
300        let summary: Vec<String> = ids_to_remove
301            .iter()
302            .filter_map(|id| entries.get(id).map(|e| e.content.clone()))
303            .collect();
304        for id in &ids_to_remove {
305            entries.remove(id);
306        }
307        Ok(CompactionResult {
308            summary: summary.join("\n"),
309            messages_compacted: count,
310        })
311    }
312}
313
314// ── SemanticMemory impl ───────────────────────────────────────────────────────
315
316impl MemoryFacade for crate::semantic::SemanticMemory {
317    #[tracing::instrument(name = "memory.facade.remember", skip_all)]
318    async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError> {
319        let parts_json = serde_json::to_string(&entry.parts).map_err(MemoryError::Json)?;
320        let (id_opt, _embedded) = self
321            .remember_with_parts(
322                entry.conversation_id,
323                &entry.role,
324                &entry.content,
325                &parts_json,
326                None,
327            )
328            .await?;
329        id_opt.ok_or_else(|| {
330            MemoryError::InvalidInput("message rejected by admission control".into())
331        })
332    }
333
334    #[tracing::instrument(name = "memory.facade.recall", skip_all)]
335    async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError> {
336        let recalled = self.recall(query, limit, None).await?;
337        Ok(recalled
338            .into_iter()
339            .map(|r| MemoryMatch {
340                content: r.message.content,
341                score: r.score,
342                source: MemorySource::Semantic,
343            })
344            .collect())
345    }
346
347    #[tracing::instrument(name = "memory.facade.summarize", skip_all)]
348    async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError> {
349        let summaries = self.load_summaries(conv_id).await?;
350        Ok(summaries
351            .into_iter()
352            .map(|s| s.content)
353            .collect::<Vec<_>>()
354            .join("\n"))
355    }
356
357    #[tracing::instrument(name = "memory.facade.compact", skip_all)]
358    async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError> {
359        // Trigger a summarization pass to reduce context below the token budget.
360        // The message_count parameter drives how many messages to summarize at once.
361        // Approximate: 4 chars per token; produce a target message count that
362        // keeps the resulting context under the token budget.
363        let target_msgs = ctx.token_budget.checked_div(4).unwrap_or(512);
364        let outcome = self.summarize(ctx.conversation_id, target_msgs).await?;
365        let messages_compacted = outcome.map_or(0, |o| o.messages_folded);
366        let summary = self
367            .load_summaries(ctx.conversation_id)
368            .await?
369            .into_iter()
370            .map(|s| s.content)
371            .collect::<Vec<_>>()
372            .join("\n");
373        Ok(CompactionResult {
374            summary,
375            messages_compacted,
376        })
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[tokio::test]
385    async fn remember_and_recall() {
386        let facade = InMemoryFacade::new();
387        let entry = MemoryEntry {
388            conversation_id: ConversationId(1),
389            role: "user".into(),
390            content: "Rust ownership model".into(),
391            parts: vec![],
392            metadata: None,
393        };
394        let id = facade.remember(entry).await.unwrap();
395        assert_eq!(id, MessageId(1));
396
397        let matches = facade.recall("ownership", 10).await.unwrap();
398        assert_eq!(matches.len(), 1);
399        assert_eq!(matches[0].content, "Rust ownership model");
400        assert_eq!(matches[0].source, MemorySource::Keyword);
401    }
402
403    #[tokio::test]
404    async fn recall_no_match() {
405        let facade = InMemoryFacade::new();
406        let entry = MemoryEntry {
407            conversation_id: ConversationId(1),
408            role: "user".into(),
409            content: "Rust ownership model".into(),
410            parts: vec![],
411            metadata: None,
412        };
413        facade.remember(entry).await.unwrap();
414        let matches = facade.recall("Python", 10).await.unwrap();
415        assert!(matches.is_empty());
416    }
417
418    #[tokio::test]
419    async fn summarize_joins_content() {
420        let facade = InMemoryFacade::new();
421        for content in ["Hello", "World"] {
422            facade
423                .remember(MemoryEntry {
424                    conversation_id: ConversationId(1),
425                    role: "user".into(),
426                    content: content.into(),
427                    parts: vec![],
428                    metadata: None,
429                })
430                .await
431                .unwrap();
432        }
433        let summary = facade.summarize(ConversationId(1)).await.unwrap();
434        assert!(summary.contains("Hello") && summary.contains("World"));
435    }
436
437    #[tokio::test]
438    async fn compact_removes_conversation_entries() {
439        let facade = InMemoryFacade::new();
440        facade
441            .remember(MemoryEntry {
442                conversation_id: ConversationId(1),
443                role: "user".into(),
444                content: "entry 1".into(),
445                parts: vec![],
446                metadata: None,
447            })
448            .await
449            .unwrap();
450        facade
451            .remember(MemoryEntry {
452                conversation_id: ConversationId(2),
453                role: "user".into(),
454                content: "other conv".into(),
455                parts: vec![],
456                metadata: None,
457            })
458            .await
459            .unwrap();
460
461        let result = facade
462            .compact(&CompactionContext {
463                conversation_id: ConversationId(1),
464                token_budget: 100,
465            })
466            .await
467            .unwrap();
468
469        assert_eq!(result.messages_compacted, 1);
470        assert!(result.summary.contains("entry 1"));
471        // Other conversation untouched
472        assert_eq!(facade.len(), 1);
473    }
474
475    #[tokio::test]
476    async fn recall_respects_limit() {
477        let facade = InMemoryFacade::new();
478        for i in 0..5 {
479            facade
480                .remember(MemoryEntry {
481                    conversation_id: ConversationId(1),
482                    role: "user".into(),
483                    content: format!("memory item {i}"),
484                    parts: vec![],
485                    metadata: None,
486                })
487                .await
488                .unwrap();
489        }
490        let matches = facade.recall("memory", 3).await.unwrap();
491        assert_eq!(matches.len(), 3);
492    }
493}