1use std::collections::BTreeMap;
25use std::sync::Mutex;
26
27use crate::error::MemoryError;
28use crate::types::{ConversationId, MessageId};
29
30#[derive(Debug, Clone)]
54pub struct MemoryEntry {
55 pub conversation_id: ConversationId,
57 pub role: String,
59 pub content: String,
61 pub parts: Vec<serde_json::Value>,
63 pub metadata: Option<serde_json::Value>,
65}
66
67#[derive(Debug, Clone)]
82pub struct MemoryMatch {
83 pub content: String,
85 pub score: f32,
87 pub source: MemorySource,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum MemorySource {
95 Semantic,
97 Episodic,
99 Graph,
101 Keyword,
103}
104
105#[derive(Debug, Clone)]
117pub struct CompactionContext {
118 pub conversation_id: ConversationId,
120 pub token_budget: usize,
122}
123
124#[derive(Debug, Clone)]
126pub struct CompactionResult {
127 pub summary: String,
129 pub messages_compacted: usize,
131}
132
133#[allow(async_fn_in_trait)]
153pub trait MemoryFacade: Send + Sync {
154 async fn remember(&self, entry: MemoryEntry) -> Result<MessageId, MemoryError>;
160
161 async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryMatch>, MemoryError>;
167
168 async fn summarize(&self, conv_id: ConversationId) -> Result<String, MemoryError>;
174
175 async fn compact(&self, ctx: &CompactionContext) -> Result<CompactionResult, MemoryError>;
181}
182
183#[derive(Debug, Default)]
211pub struct InMemoryFacade {
212 entries: Mutex<BTreeMap<i64, MemoryEntry>>,
213 next_id: Mutex<i64>,
214}
215
216impl InMemoryFacade {
217 #[must_use]
219 pub fn new() -> Self {
220 Self::default()
221 }
222
223 #[must_use]
225 pub fn len(&self) -> usize {
226 self.entries.lock().map_or(0, |g| g.len())
227 }
228
229 #[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 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
314impl 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 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 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}