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, stats, summarization, rollups,
5//! and re-embed metadata updates when a [`Summarizer`] is attached; 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, ChunkSource, 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};
33use crate::{Summarizer, SummarizerError, SummaryContext};
34
35/// The v1 backing implementation. Cheap to construct; `Clone` is shallow
36/// (shares the underlying state). Safe to share across tasks.
37#[derive(Clone)]
38pub struct BasicMemoryProvider {
39    state: Arc<Mutex<State>>,
40    /// Optional pre-write hook (redaction, governance). When unset, every
41    /// write proceeds verbatim.
42    write_hook: Option<Arc<dyn crate::MemoryWriteHook>>,
43    /// Optional summarizer for session summaries and rollups.
44    summarizer: Option<Arc<dyn Summarizer>>,
45}
46
47impl Default for BasicMemoryProvider {
48    fn default() -> Self {
49        Self {
50            state: Arc::new(Mutex::new(State::default())),
51            write_hook: None,
52            summarizer: None,
53        }
54    }
55}
56
57impl std::fmt::Debug for BasicMemoryProvider {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("BasicMemoryProvider")
60            .field("state", &self.state)
61            .field("write_hook", &self.write_hook.as_ref().map(|_| "<hook>"))
62            .field(
63                "summarizer",
64                &self.summarizer.as_ref().map(|_| "<summarizer>"),
65            )
66            .finish()
67    }
68}
69
70#[derive(Debug, Default)]
71struct State {
72    chunks: HashMap<String, MemoryChunk>,
73    sessions: HashMap<String, MemorySession>,
74    evictions: Vec<EvictionEntry>,
75    accesses: Vec<AccessEntry>,
76    /// rollup/summary id → member chunk ids (idempotent linking).
77    summary_members: HashMap<String, Vec<String>>,
78}
79
80impl BasicMemoryProvider {
81    /// Construct an empty provider. Equivalent to `Default::default()`.
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Attach a [`MemoryWriteHook`](crate::MemoryWriteHook) consulted
87    /// before every write. Use this for policy-driven memory redaction or
88    /// application-specific write filtering.
89    pub fn with_write_hook(mut self, hook: Arc<dyn crate::MemoryWriteHook>) -> Self {
90        self.write_hook = Some(hook);
91        self
92    }
93
94    /// Attach a [`Summarizer`] used by [`MemoryProvider::summarize_session`],
95    /// [`MemoryProvider::rollup_day`], and [`MemoryProvider::rollup_rule_week`].
96    pub fn with_summarizer(mut self, summarizer: Arc<dyn Summarizer>) -> Self {
97        self.summarizer = Some(summarizer);
98        self
99    }
100
101    fn next_id() -> String {
102        Uuid::now_v7().to_string()
103    }
104
105    fn metadata_str(chunk: &MemoryChunk, key: &str) -> Option<String> {
106        chunk
107            .metadata
108            .get(key)
109            .and_then(|v| v.as_str())
110            .map(str::to_string)
111    }
112
113    async fn fetch_session_chunks(&self, session_id: &str) -> Result<Vec<MemoryChunk>> {
114        let state = self.state.lock().await;
115        let mut out: Vec<MemoryChunk> = state
116            .chunks
117            .values()
118            .filter(|c| {
119                c.session_id.as_deref() == Some(session_id) && c.kind != ChunkKind::JobSummary
120            })
121            .cloned()
122            .collect();
123        out.sort_by_key(|c| c.created_at);
124        Ok(out)
125    }
126
127    async fn fetch_day_chunks(&self, date: NaiveDate) -> Result<Vec<MemoryChunk>> {
128        let state = self.state.lock().await;
129        let mut out: Vec<MemoryChunk> = state
130            .chunks
131            .values()
132            .filter(|c| c.created_at.date_naive() == date && c.kind != ChunkKind::Rollup)
133            .cloned()
134            .collect();
135        out.sort_by_key(|c| c.created_at);
136        Ok(out)
137    }
138
139    async fn fetch_rule_week_chunks(
140        &self,
141        rule_id: &str,
142        week_start: NaiveDate,
143    ) -> Result<Vec<MemoryChunk>> {
144        let week_end = week_start
145            .checked_add_days(chrono::Days::new(7))
146            .ok_or_else(|| MemoryError::InvalidArgument(format!("week overflow: {week_start}")))?;
147        let state = self.state.lock().await;
148        let mut out: Vec<MemoryChunk> = state
149            .chunks
150            .values()
151            .filter(|c| {
152                c.kind == ChunkKind::Fire
153                    && c.created_at.date_naive() >= week_start
154                    && c.created_at.date_naive() < week_end
155                    && Self::metadata_str(c, "rule_id").as_deref() == Some(rule_id)
156            })
157            .cloned()
158            .collect();
159        out.sort_by_key(|c| c.created_at);
160        Ok(out)
161    }
162
163    async fn day_rollup_exists(&self, date: NaiveDate) -> Result<bool> {
164        let date_s = date.to_string();
165        let state = self.state.lock().await;
166        Ok(state.chunks.values().any(|c| {
167            c.kind == ChunkKind::Rollup && Self::metadata_str(c, "rollup_date") == Some(date_s.clone())
168        }))
169    }
170
171    async fn rule_week_rollup_exists(&self, rule_id: &str, week_start: NaiveDate) -> Result<bool> {
172        let week_s = week_start.to_string();
173        let state = self.state.lock().await;
174        Ok(state.chunks.values().any(|c| {
175            c.kind == ChunkKind::Rollup
176                && Self::metadata_str(c, "rollup_rule_id").as_deref() == Some(rule_id)
177                && Self::metadata_str(c, "rollup_week_start") == Some(week_s.clone())
178        }))
179    }
180
181    async fn link_summary_members(&self, rollup_id: &str, member_ids: &[String]) -> Result<()> {
182        let mut state = self.state.lock().await;
183        let entry = state
184            .summary_members
185            .entry(rollup_id.to_string())
186            .or_default();
187        for mid in member_ids {
188            if !entry.contains(mid) {
189                entry.push(mid.clone());
190            }
191        }
192        Ok(())
193    }
194
195    fn map_summarizer_error(summarizer: &dyn Summarizer, err: SummarizerError) -> MemoryError {
196        match err {
197            SummarizerError::NoInput => {
198                MemoryError::InvalidArgument("summarizer received no input".into())
199            }
200            other => {
201                MemoryError::Provider(format!("summarizer {} failed: {other}", summarizer.name()))
202            }
203        }
204    }
205
206    async fn rollup_day_inner(&self, date: NaiveDate, force: bool) -> Result<Vec<MemoryChunk>> {
207        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
208            "BasicMemoryProvider::rollup_day — no summarizer attached (call `with_summarizer` first)",
209        ))?;
210
211        if !force && self.day_rollup_exists(date).await? {
212            return Ok(Vec::new());
213        }
214
215        let members = self.fetch_day_chunks(date).await?;
216        if members.is_empty() {
217            return Ok(Vec::new());
218        }
219        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
220        let ctx = SummaryContext {
221            kind_label: Some(format!("day {date}")),
222            note: Some(format!(
223                "Daily rollup for {date} ({} chunks)",
224                members.len()
225            )),
226            max_words: None,
227        };
228        let summary_text = summarizer
229            .summarize(&members, &ctx)
230            .await
231            .map_err(|e| Self::map_summarizer_error(summarizer.as_ref(), e))?;
232
233        let written = self
234            .write(NewMemoryChunk {
235                kind: ChunkKind::Rollup,
236                source: ChunkSource::System,
237                session_id: None,
238                project_root: None,
239                caller_id: "system".into(),
240                content: summary_text,
241                metadata: serde_json::json!({
242                    "rollup_kind": "day",
243                    "rollup_date": date.to_string(),
244                    "member_count": member_ids.len(),
245                    "summarizer": summarizer.name(),
246                }),
247                importance: None,
248                shareable: false,
249                pinned: false,
250            })
251            .await?;
252        self.link_summary_members(&written.id, &member_ids).await?;
253        Ok(vec![written])
254    }
255
256    async fn rollup_rule_week_inner(
257        &self,
258        rule_id: &str,
259        week_start: NaiveDate,
260        force: bool,
261    ) -> Result<MemoryChunk> {
262        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
263            "BasicMemoryProvider::rollup_rule_week — no summarizer attached \
264             (call `with_summarizer` first)",
265        ))?;
266
267        if !force && self.rule_week_rollup_exists(rule_id, week_start).await? {
268            return Err(MemoryError::InvalidArgument(format!(
269                "rollup already exists for rule {rule_id} week {week_start}"
270            )));
271        }
272
273        let members = self.fetch_rule_week_chunks(rule_id, week_start).await?;
274        if members.is_empty() {
275            return Err(MemoryError::NotFound(format!(
276                "no fires for rule {rule_id} in week of {week_start}"
277            )));
278        }
279        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
280        let ctx = SummaryContext {
281            kind_label: Some(format!("week of {week_start} for rule {rule_id}")),
282            note: Some(format!(
283                "Weekly rollup for rule {rule_id} ({} fires)",
284                members.len()
285            )),
286            max_words: None,
287        };
288        let summary_text = summarizer
289            .summarize(&members, &ctx)
290            .await
291            .map_err(|e| Self::map_summarizer_error(summarizer.as_ref(), e))?;
292
293        let written = self
294            .write(NewMemoryChunk {
295                kind: ChunkKind::Rollup,
296                source: ChunkSource::System,
297                session_id: None,
298                project_root: None,
299                caller_id: "system".into(),
300                content: summary_text,
301                metadata: serde_json::json!({
302                    "rollup_kind": "rule_week",
303                    "rollup_rule_id": rule_id,
304                    "rollup_week_start": week_start.to_string(),
305                    "member_count": member_ids.len(),
306                    "summarizer": summarizer.name(),
307                }),
308                importance: None,
309                shareable: false,
310                pinned: false,
311            })
312            .await?;
313        self.link_summary_members(&written.id, &member_ids).await?;
314        Ok(written)
315    }
316}
317
318#[async_trait]
319impl MemoryProvider for BasicMemoryProvider {
320    // ─────────────── Reads ───────────────
321
322    async fn retrieve(&self, query: MemoryQuery) -> Result<Vec<MemoryChunk>> {
323        if query.text.trim().is_empty() {
324            return Err(MemoryError::InvalidArgument(
325                "query.text must not be empty".into(),
326            ));
327        }
328        let needle = query.text.to_lowercase();
329        let state = self.state.lock().await;
330
331        let mut hits: Vec<&MemoryChunk> = state
332            .chunks
333            .values()
334            .filter(|c| {
335                // Kind filter.
336                if let Some(kinds) = &query.kinds {
337                    if !kinds.contains(&c.kind) {
338                        return false;
339                    }
340                }
341                // include_rollups=false suppresses rollup chunks.
342                if !query.include_rollups && c.kind == ChunkKind::Rollup {
343                    return false;
344                }
345                // Time bounds.
346                if let Some(since) = query.since {
347                    if c.created_at < since {
348                        return false;
349                    }
350                }
351                if let Some(until) = query.until {
352                    if c.created_at > until {
353                        return false;
354                    }
355                }
356                // Session restriction.
357                if let Some(sid) = &query.session_id {
358                    if c.session_id.as_deref() != Some(sid.as_str()) {
359                        return false;
360                    }
361                }
362                // Project root prefix.
363                if let Some(prefix) = &query.project_root_prefix {
364                    match &c.project_root {
365                        Some(root) if root.starts_with(prefix.as_str()) => {}
366                        _ => return false,
367                    }
368                }
369                // Min importance.
370                if let Some(min) = query.min_importance {
371                    if c.importance < min {
372                        return false;
373                    }
374                }
375                // CallerScope enforcement. `Own` restricts to the caller's
376                // own chunks. `OwnPlusShared` permits the caller's own
377                // chunks *and* any chunk tagged `shareable=true` (the
378                // Phase 4 multi-agent surface).
379                // `Global` permits everything.
380                match query.caller_scope {
381                    crate::query::CallerScope::Own => {
382                        if c.caller_id != query.caller_id {
383                            return false;
384                        }
385                    }
386                    crate::query::CallerScope::OwnPlusShared => {
387                        if c.caller_id != query.caller_id && !c.shareable {
388                            return false;
389                        }
390                    }
391                    crate::query::CallerScope::Global => {}
392                }
393                // Lexical: substring of content (case-insensitive).
394                c.content.to_lowercase().contains(&needle)
395            })
396            .collect();
397
398        // Order by recency (newest first). The v1 stub does not perform
399        // hybrid scoring — `RetrievalProfile` is accepted but ignored.
400        hits.sort_by_key(|c| std::cmp::Reverse(c.created_at));
401        hits.truncate(query.k);
402        Ok(hits.into_iter().cloned().collect())
403    }
404
405    async fn get(&self, chunk_id: &str) -> Result<Option<MemoryChunk>> {
406        let state = self.state.lock().await;
407        Ok(state.chunks.get(chunk_id).cloned())
408    }
409
410    async fn get_session(&self, session_id: &str) -> Result<Option<MemorySession>> {
411        let state = self.state.lock().await;
412        Ok(state.sessions.get(session_id).cloned())
413    }
414
415    async fn list_sessions(&self, filter: SessionFilter) -> Result<Vec<MemorySession>> {
416        let state = self.state.lock().await;
417        let mut out: Vec<MemorySession> = state
418            .sessions
419            .values()
420            .filter(|s| {
421                if let Some(c) = &filter.caller_id {
422                    if &s.caller_id != c {
423                        return false;
424                    }
425                }
426                if let Some(o) = filter.outcome {
427                    if s.outcome != o {
428                        return false;
429                    }
430                }
431                if filter.open_only && s.outcome != SessionOutcome::Open {
432                    return false;
433                }
434                if let Some(since) = filter.since {
435                    if s.started_at < since {
436                        return false;
437                    }
438                }
439                if let Some(until) = filter.until {
440                    if s.started_at > until {
441                        return false;
442                    }
443                }
444                true
445            })
446            .cloned()
447            .collect();
448        out.sort_by_key(|s| std::cmp::Reverse(s.started_at));
449        if let Some(n) = filter.limit {
450            out.truncate(n);
451        }
452        Ok(out)
453    }
454
455    // ─────────────── Writes ───────────────
456
457    async fn write(&self, new_chunk: NewMemoryChunk) -> Result<MemoryChunk> {
458        if new_chunk.content.trim().is_empty() {
459            return Err(MemoryError::InvalidArgument(
460                "content must not be empty".into(),
461            ));
462        }
463
464        // Consult the optional pre-write hook (memory_write_attempted seam).
465        // On Redact, return a synthetic chunk with a `redacted: true` marker
466        // and DO NOT persist it. Callers that care can inspect the returned
467        // chunk; most won't notice the difference.
468        let importance = crate::importance::score(&new_chunk);
469        if let Some(hook) = &self.write_hook {
470            match hook.before_write(&new_chunk).await? {
471                crate::WriteDecision::Allow => {}
472                crate::WriteDecision::Redact { reason } => {
473                    return Ok(MemoryChunk {
474                        id: Self::next_id(),
475                        created_at: Utc::now(),
476                        kind: new_chunk.kind,
477                        tier: MemoryTier::Session,
478                        source: new_chunk.source,
479                        session_id: new_chunk.session_id,
480                        project_root: new_chunk.project_root,
481                        caller_id: new_chunk.caller_id,
482                        content: format!("<redacted: {reason}>"),
483                        metadata: serde_json::json!({"redacted": true, "reason": reason}),
484                        importance: 0.0,
485                        pinned: false,
486                        shareable: false,
487                        superseded_by: None,
488                        embedding_model: "none".into(),
489                        embedding_dim: 0,
490                    });
491                }
492            }
493        }
494
495        let chunk = MemoryChunk {
496            id: Self::next_id(),
497            created_at: Utc::now(),
498            kind: new_chunk.kind,
499            tier: MemoryTier::Session,
500            source: new_chunk.source,
501            session_id: new_chunk.session_id,
502            project_root: new_chunk.project_root,
503            caller_id: new_chunk.caller_id,
504            content: new_chunk.content,
505            metadata: new_chunk.metadata,
506            importance,
507            pinned: new_chunk.pinned,
508            shareable: new_chunk.shareable,
509            superseded_by: None,
510            embedding_model: "none".into(),
511            embedding_dim: 0,
512        };
513        let mut state = self.state.lock().await;
514        state.chunks.insert(chunk.id.clone(), chunk.clone());
515        Ok(chunk)
516    }
517
518    async fn write_batch(&self, chunks: Vec<NewMemoryChunk>) -> Result<Vec<MemoryChunk>> {
519        // The v1 stub doesn't batch embeddings (there are none). Process
520        // one-by-one to keep the impl honest.
521        let mut out = Vec::with_capacity(chunks.len());
522        for nc in chunks {
523            out.push(self.write(nc).await?);
524        }
525        Ok(out)
526    }
527
528    async fn open_session(&self, init: NewMemorySession) -> Result<MemorySession> {
529        let s = MemorySession {
530            id: Self::next_id(),
531            started_at: Utc::now(),
532            ended_at: None,
533            caller_id: init.caller_id,
534            title: init.title,
535            summary: None,
536            outcome: SessionOutcome::Open,
537            metadata: init.metadata,
538        };
539        let mut state = self.state.lock().await;
540        state.sessions.insert(s.id.clone(), s.clone());
541        Ok(s)
542    }
543
544    async fn close_session(&self, session_id: &str, outcome: SessionOutcome) -> Result<()> {
545        let mut state = self.state.lock().await;
546        let s = state
547            .sessions
548            .get_mut(session_id)
549            .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
550        s.ended_at = Some(Utc::now());
551        s.outcome = match outcome {
552            // Refuse to close into `Open`.
553            SessionOutcome::Open => SessionOutcome::Aborted,
554            other => other,
555        };
556        Ok(())
557    }
558
559    async fn rename_session(&self, session_id: &str, title: &str) -> Result<()> {
560        let mut state = self.state.lock().await;
561        let s = state
562            .sessions
563            .get_mut(session_id)
564            .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
565        s.title = Some(title.to_string());
566        Ok(())
567    }
568
569    // ─────────────── Updates ───────────────
570
571    async fn pin(&self, chunk_id: &str, pinned: bool) -> Result<()> {
572        let mut state = self.state.lock().await;
573        let c = state
574            .chunks
575            .get_mut(chunk_id)
576            .ok_or_else(|| MemoryError::NotFound(format!("chunk {chunk_id}")))?;
577        c.pinned = pinned;
578        Ok(())
579    }
580
581    async fn update_importance(&self, _chunk_id: &str, _importance: f32) -> Result<()> {
582        // v1 does not score importance. No-op rather than NotImplemented:
583        // callers can fire-and-forget without conditional logic.
584        Ok(())
585    }
586
587    async fn supersede(&self, _old_id: &str, _new_id: &str) -> Result<()> {
588        // v1 does not track supersession. No-op (callers may set it via the
589        // full impl later; safe to call from any v1 caller).
590        Ok(())
591    }
592
593    async fn record_access(&self, chunk_id: &str, retrieved_by: &str, used: bool) -> Result<()> {
594        let mut state = self.state.lock().await;
595        if !state.chunks.contains_key(chunk_id) {
596            return Err(MemoryError::NotFound(format!("chunk {chunk_id}")));
597        }
598        state.accesses.push(AccessEntry {
599            ts: Utc::now(),
600            chunk_id: chunk_id.to_string(),
601            retrieved_by: retrieved_by.to_string(),
602            query_hash: String::new(), // v1 stub doesn't compute query hashes
603            rank: 0,
604            used,
605        });
606        Ok(())
607    }
608
609    // ─────────────── Deletes ───────────────
610
611    async fn delete(&self, chunk_id: &str, reason: EvictionReason) -> Result<()> {
612        let mut state = self.state.lock().await;
613        if state.chunks.remove(chunk_id).is_none() {
614            return Err(MemoryError::NotFound(format!("chunk {chunk_id}")));
615        }
616        state.evictions.push(EvictionEntry {
617            ts: Utc::now(),
618            chunk_id: chunk_id.to_string(),
619            reason,
620            metadata: serde_json::Value::Null,
621        });
622        Ok(())
623    }
624
625    async fn delete_matching(
626        &self,
627        predicate: MemoryPredicate,
628        reason: EvictionReason,
629    ) -> Result<usize> {
630        // Footgun guard: an empty predicate would match every chunk; callers
631        // who want "delete everything" must use `purge_all`.
632        if predicate.is_empty() {
633            return Ok(0);
634        }
635        let mut state = self.state.lock().await;
636        let now = Utc::now();
637        let to_delete: Vec<String> = state
638            .chunks
639            .values()
640            .filter(|c| chunk_matches(c, &predicate))
641            .map(|c| c.id.clone())
642            .collect();
643        let count = to_delete.len();
644        for id in to_delete {
645            state.chunks.remove(&id);
646            state.evictions.push(EvictionEntry {
647                ts: now,
648                chunk_id: id,
649                reason,
650                metadata: serde_json::Value::Null,
651            });
652        }
653        Ok(count)
654    }
655
656    async fn purge_all(&self) -> Result<PurgeReport> {
657        let mut state = self.state.lock().await;
658        let report = PurgeReport {
659            chunks_deleted: state.chunks.len(),
660            sessions_deleted: state.sessions.len(),
661            access_log_deleted: state.accesses.len(),
662            eviction_log_deleted: state.evictions.len(),
663        };
664        state.chunks.clear();
665        state.sessions.clear();
666        state.accesses.clear();
667        state.evictions.clear();
668        state.summary_members.clear();
669        Ok(report)
670    }
671
672    // ─────────────── Summarization ───────────────
673
674    async fn summarize_session(&self, session_id: &str) -> Result<MemoryChunk> {
675        let summarizer = self.summarizer.clone().ok_or(MemoryError::NotImplemented(
676            "BasicMemoryProvider::summarize_session — no summarizer attached \
677             (call `with_summarizer` first)",
678        ))?;
679
680        let session = self
681            .get_session(session_id)
682            .await?
683            .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
684
685        let members = self.fetch_session_chunks(session_id).await?;
686        if members.is_empty() {
687            return Err(MemoryError::InvalidArgument(format!(
688                "session {session_id} has no chunks to summarize"
689            )));
690        }
691        let member_ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
692        let ctx = SummaryContext {
693            kind_label: Some("session".into()),
694            note: session
695                .title
696                .as_ref()
697                .map(|t| format!("session title: {t}")),
698            max_words: None,
699        };
700        let summary_text = summarizer
701            .summarize(&members, &ctx)
702            .await
703            .map_err(|e| Self::map_summarizer_error(summarizer.as_ref(), e))?;
704
705        let written = self
706            .write(NewMemoryChunk {
707                kind: ChunkKind::JobSummary,
708                source: ChunkSource::Embedded,
709                session_id: Some(session_id.to_string()),
710                project_root: members.iter().find_map(|c| c.project_root.clone()),
711                caller_id: session.caller_id.clone(),
712                content: summary_text.clone(),
713                metadata: serde_json::json!({
714                    "session_id": session_id,
715                    "member_count": member_ids.len(),
716                    "summarizer": summarizer.name(),
717                }),
718                importance: None,
719                shareable: false,
720                pinned: false,
721            })
722            .await?;
723        self.link_summary_members(&written.id, &member_ids).await?;
724
725        if let Some(session) = self.state.lock().await.sessions.get_mut(session_id) {
726            session.summary = Some(summary_text);
727        }
728
729        Ok(written)
730    }
731
732    async fn rollup_day(&self, date: NaiveDate) -> Result<Vec<MemoryChunk>> {
733        self.rollup_day_inner(date, false).await
734    }
735
736    async fn rollup_day_forced(&self, date: NaiveDate) -> Result<Vec<MemoryChunk>> {
737        self.rollup_day_inner(date, true).await
738    }
739
740    async fn rollup_rule_week(&self, rule_id: &str, week_start: NaiveDate) -> Result<MemoryChunk> {
741        self.rollup_rule_week_inner(rule_id, week_start, false)
742            .await
743    }
744
745    async fn rollup_rule_week_forced(
746        &self,
747        rule_id: &str,
748        week_start: NaiveDate,
749    ) -> Result<MemoryChunk> {
750        self.rollup_rule_week_inner(rule_id, week_start, true).await
751    }
752
753    // ─────────────── Maintenance ───────────────
754
755    async fn run_aging_sweep(&self) -> Result<AgingReport> {
756        // v1 stub: a 30-day retention sweep over non-pinned non-correction
757        // chunks. No importance scoring; honest minimum.
758        const RETENTION_DAYS: i64 = 30;
759        let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
760        let mut state = self.state.lock().await;
761        let to_delete: Vec<String> = state
762            .chunks
763            .values()
764            .filter(|c| !c.pinned && c.kind != ChunkKind::Correction && c.created_at < cutoff)
765            .map(|c| c.id.clone())
766            .collect();
767        let deleted = to_delete.len();
768        let now = Utc::now();
769        for id in to_delete {
770            state.chunks.remove(&id);
771            state.evictions.push(EvictionEntry {
772                ts: now,
773                chunk_id: id,
774                reason: EvictionReason::Aging,
775                metadata: serde_json::Value::Null,
776            });
777        }
778        Ok(AgingReport {
779            tier_promoted: 0, // v1 doesn't transition tiers
780            deleted,
781            bytes_reclaimed: 0,
782            deletions_by_reason: vec![(EvictionReason::Aging, deleted)],
783        })
784    }
785
786    async fn re_embed_all(&self, target_model: &str) -> Result<ReEmbedReport> {
787        let started = std::time::Instant::now();
788        let mut state = self.state.lock().await;
789        let total = state.chunks.len();
790        for chunk in state.chunks.values_mut() {
791            chunk.embedding_model = target_model.to_string();
792        }
793        Ok(ReEmbedReport {
794            total,
795            succeeded: total,
796            failed: 0,
797            elapsed_ms: started.elapsed().as_millis() as u64,
798        })
799    }
800
801    async fn export(&self, filter: ExportFilter) -> Result<ExportBundle> {
802        let state = self.state.lock().await;
803        let chunks: Vec<MemoryChunk> = state
804            .chunks
805            .values()
806            .filter(|c| match &filter.predicate {
807                Some(p) if !p.is_empty() => chunk_matches(c, p),
808                _ => true,
809            })
810            .cloned()
811            .collect();
812
813        let session_ids: std::collections::HashSet<String> =
814            chunks.iter().filter_map(|c| c.session_id.clone()).collect();
815        let sessions = if filter.include_sessions {
816            state
817                .sessions
818                .values()
819                .filter(|s| session_ids.contains(&s.id))
820                .cloned()
821                .collect()
822        } else {
823            Vec::new()
824        };
825        let evictions = if filter.include_eviction_log {
826            state.evictions.clone()
827        } else {
828            Vec::new()
829        };
830        let accesses = if filter.include_access_log {
831            state.accesses.clone()
832        } else {
833            Vec::new()
834        };
835        Ok(ExportBundle {
836            chunks,
837            sessions,
838            evictions,
839            accesses,
840        })
841    }
842
843    async fn stats(&self) -> Result<MemoryStats> {
844        let state = self.state.lock().await;
845        let total = state.chunks.len();
846        Ok(MemoryStats {
847            total_chunks: total,
848            session_chunks: state
849                .chunks
850                .values()
851                .filter(|c| c.tier == MemoryTier::Session)
852                .count(),
853            long_term_chunks: state
854                .chunks
855                .values()
856                .filter(|c| c.tier == MemoryTier::LongTerm)
857                .count(),
858            total_sessions: state.sessions.len(),
859            open_sessions: state
860                .sessions
861                .values()
862                .filter(|s| s.outcome == SessionOutcome::Open)
863                .count(),
864            db_bytes: 0, // in-memory: not meaningful in v1
865            embedding_model: state
866                .chunks
867                .values()
868                .find(|c| c.embedding_model != "none")
869                .map(|c| c.embedding_model.clone()),
870        })
871    }
872}
873
874fn chunk_matches(c: &MemoryChunk, p: &MemoryPredicate) -> bool {
875    if let Some(kinds) = &p.kinds {
876        if !kinds.contains(&c.kind) {
877            return false;
878        }
879    }
880    if let Some(callers) = &p.callers {
881        if !callers.iter().any(|x| x == &c.caller_id) {
882            return false;
883        }
884    }
885    if let Some(sids) = &p.session_ids {
886        match &c.session_id {
887            Some(id) if sids.iter().any(|x| x == id) => {}
888            _ => return false,
889        }
890    }
891    if let Some(prefix) = &p.project_root_prefix {
892        match &c.project_root {
893            Some(root) if root.starts_with(prefix.as_str()) => {}
894            _ => return false,
895        }
896    }
897    if let Some(before) = p.before {
898        if c.created_at >= before {
899            return false;
900        }
901    }
902    if let Some(after) = p.after {
903        if c.created_at <= after {
904            return false;
905        }
906    }
907    if let Some(pinned) = p.pinned {
908        if c.pinned != pinned {
909            return false;
910        }
911    }
912    if let Some(below) = p.importance_below {
913        if c.importance >= below {
914            return false;
915        }
916    }
917    if let Some(needle) = &p.content_contains {
918        if !c.content.to_lowercase().contains(&needle.to_lowercase()) {
919            return false;
920        }
921    }
922    true
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use crate::chunk::ChunkSource;
929    use crate::query::{CallerScope, RetrievalProfile};
930    use serde_json::json;
931
932    fn nc(caller: &str, content: &str) -> NewMemoryChunk {
933        NewMemoryChunk {
934            kind: ChunkKind::Chat,
935            source: ChunkSource::Embedded,
936            session_id: None,
937            project_root: None,
938            caller_id: caller.into(),
939            content: content.into(),
940            metadata: json!(null),
941            importance: None,
942            shareable: false,
943            pinned: false,
944        }
945    }
946
947    fn q(caller: &str, text: &str) -> MemoryQuery {
948        MemoryQuery {
949            text: text.into(),
950            kinds: None,
951            since: None,
952            until: None,
953            session_id: None,
954            caller_scope: CallerScope::Own,
955            project_root_prefix: None,
956            k: 8,
957            include_rollups: true,
958            min_importance: None,
959            profile: RetrievalProfile::AgentChatTurn,
960            caller_id: caller.into(),
961        }
962    }
963
964    #[tokio::test]
965    async fn write_and_retrieve_round_trips() {
966        let m = BasicMemoryProvider::new();
967        let c = m
968            .write(nc("embedded", "Q4 report is filed under Workspace"))
969            .await
970            .unwrap();
971        let hits = m.retrieve(q("embedded", "q4 report")).await.unwrap();
972        assert_eq!(hits.len(), 1);
973        assert_eq!(hits[0].id, c.id);
974    }
975
976    #[tokio::test]
977    async fn retrieve_respects_caller_scope_own() {
978        let m = BasicMemoryProvider::new();
979        m.write(nc("embedded", "my secret")).await.unwrap();
980        m.write(nc("mcp:cursor", "my secret")).await.unwrap();
981        let hits = m.retrieve(q("embedded", "secret")).await.unwrap();
982        assert_eq!(hits.len(), 1);
983        assert_eq!(hits[0].caller_id, "embedded");
984    }
985
986    #[tokio::test]
987    async fn retrieve_global_scope_sees_all() {
988        let m = BasicMemoryProvider::new();
989        m.write(nc("embedded", "my secret")).await.unwrap();
990        m.write(nc("mcp:cursor", "my secret")).await.unwrap();
991        let mut query = q("audit", "secret");
992        query.caller_scope = CallerScope::Global;
993        let hits = m.retrieve(query).await.unwrap();
994        assert_eq!(hits.len(), 2);
995    }
996
997    #[tokio::test]
998    async fn retrieve_own_plus_shared_surfaces_shareable_across_callers() {
999        let m = BasicMemoryProvider::new();
1000        // Two callers, two chunks: one shareable from "mcp:cursor",
1001        // one private from "mcp:cursor". A third caller asks with
1002        // OwnPlusShared scope; only the shareable one surfaces.
1003        let mut shared = nc("mcp:cursor", "user prefers dry-run mode");
1004        shared.shareable = true;
1005        m.write(shared).await.unwrap();
1006        m.write(nc("mcp:cursor", "user said hi to cursor"))
1007            .await
1008            .unwrap();
1009        m.write(nc("mcp:codex", "user said hi to codex"))
1010            .await
1011            .unwrap();
1012        let mut query = q("embedded", "user");
1013        query.caller_scope = CallerScope::OwnPlusShared;
1014        let hits = m.retrieve(query).await.unwrap();
1015        // Only the shareable chunk is visible to the embedded caller.
1016        assert_eq!(hits.len(), 1);
1017        assert!(hits[0].content.contains("dry-run"));
1018        assert!(hits[0].shareable);
1019    }
1020
1021    #[tokio::test]
1022    async fn retrieve_own_plus_shared_includes_own_unshared() {
1023        let m = BasicMemoryProvider::new();
1024        // The caller's own chunks are always visible under OwnPlusShared
1025        // regardless of the shareable flag.
1026        m.write(nc("embedded", "embedded private note"))
1027            .await
1028            .unwrap();
1029        m.write(nc("mcp:cursor", "cursor private note"))
1030            .await
1031            .unwrap();
1032        let mut query = q("embedded", "note");
1033        query.caller_scope = CallerScope::OwnPlusShared;
1034        let hits = m.retrieve(query).await.unwrap();
1035        assert_eq!(hits.len(), 1);
1036        assert_eq!(hits[0].caller_id, "embedded");
1037    }
1038
1039    #[tokio::test]
1040    async fn retrieve_orders_recent_first() {
1041        let m = BasicMemoryProvider::new();
1042        m.write(nc("embedded", "first about cats")).await.unwrap();
1043        // Small delay to ensure created_at differs.
1044        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1045        let second = m.write(nc("embedded", "second about cats")).await.unwrap();
1046        let hits = m.retrieve(q("embedded", "cats")).await.unwrap();
1047        assert_eq!(hits[0].id, second.id);
1048    }
1049
1050    #[tokio::test]
1051    async fn retrieve_kind_filter() {
1052        let m = BasicMemoryProvider::new();
1053        let chat = m.write(nc("embedded", "alpha")).await.unwrap();
1054        let mut action = nc("embedded", "alpha");
1055        action.kind = ChunkKind::Action;
1056        let _ = m.write(action).await.unwrap();
1057        let mut query = q("embedded", "alpha");
1058        query.kinds = Some(vec![ChunkKind::Chat]);
1059        let hits = m.retrieve(query).await.unwrap();
1060        assert_eq!(hits.len(), 1);
1061        assert_eq!(hits[0].id, chat.id);
1062    }
1063
1064    #[tokio::test]
1065    async fn empty_query_text_rejected() {
1066        let m = BasicMemoryProvider::new();
1067        let err = m.retrieve(q("embedded", "   ")).await.unwrap_err();
1068        assert!(matches!(err, MemoryError::InvalidArgument(_)));
1069    }
1070
1071    #[tokio::test]
1072    async fn empty_content_rejected() {
1073        let m = BasicMemoryProvider::new();
1074        let err = m.write(nc("embedded", "")).await.unwrap_err();
1075        assert!(matches!(err, MemoryError::InvalidArgument(_)));
1076    }
1077
1078    #[tokio::test]
1079    async fn session_open_close() {
1080        let m = BasicMemoryProvider::new();
1081        let s = m
1082            .open_session(NewMemorySession {
1083                caller_id: "embedded".into(),
1084                title: Some("test".into()),
1085                metadata: json!(null),
1086            })
1087            .await
1088            .unwrap();
1089        assert_eq!(s.outcome, SessionOutcome::Open);
1090        m.close_session(&s.id, SessionOutcome::Success)
1091            .await
1092            .unwrap();
1093        let s2 = m.get_session(&s.id).await.unwrap().unwrap();
1094        assert_eq!(s2.outcome, SessionOutcome::Success);
1095        assert!(s2.ended_at.is_some());
1096    }
1097
1098    #[tokio::test]
1099    async fn close_session_unknown_id_errors() {
1100        let m = BasicMemoryProvider::new();
1101        let err = m
1102            .close_session("nope", SessionOutcome::Success)
1103            .await
1104            .unwrap_err();
1105        assert!(matches!(err, MemoryError::NotFound(_)));
1106    }
1107
1108    #[tokio::test]
1109    async fn delete_matching_empty_predicate_is_noop() {
1110        let m = BasicMemoryProvider::new();
1111        m.write(nc("embedded", "x")).await.unwrap();
1112        let n = m
1113            .delete_matching(MemoryPredicate::default(), EvictionReason::UserDelete)
1114            .await
1115            .unwrap();
1116        assert_eq!(n, 0);
1117        let stats = m.stats().await.unwrap();
1118        assert_eq!(stats.total_chunks, 1);
1119    }
1120
1121    #[tokio::test]
1122    async fn delete_matching_by_caller() {
1123        let m = BasicMemoryProvider::new();
1124        m.write(nc("embedded", "x")).await.unwrap();
1125        m.write(nc("mcp:cursor", "y")).await.unwrap();
1126        let n = m
1127            .delete_matching(
1128                MemoryPredicate {
1129                    callers: Some(vec!["embedded".into()]),
1130                    ..Default::default()
1131                },
1132                EvictionReason::UserDelete,
1133            )
1134            .await
1135            .unwrap();
1136        assert_eq!(n, 1);
1137        let stats = m.stats().await.unwrap();
1138        assert_eq!(stats.total_chunks, 1);
1139    }
1140
1141    #[tokio::test]
1142    async fn purge_all_clears_state() {
1143        let m = BasicMemoryProvider::new();
1144        m.write(nc("embedded", "x")).await.unwrap();
1145        let _ = m
1146            .open_session(NewMemorySession {
1147                caller_id: "embedded".into(),
1148                title: None,
1149                metadata: json!(null),
1150            })
1151            .await
1152            .unwrap();
1153        let r = m.purge_all().await.unwrap();
1154        assert_eq!(r.chunks_deleted, 1);
1155        assert_eq!(r.sessions_deleted, 1);
1156        let stats = m.stats().await.unwrap();
1157        assert_eq!(stats.total_chunks, 0);
1158        assert_eq!(stats.total_sessions, 0);
1159    }
1160
1161    #[tokio::test]
1162    async fn summarize_without_summarizer_returns_not_implemented() {
1163        let m = BasicMemoryProvider::new();
1164        let err = m.summarize_session("anything").await.unwrap_err();
1165        assert!(matches!(err, MemoryError::NotImplemented(_)));
1166    }
1167
1168    #[tokio::test]
1169    async fn summarize_session_with_summarizer_writes_summary() {
1170        let summarizer = crate::MockSummarizer::new("session recap");
1171        let m = BasicMemoryProvider::new().with_summarizer(summarizer);
1172        let session = m
1173            .open_session(NewMemorySession {
1174                caller_id: "embedded".into(),
1175                title: Some("chat".into()),
1176                metadata: json!(null),
1177            })
1178            .await
1179            .unwrap();
1180        m.write(NewMemoryChunk {
1181            kind: ChunkKind::Chat,
1182            source: ChunkSource::Embedded,
1183            caller_id: "embedded".into(),
1184            content: "hello".into(),
1185            session_id: Some(session.id.clone()),
1186            project_root: None,
1187            metadata: json!(null),
1188            importance: None,
1189            shareable: false,
1190            pinned: false,
1191        })
1192        .await
1193        .unwrap();
1194
1195        let summary = m.summarize_session(&session.id).await.unwrap();
1196        assert_eq!(summary.kind, ChunkKind::JobSummary);
1197        assert_eq!(summary.content, "session recap");
1198
1199        let updated = m.get_session(&session.id).await.unwrap().unwrap();
1200        assert_eq!(updated.summary.as_deref(), Some("session recap"));
1201    }
1202
1203    #[tokio::test]
1204    async fn re_embed_updates_embedding_model_metadata() {
1205        let m = BasicMemoryProvider::new();
1206        let chunk = m.write(nc("embedded", "hello")).await.unwrap();
1207        assert_eq!(chunk.embedding_model, "none");
1208
1209        let report = m.re_embed_all("mock-384").await.unwrap();
1210        assert_eq!(report.total, 1);
1211        assert_eq!(report.succeeded, 1);
1212        assert_eq!(report.failed, 0);
1213
1214        let got = m.get(&chunk.id).await.unwrap().unwrap();
1215        assert_eq!(got.embedding_model, "mock-384");
1216    }
1217
1218    #[tokio::test]
1219    async fn update_importance_and_supersede_noop() {
1220        let m = BasicMemoryProvider::new();
1221        let c = m.write(nc("embedded", "x")).await.unwrap();
1222        m.update_importance(&c.id, 0.9).await.unwrap();
1223        m.supersede(&c.id, "other").await.unwrap();
1224        // Importance unchanged: v1 stub no-ops.
1225        let got = m.get(&c.id).await.unwrap().unwrap();
1226        assert_eq!(got.importance, 0.5);
1227        assert!(got.superseded_by.is_none());
1228    }
1229
1230    #[tokio::test]
1231    async fn export_filters_by_predicate() {
1232        let m = BasicMemoryProvider::new();
1233        m.write(nc("embedded", "alpha")).await.unwrap();
1234        m.write(nc("embedded", "beta")).await.unwrap();
1235        let bundle = m
1236            .export(ExportFilter {
1237                predicate: Some(MemoryPredicate {
1238                    content_contains: Some("alpha".into()),
1239                    ..Default::default()
1240                }),
1241                include_eviction_log: false,
1242                include_access_log: false,
1243                include_sessions: true,
1244            })
1245            .await
1246            .unwrap();
1247        assert_eq!(bundle.chunks.len(), 1);
1248        assert!(bundle.chunks[0].content.contains("alpha"));
1249    }
1250
1251    #[tokio::test]
1252    async fn aging_sweep_deletes_old_unpinned() {
1253        let m = BasicMemoryProvider::new();
1254        let fresh = m.write(nc("embedded", "fresh")).await.unwrap();
1255        // Forcibly age a chunk by editing the state directly.
1256        {
1257            let mut state = m.state.lock().await;
1258            let c = state.chunks.get_mut(&fresh.id).unwrap();
1259            c.created_at = Utc::now() - chrono::Duration::days(45);
1260        }
1261        let r = m.run_aging_sweep().await.unwrap();
1262        assert_eq!(r.deleted, 1);
1263        let stats = m.stats().await.unwrap();
1264        assert_eq!(stats.total_chunks, 0);
1265    }
1266
1267    #[tokio::test]
1268    async fn aging_sweep_preserves_pinned_and_corrections() {
1269        let m = BasicMemoryProvider::new();
1270        let pinned = m.write(nc("embedded", "pinned")).await.unwrap();
1271        let mut correction_nc = nc("embedded", "correction");
1272        correction_nc.kind = ChunkKind::Correction;
1273        let correction = m.write(correction_nc).await.unwrap();
1274        {
1275            let mut state = m.state.lock().await;
1276            for id in [&pinned.id, &correction.id] {
1277                let c = state.chunks.get_mut(id).unwrap();
1278                c.created_at = Utc::now() - chrono::Duration::days(45);
1279            }
1280            // Pin the pinned one.
1281            let c = state.chunks.get_mut(&pinned.id).unwrap();
1282            c.pinned = true;
1283        }
1284        let r = m.run_aging_sweep().await.unwrap();
1285        assert_eq!(r.deleted, 0);
1286    }
1287}