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