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
236impl MemoryFacade for InMemoryFacade {
237 async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError> {
238 let mut id_guard = self
239 .next_id
240 .lock()
241 .map_err(|e| MemoryError::LockPoisoned(format!("InMemoryFacade lock poisoned: {e}")))?;
242 *id_guard += 1;
243 let id = *id_guard;
244 let mut entries = self
245 .entries
246 .lock()
247 .map_err(|e| MemoryError::LockPoisoned(format!("InMemoryFacade lock poisoned: {e}")))?;
248 entries.insert(id, entry);
249 Ok(MessageId(id))
250 }
251
252 async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError> {
253 let entries = self
254 .entries
255 .lock()
256 .map_err(|e| MemoryError::LockPoisoned(format!("InMemoryFacade lock poisoned: {e}")))?;
257 let query_lower = query.to_lowercase();
258 let mut matches: Vec<MemoryMatch> = entries
259 .values()
260 .filter(|e| e.content.to_lowercase().contains(&query_lower))
261 .map(|e| MemoryMatch {
262 content: e.content.clone(),
263 score: 1.0,
264 source: MemorySource::Keyword,
265 })
266 .take(limit)
267 .collect();
268 // Stable order for deterministic tests
269 matches.sort_by(|a, b| a.content.cmp(&b.content));
270 Ok(matches)
271 }
272
273 async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError> {
274 let entries = self
275 .entries
276 .lock()
277 .map_err(|e| MemoryError::LockPoisoned(format!("InMemoryFacade lock poisoned: {e}")))?;
278 let texts: Vec<&str> = entries
279 .values()
280 .filter(|e| e.conversation_id == conv_id)
281 .map(|e| e.content.as_str())
282 .collect();
283 Ok(texts.join("\n"))
284 }
285
286 async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError> {
287 let mut entries = self
288 .entries
289 .lock()
290 .map_err(|e| MemoryError::LockPoisoned(format!("InMemoryFacade lock poisoned: {e}")))?;
291 let ids_to_remove: Vec<i64> = entries
292 .iter()
293 .filter(|(_, e)| e.conversation_id == ctx.conversation_id)
294 .map(|(&id, _)| id)
295 .collect();
296 let count = ids_to_remove.len();
297 let summary: Vec<String> = ids_to_remove
298 .iter()
299 .filter_map(|id| entries.get(id).map(|e| e.content.clone()))
300 .collect();
301 for id in &ids_to_remove {
302 entries.remove(id);
303 }
304 Ok(CompactionResult {
305 summary: summary.join("\n"),
306 messages_compacted: count,
307 })
308 }
309}
310
311// ── SemanticMemory impl ───────────────────────────────────────────────────────
312
313impl MemoryFacade for crate::semantic::SemanticMemory {
314 async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError> {
315 let parts_json = serde_json::to_string(&entry.parts).map_err(MemoryError::Json)?;
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(|| {
326 MemoryError::InvalidInput("message rejected by admission control".into())
327 })
328 }
329
330 async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError> {
331 let recalled = self.recall(query, limit, None).await?;
332 Ok(recalled
333 .into_iter()
334 .map(|r| MemoryMatch {
335 content: r.message.content,
336 score: r.score,
337 source: MemorySource::Semantic,
338 })
339 .collect())
340 }
341
342 async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError> {
343 let summaries = self.load_summaries(conv_id).await?;
344 Ok(summaries
345 .into_iter()
346 .map(|s| s.content)
347 .collect::<Vec<_>>()
348 .join("\n"))
349 }
350
351 async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError> {
352 let before = self.message_count(ctx.conversation_id).await?;
353 let messages_compacted = usize::try_from(before).unwrap_or(0);
354 // Trigger a summarization pass to reduce context below the token budget.
355 // The message_count parameter drives how many messages to summarize at once.
356 // Approximate: 4 chars per token; produce a target message count that
357 // keeps the resulting context under the token budget.
358 let target_msgs = ctx.token_budget.checked_div(4).unwrap_or(512);
359 let _ = self.summarize(ctx.conversation_id, target_msgs).await?;
360 let summary = self
361 .load_summaries(ctx.conversation_id)
362 .await?
363 .into_iter()
364 .map(|s| s.content)
365 .collect::<Vec<_>>()
366 .join("\n");
367 Ok(CompactionResult {
368 summary,
369 messages_compacted,
370 })
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[tokio::test]
379 async fn remember_and_recall() {
380 let facade = InMemoryFacade::new();
381 let entry = MemoryEntry {
382 conversation_id: ConversationId(1),
383 role: "user".into(),
384 content: "Rust ownership model".into(),
385 parts: vec![],
386 metadata: None,
387 };
388 let id = facade.remember(entry).await.unwrap();
389 assert_eq!(id, MessageId(1));
390
391 let matches = facade.recall("ownership", 10).await.unwrap();
392 assert_eq!(matches.len(), 1);
393 assert_eq!(matches[0].content, "Rust ownership model");
394 assert_eq!(matches[0].source, MemorySource::Keyword);
395 }
396
397 #[tokio::test]
398 async fn recall_no_match() {
399 let facade = InMemoryFacade::new();
400 let entry = MemoryEntry {
401 conversation_id: ConversationId(1),
402 role: "user".into(),
403 content: "Rust ownership model".into(),
404 parts: vec![],
405 metadata: None,
406 };
407 facade.remember(entry).await.unwrap();
408 let matches = facade.recall("Python", 10).await.unwrap();
409 assert!(matches.is_empty());
410 }
411
412 #[tokio::test]
413 async fn summarize_joins_content() {
414 let facade = InMemoryFacade::new();
415 for content in ["Hello", "World"] {
416 facade
417 .remember(MemoryEntry {
418 conversation_id: ConversationId(1),
419 role: "user".into(),
420 content: content.into(),
421 parts: vec![],
422 metadata: None,
423 })
424 .await
425 .unwrap();
426 }
427 let summary = facade.summarize(ConversationId(1)).await.unwrap();
428 assert!(summary.contains("Hello") && summary.contains("World"));
429 }
430
431 #[tokio::test]
432 async fn compact_removes_conversation_entries() {
433 let facade = InMemoryFacade::new();
434 facade
435 .remember(MemoryEntry {
436 conversation_id: ConversationId(1),
437 role: "user".into(),
438 content: "entry 1".into(),
439 parts: vec![],
440 metadata: None,
441 })
442 .await
443 .unwrap();
444 facade
445 .remember(MemoryEntry {
446 conversation_id: ConversationId(2),
447 role: "user".into(),
448 content: "other conv".into(),
449 parts: vec![],
450 metadata: None,
451 })
452 .await
453 .unwrap();
454
455 let result = facade
456 .compact(&CompactionContext {
457 conversation_id: ConversationId(1),
458 token_budget: 100,
459 })
460 .await
461 .unwrap();
462
463 assert_eq!(result.messages_compacted, 1);
464 assert!(result.summary.contains("entry 1"));
465 // Other conversation untouched
466 assert_eq!(facade.len(), 1);
467 }
468
469 #[tokio::test]
470 async fn recall_respects_limit() {
471 let facade = InMemoryFacade::new();
472 for i in 0..5 {
473 facade
474 .remember(MemoryEntry {
475 conversation_id: ConversationId(1),
476 role: "user".into(),
477 content: format!("memory item {i}"),
478 parts: vec![],
479 metadata: None,
480 })
481 .await
482 .unwrap();
483 }
484 let matches = facade.recall("memory", 3).await.unwrap();
485 assert_eq!(matches.len(), 3);
486 }
487}