Skip to main content

cel_memory/
basic.rs

1//! `BasicMemoryProvider` — the v1 backing implementation.
2//!
3//! In-process, in-memory, deliberately simple. Real bodies for the methods the
4//! embedded agent and NL compiler actually invoke in v1
5//! ([`retrieve`](MemoryProvider::retrieve), [`write`](MemoryProvider::write),
6//! session lifecycle, simple deletes, [`export`](MemoryProvider::export),
7//! [`stats`](MemoryProvider::stats)); `Err(NotImplemented)` for summarization
8//! and re-embed; no-ops for `update_importance` and `supersede` since v1 does
9//! not score importance.
10//!
11//! Lexical retrieval only — substring + recency. A full storage backend
12//! (e.g. the `cel-memory-sqlite` crate) replaces this with hybrid
13//! (vector + FTS + recency) retrieval.
14//!
15//! The persistence layer here is `Arc<Mutex<State>>`. The daemon will eventually
16//! back this with a `search_knowledge`-style SQLite table; in this crate's tests
17//! and during the v1 Phase 0 -> Phase 1 transition the in-memory store is
18//! sufficient because no caller depends on durability across daemon restarts.
19
20use 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/// The v1 backing implementation. Cheap to construct; `Clone` is shallow
39/// (shares the underlying state). Safe to share across tasks.
40#[derive(Clone, Default)]
41pub struct BasicMemoryProvider {
42    state: Arc<Mutex<State>>,
43    /// Optional pre-write hook (redaction, governance). When unset, every
44    /// write proceeds verbatim.
45    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    /// Construct an empty provider. Equivalent to `Default::default()`.
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Attach a [`MemoryWriteHook`](crate::MemoryWriteHook) consulted
72    /// before every write. Used by the daemon to wire rule-matcher-driven
73    /// memory redaction.
74    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    // ─────────────── Reads ───────────────
87
88    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                // Kind filter.
102                if let Some(kinds) = &query.kinds {
103                    if !kinds.contains(&c.kind) {
104                        return false;
105                    }
106                }
107                // include_rollups=false suppresses rollup chunks.
108                if !query.include_rollups && c.kind == ChunkKind::Rollup {
109                    return false;
110                }
111                // Time bounds.
112                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                // Session restriction.
123                if let Some(sid) = &query.session_id {
124                    if c.session_id.as_deref() != Some(sid.as_str()) {
125                        return false;
126                    }
127                }
128                // Project root prefix.
129                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                // Min importance.
136                if let Some(min) = query.min_importance {
137                    if c.importance < min {
138                        return false;
139                    }
140                }
141                // CallerScope enforcement. `Own` restricts to the caller's
142                // own chunks. `OwnPlusShared` permits the caller's own
143                // chunks *and* any chunk tagged `shareable=true` (the
144                // Phase 4 multi-agent surface).
145                // `Global` permits everything.
146                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                // Lexical: substring of content (case-insensitive).
160                c.content.to_lowercase().contains(&needle)
161            })
162            .collect();
163
164        // Order by recency (newest first). The v1 stub does not perform
165        // hybrid scoring — `RetrievalProfile` is accepted but ignored.
166        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    // ─────────────── Writes ───────────────
222
223    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        // Consult the optional pre-write hook (memory_write_attempted seam).
231        // On Redact, return a synthetic chunk with a `redacted: true` marker
232        // and DO NOT persist it. Callers that care can inspect the returned
233        // chunk; most won't notice the difference.
234        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        // The v1 stub doesn't batch embeddings (there are none). Process
286        // one-by-one to keep the impl honest.
287        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            // Refuse to close into `Open`.
319            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    // ─────────────── Updates ───────────────
336
337    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        // v1 does not score importance. No-op rather than NotImplemented:
349        // callers can fire-and-forget without conditional logic.
350        Ok(())
351    }
352
353    async fn supersede(&self, _old_id: &str, _new_id: &str) -> Result<()> {
354        // v1 does not track supersession. No-op (callers may set it via the
355        // full impl later; safe to call from any v1 caller).
356        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(), // v1 stub doesn't compute query hashes
369            rank: 0,
370            used,
371        });
372        Ok(())
373    }
374
375    // ─────────────── Deletes ───────────────
376
377    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        // Footgun guard: an empty predicate would match every chunk; callers
397        // who want "delete everything" must use `purge_all`.
398        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    // ─────────────── Summarization (NotImplemented in v1) ───────────────
438
439    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    // ─────────────── Maintenance ───────────────
456
457    async fn run_aging_sweep(&self) -> Result<AgingReport> {
458        // v1 stub: a 30-day retention sweep over non-pinned non-correction
459        // chunks. No importance scoring; honest minimum.
460        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, // v1 doesn't transition tiers
482            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, // in-memory: not meaningful in v1
556            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        // Two callers, two chunks: one shareable from "mcp:cursor",
688        // one private from "mcp:cursor". A third caller asks with
689        // OwnPlusShared scope; only the shareable one surfaces.
690        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        // Only the shareable chunk is visible to the embedded caller.
703        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        // The caller's own chunks are always visible under OwnPlusShared
712        // regardless of the shareable flag.
713        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        // Small delay to ensure created_at differs.
731        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        // Importance unchanged: v1 stub no-ops.
872        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        // Forcibly age a chunk by editing the state directly.
903        {
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            // Pin the pinned one.
928            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}