Skip to main content

cel_memory/
basic.rs

1//! `BasicMemoryProvider` — the v1 backing implementation.
2//!
3//! In-process, in-memory, deliberately simple. Implements retrieval, writes,
4//! session lifecycle, simple deletes, export, and stats; returns
5//! `Err(NotImplemented)` for summarization and re-embed; no-ops for
6//! `update_importance` and `supersede`.
7//!
8//! Lexical retrieval only — substring + recency. A full storage backend
9//! (e.g. the `cel-memory-sqlite` crate) replaces this with hybrid
10//! (vector + FTS + recency) retrieval.
11//!
12//! The persistence layer here is `Arc<Mutex<State>>`. It is useful for tests,
13//! examples, and lightweight agents that do not need durability across process
14//! restarts.
15
16use 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/// The v1 backing implementation. Cheap to construct; `Clone` is shallow
35/// (shares the underlying state). Safe to share across tasks.
36#[derive(Clone, Default)]
37pub struct BasicMemoryProvider {
38    state: Arc<Mutex<State>>,
39    /// Optional pre-write hook (redaction, governance). When unset, every
40    /// write proceeds verbatim.
41    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    /// Construct an empty provider. Equivalent to `Default::default()`.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Attach a [`MemoryWriteHook`](crate::MemoryWriteHook) consulted
68    /// before every write. Use this for policy-driven memory redaction or
69    /// application-specific write filtering.
70    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    // ─────────────── Reads ───────────────
83
84    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                // Kind filter.
98                if let Some(kinds) = &query.kinds {
99                    if !kinds.contains(&c.kind) {
100                        return false;
101                    }
102                }
103                // include_rollups=false suppresses rollup chunks.
104                if !query.include_rollups && c.kind == ChunkKind::Rollup {
105                    return false;
106                }
107                // Time bounds.
108                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                // Session restriction.
119                if let Some(sid) = &query.session_id {
120                    if c.session_id.as_deref() != Some(sid.as_str()) {
121                        return false;
122                    }
123                }
124                // Project root prefix.
125                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                // Min importance.
132                if let Some(min) = query.min_importance {
133                    if c.importance < min {
134                        return false;
135                    }
136                }
137                // CallerScope enforcement. `Own` restricts to the caller's
138                // own chunks. `OwnPlusShared` permits the caller's own
139                // chunks *and* any chunk tagged `shareable=true` (the
140                // Phase 4 multi-agent surface).
141                // `Global` permits everything.
142                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                // Lexical: substring of content (case-insensitive).
156                c.content.to_lowercase().contains(&needle)
157            })
158            .collect();
159
160        // Order by recency (newest first). The v1 stub does not perform
161        // hybrid scoring — `RetrievalProfile` is accepted but ignored.
162        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    // ─────────────── Writes ───────────────
218
219    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        // Consult the optional pre-write hook (memory_write_attempted seam).
227        // On Redact, return a synthetic chunk with a `redacted: true` marker
228        // and DO NOT persist it. Callers that care can inspect the returned
229        // chunk; most won't notice the difference.
230        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        // The v1 stub doesn't batch embeddings (there are none). Process
282        // one-by-one to keep the impl honest.
283        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            // Refuse to close into `Open`.
315            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    // ─────────────── Updates ───────────────
332
333    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        // v1 does not score importance. No-op rather than NotImplemented:
345        // callers can fire-and-forget without conditional logic.
346        Ok(())
347    }
348
349    async fn supersede(&self, _old_id: &str, _new_id: &str) -> Result<()> {
350        // v1 does not track supersession. No-op (callers may set it via the
351        // full impl later; safe to call from any v1 caller).
352        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(), // v1 stub doesn't compute query hashes
365            rank: 0,
366            used,
367        });
368        Ok(())
369    }
370
371    // ─────────────── Deletes ───────────────
372
373    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        // Footgun guard: an empty predicate would match every chunk; callers
393        // who want "delete everything" must use `purge_all`.
394        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    // ─────────────── Summarization (NotImplemented in v1) ───────────────
434
435    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    // ─────────────── Maintenance ───────────────
452
453    async fn run_aging_sweep(&self) -> Result<AgingReport> {
454        // v1 stub: a 30-day retention sweep over non-pinned non-correction
455        // chunks. No importance scoring; honest minimum.
456        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, // v1 doesn't transition tiers
478            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, // in-memory: not meaningful in v1
552            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        // Two callers, two chunks: one shareable from "mcp:cursor",
684        // one private from "mcp:cursor". A third caller asks with
685        // OwnPlusShared scope; only the shareable one surfaces.
686        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        // Only the shareable chunk is visible to the embedded caller.
699        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        // The caller's own chunks are always visible under OwnPlusShared
708        // regardless of the shareable flag.
709        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        // Small delay to ensure created_at differs.
727        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        // Importance unchanged: v1 stub no-ops.
868        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        // Forcibly age a chunk by editing the state directly.
899        {
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            // Pin the pinned one.
924            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}