1use std::collections::HashMap;
17use std::sync::Arc;
18
19use async_trait::async_trait;
20use chrono::{NaiveDate, Utc};
21use tokio::sync::Mutex;
22use uuid::Uuid;
23
24use crate::chunk::{ChunkKind, MemoryChunk, MemoryTier, NewMemoryChunk};
25use crate::error::{MemoryError, Result};
26use crate::ops::{
27 AccessEntry, AgingReport, EvictionEntry, EvictionReason, ExportBundle, ExportFilter,
28 MemoryStats, PurgeReport, ReEmbedReport,
29};
30use crate::provider::MemoryProvider;
31use crate::query::{MemoryPredicate, MemoryQuery};
32use crate::session::{MemorySession, NewMemorySession, SessionFilter, SessionOutcome};
33
34#[derive(Clone, Default)]
37pub struct BasicMemoryProvider {
38 state: Arc<Mutex<State>>,
39 write_hook: Option<Arc<dyn crate::MemoryWriteHook>>,
42}
43
44impl std::fmt::Debug for BasicMemoryProvider {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("BasicMemoryProvider")
47 .field("state", &self.state)
48 .field("write_hook", &self.write_hook.as_ref().map(|_| "<hook>"))
49 .finish()
50 }
51}
52
53#[derive(Debug, Default)]
54struct State {
55 chunks: HashMap<String, MemoryChunk>,
56 sessions: HashMap<String, MemorySession>,
57 evictions: Vec<EvictionEntry>,
58 accesses: Vec<AccessEntry>,
59}
60
61impl BasicMemoryProvider {
62 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn with_write_hook(mut self, hook: Arc<dyn crate::MemoryWriteHook>) -> Self {
71 self.write_hook = Some(hook);
72 self
73 }
74
75 fn next_id() -> String {
76 Uuid::now_v7().to_string()
77 }
78}
79
80#[async_trait]
81impl MemoryProvider for BasicMemoryProvider {
82 async fn retrieve(&self, query: MemoryQuery) -> Result<Vec<MemoryChunk>> {
85 if query.text.trim().is_empty() {
86 return Err(MemoryError::InvalidArgument(
87 "query.text must not be empty".into(),
88 ));
89 }
90 let needle = query.text.to_lowercase();
91 let state = self.state.lock().await;
92
93 let mut hits: Vec<&MemoryChunk> = state
94 .chunks
95 .values()
96 .filter(|c| {
97 if let Some(kinds) = &query.kinds {
99 if !kinds.contains(&c.kind) {
100 return false;
101 }
102 }
103 if !query.include_rollups && c.kind == ChunkKind::Rollup {
105 return false;
106 }
107 if let Some(since) = query.since {
109 if c.created_at < since {
110 return false;
111 }
112 }
113 if let Some(until) = query.until {
114 if c.created_at > until {
115 return false;
116 }
117 }
118 if let Some(sid) = &query.session_id {
120 if c.session_id.as_deref() != Some(sid.as_str()) {
121 return false;
122 }
123 }
124 if let Some(prefix) = &query.project_root_prefix {
126 match &c.project_root {
127 Some(root) if root.starts_with(prefix.as_str()) => {}
128 _ => return false,
129 }
130 }
131 if let Some(min) = query.min_importance {
133 if c.importance < min {
134 return false;
135 }
136 }
137 match query.caller_scope {
143 crate::query::CallerScope::Own => {
144 if c.caller_id != query.caller_id {
145 return false;
146 }
147 }
148 crate::query::CallerScope::OwnPlusShared => {
149 if c.caller_id != query.caller_id && !c.shareable {
150 return false;
151 }
152 }
153 crate::query::CallerScope::Global => {}
154 }
155 c.content.to_lowercase().contains(&needle)
157 })
158 .collect();
159
160 hits.sort_by_key(|c| std::cmp::Reverse(c.created_at));
163 hits.truncate(query.k);
164 Ok(hits.into_iter().cloned().collect())
165 }
166
167 async fn get(&self, chunk_id: &str) -> Result<Option<MemoryChunk>> {
168 let state = self.state.lock().await;
169 Ok(state.chunks.get(chunk_id).cloned())
170 }
171
172 async fn get_session(&self, session_id: &str) -> Result<Option<MemorySession>> {
173 let state = self.state.lock().await;
174 Ok(state.sessions.get(session_id).cloned())
175 }
176
177 async fn list_sessions(&self, filter: SessionFilter) -> Result<Vec<MemorySession>> {
178 let state = self.state.lock().await;
179 let mut out: Vec<MemorySession> = state
180 .sessions
181 .values()
182 .filter(|s| {
183 if let Some(c) = &filter.caller_id {
184 if &s.caller_id != c {
185 return false;
186 }
187 }
188 if let Some(o) = filter.outcome {
189 if s.outcome != o {
190 return false;
191 }
192 }
193 if filter.open_only && s.outcome != SessionOutcome::Open {
194 return false;
195 }
196 if let Some(since) = filter.since {
197 if s.started_at < since {
198 return false;
199 }
200 }
201 if let Some(until) = filter.until {
202 if s.started_at > until {
203 return false;
204 }
205 }
206 true
207 })
208 .cloned()
209 .collect();
210 out.sort_by_key(|s| std::cmp::Reverse(s.started_at));
211 if let Some(n) = filter.limit {
212 out.truncate(n);
213 }
214 Ok(out)
215 }
216
217 async fn write(&self, new_chunk: NewMemoryChunk) -> Result<MemoryChunk> {
220 if new_chunk.content.trim().is_empty() {
221 return Err(MemoryError::InvalidArgument(
222 "content must not be empty".into(),
223 ));
224 }
225
226 let importance = crate::importance::score(&new_chunk);
231 if let Some(hook) = &self.write_hook {
232 match hook.before_write(&new_chunk).await? {
233 crate::WriteDecision::Allow => {}
234 crate::WriteDecision::Redact { reason } => {
235 return Ok(MemoryChunk {
236 id: Self::next_id(),
237 created_at: Utc::now(),
238 kind: new_chunk.kind,
239 tier: MemoryTier::Session,
240 source: new_chunk.source,
241 session_id: new_chunk.session_id,
242 project_root: new_chunk.project_root,
243 caller_id: new_chunk.caller_id,
244 content: format!("<redacted: {reason}>"),
245 metadata: serde_json::json!({"redacted": true, "reason": reason}),
246 importance: 0.0,
247 pinned: false,
248 shareable: false,
249 superseded_by: None,
250 embedding_model: "none".into(),
251 embedding_dim: 0,
252 });
253 }
254 }
255 }
256
257 let chunk = MemoryChunk {
258 id: Self::next_id(),
259 created_at: Utc::now(),
260 kind: new_chunk.kind,
261 tier: MemoryTier::Session,
262 source: new_chunk.source,
263 session_id: new_chunk.session_id,
264 project_root: new_chunk.project_root,
265 caller_id: new_chunk.caller_id,
266 content: new_chunk.content,
267 metadata: new_chunk.metadata,
268 importance,
269 pinned: new_chunk.pinned,
270 shareable: new_chunk.shareable,
271 superseded_by: None,
272 embedding_model: "none".into(),
273 embedding_dim: 0,
274 };
275 let mut state = self.state.lock().await;
276 state.chunks.insert(chunk.id.clone(), chunk.clone());
277 Ok(chunk)
278 }
279
280 async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> Result<Vec<MemoryChunk>> {
281 let mut out = Vec::with_capacity(chunks.len());
284 for nc in chunks {
285 out.push(self.write(nc).await?);
286 }
287 Ok(out)
288 }
289
290 async fn open_session(&self, init: NewMemorySession) -> Result<MemorySession> {
291 let s = MemorySession {
292 id: Self::next_id(),
293 started_at: Utc::now(),
294 ended_at: None,
295 caller_id: init.caller_id,
296 title: init.title,
297 summary: None,
298 outcome: SessionOutcome::Open,
299 metadata: init.metadata,
300 };
301 let mut state = self.state.lock().await;
302 state.sessions.insert(s.id.clone(), s.clone());
303 Ok(s)
304 }
305
306 async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> Result<()> {
307 let mut state = self.state.lock().await;
308 let s = state
309 .sessions
310 .get_mut(session_id)
311 .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
312 s.ended_at = Some(Utc::now());
313 s.outcome = match outcome {
314 SessionOutcome::Open => SessionOutcome::Aborted,
316 other => other,
317 };
318 Ok(())
319 }
320
321 async fn rename_session(&self, session_id: &str, title: &str) -> Result<()> {
322 let mut state = self.state.lock().await;
323 let s = state
324 .sessions
325 .get_mut(session_id)
326 .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
327 s.title = Some(title.to_string());
328 Ok(())
329 }
330
331 async fn pin(&self, chunk_id: &str, pinned: bool) -> Result<()> {
334 let mut state = self.state.lock().await;
335 let c = state
336 .chunks
337 .get_mut(chunk_id)
338 .ok_or_else(|| MemoryError::NotFound(format!("chunk {chunk_id}")))?;
339 c.pinned = pinned;
340 Ok(())
341 }
342
343 async fn update_importance(&self, _chunk_id: &str, _importance: f32) -> Result<()> {
344 Ok(())
347 }
348
349 async fn supersede(&self, _old_id: &str, _new_id: &str) -> Result<()> {
350 Ok(())
353 }
354
355 async fn record_access(&self, chunk_id: &str, retrieved_by: &str, used: bool) -> Result<()> {
356 let mut state = self.state.lock().await;
357 if !state.chunks.contains_key(chunk_id) {
358 return Err(MemoryError::NotFound(format!("chunk {chunk_id}")));
359 }
360 state.accesses.push(AccessEntry {
361 ts: Utc::now(),
362 chunk_id: chunk_id.to_string(),
363 retrieved_by: retrieved_by.to_string(),
364 query_hash: String::new(), rank: 0,
366 used,
367 });
368 Ok(())
369 }
370
371 async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> Result<()> {
374 let mut state = self.state.lock().await;
375 if state.chunks.remove(chunk_id).is_none() {
376 return Err(MemoryError::NotFound(format!("chunk {chunk_id}")));
377 }
378 state.evictions.push(EvictionEntry {
379 ts: Utc::now(),
380 chunk_id: chunk_id.to_string(),
381 reason,
382 metadata: serde_json::Value::Null,
383 });
384 Ok(())
385 }
386
387 async fn delete_matching(
388 &self,
389 predicate: MemoryPredicate,
390 reason: EvictionReason,
391 ) -> Result<usize> {
392 if predicate.is_empty() {
395 return Ok(0);
396 }
397 let mut state = self.state.lock().await;
398 let now = Utc::now();
399 let to_delete: Vec<String> = state
400 .chunks
401 .values()
402 .filter(|c| chunk_matches(c, &predicate))
403 .map(|c| c.id.clone())
404 .collect();
405 let count = to_delete.len();
406 for id in to_delete {
407 state.chunks.remove(&id);
408 state.evictions.push(EvictionEntry {
409 ts: now,
410 chunk_id: id,
411 reason,
412 metadata: serde_json::Value::Null,
413 });
414 }
415 Ok(count)
416 }
417
418 async fn purge_all(&self) -> Result<PurgeReport> {
419 let mut state = self.state.lock().await;
420 let report = PurgeReport {
421 chunks_deleted: state.chunks.len(),
422 sessions_deleted: state.sessions.len(),
423 access_log_deleted: state.accesses.len(),
424 eviction_log_deleted: state.evictions.len(),
425 };
426 state.chunks.clear();
427 state.sessions.clear();
428 state.accesses.clear();
429 state.evictions.clear();
430 Ok(report)
431 }
432
433 async fn summarize_session(&self, _session_id: &str) -> Result<MemoryChunk> {
436 Err(MemoryError::NotImplemented("summarize_session"))
437 }
438
439 async fn rollup_day(&self, _date: NaiveDate) -> Result<Vec<MemoryChunk>> {
440 Err(MemoryError::NotImplemented("rollup_day"))
441 }
442
443 async fn rollup_rule_week(
444 &self,
445 _rule_id: &str,
446 _week_start: NaiveDate,
447 ) -> Result<MemoryChunk> {
448 Err(MemoryError::NotImplemented("rollup_rule_week"))
449 }
450
451 async fn run_aging_sweep(&self) -> Result<AgingReport> {
454 const RETENTION_DAYS: i64 = 30;
457 let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
458 let mut state = self.state.lock().await;
459 let to_delete: Vec<String> = state
460 .chunks
461 .values()
462 .filter(|c| !c.pinned && c.kind != ChunkKind::Correction && c.created_at < cutoff)
463 .map(|c| c.id.clone())
464 .collect();
465 let deleted = to_delete.len();
466 let now = Utc::now();
467 for id in to_delete {
468 state.chunks.remove(&id);
469 state.evictions.push(EvictionEntry {
470 ts: now,
471 chunk_id: id,
472 reason: EvictionReason::Aging,
473 metadata: serde_json::Value::Null,
474 });
475 }
476 Ok(AgingReport {
477 tier_promoted: 0, deleted,
479 bytes_reclaimed: 0,
480 deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
481 })
482 }
483
484 async fn re_embed_all(&self, _target_model: &str) -> Result<ReEmbedReport> {
485 Err(MemoryError::NotImplemented("re_embed_all"))
486 }
487
488 async fn export(&self, filter: ExportFilter) -> Result<ExportBundle> {
489 let state = self.state.lock().await;
490 let chunks: Vec<MemoryChunk> = state
491 .chunks
492 .values()
493 .filter(|c| match &filter.predicate {
494 Some(p) if !p.is_empty() => chunk_matches(c, p),
495 _ => true,
496 })
497 .cloned()
498 .collect();
499
500 let session_ids: std::collections::HashSet<String> =
501 chunks.iter().filter_map(|c| c.session_id.clone()).collect();
502 let sessions = if filter.include_sessions {
503 state
504 .sessions
505 .values()
506 .filter(|s| session_ids.contains(&s.id))
507 .cloned()
508 .collect()
509 } else {
510 Vec::new()
511 };
512 let evictions = if filter.include_eviction_log {
513 state.evictions.clone()
514 } else {
515 Vec::new()
516 };
517 let accesses = if filter.include_access_log {
518 state.accesses.clone()
519 } else {
520 Vec::new()
521 };
522 Ok(ExportBundle {
523 chunks,
524 sessions,
525 evictions,
526 accesses,
527 })
528 }
529
530 async fn stats(&self) -> Result<MemoryStats> {
531 let state = self.state.lock().await;
532 let total = state.chunks.len();
533 Ok(MemoryStats {
534 total_chunks: total,
535 session_chunks: state
536 .chunks
537 .values()
538 .filter(|c| c.tier == MemoryTier::Session)
539 .count(),
540 long_term_chunks: state
541 .chunks
542 .values()
543 .filter(|c| c.tier == MemoryTier::LongTerm)
544 .count(),
545 total_sessions: state.sessions.len(),
546 open_sessions: state
547 .sessions
548 .values()
549 .filter(|s| s.outcome == SessionOutcome::Open)
550 .count(),
551 db_bytes: 0, embedding_model: None,
553 })
554 }
555}
556
557fn chunk_matches(c: &MemoryChunk, p: &MemoryPredicate) -> bool {
558 if let Some(kinds) = &p.kinds {
559 if !kinds.contains(&c.kind) {
560 return false;
561 }
562 }
563 if let Some(callers) = &p.callers {
564 if !callers.iter().any(|x| x == &c.caller_id) {
565 return false;
566 }
567 }
568 if let Some(sids) = &p.session_ids {
569 match &c.session_id {
570 Some(id) if sids.iter().any(|x| x == id) => {}
571 _ => return false,
572 }
573 }
574 if let Some(prefix) = &p.project_root_prefix {
575 match &c.project_root {
576 Some(root) if root.starts_with(prefix.as_str()) => {}
577 _ => return false,
578 }
579 }
580 if let Some(before) = p.before {
581 if c.created_at >= before {
582 return false;
583 }
584 }
585 if let Some(after) = p.after {
586 if c.created_at <= after {
587 return false;
588 }
589 }
590 if let Some(pinned) = p.pinned {
591 if c.pinned != pinned {
592 return false;
593 }
594 }
595 if let Some(below) = p.importance_below {
596 if c.importance >= below {
597 return false;
598 }
599 }
600 if let Some(needle) = &p.content_contains {
601 if !c.content.to_lowercase().contains(&needle.to_lowercase()) {
602 return false;
603 }
604 }
605 true
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::chunk::ChunkSource;
612 use crate::query::{CallerScope, RetrievalProfile};
613 use serde_json::json;
614
615 fn nc(caller: &str, content: &str) -> NewMemoryChunk {
616 NewMemoryChunk {
617 kind: ChunkKind::Chat,
618 source: ChunkSource::Embedded,
619 session_id: None,
620 project_root: None,
621 caller_id: caller.into(),
622 content: content.into(),
623 metadata: json!(null),
624 importance: None,
625 shareable: false,
626 pinned: false,
627 }
628 }
629
630 fn q(caller: &str, text: &str) -> MemoryQuery {
631 MemoryQuery {
632 text: text.into(),
633 kinds: None,
634 since: None,
635 until: None,
636 session_id: None,
637 caller_scope: CallerScope::Own,
638 project_root_prefix: None,
639 k: 8,
640 include_rollups: true,
641 min_importance: None,
642 profile: RetrievalProfile::AgentChatTurn,
643 caller_id: caller.into(),
644 }
645 }
646
647 #[tokio::test]
648 async fn write_and_retrieve_round_trips() {
649 let m = BasicMemoryProvider::new();
650 let c = m
651 .write(nc("embedded", "Q4 report is filed under Workspace"))
652 .await
653 .unwrap();
654 let hits = m.retrieve(q("embedded", "q4 report")).await.unwrap();
655 assert_eq!(hits.len(), 1);
656 assert_eq!(hits[0].id, c.id);
657 }
658
659 #[tokio::test]
660 async fn retrieve_respects_caller_scope_own() {
661 let m = BasicMemoryProvider::new();
662 m.write(nc("embedded", "my secret")).await.unwrap();
663 m.write(nc("mcp:cursor", "my secret")).await.unwrap();
664 let hits = m.retrieve(q("embedded", "secret")).await.unwrap();
665 assert_eq!(hits.len(), 1);
666 assert_eq!(hits[0].caller_id, "embedded");
667 }
668
669 #[tokio::test]
670 async fn retrieve_global_scope_sees_all() {
671 let m = BasicMemoryProvider::new();
672 m.write(nc("embedded", "my secret")).await.unwrap();
673 m.write(nc("mcp:cursor", "my secret")).await.unwrap();
674 let mut query = q("audit", "secret");
675 query.caller_scope = CallerScope::Global;
676 let hits = m.retrieve(query).await.unwrap();
677 assert_eq!(hits.len(), 2);
678 }
679
680 #[tokio::test]
681 async fn retrieve_own_plus_shared_surfaces_shareable_across_callers() {
682 let m = BasicMemoryProvider::new();
683 let mut shared = nc("mcp:cursor", "user prefers dry-run mode");
687 shared.shareable = true;
688 m.write(shared).await.unwrap();
689 m.write(nc("mcp:cursor", "user said hi to cursor"))
690 .await
691 .unwrap();
692 m.write(nc("mcp:codex", "user said hi to codex"))
693 .await
694 .unwrap();
695 let mut query = q("embedded", "user");
696 query.caller_scope = CallerScope::OwnPlusShared;
697 let hits = m.retrieve(query).await.unwrap();
698 assert_eq!(hits.len(), 1);
700 assert!(hits[0].content.contains("dry-run"));
701 assert!(hits[0].shareable);
702 }
703
704 #[tokio::test]
705 async fn retrieve_own_plus_shared_includes_own_unshared() {
706 let m = BasicMemoryProvider::new();
707 m.write(nc("embedded", "embedded private note"))
710 .await
711 .unwrap();
712 m.write(nc("mcp:cursor", "cursor private note"))
713 .await
714 .unwrap();
715 let mut query = q("embedded", "note");
716 query.caller_scope = CallerScope::OwnPlusShared;
717 let hits = m.retrieve(query).await.unwrap();
718 assert_eq!(hits.len(), 1);
719 assert_eq!(hits[0].caller_id, "embedded");
720 }
721
722 #[tokio::test]
723 async fn retrieve_orders_recent_first() {
724 let m = BasicMemoryProvider::new();
725 m.write(nc("embedded", "first about cats")).await.unwrap();
726 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
728 let second = m.write(nc("embedded", "second about cats")).await.unwrap();
729 let hits = m.retrieve(q("embedded", "cats")).await.unwrap();
730 assert_eq!(hits[0].id, second.id);
731 }
732
733 #[tokio::test]
734 async fn retrieve_kind_filter() {
735 let m = BasicMemoryProvider::new();
736 let chat = m.write(nc("embedded", "alpha")).await.unwrap();
737 let mut action = nc("embedded", "alpha");
738 action.kind = ChunkKind::Action;
739 let _ = m.write(action).await.unwrap();
740 let mut query = q("embedded", "alpha");
741 query.kinds = Some(vec![ChunkKind::Chat]);
742 let hits = m.retrieve(query).await.unwrap();
743 assert_eq!(hits.len(), 1);
744 assert_eq!(hits[0].id, chat.id);
745 }
746
747 #[tokio::test]
748 async fn empty_query_text_rejected() {
749 let m = BasicMemoryProvider::new();
750 let err = m.retrieve(q("embedded", " ")).await.unwrap_err();
751 assert!(matches!(err, MemoryError::InvalidArgument(_)));
752 }
753
754 #[tokio::test]
755 async fn empty_content_rejected() {
756 let m = BasicMemoryProvider::new();
757 let err = m.write(nc("embedded", "")).await.unwrap_err();
758 assert!(matches!(err, MemoryError::InvalidArgument(_)));
759 }
760
761 #[tokio::test]
762 async fn session_open_close() {
763 let m = BasicMemoryProvider::new();
764 let s = m
765 .open_session(NewMemorySession {
766 caller_id: "embedded".into(),
767 title: Some("test".into()),
768 metadata: json!(null),
769 })
770 .await
771 .unwrap();
772 assert_eq!(s.outcome, SessionOutcome::Open);
773 m.close_session(&s.id, SessionOutcome::Success)
774 .await
775 .unwrap();
776 let s2 = m.get_session(&s.id).await.unwrap().unwrap();
777 assert_eq!(s2.outcome, SessionOutcome::Success);
778 assert!(s2.ended_at.is_some());
779 }
780
781 #[tokio::test]
782 async fn close_session_unknown_id_errors() {
783 let m = BasicMemoryProvider::new();
784 let err = m
785 .close_session("nope", SessionOutcome::Success)
786 .await
787 .unwrap_err();
788 assert!(matches!(err, MemoryError::NotFound(_)));
789 }
790
791 #[tokio::test]
792 async fn delete_matching_empty_predicate_is_noop() {
793 let m = BasicMemoryProvider::new();
794 m.write(nc("embedded", "x")).await.unwrap();
795 let n = m
796 .delete_matching(MemoryPredicate::default(), EvictionReason::UserDelete)
797 .await
798 .unwrap();
799 assert_eq!(n, 0);
800 let stats = m.stats().await.unwrap();
801 assert_eq!(stats.total_chunks, 1);
802 }
803
804 #[tokio::test]
805 async fn delete_matching_by_caller() {
806 let m = BasicMemoryProvider::new();
807 m.write(nc("embedded", "x")).await.unwrap();
808 m.write(nc("mcp:cursor", "y")).await.unwrap();
809 let n = m
810 .delete_matching(
811 MemoryPredicate {
812 callers: Some(vec!["embedded".into()]),
813 ..Default::default()
814 },
815 EvictionReason::UserDelete,
816 )
817 .await
818 .unwrap();
819 assert_eq!(n, 1);
820 let stats = m.stats().await.unwrap();
821 assert_eq!(stats.total_chunks, 1);
822 }
823
824 #[tokio::test]
825 async fn purge_all_clears_state() {
826 let m = BasicMemoryProvider::new();
827 m.write(nc("embedded", "x")).await.unwrap();
828 let _ = m
829 .open_session(NewMemorySession {
830 caller_id: "embedded".into(),
831 title: None,
832 metadata: json!(null),
833 })
834 .await
835 .unwrap();
836 let r = m.purge_all().await.unwrap();
837 assert_eq!(r.chunks_deleted, 1);
838 assert_eq!(r.sessions_deleted, 1);
839 let stats = m.stats().await.unwrap();
840 assert_eq!(stats.total_chunks, 0);
841 assert_eq!(stats.total_sessions, 0);
842 }
843
844 #[tokio::test]
845 async fn summarize_returns_not_implemented() {
846 let m = BasicMemoryProvider::new();
847 let err = m.summarize_session("anything").await.unwrap_err();
848 assert!(matches!(
849 err,
850 MemoryError::NotImplemented("summarize_session")
851 ));
852 }
853
854 #[tokio::test]
855 async fn re_embed_returns_not_implemented() {
856 let m = BasicMemoryProvider::new();
857 let err = m.re_embed_all("bge-small-en-v1.5").await.unwrap_err();
858 assert!(matches!(err, MemoryError::NotImplemented("re_embed_all")));
859 }
860
861 #[tokio::test]
862 async fn update_importance_and_supersede_noop() {
863 let m = BasicMemoryProvider::new();
864 let c = m.write(nc("embedded", "x")).await.unwrap();
865 m.update_importance(&c.id, 0.9).await.unwrap();
866 m.supersede(&c.id, "other").await.unwrap();
867 let got = m.get(&c.id).await.unwrap().unwrap();
869 assert_eq!(got.importance, 0.5);
870 assert!(got.superseded_by.is_none());
871 }
872
873 #[tokio::test]
874 async fn export_filters_by_predicate() {
875 let m = BasicMemoryProvider::new();
876 m.write(nc("embedded", "alpha")).await.unwrap();
877 m.write(nc("embedded", "beta")).await.unwrap();
878 let bundle = m
879 .export(ExportFilter {
880 predicate: Some(MemoryPredicate {
881 content_contains: Some("alpha".into()),
882 ..Default::default()
883 }),
884 include_eviction_log: false,
885 include_access_log: false,
886 include_sessions: true,
887 })
888 .await
889 .unwrap();
890 assert_eq!(bundle.chunks.len(), 1);
891 assert!(bundle.chunks[0].content.contains("alpha"));
892 }
893
894 #[tokio::test]
895 async fn aging_sweep_deletes_old_unpinned() {
896 let m = BasicMemoryProvider::new();
897 let fresh = m.write(nc("embedded", "fresh")).await.unwrap();
898 {
900 let mut state = m.state.lock().await;
901 let c = state.chunks.get_mut(&fresh.id).unwrap();
902 c.created_at = Utc::now() - chrono::Duration::days(45);
903 }
904 let r = m.run_aging_sweep().await.unwrap();
905 assert_eq!(r.deleted, 1);
906 let stats = m.stats().await.unwrap();
907 assert_eq!(stats.total_chunks, 0);
908 }
909
910 #[tokio::test]
911 async fn aging_sweep_preserves_pinned_and_corrections() {
912 let m = BasicMemoryProvider::new();
913 let pinned = m.write(nc("embedded", "pinned")).await.unwrap();
914 let mut correction_nc = nc("embedded", "correction");
915 correction_nc.kind = ChunkKind::Correction;
916 let correction = m.write(correction_nc).await.unwrap();
917 {
918 let mut state = m.state.lock().await;
919 for id in [&pinned.id, &correction.id] {
920 let c = state.chunks.get_mut(id).unwrap();
921 c.created_at = Utc::now() - chrono::Duration::days(45);
922 }
923 let c = state.chunks.get_mut(&pinned.id).unwrap();
925 c.pinned = true;
926 }
927 let r = m.run_aging_sweep().await.unwrap();
928 assert_eq!(r.deleted, 0);
929 }
930}