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
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 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 if let Some(kinds) = &query.kinds {
338 if !kinds.contains(&c.kind) {
339 return false;
340 }
341 }
342 if !query.include_rollups && c.kind == ChunkKind::Rollup {
344 return false;
345 }
346 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 if let Some(sid) = &query.session_id {
359 if c.session_id.as_deref() != Some(sid.as_str()) {
360 return false;
361 }
362 }
363 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 if let Some(min) = query.min_importance {
372 if c.importance < min {
373 return false;
374 }
375 }
376 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 c.content.to_lowercase().contains(&needle)
396 })
397 .collect();
398
399 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 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 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 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 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 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 Ok(())
586 }
587
588 async fn supersede(&self, _old_id: &str, _new_id: &str) -> Result<()> {
589 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(), rank: 0,
605 used,
606 });
607 Ok(())
608 }
609
610 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 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 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 async fn run_aging_sweep(&self) -> Result<AgingReport> {
757 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, 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, 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 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 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 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 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 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 {
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 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}