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