1use 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#[derive(Clone)]
38pub struct BasicMemoryProvider {
39 state: Arc<Mutex<State>>,
40 write_hook: Option<Arc<dyn crate::MemoryWriteHook>>,
43 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 summary_members: HashMap<String, Vec<String>>,
78}
79
80impl BasicMemoryProvider {
81 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn with_write_hook(mut self, hook: Arc<dyn crate::MemoryWriteHook>) -> Self {
90 self.write_hook = Some(hook);
91 self
92 }
93
94 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 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 if let Some(kinds) = &query.kinds {
337 if !kinds.contains(&c.kind) {
338 return false;
339 }
340 }
341 if !query.include_rollups && c.kind == ChunkKind::Rollup {
343 return false;
344 }
345 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 if let Some(sid) = &query.session_id {
358 if c.session_id.as_deref() != Some(sid.as_str()) {
359 return false;
360 }
361 }
362 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 if let Some(min) = query.min_importance {
371 if c.importance < min {
372 return false;
373 }
374 }
375 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 c.content.to_lowercase().contains(&needle)
395 })
396 .collect();
397
398 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 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 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 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 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 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 Ok(())
585 }
586
587 async fn supersede(&self, _old_id: &str, _new_id: &str) -> Result<()> {
588 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(), rank: 0,
604 used,
605 });
606 Ok(())
607 }
608
609 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 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 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 async fn run_aging_sweep(&self) -> Result<AgingReport> {
756 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, 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, 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 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 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 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 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 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 {
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 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}